Compare commits

...

14 Commits

Author SHA1 Message Date
cxymds 0913686b12 fix(heal): detect stale delete-marker metadata (#8102) 2026-09-24 11:16:02 +08:00
George Melikov df64242f64 fix(ecstore): resume scan_dir past a dash-suffixed sibling directory (#8071)
A flat (recursive) listing resumed inside "s/" re-emitted everything in
its sibling "s-x/". scan_dir dropped the entries before forward_to by
comparing directory names without their trailing slash, where "s" sorts
before "s-x", while the keys they stand for sort the other way round:
'-' (0x2d) is below '/' (0x2f), so all of "s-x/..." precedes "s/...".
The drain stopped at "s" and kept "s-x".

The next page then started with keys at or before the marker; the
listing layer filtered all of them out, found no more candidates and
answered IsTruncated=false. A bucket of 104,137 objects with backup
directories named "<id>" and "<id>-rollbacks" listed as 5,000; two such
directories of 1,200 keys each listed as 2,000.

Compare every remaining entry as the key prefix it stands for, slash
included, and keep it only when it sorts at or after forward_to or
contains it. The remainder of forward_to is taken before `current` is
trimmed, so this also holds below the bucket root; retain also drops
entries when all of them precede forward_to, which the drain never did.
2026-09-24 07:13:30 +08:00
Chris d3b75e695e test(connect): add scheduler receipt acceptance workflow (#8078)
* test(connect): add scheduler receipt acceptance workflow

* chore(deps): upgrade crates and fix faster-hex advisory (#8098)

* test(connect): isolate scheduler acceptance workflow

---------

Co-authored-by: Hauser <housemecn@gmail.com>
2026-09-24 03:34:27 +08:00
Chris dde1aa77a1 fix(connect): preserve diagnostic schedules across restart (#8092) 2026-09-24 03:28:37 +08:00
Chris be01e513be feat(connect): sample memory within the running service (#8091)
* feat(connect): sample memory within the running service

* test(connect): add official memory service acceptance

* test(connect): consume the final memory job without cloning
2026-09-24 03:25:28 +08:00
cxymds 9ab034df62 fix(heal): preserve retryable writer failures (#8089)
Co-authored-by: Hauser <housemecn@gmail.com>
2026-09-24 02:41:16 +08:00
Tyler Hillery 624463c5ba fix(s3): default CopyObject content-type to binary/octet-stream on REPLACE (#8073)
fix: default CopyObject content-type to binary/octet-stream on REPLACE
2026-09-23 18:04:05 +00:00
Chris 713920dcd6 docs: update license copyright to RustFS, Inc. (#8097) 2026-09-24 00:56:08 +08:00
GatewayJ f219a0aba8 perf(ecstore): parallelize multipart I/O setup and metadata reads (#8085)
* perf(ecstore): parallelize multipart I/O setup and metadata reads

* perf(ecstore): share multipart paths and increase read concurrency

* fix(ecstore): route multipart benchmark through storage API facade
2026-09-23 22:19:23 +08:00
Hauser 311e4306e6 chore(ci): complete action pin and HAProxy upgrades (#8090) 2026-09-23 19:20:39 +08:00
Hauser 7f0f4941cf chore(ci): refresh checkout pin in validated workflows (#8083)
* chore(ci): update checkout pin in validated workflows

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ci): pin migrated checkout alerts to requested commit

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(ci): pin additional checkout workflows to verified commit (#8084)

* chore(ci): pin more checkout workflows to requested commit

Update six additional workflows to the verified upstream checkout commit without changing their permissions, inputs, or triggers.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(ci): pin functional and OIDC checkout uses

Extend the verified checkout commit pin to functional-chain and OIDC workflows without changing their behavior.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

---------

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-23 09:27:54 +00:00
cxymds 655bb470b8 fix(heal): retry faulty storage disk errors (#8086) 2026-09-23 07:48:39 +00:00
cxymds 56a4099f99 fix(heal): recreate missing bucket volumes (#8082)
* fix(heal): recreate missing bucket volumes

* fix(heal): use typed error for empty targets
2026-09-23 14:51:24 +08:00
hector ee6de7d786 ci(package): build gnu and musl DEB/RPM variants with distinct file names (#8079)
* ci(package): build gnu and musl DEB/RPM variants with distinct file names

The Build and Release workflow produces four Linux binaries
(x86_64-gnu, aarch64-gnu, x86_64-musl, aarch64-musl), but packaging
only consumed the two gnu artifacts. Add matrix entries for the two
musl artifacts so every release ships all four DEB/RPM variants.

The libc variant is now part of the package file names, which would
otherwise collide between gnu and musl builds of the same version:

- deb: rustfs_<version>_<libc>_<arch>.deb
- rpm: rustfs-<libc>-<version>-<release>.<arch>.rpm

The dpkg Package and rpm Name stay plain "rustfs", so gnu and musl
remain mutually exclusive upgrades of one package rather than
co-installable packages fighting over /usr/bin/rustfs.

Dependency declarations now follow the linkage: gnu binaries
dynamically link glibc and keep Depends: libc6 (>= 2.31) /
glibc >= 2.31; musl binaries are statically linked and declare no
libc dependency. The libc variant is also visible in the package
description.

scripts/release/package_versions.sh gains a LIBC argument and its
contract tests cover both variants plus the invalid-libc cases.

* ci(package): align deb/rpm file names with the zip artifact naming

Rename the package file names so every release asset of one build
shares the same stem as its binary artifact, differing only by
extension:

- before: rustfs_<deb_version>_<libc>_<deb_arch>.deb
          rustfs-<libc>-<rpm_version>-<rpm_release>.<rpm_arch>.rpm
- after:  rustfs-linux-<arch>-<libc>-v<version>.deb / .rpm

e.g. rustfs-linux-x86_64-gnu-v1.0.0.zip,
     rustfs-linux-x86_64-gnu-v1.0.0.deb,
     rustfs-linux-x86_64-gnu-v1.0.0.rpm.

Non-development builds embed the raw release tag (with 'v'), like the
zips; development builds embed dev-<full sha>. The dpkg/rpm versions
(including the '~' prerelease ordering) are unchanged - they live in
the package metadata, and a side effect is that release asset names no
longer contain '~' (which GitHub normalizes to '.').

package_versions.sh now takes the target arch (x86_64|aarch64) instead
of the deb/rpm arch pair; the deb Architecture (amd64/arm64) in the
control metadata still comes from the workflow matrix. The two test
workflows that assemble deb download URLs from a release tag
(rustfs-table-test, rustfs-upgrade-test) are updated to the new name,
which also removes their '~'-to-'.' asset name workaround.
2026-09-23 03:17:47 +00:00
87 changed files with 2488 additions and 508 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ runs:
using: composite
steps:
- name: Install quality tools
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: |
ripgrep@15.2.0
+1 -1
View File
@@ -109,7 +109,7 @@ runs:
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
- name: Setup Rust cache
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
# false is rust-cache's own default. With true, cleanup.ts returns
# *before* pruning ~/.cargo/registry/src, and config.ts archives the
@@ -48,12 +48,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install ripgrep
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: ripgrep@15.2.0
+6 -6
View File
@@ -93,7 +93,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -112,7 +112,7 @@ jobs:
# default is now "false", but state it explicitly — see
# scripts/security/check_cache_save_if.sh.
- name: Setup Rust cache
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
# Same reasoning as the setup composite: true archives every
# dependency's unpacked source tree.
@@ -122,7 +122,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install cargo-deny
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: cargo-deny
@@ -136,7 +136,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -210,7 +210,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+7 -7
View File
@@ -98,7 +98,7 @@ jobs:
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -257,7 +257,7 @@ jobs:
matrix: ${{ fromJson(needs.prepare-platform-matrix.outputs.matrix) }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0
@@ -702,7 +702,7 @@ jobs:
exit 1
- name: Upload to GitHub artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.package.outputs.package_name }}
path: "rustfs-*.zip"
@@ -840,7 +840,7 @@ jobs:
release_url: ${{ steps.create.outputs.release_url }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0
@@ -899,12 +899,12 @@ jobs:
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download all build artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ./artifacts
pattern: rustfs-*
@@ -1138,7 +1138,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+5 -5
View File
@@ -109,7 +109,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -141,7 +141,7 @@ jobs:
- name: Upload cargo timings report
if: inputs.emit_timings
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cargo-timings-ci-dev
path: target/cargo-timings/
@@ -199,7 +199,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -230,7 +230,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -260,7 +260,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
+39 -39
View File
@@ -77,7 +77,7 @@ jobs:
outputs:
mode: ${{ steps.scope.outputs.mode }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 2
persist-credentials: false
@@ -101,11 +101,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
# Fail early with compile-free checks for every pull request.
quick-checks:
@@ -115,7 +115,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -132,7 +132,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
# Checkout otherwise writes the token into .git/config, where a PR's
# own build.rs or proc-macro could read it back out.
@@ -261,7 +261,7 @@ jobs:
- name: Upload test reports and diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: junit-test-and-lint-${{ github.run_number }}
path: |
@@ -305,7 +305,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -344,7 +344,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -391,7 +391,7 @@ jobs:
- name: Upload ILM test diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
path: |
@@ -408,7 +408,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -449,7 +449,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -521,7 +521,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -561,7 +561,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -621,7 +621,7 @@ jobs:
PYBUILD
- name: Upload debug binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-debug-binary
path: |
@@ -646,7 +646,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -662,7 +662,7 @@ jobs:
run: python3 scripts/e2e_binary.py build --bins --features rio-v2,e2e-test-hooks
- name: Upload debug binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-debug-binary-rio-v2
path: |
@@ -690,7 +690,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -754,7 +754,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -770,7 +770,7 @@ jobs:
install-build-packaging-tools: 'false'
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -785,7 +785,7 @@ jobs:
# Download after the cache restore so the freshly built binary from the
# build job always wins over anything restored into target/debug.
- name: Download debug binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary
path: target/debug
@@ -821,7 +821,7 @@ jobs:
- name: Upload e2e smoke diagnostics
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-diagnostics-${{ github.run_number }}
path: |
@@ -831,7 +831,7 @@ jobs:
- name: Upload e2e smoke JUnit report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-junit-${{ github.run_number }}
path: target/nextest/e2e-smoke/junit.xml
@@ -856,7 +856,7 @@ jobs:
- name: Upload test logs
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-logs-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-e2e-*/rustfs.log
@@ -879,7 +879,7 @@ jobs:
timeout-minutes: 55
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -903,7 +903,7 @@ jobs:
fi
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -945,7 +945,7 @@ jobs:
# Download after the cache restore so the freshly built binary from the
# build job always wins over anything restored into target/debug.
- name: Download debug binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary
path: target/debug
@@ -1005,7 +1005,7 @@ jobs:
- name: Upload junit
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-full-junit-${{ github.run_number }}
path: |
@@ -1015,7 +1015,7 @@ jobs:
- name: Upload startup CAS evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fresh-startup-cas-evidence-${{ github.run_number }}
path: |
@@ -1034,7 +1034,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -1044,7 +1044,7 @@ jobs:
rm -f /tmp/rustfs.log
- name: Download debug binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary-rio-v2
path: target/debug
@@ -1077,7 +1077,7 @@ jobs:
- name: Upload test logs
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-logs-rio-v2-${{ github.run_number }}
path: /tmp/rustfs.log
@@ -1090,12 +1090,12 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download debug binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary
path: target/debug
@@ -1120,7 +1120,7 @@ jobs:
- name: Upload s3 test artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: s3tests-implemented-${{ github.run_number }}
path: artifacts/s3tests-single/**
@@ -1164,12 +1164,12 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download debug binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary
path: target/debug
@@ -1202,7 +1202,7 @@ jobs:
- name: Upload s3 test artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: s3tests-lifecycle-behavior-${{ github.run_number }}
path: artifacts/s3tests-single/**
@@ -1233,7 +1233,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Require the expected result of every CI lane
@@ -1272,7 +1272,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
steps:
- name: Report CLA result for merge queue
if: github.event_name == 'merge_group'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.checks.create({
@@ -0,0 +1,198 @@
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Connect diagnostic scheduler receipt acceptance
on:
workflow_dispatch:
inputs:
build_run_id:
description: Main-branch Build and Release run ID
required: true
type: string
artifact_id:
description: Linux x86_64 GNU artifact from that run
required: true
type: string
source_sha:
description: Exact RustFS source commit
required: true
type: string
artifact_digest:
description: GitHub artifact digest including sha256 prefix
required: true
type: string
binary_sha256:
description: Independently verified rustfs binary SHA-256
required: true
type: string
connect_sha:
description: Exact Connect scheduler acceptance harness commit
required: true
type: string
permissions:
actions: read
contents: read
jobs:
diagnostic-schedule:
name: Verify production scheduler receipts over mTLS
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/connect
path: connect-harness
persist-credentials: false
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.22.2
cache: npm
cache-dependency-path: connect-harness/web/package-lock.json
- name: Verify official source and artifact identity
shell: bash
env:
GH_TOKEN: ${{ github.token }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
ARTIFACT_ID: ${{ inputs.artifact_id }}
SOURCE_SHA: ${{ inputs.source_sha }}
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
BINARY_SHA256: ${{ inputs.binary_sha256 }}
CONNECT_SHA: ${{ inputs.connect_sha }}
run: |
set -euo pipefail
[[ "$BUILD_RUN_ID" =~ ^[0-9]+$ && "$ARTIFACT_ID" =~ ^[0-9]+$ ]]
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ && "$CONNECT_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
[[ "$BINARY_SHA256" =~ ^[0-9a-f]{64}$ ]]
[[ $(git -C rustfs-source rev-parse HEAD) == "$SOURCE_SHA" ]]
[[ $(git -C connect-harness rev-parse HEAD) == "$CONNECT_SHA" ]]
[[ $(git -C rustfs-source remote get-url origin) == https://github.com/rustfs/rustfs ]]
[[ $(git -C connect-harness remote get-url origin) == https://github.com/rustfs/connect ]]
run=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}")
jq -e --arg source "$SOURCE_SHA" --arg repository "$GITHUB_REPOSITORY" '
.head_sha == $source and .head_branch == "main"
and .head_repository.full_name == $repository
and .name == "Build and Release" and .path == ".github/workflows/build.yml"
and .status == "completed" and .conclusion == "success"
' <<<"$run" >/dev/null
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e '
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
| ($matches | length) == 1 and $matches[0].conclusion == "success"
' <<<"$jobs" >/dev/null
artifact=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
jq -e --argjson run "$BUILD_RUN_ID" --arg source "$SOURCE_SHA" --arg digest "$ARTIFACT_DIGEST" '
.workflow_run.id == $run and .workflow_run.head_sha == $source
and .name == ("rustfs-linux-x86_64-gnu-dev-" + $source[0:7])
and .digest == $digest and .expired == false
and (.expires_at | fromdateiso8601) > now
and .size_in_bytes > 0 and .size_in_bytes <= 2147483648
' <<<"$artifact" >/dev/null
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > official-artifact.zip
printf '%s official-artifact.zip\n' "${ARTIFACT_DIGEST#sha256:}" | sha256sum --check --strict
- name: Extract the verified official binary
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
BINARY_SHA256: ${{ inputs.binary_sha256 }}
run: |
set -euo pipefail
python3 - <<'PY'
import io
import os
import pathlib
import stat
import zipfile
def regular(entry):
mode = entry.external_attr >> 16
return not entry.is_dir() and (stat.S_IFMT(mode) in (0, stat.S_IFREG))
package = f"rustfs-linux-x86_64-gnu-dev-{os.environ['SOURCE_SHA'][:7]}.zip"
with zipfile.ZipFile('official-artifact.zip') as outer:
entries = outer.infolist()
assert 1 <= len(entries) <= 16
assert len({item.filename for item in entries}) == len(entries)
assert all(regular(item) and pathlib.PurePosixPath(item.filename).name == item.filename for item in entries)
assert sum(item.file_size for item in entries) <= 2147483648
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
assert {item.filename for item in entries} == {'rustfs', 'rustfs-cli'} and len(entries) == 2
assert all(regular(item) and 0 < item.file_size <= 1073741824 for item in entries)
assert inner.testzip() is None
pathlib.Path('binary').mkdir()
with inner.open('rustfs') as source, open('binary/rustfs', 'xb') as destination:
import shutil
shutil.copyfileobj(source, destination)
PY
chmod 0755 binary/rustfs
printf '%s binary/rustfs\n' "$BINARY_SHA256" | sha256sum --check --strict
file binary/rustfs | grep -Eq 'ELF 64-bit LSB.*x86-64'
- name: Run the real scheduler and receipt delivery scenario
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
git -C connect-harness diff --exit-code
printf '%s\n' "$SOURCE_SHA" >connect-harness/tests/e2e/connected/rustfs-ref
[[ $(git -C connect-harness diff --name-only) == tests/e2e/connected/rustfs-ref ]]
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
(cd connect-harness && ./tests/connectivity/diagnostic-schedule-evidence.test.sh)
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_DIAGNOSTIC_SCHEDULE_EVIDENCE="$GITHUB_WORKSPACE/diagnostic-schedule-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=diagnostic-schedule
- name: Bind and validate sanitized evidence
shell: bash
env:
BUILD_RUN_ID: ${{ inputs.build_run_id }}
ARTIFACT_ID: ${{ inputs.artifact_id }}
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
BINARY_SHA256: ${{ inputs.binary_sha256 }}
CONNECT_SHA: ${{ inputs.connect_sha }}
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
jq -e --arg connect "$CONNECT_SHA" --arg source "$SOURCE_SHA" --arg binary "$BINARY_SHA256" '
.provenance.connectSha == $connect and .provenance.sourceSha == $source
and .provenance.binarySha256 == $binary
' diagnostic-schedule-evidence.json >/dev/null
jq --arg run "$GITHUB_RUN_ID" --arg build "$BUILD_RUN_ID" \
--arg artifact "$ARTIFACT_ID" --arg digest "$ARTIFACT_DIGEST" '
.provenance += {workflowRunId:$run,buildRunId:$build,artifactId:$artifact,artifactDigest:$digest}
' diagnostic-schedule-evidence.json > diagnostic-schedule-evidence.bound.json
mv diagnostic-schedule-evidence.bound.json diagnostic-schedule-evidence.json
node connect-harness/tests/connectivity/verify-diagnostic-schedule-evidence.mjs diagnostic-schedule-evidence.json --bound
- name: Upload verified receipt evidence
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: connect-diagnostic-schedule-evidence-${{ github.run_id }}
path: diagnostic-schedule-evidence.json
retention-days: 14
if-no-files-found: error
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('performance-drive-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-performance-drive-evidence-${{ github.run_id }}
path: |
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('performance-network-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-performance-network-evidence-${{ github.run_id }}
path: |
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('profile-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-profile-cpu-evidence-${{ github.run_id }}
path: |
@@ -0,0 +1,201 @@
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Connect service memory profile acceptance
on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful main-branch Build and Release run ID
required: true
type: string
artifact_id:
description: Linux x86_64 GNU artifact from that run
required: true
type: string
source_sha:
description: Exact RustFS source commit
required: true
type: string
artifact_digest:
description: GitHub artifact digest including sha256 prefix
required: true
type: string
binary_sha256:
description: Independently verified rustfs binary SHA-256
required: true
type: string
connect_sha:
description: Exact Connect memory acceptance harness commit
required: true
type: string
permissions:
actions: read
contents: read
jobs:
profile-memory:
name: Verify signed service memory profile over mTLS
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/connect
path: connect-harness
persist-credentials: false
ref: ${{ inputs.connect_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.22.2
cache: npm
cache-dependency-path: connect-harness/web/package-lock.json
- name: Verify official source and artifact identity
shell: bash
env:
GH_TOKEN: ${{ github.token }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
ARTIFACT_ID: ${{ inputs.artifact_id }}
SOURCE_SHA: ${{ inputs.source_sha }}
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
BINARY_SHA256: ${{ inputs.binary_sha256 }}
CONNECT_SHA: ${{ inputs.connect_sha }}
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == rustfs/rustfs ]]
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ && "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ && "$CONNECT_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
[[ "$BINARY_SHA256" =~ ^[0-9a-f]{64}$ ]]
[[ $(git -C rustfs-source rev-parse HEAD) == "$SOURCE_SHA" ]]
[[ $(git -C connect-harness rev-parse HEAD) == "$CONNECT_SHA" ]]
[[ $(git -C rustfs-source remote get-url origin) == https://github.com/rustfs/rustfs ]]
[[ $(git -C connect-harness remote get-url origin) == https://github.com/rustfs/connect ]]
run=$(gh api "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}")
jq -e --arg source "$SOURCE_SHA" '
.head_sha == $source and .head_branch == "main"
and .head_repository.full_name == "rustfs/rustfs"
and .name == "Build and Release" and .path == ".github/workflows/build.yml"
and .status == "completed" and .conclusion == "success"
' <<<"$run" >/dev/null
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
jq -e '
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
| ($matches | length) == 1 and $matches[0].conclusion == "success"
' <<<"$jobs" >/dev/null
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
jq -e --argjson run "$BUILD_RUN_ID" --arg source "$SOURCE_SHA" --arg digest "$ARTIFACT_DIGEST" '
.workflow_run.id == $run and .workflow_run.head_sha == $source
and .name == ("rustfs-linux-x86_64-gnu-dev-" + $source[0:7])
and .digest == $digest and .expired == false
and (.expires_at | fromdateiso8601) > now
and .size_in_bytes > 0 and .size_in_bytes <= 2147483648
' <<<"$artifact" >/dev/null
jq -r '.expires_at' <<<"$artifact" > artifact-expires-at
gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}/zip" > official-artifact.zip
[[ $(stat --format=%s official-artifact.zip) == $(jq -r '.size_in_bytes' <<<"$artifact") ]]
printf '%s official-artifact.zip\n' "${ARTIFACT_DIGEST#sha256:}" | sha256sum --check --strict
- name: Extract only the verified official service binary
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
BINARY_SHA256: ${{ inputs.binary_sha256 }}
run: |
set -euo pipefail
python3 - <<'PY'
import io
import os
import pathlib
import shutil
import stat
import zipfile
def regular(entry):
mode = entry.external_attr >> 16
return not entry.is_dir() and stat.S_IFMT(mode) in (0, stat.S_IFREG)
package = f"rustfs-linux-x86_64-gnu-dev-{os.environ['SOURCE_SHA'][:7]}.zip"
with zipfile.ZipFile('official-artifact.zip') as outer:
entries = outer.infolist()
assert 1 <= len(entries) <= 16
assert len({entry.filename for entry in entries}) == len(entries)
assert all(regular(entry) and pathlib.PurePosixPath(entry.filename).name == entry.filename for entry in entries)
assert sum(entry.file_size for entry in entries) <= 2147483648
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs-cli'} and len(entries) == 2
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
assert inner.testzip() is None
pathlib.Path('binary').mkdir()
with inner.open('rustfs') as source, open('binary/rustfs', 'xb') as destination:
shutil.copyfileobj(source, destination)
PY
chmod 0755 binary/rustfs
printf '%s binary/rustfs\n' "$BINARY_SHA256" | sha256sum --check --strict
file binary/rustfs | grep -Eq 'ELF 64-bit LSB.*x86-64'
- name: Run the real service job under S3 workload
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
git -C connect-harness diff --exit-code
printf '%s\n' "$SOURCE_SHA" >connect-harness/tests/e2e/connected/rustfs-ref
changed=$(git -C connect-harness diff --name-only)
[[ -z "$changed" || "$changed" == tests/e2e/connected/rustfs-ref ]]
npm --prefix connect-harness/web ci
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
make -C connect-harness e2e-connected-dispatch-check
(cd connect-harness && ./tests/connectivity/profile-memory-evidence.test.sh)
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_PROFILE_MEMORY_EVIDENCE="$GITHUB_WORKSPACE/profile-memory-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=profile-memory
- name: Bind and validate sanitized evidence
shell: bash
env:
BUILD_RUN_ID: ${{ inputs.build_run_id }}
ARTIFACT_ID: ${{ inputs.artifact_id }}
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
BINARY_SHA256: ${{ inputs.binary_sha256 }}
CONNECT_SHA: ${{ inputs.connect_sha }}
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
jq -e --arg connect "$CONNECT_SHA" --arg source "$SOURCE_SHA" --arg binary "$BINARY_SHA256" '
.provenance.connectSha == $connect and .provenance.sourceSha == $source
and .provenance.binarySha256 == $binary
' profile-memory-evidence.json >/dev/null
jq --arg run "$GITHUB_RUN_ID" --arg build "$BUILD_RUN_ID" \
--arg artifact "$ARTIFACT_ID" --arg digest "$ARTIFACT_DIGEST" \
--arg expiry "$(cat artifact-expires-at)" '
.provenance += {workflowRunId:$run,buildRunId:$build,artifactId:$artifact,artifactDigest:$digest,artifactExpiresAt:$expiry}
' profile-memory-evidence.json > profile-memory-evidence.bound.json
mv profile-memory-evidence.bound.json profile-memory-evidence.json
node connect-harness/tests/connectivity/profile-memory-evidence.mjs profile-memory-evidence.json --bound
- name: Upload only verified sanitized evidence
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: connect-profile-memory-evidence-${{ github.run_id }}
path: profile-memory-evidence.json
retention-days: 14
if-no-files-found: error
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('profile-threads-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-profile-threads-evidence-${{ github.run_id }}
path: |
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('top-api-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-top-api-evidence-${{ github.run_id }}
path: |
@@ -49,7 +49,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -83,7 +83,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -132,7 +132,7 @@ jobs:
mv top-disk-runtime-evidence.bound.json top-disk-runtime-evidence.json
- name: Upload acceptance evidence
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-top-disk-evidence-${{ github.run_id }}
path: |
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('top-locks-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-top-locks-evidence-${{ github.run_id }}
path: |
@@ -53,19 +53,19 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Checkout exact RustFS source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
path: rustfs-source
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Checkout exact Connect harness
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/connect
path: connect-harness
@@ -122,7 +122,7 @@ jobs:
[[ $(jq -r '.expired' <<<"$artifact") == false ]]
- name: Download exact build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ inputs.artifact_id }}
path: artifact
@@ -183,7 +183,7 @@ jobs:
- name: Upload acceptance evidence
if: ${{ always() && hashFiles('top-rpc-service-job-evidence.json') != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-top-rpc-evidence-${{ github.run_id }}
path: |
+4 -4
View File
@@ -76,7 +76,7 @@ jobs:
NEXTEST_PROFILE: ci
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -89,7 +89,7 @@ jobs:
install-build-packaging-tools: 'false'
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: cargo-llvm-cov
@@ -112,7 +112,7 @@ jobs:
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-lcov-${{ github.run_number }}
path: |
@@ -134,7 +134,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+4 -4
View File
@@ -96,7 +96,7 @@ jobs:
source_ref: ${{ steps.check.outputs.source_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
# For workflow_run events, checkout the specific commit that triggered the workflow
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
@@ -544,7 +544,7 @@ jobs:
category: container-image-${{ matrix.variant }}
- name: Upload container scan report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-image-scan-${{ matrix.variant }}
path: trivy-${{ matrix.variant }}.sarif
@@ -563,7 +563,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
+3 -3
View File
@@ -93,7 +93,7 @@ jobs:
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -174,7 +174,7 @@ jobs:
- name: Upload distributed e2e diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-distributed-${{ github.run_number }}
path: |
@@ -206,7 +206,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
@@ -63,7 +63,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -77,7 +77,7 @@ jobs:
# The STS dual-node test requires awscurl and fails if it is unavailable.
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -108,7 +108,7 @@ jobs:
- name: Upload nextest junit report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-replication-nightly-junit-${{ github.run_number }}
path: |
@@ -126,7 +126,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -157,7 +157,7 @@ jobs:
- name: Upload cluster fault diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-cluster-nightly-${{ github.run_number }}
path: |
@@ -176,7 +176,7 @@ jobs:
RUSTFS_BUILD_FEATURES: ftps,webdav,sftp
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -213,7 +213,7 @@ jobs:
- name: Upload protocol diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-protocol-nightly-${{ github.run_number }}
path: |
@@ -236,7 +236,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+13 -8
View File
@@ -159,7 +159,7 @@ jobs:
TEST_MODE: ${{ matrix.test-mode }}
S3_SHARD_INDEX: ${{ matrix.shard-index }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -167,7 +167,7 @@ jobs:
# ship a working pip (ci-1: a bare python3 without pip is what broke the
# scheduled sweep). Keeps the workflow correct across runner drift.
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -264,7 +264,7 @@ jobs:
hostname: rustfs4
volumes: [rustfs4-data:/data]
lb:
image: haproxy:2.9
image: haproxy:3.4.4
hostname: lb
networks: [rustfs-net]
ports:
@@ -308,6 +308,11 @@ jobs:
docker compose -f compose.yml up -d
# Validate the pinned HAProxy image parses the exact config before
# the S3 compatibility sweep starts.
docker compose -f compose.yml exec -T lb \
haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
- name: Wait for RustFS ready
run: |
for _ in {1..120}; do
@@ -357,7 +362,7 @@ jobs:
- name: Upload artifacts
if: always() && env.ACT != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: s3tests-${{ env.TEST_MODE }}-shard-${{ matrix.shard-index }}
path: artifacts/**
@@ -369,12 +374,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -435,7 +440,7 @@ jobs:
- name: Upload canary artifacts
if: always() && env.ACT != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: s3tests-upstream-head
path: artifacts/s3tests-upstream-head/**
@@ -454,7 +459,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+3 -3
View File
@@ -96,7 +96,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -137,7 +137,7 @@ jobs:
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
@@ -146,7 +146,7 @@ jobs:
- name: Upload server logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-logs
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Collect and publish verified chain health
@@ -24,7 +24,7 @@ jobs:
run: python3 scripts/functional_chain_health.py --source-ref "${NIGHTLY_SOURCE_REF}" --publish --output "${RUNNER_TEMP}/chain-health.json"
- name: Retain health observation
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-health-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-health.json
+10 -10
View File
@@ -81,7 +81,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
- name: Install cargo-fuzz
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: cargo-fuzz
@@ -113,7 +113,7 @@ jobs:
done
- name: Upload prebuilt fuzz binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-prebuilt-binaries-${{ github.run_number }}
path: fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/
@@ -143,12 +143,12 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download prebuilt fuzz binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: fuzz-prebuilt-binaries-${{ github.run_number }}
path: fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release
@@ -167,7 +167,7 @@ jobs:
- name: Upload fuzz smoke artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-smoke-${{ matrix.target }}-${{ github.run_number }}
path: |
@@ -197,12 +197,12 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download prebuilt fuzz binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: fuzz-prebuilt-binaries-${{ github.run_number }}
path: fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release
@@ -221,7 +221,7 @@ jobs:
- name: Upload nightly fuzz artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-nightly-${{ matrix.target }}-${{ github.run_number }}
path: |
@@ -246,7 +246,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+4 -4
View File
@@ -49,7 +49,7 @@ jobs:
steps:
- name: Checkout helm chart repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -107,7 +107,7 @@ jobs:
--version "${{ steps.version.outputs.chart_version }}"
- name: Upload helm package as artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: helm-package
path: helm/rustfs/*.tgz
@@ -121,7 +121,7 @@ jobs:
steps:
- name: Checkout helm package repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
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.
@@ -129,7 +129,7 @@ jobs:
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
- name: Download helm package
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: helm-package
path: ./
+2 -2
View File
@@ -83,7 +83,7 @@ jobs:
INTEROP_REQUIRED_TESTS: '["reads_minio_generated_sse_s3_multipart_fixture", "reads_minio_generated_sse_kms_multipart_fixture", "rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key", "rejects_minio_generated_sse_s3_fixture_with_truncated_ciphertext"]'
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -137,7 +137,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+3 -3
View File
@@ -117,7 +117,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -246,7 +246,7 @@ jobs:
- name: Upload artifacts
if: always() && env.ACT != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mint
path: artifacts/**
@@ -264,7 +264,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+8 -8
View File
@@ -51,7 +51,7 @@ jobs:
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Checkout selected source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ env.NIGHTLY_BUILD_REF }}
@@ -71,7 +71,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
@@ -237,12 +237,12 @@ jobs:
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
- name: Upload DEB artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.deb.outputs.deb_file }}
path: ${{ steps.deb.outputs.deb_file }}
- name: Upload RPM artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.rpm.outputs.rpm_file }}
path: ${{ steps.rpm.outputs.rpm_file }}
@@ -328,7 +328,7 @@ jobs:
- name: Upload nightly candidate manifest
if: ${{ steps.publish.outputs.candidate_file != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
@@ -383,7 +383,7 @@ jobs:
NO_PROXY: 127.0.0.1,localhost
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
@@ -473,7 +473,7 @@ jobs:
NO_PROXY: 127.0.0.1,localhost
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
@@ -501,7 +501,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+5 -5
View File
@@ -39,24 +39,24 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
# persist-credentials-exempt: update-flake-lock pushes the branch and opens
# the PR. It passes FLAKE_UPDATE_TOKEN to create-pull-request itself rather
# than reusing .git/config, but that is unverified — exempt until a
# workflow_dispatch run confirms it (rustfs/backlog#1602).
- name: Install Nix
uses: DeterminateSystems/determinate-nix-action@629b284231c2a82554b724e357e47fc6020833c8 # v3
uses: DeterminateSystems/determinate-nix-action@8d87e8d5e5b8a8309d4281094560f127d9a265f1 # v3.22.5
- name: Cache Nix
uses: DeterminateSystems/flakehub-cache-action@1f9a51a2959d3e26c7838c6f3bf9f48acae525ea # v3.20.0
uses: DeterminateSystems/flakehub-cache-action@83282a8aef353db1d659d3bacf4a28b6683de0b3 # v3.22.5
- name: Check Nix flake inputs
uses: DeterminateSystems/flake-checker-action@3164002371bc90729c68af0e24d5aacf20d7c9f6 # v12
uses: DeterminateSystems/flake-checker-action@786422608c7bded2bbc9741ad9f91356842bf520 # v14
- name: Update flake.lock
id: update
uses: DeterminateSystems/update-flake-lock@fd9359ac79d0e912f1b4b947a48470b3e2799b56 # main
uses: DeterminateSystems/update-flake-lock@da03c0f078bc4b2c37ee4f7e072d34bf8f188bb3 # v29
with:
git-author-name: houseme
git-author-email: housemecn@gmail.com
+4 -4
View File
@@ -72,12 +72,12 @@ jobs:
NIX_CURL_FLAGS: -A cargo/stable
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install Nix
uses: DeterminateSystems/determinate-nix-action@4eea0b33e3d1f02ecfe37cf16e7204c424009606 # v3.21.0
uses: DeterminateSystems/determinate-nix-action@8d87e8d5e5b8a8309d4281094560f127d9a265f1 # v3.22.5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
extra-conf: |
@@ -86,10 +86,10 @@ jobs:
max-jobs = 1
- name: Cache Nix
uses: DeterminateSystems/flakehub-cache-action@c01e819d047464c3edf6ba778f075952af5a3aa7 # v3.21.0
uses: DeterminateSystems/flakehub-cache-action@83282a8aef353db1d659d3bacf4a28b6683de0b3 # v3.22.5
- name: Check Nix Flake Inputs
uses: DeterminateSystems/flake-checker-action@3164002371bc90729c68af0e24d5aacf20d7c9f6 # v12
uses: DeterminateSystems/flake-checker-action@786422608c7bded2bbc9741ad9f91356842bf520 # v14
with:
fail-mode: true
ignore-missing-flake-lock: false
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -82,7 +82,7 @@ jobs:
- name: Upload service logs
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: oidc-keycloak-live-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-keycloak-live-*/**/*.log
@@ -101,7 +101,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
@@ -93,7 +93,7 @@ jobs:
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
- name: Upload the MinIO interop report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
path: |
@@ -237,7 +237,7 @@ jobs:
- name: Checkout repository
if: steps.credentials.outputs.present == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -286,7 +286,7 @@ jobs:
- name: Upload the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
path: |
@@ -306,7 +306,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+64 -21
View File
@@ -25,9 +25,10 @@
#
# Flow:
# 1. Resolve and validate the selected Build workflow run and source identity
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
# 3. Build DEB packages for amd64 and arm64
# 4. Build RPM packages for x86_64 and aarch64
# 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
@@ -224,9 +225,9 @@ jobs:
echo " Build run ID: $BUILD_RUN_ID"
echo " Build run number: $RUN_NUMBER"
# Build DEB and RPM packages for each architecture
# Build DEB and RPM packages for each architecture and libc variant
package:
name: Package (${{ matrix.arch }})
name: Package (${{ matrix.arch }}-${{ matrix.libc }})
needs: resolve
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -236,16 +237,38 @@ jobs:
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: ""
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -256,17 +279,17 @@ jobs:
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
SOURCE_VERSION: ${{ needs.resolve.outputs.version }}
DEV_SEQUENCE: ${{ needs.resolve.outputs.dev_sequence }}
DEB_ARCH: ${{ matrix.deb_arch }}
RPM_ARCH: ${{ matrix.rpm_arch }}
ARCH: ${{ matrix.rpm_arch }}
LIBC: ${{ matrix.libc }}
run: |
set -euo pipefail
normalized=$(./scripts/release/package_versions.sh \
"$BUILD_TYPE" "$SOURCE_VERSION" "$DEV_SEQUENCE" "$DEB_ARCH" "$RPM_ARCH")
"$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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: ${{ matrix.artifact_name }}*
path: ./binary-artifact
@@ -308,6 +331,8 @@ jobs:
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
@@ -345,9 +370,17 @@ jobs:
Section: utils
Priority: optional
Architecture: ${DEB_ARCH}
Depends: libc6 (>= 2.31)
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
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
@@ -389,15 +422,24 @@ jobs:
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}"
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
@@ -420,9 +462,9 @@ jobs:
--iteration "$RPM_RELEASE" \
--architecture "$RPM_ARCH" \
--package "$RPM_FILE" \
--depends "glibc >= 2.31" \
"${depends_args[@]}" \
--description "High-performance distributed object storage (${LIBC} build)" \
--maintainer "RustFS Team <support@rustfs.com>" \
--description "High-performance distributed object storage" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
@@ -453,9 +495,9 @@ jobs:
echo "✅ RPM built: $RPM_FILE"
- name: Upload packages to artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages-${{ matrix.arch }}
name: packages-${{ matrix.arch }}-${{ matrix.libc }}
path: |
*.deb
*.rpm
@@ -551,10 +593,11 @@ jobs:
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 '.'
# (e.g. rustfs_1.0.0~rc.2_amd64.deb is stored as
# rustfs_1.0.0.rc.2_amd64.deb), so checksum entries must
# reference the name as stored on the release.
# 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
+4 -4
View File
@@ -57,7 +57,7 @@ jobs:
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0 # baseline may be an earlier successful scheduled head
@@ -94,7 +94,7 @@ jobs:
- name: Find last successful scheduled baseline
id: scheduled_baseline
if: github.event_name == 'schedule'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
result-encoding: string
script: |
@@ -271,7 +271,7 @@ jobs:
- name: Upload A/B results
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: hotpath-warp-ab-${{ github.run_number }}
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
@@ -372,7 +372,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+2 -2
View File
@@ -47,7 +47,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -72,7 +72,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
@@ -95,7 +95,7 @@ jobs:
- name: Checkout auto-testing scripts
timeout-minutes: 5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_ref }}
@@ -204,7 +204,7 @@ jobs:
- name: Upload test logs & evidence
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-fault-tolerance-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -83,7 +83,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -113,7 +113,7 @@ jobs:
- name: Checkout auto-testing scripts
timeout-minutes: 5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || inputs.auto_testing_ref || 'main' }}
@@ -294,7 +294,7 @@ jobs:
- name: Upload test logs & evidence
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-fault-tolerance-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -386,7 +386,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-fault-tolerance-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-fault-tolerance-${{ github.run_id }}-${{ github.run_attempt }}/fault-tolerance.json
@@ -48,7 +48,7 @@ jobs:
manifest: ${{ steps.candidate.outputs.manifest }}
performance_ready: ${{ steps.perf_probe.outputs.performance_ready }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Resolve published candidate
@@ -63,7 +63,7 @@ jobs:
CHAIN_OUTPUT: ${{ runner.temp }}/chain-candidate.json
run: python3 scripts/resolve_functional_candidate.py
- name: Retain candidate identity
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-candidate.json
@@ -191,13 +191,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download suite evidence
id: download
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: functional-chain-*-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-evidence
@@ -213,7 +213,7 @@ jobs:
--output "${RUNNER_TEMP}/chain-report.json"
- name: Retain chain report regardless of test verdict
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-report-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -231,7 +231,7 @@ jobs:
--directory "${RUNNER_TEMP}/chain-evidence"
--output "${RUNNER_TEMP}/chain-complete.json"
- name: Upload complete-chain evidence
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-complete-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-complete.json
+4 -4
View File
@@ -66,7 +66,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout chain tooling
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -366,7 +366,7 @@ jobs:
- name: Upload test logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -467,7 +467,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-heal-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-heal-${{ github.run_id }}-${{ github.run_attempt }}/heal.json
+4 -4
View File
@@ -59,7 +59,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -85,7 +85,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -315,7 +315,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -414,7 +414,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-kms-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-kms-${{ github.run_id }}-${{ github.run_attempt }}/kms.json
@@ -93,7 +93,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Check performance runner before entering its queue
@@ -116,7 +116,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout chain tooling
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -144,7 +144,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -307,7 +307,7 @@ jobs:
- name: Upload test logs & results
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -349,7 +349,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-performance-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-performance-${{ github.run_id }}-${{ github.run_attempt }}/performance.json
@@ -92,7 +92,7 @@ jobs:
RUSTFS_POOL_NODE_ENDPOINTS: ${{ secrets.RUSTFS_POOL_NODE_ENDPOINTS || vars.RUSTFS_POOL_NODE_ENDPOINTS || 'http://rustfs-node1:9000 http://rustfs-node2:9000 http://rustfs-node3:9000 http://rustfs-node4:9000' }}
steps:
- name: Checkout chain tooling
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -104,7 +104,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -656,7 +656,7 @@ jobs:
- name: Upload test logs
timeout-minutes: 2
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-pool-test-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/rustfs-pool-${{ github.run_id }}-${{ github.run_attempt }}
@@ -764,7 +764,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-pool-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-pool-${{ github.run_id }}-${{ github.run_attempt }}/pool.json
@@ -71,7 +71,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -97,7 +97,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -323,7 +323,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -419,7 +419,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-replication-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-replication-${{ github.run_id }}-${{ github.run_attempt }}/replication.json
+4 -4
View File
@@ -47,7 +47,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -73,7 +73,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -292,7 +292,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -391,7 +391,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-s3-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-s3-${{ github.run_id }}-${{ github.run_attempt }}/s3.json
+4 -4
View File
@@ -87,7 +87,7 @@ jobs:
# the workspace root would wipe the auto-testing clone above (that is
# exactly how run 33934141181 lost rustfs-security-test.sh).
- name: Checkout repository (for the OIDC live gate script)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
path: rustfs-repo
@@ -109,7 +109,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -325,7 +325,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -395,7 +395,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-security-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-security-${{ github.run_id }}-${{ github.run_attempt }}/security.json
+4 -4
View File
@@ -56,7 +56,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -82,7 +82,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -307,7 +307,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -406,7 +406,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-storage-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-storage-${{ github.run_id }}-${{ github.run_attempt }}/storage.json
+5 -5
View File
@@ -46,7 +46,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -73,7 +73,7 @@ jobs:
- name: Checkout auto-testing scripts
timeout-minutes: 5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -133,7 +133,7 @@ jobs:
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
ARGS+=(--package-url "https://github.com/rustfs/rustfs/releases/download/${RUSTFS_VERSION}/rustfs_${RUSTFS_VERSION}_amd64.deb")
ARGS+=(--package-url "https://github.com/rustfs/rustfs/releases/download/${RUSTFS_VERSION}/rustfs-linux-x86_64-gnu-v${RUSTFS_VERSION}.deb")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
@@ -281,7 +281,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-table-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -353,7 +353,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-table-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-table-${{ github.run_id }}-${{ github.run_attempt }}/table.json
+4 -4
View File
@@ -71,7 +71,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout chain tooling
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -95,7 +95,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -460,7 +460,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-tier-test-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.TIER_ARTIFACTS_DIR }}/
@@ -734,7 +734,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-tier-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-tier-${{ github.run_id }}-${{ github.run_attempt }}/tier.json
+5 -5
View File
@@ -89,7 +89,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -115,7 +115,7 @@ jobs:
run: python3 scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
@@ -207,7 +207,7 @@ jobs:
local version="$1" tag asset url
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
tag="${version#v}"
asset="rustfs_${tag//-/.}_amd64.deb"
asset="rustfs-linux-x86_64-gnu-v${tag}.deb"
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
@@ -400,7 +400,7 @@ jobs:
- name: Upload report and logs
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -503,7 +503,7 @@ jobs:
- name: Upload chain evidence
if: ${{ always() && steps.chain_record.outputs.written == 'true' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: functional-chain-upgrade-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/chain-upgrade-${{ github.run_id }}-${{ github.run_attempt }}/upgrade.json
@@ -64,7 +64,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
@@ -36,7 +36,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Check latest scheduled runs
@@ -49,7 +49,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update incomplete-run issue
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.'
+3 -3
View File
@@ -52,7 +52,7 @@ jobs:
RUSTFS_TEST_NATS_URL: nats://127.0.0.1:4222
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
- name: Upload target integration diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: targets-integration-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/targets-live
@@ -183,7 +183,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Beijing Henghesha Technology Co., Ltd.
Copyright 2024-2026 RustFS, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -298,7 +298,11 @@ mod tests {
assert_eq!(empty_head_resp.content_disposition(), None);
assert_eq!(empty_head_resp.content_encoding(), None);
assert_eq!(empty_head_resp.content_language(), None);
assert_eq!(empty_head_resp.content_type(), None);
assert_eq!(
empty_head_resp.content_type(),
Some("binary/octet-stream"),
"REPLACE without an explicit content type must default to binary/octet-stream, matching S3"
);
assert_eq!(empty_head_resp.expires_string(), None);
assert_eq!(empty_head_resp.website_redirect_location(), None);
@@ -394,7 +398,15 @@ mod tests {
assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment"));
assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip"));
assert_eq!(head.content_language(), (field == "content-language").then_some("de"));
assert_eq!(head.content_type(), (field == "content-type").then_some("text/field"));
assert_eq!(
head.content_type(),
Some(if field == "content-type" {
"text/field"
} else {
"binary/octet-stream"
}),
"REPLACE without an explicit content type must default to binary/octet-stream, matching S3"
);
assert_eq!(
head.expires_string(),
(field == "expires").then_some(replacement_expires_http_date.as_str())
@@ -420,7 +432,11 @@ mod tests {
.send()
.await
.expect("HEAD failed for metadata collision case");
assert_eq!(collision_head.content_type(), None);
assert_eq!(
collision_head.content_type(),
Some("binary/octet-stream"),
"user-metadata under the reserved 'content-type' key must not satisfy the system content-type default"
);
assert_eq!(collision_head.content_encoding(), None);
assert_eq!(
collision_head.metadata().and_then(|metadata| metadata.get("content-type")),
+4
View File
@@ -282,5 +282,9 @@ harness = false
name = "single_block_non_inline_benchmark"
harness = false
[[bench]]
name = "multipart_read_parts_benchmark"
harness = false
[lib]
doctest = false
@@ -0,0 +1,92 @@
// 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 criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use rustfs_filemeta::ObjectPartInfo;
use std::hint::black_box;
use std::time::Duration;
mod storage_api;
use storage_api::multipart::{DiskAPI, DiskOption, Endpoint, new_disk};
fn bench_multipart_read_parts(c: &mut Criterion) {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("benchmark runtime");
let root = tempfile::tempdir().expect("benchmark disk");
let mut endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 path")).expect("endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let disk = runtime
.block_on(new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
))
.expect("local disk");
let bucket = "multipart-bench";
runtime.block_on(disk.make_volume(bucket)).expect("benchmark volume");
let upload = root.path().join(bucket).join("upload");
std::fs::create_dir_all(&upload).expect("upload directory");
let mut paths = Vec::with_capacity(1024);
for number in 1..=1024 {
let part = ObjectPartInfo {
number,
etag: format!("{number:032x}"),
size: 1024,
actual_size: 1024,
..Default::default()
};
std::fs::write(upload.join(format!("part.{number}")), b"data").expect("part data");
std::fs::write(upload.join(format!("part.{number}.meta")), part.marshal_msg().expect("metadata")).expect("part metadata");
paths.push(format!("upload/part.{number}.meta"));
}
// Measures the real DiskAPI path against warm local files. Fixture creation
// and correctness checks stay outside the timed region; this is not a cold-disk benchmark.
let mut group = c.benchmark_group("multipart_read_parts");
group.sample_size(20);
group.warm_up_time(Duration::from_secs(1));
group.measurement_time(Duration::from_secs(2));
for count in [1, 32, 256, 1024] {
let paths = &paths[..count];
let parts = runtime.block_on(disk.read_parts(bucket, paths)).expect("read parts");
assert_eq!(parts.len(), count);
assert!(
parts
.iter()
.enumerate()
.all(|(i, part)| part.number == i + 1 && part.error.is_none())
);
group.throughput(Throughput::Elements(u64::try_from(count).expect("part count")));
group.bench_with_input(BenchmarkId::from_parameter(count), &paths, |b, paths| {
b.iter(|| {
black_box(
runtime
.block_on(disk.read_parts(bucket, black_box(paths)))
.expect("read parts"),
)
});
});
}
group.finish();
}
criterion_group!(benches, bench_multipart_read_parts);
criterion_main!(benches);
@@ -29,3 +29,7 @@ pub(crate) mod erasure {
pub(crate) mod single_block_non_inline {
pub(crate) use super::{BitrotWriterWrapper, CustomWriter, Erasure};
}
pub(crate) mod multipart {
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, Endpoint, new_disk};
}
+11 -7
View File
@@ -1185,14 +1185,18 @@ mod decommission_lock_order_tests {
barrier.wait_until_paused().await;
let probe_store = Arc::clone(&other_store);
tokio::time::timeout(std::time::Duration::from_secs(1), async move {
probe_store
.save_current_pool_meta_for_test(&[0])
.await
.expect("pool metadata mutation probe should commit before UploadPart admission");
})
let pool_meta_lock = probe_store
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("create the public UploadPart capacity guard probe");
let pool_meta_guard = tokio::time::timeout(
std::time::Duration::from_secs(1),
pool_meta_lock.get_write_lock(std::time::Duration::from_secs(30)),
)
.await
.expect("public UploadPart must not hold a capacity read guard during staging");
.expect("public UploadPart must not hold a capacity read guard during staging")
.expect("public UploadPart capacity guard probe should acquire");
drop(pool_meta_guard);
barrier.release();
let part = tokio::time::timeout(std::time::Duration::from_secs(30), put)
+249 -81
View File
@@ -53,6 +53,7 @@ use crate::disk::{
use crate::erasure::coding::{self, bitrot_verify};
use crate::runtime::sources as runtime_sources;
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt, stream};
use metrics::counter;
#[cfg(target_os = "linux")]
use metrics::gauge;
@@ -88,6 +89,9 @@ use tokio::time::{Instant, Sleep, interval_at, timeout};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
// Bound outstanding filesystem jobs and metadata buffers per disk request.
const PART_METADATA_READ_CONCURRENCY: usize = 8;
const DELETED_OBJECTS_CLEANUP_INTERVAL: Duration = Duration::from_secs(60 * 5);
const STALE_TMP_OBJECT_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60);
@@ -6679,6 +6683,53 @@ impl LocalDisk {
Ok(data)
}
async fn read_part_metadata(&self, bucket: &str, volume_dir: &Path, path_str: &str) -> Result<ObjectPartInfo> {
let path = Path::new(path_str);
let num = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or_default()
.strip_prefix("part.")
.and_then(|v| v.strip_suffix(".meta"))
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_default();
let data_path = self.io_get_object_path(
bucket,
&path_join_buf(&[
path.parent().unwrap_or_else(|| Path::new("")).to_string_lossy().as_ref(),
&format!("part.{num}"),
]),
)?;
let metadata_path = self.io_get_object_path(bucket, path.to_string_lossy().as_ref());
// A part's existence check, metadata read and decode share one dispatch.
// Keep open errors unmapped for the existing missing-volume fallback.
let result = tokio::task::spawn_blocking(move || -> Result<_> {
let part_error = |error: String| ObjectPartInfo {
number: num,
error: Some(error),
..Default::default()
};
if let Err(err) = std::fs::metadata(data_path) {
return Ok(Ok(part_error(err.to_string())));
}
// Invalid metadata paths remain request errors, but missing data wins
// first, as it does in the serial reader.
let metadata_path = metadata_path?;
Ok(read_all_data_std(&metadata_path)
.map(|(data, _)| ObjectPartInfo::unmarshal(&data).unwrap_or_else(|err| part_error(err.to_string()))))
})
.await;
let result = match result {
Ok(result) => self.resolve_read_all_result(bucket, volume_dir, result?).await,
Err(err) => Err(DiskError::from(err)),
};
Ok(result.unwrap_or_else(|err| ObjectPartInfo {
number: num,
error: Some(err.to_string()),
..Default::default()
}))
}
async fn read_listing_metadata(&self, volume: &str, object_name: &str) -> Result<ListingMetadataRead> {
let object_dir = self.io_get_object_path(volume, object_name)?;
let metadata_path = object_dir.join(STORAGE_FORMAT_FILE);
@@ -7615,17 +7666,21 @@ impl LocalDisk {
where
W: AsyncWrite + Unpin + Send,
{
// The part of forward_to below this directory, taken before `current`
// loses its trailing slash.
let forward_rest = opts
.forward_to
.as_ref()
.and_then(|v| v.strip_prefix(&current))
.map(str::to_owned);
let forward = {
opts.forward_to
.as_ref()
.and_then(|v| v.strip_prefix(&current))
.map(|forward| {
if let Some(idx) = forward.find('/') {
forward[..idx].to_owned()
} else {
forward.to_owned()
}
})
forward_rest.as_deref().map(|forward| {
if let Some(idx) = forward.find('/') {
forward[..idx].to_owned()
} else {
forward.to_owned()
}
})
};
if opts.limit > 0 && *objs_returned >= opts.limit {
@@ -7778,13 +7833,22 @@ impl LocalDisk {
entries.sort();
if let Some(forward) = &forward {
for (i, entry) in entries.iter().enumerate() {
if entry >= forward || forward.starts_with(entry.as_str()) {
entries.drain(..i);
break;
// Every entry left here is a directory. Compare it as the key prefix it
// stands for, slash included: "s-x" sorts after "s" as a name, but all
// of "s-x/..." sorts before "s/..." ('-' < '/'), so a scan resuming
// inside "s/" has to leave "s-x" out even though it comes later.
if let Some(forward) = forward_rest.as_deref() {
entries.retain(|entry| {
if entry.is_empty() {
return true;
}
}
let key = if entry.ends_with(SLASH_SEPARATOR) {
std::borrow::Cow::Borrowed(entry.as_str())
} else {
std::borrow::Cow::Owned(format!("{entry}{SLASH_SEPARATOR}"))
};
key.as_ref() >= forward || forward.starts_with(key.as_ref())
});
}
let mut dir_stack: Vec<(String, bool, Option<HashSet<String>>, bool)> = Vec::with_capacity(5);
@@ -9340,72 +9404,14 @@ impl DiskAPI for LocalDisk {
#[tracing::instrument(level = "trace", skip_all)]
async fn read_parts(&self, bucket: &str, paths: &[String]) -> Result<Vec<ObjectPartInfo>> {
let volume_dir = self.io_get_bucket_path(bucket)?;
let mut ret = vec![ObjectPartInfo::default(); paths.len()];
for (i, path_str) in paths.iter().enumerate() {
let path = Path::new(path_str);
let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or_default();
let num = file_name
.strip_prefix("part.")
.and_then(|v| v.strip_suffix(".meta"))
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_default();
if let Err(err) = access(
self.io_get_object_path(
bucket,
path_join_buf(&[
path.parent().unwrap_or_else(|| Path::new("")).to_string_lossy().as_ref(),
&format!("part.{num}"),
])
.as_str(),
)?,
)
stream::iter(0..paths.len())
.map(|index| self.read_part_metadata(bucket, &volume_dir, &paths[index]))
.buffered(PART_METADATA_READ_CONCURRENCY)
.try_fold(Vec::with_capacity(paths.len()), |mut parts, part| async move {
parts.push(part);
Ok(parts)
})
.await
{
ret[i] = ObjectPartInfo {
number: num,
error: Some(err.to_string()),
..Default::default()
};
continue;
}
let data = match self
.read_all_data(
bucket,
volume_dir.clone(),
self.io_get_object_path(bucket, path.to_string_lossy().as_ref())?,
)
.await
{
Ok(data) => data,
Err(err) => {
ret[i] = ObjectPartInfo {
number: num,
error: Some(err.to_string()),
..Default::default()
};
continue;
}
};
match ObjectPartInfo::unmarshal(&data) {
Ok(meta) => {
ret[i] = meta;
}
Err(err) => {
ret[i] = ObjectPartInfo {
number: num,
error: Some(err.to_string()),
..Default::default()
};
}
};
}
Ok(ret)
}
#[tracing::instrument(level = "trace", skip_all)]
async fn check_parts(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp> {
@@ -12460,6 +12466,79 @@ mod test {
);
}
#[tokio::test]
async fn test_read_parts_preserves_order_across_concurrency_windows() {
let dir = tempfile::tempdir().expect("temporary disk");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 path")).expect("endpoint");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
let bucket = "bucket";
ensure_test_volume(&disk, bucket).await;
let count = PART_METADATA_READ_CONCURRENCY * 3 + 1;
let mut paths = Vec::with_capacity(count);
let mut expected = Vec::with_capacity(count);
for number in (1..=count).rev() {
let part = ObjectPartInfo {
number,
etag: format!("etag-{number}"),
size: number,
actual_size: i64::try_from(number).expect("small part"),
..Default::default()
};
let data_path = format!("upload/part.{number}");
let meta_path = format!("{data_path}.meta");
disk.write_all(bucket, &data_path, Bytes::from_static(b"data"))
.await
.expect("part data");
disk.write_all(bucket, &meta_path, Bytes::from(part.marshal_msg().expect("metadata")))
.await
.expect("part metadata");
paths.push(meta_path);
expected.push(part);
}
assert_eq!(disk.read_parts(bucket, &paths).await.expect("read parts"), expected);
assert!(disk.read_parts(bucket, &[]).await.expect("empty batch").is_empty());
let missing_meta = "upload/part.99.meta".to_owned();
disk.write_all(bucket, "upload/part.99", Bytes::from_static(b"data"))
.await
.expect("data without metadata");
paths.insert(PART_METADATA_READ_CONCURRENCY, missing_meta.clone());
let parts = disk.read_parts(bucket, &paths).await.expect("per-part failures");
let error = disk
.read_all_data(
bucket,
disk.io_get_bucket_path(bucket).expect("bucket path"),
disk.io_get_object_path(bucket, &missing_meta).expect("metadata path"),
)
.await
.expect_err("missing metadata");
assert_eq!(parts[PART_METADATA_READ_CONCURRENCY].number, 99);
assert_eq!(parts[PART_METADATA_READ_CONCURRENCY].error.as_deref(), Some(error.to_string().as_str()));
let successful: Vec<_> = parts.into_iter().filter(|part| part.error.is_none()).collect();
assert_eq!(successful, expected, "later windows must survive an earlier part error");
#[cfg(unix)]
{
let meta_path = disk
.io_get_object_path(bucket, "upload")
.expect("upload path")
.join("part.100.meta");
std::os::unix::fs::symlink(dir.path(), meta_path).expect("invalid metadata symlink");
let paths = ["upload/part.100.meta".to_owned()];
let parts = disk
.read_parts(bucket, &paths)
.await
.expect("missing data remains a per-part error");
assert!(parts[0].error.is_some());
disk.write_all(bucket, "upload/part.100", Bytes::from_static(b"data"))
.await
.expect("data for invalid metadata path");
assert_eq!(
disk.read_parts(bucket, &paths).await.expect_err("reject metadata symlink"),
DiskError::InvalidPath
);
}
}
#[tokio::test]
async fn test_read_parts_reports_bad_metadata_and_missing_data_part() {
use tempfile::tempdir;
@@ -18646,6 +18725,95 @@ mod test {
assert_eq!(double_count as usize, double_names.len());
}
#[tokio::test]
async fn test_scan_dir_orders_directory_before_its_dash_suffixed_sibling() {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
// "s-x/..." sorts before "s/..." because '-' (0x2d) sorts before '/'
// (0x2f), while the directory names alone sort the other way round
// ("s" < "s-x"). Backup tools keep "<name>" and "<name>-rollbacks" side
// by side this way, which is how flat listings of real buckets ended
// early without being reported as truncated.
let dir = tempdir().expect("operation should succeed");
let bucket = "test-bucket";
let bucket_dir = dir.path().join(bucket);
let mut expected = Vec::new();
for parent in ["s", "s-x", "db/backup/s", "db/backup/s-x"] {
for n in 0..3 {
let name = format!("{parent}/l/l/{n:05}");
let object_dir = bucket_dir.join(&name);
fs::create_dir_all(&object_dir).await.expect("operation should succeed");
fs::write(object_dir.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("operation should succeed");
expected.push(name);
}
}
expected.sort();
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("operation should succeed")).expect("operation should succeed");
let disk = LocalDisk::new(&endpoint, false).await.expect("operation should succeed");
async fn scan_names(disk: &LocalDisk, bucket: &str, forward_to: Option<&str>, limit: i32) -> Vec<String> {
let (reader, mut writer) = tokio::io::duplex(1 << 16);
let mut out = MetacacheWriter::new(&mut writer);
let opts = WalkDirOptions {
bucket: bucket.to_string(),
base_dir: "".to_string(),
recursive: true,
forward_to: forward_to.map(str::to_string),
limit,
..Default::default()
};
let mut objs_returned = 0;
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.expect("operation should succeed");
out.close().await.expect("operation should succeed");
drop(out);
drop(writer);
let mut reader = MetacacheReader::new(reader);
let entries = reader.read_all().await.expect("operation should succeed");
entries
.into_iter()
.filter(|entry| !entry.metadata.is_empty())
.map(|entry| entry.name)
.collect()
}
assert_eq!(
scan_names(&disk, bucket, None, 0).await,
expected,
"a full recursive scan must emit keys in lexicographic order"
);
for (i, forward_to) in expected.iter().enumerate() {
assert_eq!(
scan_names(&disk, bucket, Some(forward_to), 0).await,
expected[i..].to_vec(),
"resuming at {forward_to} must return every later key"
);
}
let mut paged = Vec::new();
let mut forward_to: Option<String> = None;
while paged.len() < expected.len() {
let page = scan_names(&disk, bucket, forward_to.as_deref(), 2).await;
let page: Vec<String> = page.into_iter().filter(|name| Some(name) != forward_to.as_ref()).collect();
if page.is_empty() {
break;
}
forward_to = page.last().cloned();
paged.extend(page);
}
assert_eq!(paged, expected, "paging through the tree must not lose keys");
}
#[tokio::test]
async fn test_scan_dir_hidden_delete_markers_do_not_exhaust_limit() {
use rustfs_filemeta::MetacacheReader;
@@ -2490,15 +2490,15 @@ impl SetDisks {
part_numbers: &[usize],
read_quorum: usize,
) -> disk::error::Result<Vec<ObjectPartInfo>> {
let bucket = bucket.to_string();
let part_meta_paths = part_meta_paths.to_vec();
let bucket: Arc<str> = Arc::from(bucket);
let part_meta_paths: Arc<[String]> = Arc::from(part_meta_paths);
let tasks: Vec<_> = disks
.iter()
.map(|disk| {
let disk = disk.clone();
let bucket = bucket.clone();
let part_meta_paths = part_meta_paths.clone();
let bucket = Arc::clone(&bucket);
let part_meta_paths = Arc::clone(&part_meta_paths);
async move {
if let Some(disk) = disk {
+44 -2
View File
@@ -6809,7 +6809,10 @@ pub fn should_heal_object_on_disk(
latest_meta: &FileInfo,
) -> (bool, bool, Option<DiskError>) {
if let Some(err) = err
&& (err == &DiskError::FileNotFound || err == &DiskError::FileVersionNotFound || err == &DiskError::FileCorrupt)
&& (err == &DiskError::FileNotFound
|| err == &DiskError::FileVersionNotFound
|| err == &DiskError::FileCorrupt
|| err == &DiskError::VolumeNotFound)
{
return (true, true, Some(err.clone()));
}
@@ -6818,7 +6821,12 @@ pub fn should_heal_object_on_disk(
return (false, false, err.clone());
}
if !meta.equals(latest_meta) {
// `FileInfo::equals` intentionally compares the erasure payload shape and
// modification time, but it does not compare the selected version or the
// delete-marker bit. Heal uses this decision for versioned metadata, so a
// stale historical version that is still marked latest must be treated as
// outdated even when those storage-level fields happen to match.
if !meta.equals(latest_meta) || !heal_metadata_identity_matches(meta, latest_meta) {
debug!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
@@ -6841,6 +6849,11 @@ pub fn should_heal_object_on_disk(
(false, false, None)
}
fn heal_metadata_identity_matches(meta: &FileInfo, latest_meta: &FileInfo) -> bool {
meta.deleted == latest_meta.deleted
&& meta.version_id.filter(|version| !version.is_nil()) == latest_meta.version_id.filter(|version| !version.is_nil())
}
/// Probe every drive of the set at once. Each live probe is bounded by the
/// drive `disk_info` timeout, and the admin peer probe budget only covers one
/// such timeout; a sequential walk over several stalled drives after a power
@@ -11263,6 +11276,12 @@ mod tests {
let (should_heal, _, _) = should_heal_object_on_disk(&err, &[], &meta, &latest_meta);
assert!(should_heal);
let err = Some(DiskError::VolumeNotFound);
let (should_heal, is_meta, reason) = should_heal_object_on_disk(&err, &[], &meta, &latest_meta);
assert!(should_heal);
assert!(is_meta);
assert_eq!(reason, Some(DiskError::VolumeNotFound));
let err = Some(DiskError::FileCorrupt);
let (should_heal, is_meta, reason) = should_heal_object_on_disk(&err, &[], &meta, &latest_meta);
assert!(should_heal);
@@ -11279,6 +11298,29 @@ mod tests {
assert_eq!(reason, Some(DiskError::FileCorrupt));
}
#[test]
fn stale_delete_marker_identity_requires_metadata_heal() {
let historical_version = Uuid::new_v4();
let marker_version = Uuid::new_v4();
let mut stale = FileInfo {
version_id: Some(historical_version),
..FileInfo::default()
};
let mut latest = stale.clone();
stale.deleted = false;
latest.deleted = true;
latest.version_id = Some(marker_version);
// The generic equality helper intentionally considers these records
// equal when their erasure shape and mod-time match. Heal must still
// repair the version identity and delete-marker state.
assert!(stale.equals(&latest));
let (should_heal, metadata, reason) = should_heal_object_on_disk(&None, &[], &stale, &latest);
assert!(should_heal);
assert!(metadata);
assert_eq!(reason, Some(DiskError::OutdatedXLMeta));
}
#[test]
fn metadata_io_failures_never_authorize_heal_overwrite() {
let meta = FileInfo::default();
+203 -7
View File
@@ -502,6 +502,22 @@ fn heal_writer_error_summary(error: &DiskError) -> String {
}
}
/// Whether a writer failure can be retried after the target disk or peer
/// becomes available again. Keep this decision typed: the aggregate
/// `all_targets_unavailable` path must not turn permanent errors such as
/// `DiskFull` into retryable failures.
fn is_retryable_heal_writer_error(error: &DiskError) -> bool {
matches!(
error,
DiskError::DiskNotFound
| DiskError::Timeout
| DiskError::SourceStalled
| DiskError::FaultyRemoteDisk
| DiskError::FaultyDisk
| DiskError::RemoteClientUnavailable(_)
) || error.is_retryable_internode_write_failure()
}
fn warn_heal_writer_failures(
bucket: &str,
object: &str,
@@ -1398,6 +1414,26 @@ impl SetDisks {
));
}
// `errs` is reported in physical-disk order while the
// reconstruction loop below uses the shard order from
// the selected metadata. Preserve which targets are
// missing their bucket volume across that permutation.
let mut missing_bucket_volumes = vec![false; out_dated_disks.len()];
for (physical_index, error) in errs.iter().enumerate() {
if error.as_ref() != Some(&DiskError::VolumeNotFound) {
continue;
}
let shard_index = latest_meta
.erasure
.distribution
.get(physical_index)
.and_then(|index| index.checked_sub(1))
.unwrap_or(physical_index);
if let Some(target) = missing_bucket_volumes.get_mut(shard_index) {
*target = true;
}
}
out_dated_disks = Self::shuffle_disks(&out_dated_disks, &latest_meta.erasure.distribution);
let mut parts_metadata = Self::shuffle_parts_metadata(&parts_metadata, &latest_meta.erasure.distribution);
let mut copy_parts_metadata = vec![None; parts_metadata.len()];
@@ -1424,6 +1460,58 @@ impl SetDisks {
}
}
// A missing bucket volume is a repairable object target,
// but the final rename cannot create its parent volume.
// Recreate only the affected volumes after quorum and
// lifecycle checks have passed. Keep failures visible so
// a partial repair cannot be reported as successful.
let mut volume_creation_error = None;
for (index, outdated_disk) in out_dated_disks.iter_mut().enumerate() {
if !missing_bucket_volumes.get(index).copied().unwrap_or(false) || outdated_disk.is_none() {
continue;
}
if let Some(scope) = &bucket_heal_scope {
scope.check()?;
}
let disk = outdated_disk.as_ref().expect("checked above").clone();
let endpoint = disk.endpoint().to_string();
match disk.make_volume(bucket).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(error) => {
warn!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_HEAL,
bucket,
object,
version_id,
disk_index = index,
endpoint = %endpoint,
error = %error,
state = "bucket_volume_recreate_failed",
"Heal object could not recreate the target bucket volume"
);
if volume_creation_error.is_none() {
volume_creation_error = Some(error.clone());
}
*outdated_disk = None;
disks_to_heal_count = disks_to_heal_count.saturating_sub(1);
for drive in &mut result.after.drives {
if drive.endpoint == endpoint {
drive.state = heal_drive_state_for_error(&error).to_string();
}
}
}
}
}
if let Some(scope) = &bucket_heal_scope {
scope.check()?;
}
if disks_to_heal_count == 0 {
return Ok((result, volume_creation_error.or(Some(DiskError::ErasureWriteQuorum))));
}
// We write at temporary location and then rename to final location.
let tmp_id = Uuid::new_v4().to_string();
// Delete markers and remote (transitioned) objects carry no data_dir and
@@ -1458,6 +1546,8 @@ impl SetDisks {
let erasure_info = latest_meta.erasure.clone();
let mut writer_failure_count = 0usize;
let mut first_writer_failure = None;
let mut first_writer_error = None;
let mut all_writer_failures_retryable = true;
let mut writer_failure_warned = false;
for (part_index, part) in latest_meta.parts.iter().enumerate() {
@@ -1584,9 +1674,11 @@ impl SetDisks {
Ok(writer) => writer,
Err(err) => {
writer_failure_count += 1;
all_writer_failures_retryable &= is_retryable_heal_writer_error(&err);
if first_writer_failure.is_none() {
first_writer_failure =
Some((part.number, index, heal_writer_error_summary(&err)));
first_writer_error = Some(err);
}
writers.push(None);
continue;
@@ -1714,12 +1806,12 @@ impl SetDisks {
}
// Clean up healed shards written to .rustfs/tmp before bailing (B20).
let _ = self.delete_all(RUSTFS_META_TMP_BUCKET, &tmp_id).await;
return Ok((
result,
Some(DiskError::other(format!(
"all drives had write errors, unable to heal {bucket}/{object}"
))),
));
let error = if all_writer_failures_retryable {
first_writer_error.take().unwrap_or(DiskError::FaultyDisk)
} else {
DiskError::other(format!("all drives had write errors, unable to heal {bucket}/{object}"))
};
return Ok((result, Some(error)));
}
}
@@ -1865,6 +1957,10 @@ impl SetDisks {
));
}
if let Some(error) = volume_creation_error {
return Ok((result, Some(error)));
}
if result.integrity_verified
&& let Err(error) =
crate::io_support::shard_integrity::restore_proof_replicas(&latest_meta, &disks, bucket, object)
@@ -3430,7 +3526,7 @@ impl SetDisks {
mod heal_result_report_tests {
use super::{
AbsenceProofRequest, DanglingCheckPartsFailure, DanglingDeleteFailure, DanglingDeleteSafety, ReadRepairCommitFingerprint,
ReadRepairPauseScope, SetDisks, heal_writer_error_summary,
ReadRepairPauseScope, SetDisks, heal_writer_error_summary, is_retryable_heal_writer_error,
};
use super::{HEAL_RENAME_INCOMPLETE, HealRenameFailureScope, HealWriterFailureScope};
use crate::disk::endpoint::Endpoint;
@@ -3663,6 +3759,24 @@ mod heal_result_report_tests {
assert!(!summary.contains("sensitive"));
}
#[test]
fn retryable_heal_writer_error_keeps_permanent_errors_out() {
for error in [
DiskError::DiskNotFound,
DiskError::Timeout,
DiskError::SourceStalled,
DiskError::FaultyRemoteDisk,
DiskError::FaultyDisk,
DiskError::RemoteClientUnavailable("peer restarting".to_string()),
] {
assert!(is_retryable_heal_writer_error(&error), "{error:?} should be retryable");
}
for error in [DiskError::DiskFull, DiskError::DiskAccessDenied, DiskError::FileCorrupt] {
assert!(!is_retryable_heal_writer_error(&error), "{error:?} should remain permanent");
}
}
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn heal_writer_failures_emit_one_aggregate_warning_per_object() {
@@ -4223,6 +4337,88 @@ mod heal_result_report_tests {
);
}
#[tokio::test]
async fn deep_heal_recreates_missing_bucket_volume_before_rebuilding_shard() {
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "deep-heal-missing-bucket-volume";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let mut reader = PutObjReader::from_vec(vec![0x5c; 1024 * 1024]);
set.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("source object should be written before volume loss");
let source = disks[2]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("source metadata should be readable");
let data_dir = source.data_dir.expect("non-inline source should have a data directory");
disks[1]
.delete_volume(bucket, true)
.await
.expect("target bucket volume should be removable for the regression setup");
assert!(matches!(disks[1].stat_volume(bucket).await, Err(DiskError::VolumeNotFound)));
let (result, error) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("deep heal should finish after bucket volume loss");
assert!(error.is_none(), "deep heal should recreate the missing bucket volume: {error:?}");
assert!(disks[1].stat_volume(bucket).await.is_ok(), "target bucket volume should be restored");
let healed = disks[1]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("healed target metadata should be readable");
assert_eq!(healed.data_dir, Some(data_dir));
assert_eq!(result.after.drives[1].state, DriveState::Ok.to_string());
assert!(
temp_dirs[1]
.path()
.join(bucket)
.join(object)
.join(data_dir.to_string())
.join("part.1")
.exists(),
"deep heal should rebuild the object shard after recreating its volume"
);
let (_, retry_error) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("repeat heal should complete");
assert!(retry_error.is_none(), "repeat heal should be idempotent: {retry_error:?}");
}
#[tokio::test]
async fn deep_heal_rebuilds_a_stale_current_delete_marker_metadata_replica() {
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
+128 -40
View File
@@ -66,6 +66,7 @@ use crate::disk::DiskOption;
use crate::disk::STORAGE_FORMAT_FILE;
#[cfg(test)]
use crate::disk::new_disk;
use crate::erasure::coding::BitrotWriterWrapper;
use crate::multipart_listing::paginate_multipart_listing;
#[cfg(test)]
use crate::object_api::ObjectLockConfigSnapshot;
@@ -77,7 +78,7 @@ use crate::storage_api_contracts::multipart::MultipartOperations;
#[cfg(test)]
use crate::storage_api_contracts::object::HTTPPreconditions;
use crate::storage_api_contracts::object::ObjectOperations;
use futures::{StreamExt, stream};
use futures::{StreamExt, future::join_all, stream};
#[cfg(test)]
use http::HeaderMap;
use rustfs_filemeta::metadata_keys;
@@ -100,6 +101,49 @@ use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::task::JoinSet;
// The erasure-set width bounds fan-out. Await every opener so errors retain
// their disk slots and every successful writer remains owned until quorum is checked.
async fn create_part_writers(
disks: &[Option<DiskStore>],
path: &str,
length: i64,
shard_size: usize,
) -> (Vec<Option<BitrotWriterWrapper>>, Vec<Option<DiskError>>) {
join_all(disks.iter().map(|disk| async move {
let Some(disk) = disk else {
return (None, Some(DiskError::DiskNotFound));
};
match create_bitrot_writer(
false,
Some(disk),
RUSTFS_META_TMP_BUCKET,
path,
length,
shard_size,
HashAlgorithm::HighwayHash256S,
)
.await
{
Ok(writer) => (Some(writer), None),
Err(err) => {
warn!(
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
disk = ?disk,
state = "bitrot_writer_skipped",
error = ?err,
"Set disk multipart bitrot writer skipped"
);
(None, Some(err))
}
}
}))
.await
.into_iter()
.unzip()
}
const MULTIPART_LIST_IO_CONCURRENCY: usize = 16;
static CAPPED_MULTIPART_STAGING: OnceLock<Mutex<HashMap<String, Arc<tokio::sync::Semaphore>>>> = OnceLock::new();
@@ -1542,45 +1586,13 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
.map_err(Error::from)?);
let writer_setup_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
let mut writers = Vec::with_capacity(shuffle_disks.len());
let mut errors = Vec::with_capacity(shuffle_disks.len());
for disk_op in shuffle_disks.iter() {
if let Some(disk) = disk_op {
let writer = match create_bitrot_writer(
false,
Some(disk),
RUSTFS_META_TMP_BUCKET,
&tmp_part_path,
erasure.shard_file_size(data.size()),
erasure.shard_size(),
HashAlgorithm::HighwayHash256S,
)
.await
{
Ok(writer) => writer,
Err(err) => {
warn!(
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
disk = ?disk,
state = "bitrot_writer_skipped",
error = ?err,
"Set disk multipart bitrot writer skipped"
);
errors.push(Some(err));
writers.push(None);
continue;
}
};
writers.push(Some(writer));
errors.push(None);
} else {
errors.push(Some(DiskError::DiskNotFound));
writers.push(None);
}
}
let (mut writers, errors) = create_part_writers(
&shuffle_disks,
&tmp_part_path,
erasure.shard_file_size(data.size()),
erasure.shard_size(),
)
.await;
if let Some(stage_start) = writer_setup_stage_start {
rustfs_io_metrics::record_put_object_stage_duration(
@@ -3594,6 +3606,82 @@ mod tests {
use tempfile::TempDir;
use tokio::sync::{Notify, RwLock};
#[tokio::test]
async fn multipart_writer_setup_opens_disks_concurrently_and_preserves_error_slots() {
use crate::cluster::rpc::internode_data_transport::{
InternodeDataTransport, InternodeDataTransportCapabilities, ReadStreamRequest, WalkDirStreamRequest,
WriteStreamRequest,
};
use crate::cluster::rpc::remote_disk::RemoteDisk;
use crate::disk::{Disk, FileReader, FileWriter};
#[derive(Debug)]
struct BarrierTransport {
barrier: Arc<tokio::sync::Barrier>,
fail: bool,
}
#[async_trait::async_trait]
impl InternodeDataTransport for BarrierTransport {
async fn open_read(&self, _: ReadStreamRequest) -> disk::error::Result<FileReader> {
unreachable!("writer setup must not read")
}
async fn open_walk_dir(&self, _: WalkDirStreamRequest) -> disk::error::Result<FileReader> {
unreachable!("writer setup must not list")
}
async fn open_write(&self, request: WriteStreamRequest) -> disk::error::Result<FileWriter> {
assert_eq!(request.volume, RUSTFS_META_TMP_BUCKET);
assert_eq!(request.path, "upload/part.1");
self.barrier.wait().await;
if self.fail {
Err(DiskError::FileAccessDenied)
} else {
Ok(Box::new(tokio::io::sink()))
}
}
fn name(&self) -> &'static str {
"multipart-writer-test"
}
fn capabilities(&self) -> InternodeDataTransportCapabilities {
InternodeDataTransportCapabilities::tcp_http()
}
}
let barrier = Arc::new(tokio::sync::Barrier::new(3));
let mut disks = Vec::new();
for i in 0..3 {
let endpoint = Endpoint {
url: url::Url::parse(&format!("http://multipart-test.invalid:9000/disk{i}")).expect("endpoint"),
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: i,
};
let disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
Arc::new(BarrierTransport {
barrier: Arc::clone(&barrier),
fail: i == 1,
}),
)
.await
.expect("remote disk");
disks.push(Some(Arc::new(Disk::Remote(Box::new(disk)))));
}
disks.insert(1, None);
// A serial opener cannot cross the barrier. The timeout only bounds failures;
// the assertion depends on all three independent openers making progress.
let (writers, errors) =
tokio::time::timeout(Duration::from_secs(10), create_part_writers(&disks, "upload/part.1", 1024, 256))
.await
.expect("all disk openers must be polled concurrently");
assert_eq!(writers.iter().map(Option::is_some).collect::<Vec<_>>(), [true, false, false, true]);
assert_eq!(errors, [None, Some(DiskError::DiskNotFound), Some(DiskError::FileAccessDenied), None]);
}
#[test]
fn multipart_bucket_incarnation_metadata_is_consistent_and_non_nil() {
let incarnation = Uuid::new_v4();
+4 -5
View File
@@ -857,11 +857,10 @@ impl ECStore {
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
if opts.dry_run
|| opts.no_lock
|| (!allow_unversioned_absence && version_id.is_empty())
|| super::utils::is_reserved_or_invalid_bucket(bucket, false)
{
// Object heal may recreate a missing bucket volume before committing a
// shard. Keep the current bucket generation fenced for the whole
// operation, including the ordinary unversioned path.
if opts.dry_run || opts.no_lock || super::utils::is_reserved_or_invalid_bucket(bucket, false) {
let (item, error) = self.handle_heal_object(bucket, object, version_id, opts).await?;
return Ok(HealObjectStorageResult {
item,
+4
View File
@@ -143,6 +143,8 @@ impl Error {
err,
EcstoreError::DiskNotFound
| EcstoreError::VolumeNotFound
| EcstoreError::FaultyDisk
| EcstoreError::FaultyRemoteDisk
| EcstoreError::SlowDown
| EcstoreError::OperationCanceled
| EcstoreError::RemoteClientUnavailable(_)
@@ -348,6 +350,8 @@ mod tests {
assert!(Error::Disk(DiskError::DiskNotFound).is_recoverable_heal());
assert!(Error::Storage(EcstoreError::DiskNotFound).is_recoverable_heal());
assert!(Error::Storage(EcstoreError::VolumeNotFound).is_recoverable_heal());
assert!(Error::Storage(EcstoreError::FaultyDisk).is_recoverable_heal());
assert!(Error::Storage(EcstoreError::FaultyRemoteDisk).is_recoverable_heal());
}
#[test]
+4
View File
@@ -341,6 +341,8 @@ impl ErasureSetHealer {
se,
EcstoreError::DiskNotFound
| EcstoreError::VolumeNotFound
| EcstoreError::FaultyDisk
| EcstoreError::FaultyRemoteDisk
| EcstoreError::SlowDown
| EcstoreError::OperationCanceled
)
@@ -2055,6 +2057,8 @@ mod tests {
fn disk_not_found_is_transient_not_absent() {
assert!(matches!(classify(EcstoreError::DiskNotFound), HealObjectOutcome::Transient));
assert!(matches!(classify(EcstoreError::VolumeNotFound), HealObjectOutcome::Transient));
assert!(matches!(classify(EcstoreError::FaultyDisk), HealObjectOutcome::Transient));
assert!(matches!(classify(EcstoreError::FaultyRemoteDisk), HealObjectOutcome::Transient));
}
#[test]
+69
View File
@@ -407,6 +407,64 @@ mod canonical_outcome {
);
}
#[tokio::test(start_paused = true)]
async fn faulty_storage_disks_recover_or_exhaust_without_losing_objects() {
for remote in [false, true] {
for failures in [1, 4] {
let incarnation = Uuid::new_v4();
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(incarnation)),
heal_object_receipts: Mutex::new(HashMap::from([
(
"object-a".to_string(),
VecDeque::from([object_receipt("object-a", None, HealObjectDisposition::Repaired, incarnation)]),
),
(
"object-b".to_string(),
VecDeque::from([object_receipt("object-b", None, HealObjectDisposition::Repaired, incarnation)]),
),
])),
..Default::default()
});
storage.heal_object_outcomes.lock().expect("outcomes").insert(
"object-a".to_string(),
(0..failures)
.map(|_| MockHealObjectOutcome::FaultyStorageDisk(remote))
.collect(),
);
let task = bucket_task(storage.clone());
let result = task.execute().await;
let outcome = task.get_outcome().await;
assert_eq!(outcome.coverage, HealTraversalCoverage::Complete);
assert_eq!(outcome.counters.processed, 2);
assert_eq!(outcome.counters.attempt_failures, failures);
assert_eq!(outcome.counters.unknown, 0);
let object = outcome
.objects
.iter()
.find(|item| item.identity.object == "object-a")
.expect("object outcome");
if failures == 1 {
result.expect("faulty disk recovered within retry budget");
assert_eq!(outcome.execution, HealExecutionOutcome::Completed);
assert_eq!((outcome.counters.healed, outcome.counters.failed), (2, 0));
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
} else {
result.expect_err("persistent faulty disk must fail");
assert_eq!(outcome.execution, HealExecutionOutcome::CompletedWithErrors);
assert_eq!((outcome.counters.healed, outcome.counters.failed), (1, 1));
assert_eq!(object.disposition, HealObjectDisposition::Failed(HealFailureClass::RetryExhausted));
}
let calls = storage.heal_object_calls.lock().expect("calls");
assert_eq!(
calls.iter().filter(|name| name.as_str() == "object-a").count(),
if failures == 1 { 2 } else { 4 }
);
assert_eq!(calls.iter().filter(|name| name.as_str() == "object-b").count(), 1);
}
}
}
#[tokio::test]
async fn mixed_grace_and_legacy_success_keep_distinct_dispositions() {
let storage = Arc::new(MockStorage::default());
@@ -1854,6 +1912,7 @@ enum MockHealObjectOutcome {
RetiredMarkerDeferred,
UnavailableDrive(DriveState),
RetryableReadQuorum,
FaultyStorageDisk(bool),
InternodeHttp(http::StatusCode),
RetryableSlowDown,
PermanentOther(&'static str),
@@ -2016,6 +2075,11 @@ impl HealStorageAPI for MockStorage {
))),
)),
MockHealObjectOutcome::UnavailableDrive(state) => Ok(unavailable_drive_heal_result(state)),
MockHealObjectOutcome::FaultyStorageDisk(remote) => Err(Error::Storage(if remote {
EcstoreError::FaultyRemoteDisk
} else {
EcstoreError::FaultyDisk
})),
MockHealObjectOutcome::InternodeHttp(status) => Err(Error::Storage(EcstoreError::from(DiskError::from(
rustfs_rio::new_test_internode_http_io_error(rustfs_rio::InternodeHttpErrorKind::HttpStatus(status)),
)))),
@@ -2071,6 +2135,11 @@ impl HealStorageAPI for MockStorage {
))),
)),
MockHealObjectOutcome::UnavailableDrive(state) => Ok(unavailable_drive_heal_result(state)),
MockHealObjectOutcome::FaultyStorageDisk(remote) => Err(Error::Storage(if remote {
EcstoreError::FaultyRemoteDisk
} else {
EcstoreError::FaultyDisk
})),
MockHealObjectOutcome::OkWithOtherError(message) => Ok((HealResultItem::default(), Some(Error::other(message)))),
MockHealObjectOutcome::ErrOther(message) | MockHealObjectOutcome::PermanentOther(message) => {
Err(Error::other(message))
@@ -11,6 +11,8 @@
## Open Items
- `connect-894` pending Connect heartbeats: replay the exact preceding producer-capability list, with or without jobs, when upgrading to the memory-service capability. Preserve request ID, sequence, and persisted body rather than inserting the new capability into a retry. Remove after upgrades from the pre-service-memory capability set are unsupported.
- `backlog-2539` administrator erasure-set scope: decode historical pending intents whose empty bucket list omitted the all-buckets marker. Normalize that representation to the explicit scope before replay or checkpoint comparison. Remove after all pre-marker administrator ErasureSet intents are retired; deployment verification must confirm that no such pending records remain on coordinator disks.
- `backlog-2519` retained admin heal reports: keep the schema-1 terminal as the commit and replay fence, with a bounded versioned report in a separate namespace on the same disk. Older rollback readers can still query the terminal and suppress replay, but cannot expose its outcome; newer readers mark missing outcomes as unavailable. Remove the legacy marker and missing-report adapter only after all supported direct-upgrade and rollback readers understand the report format and retained schema-1-only receipts have expired.
@@ -1,6 +1,6 @@
975c1ca53eefeef6766a6fc0b3d3281f7408255342b0686e5e2aee5ad055414c duplicate.json
963529a38a02849c6c2acc6d72668dca9f63218b49c89fae41a451b584850411 overflow.json
6afec3806c98fb55d477455b43935798d8fdb7f9ae39bd9f6637465539e5c02b producer-capabilities.json
60300d053dd3746a3aceb327315e8c5abe292fb6505dd02a64b8d6279fb56c09 producer-capabilities.json
e3adeee1c8a19aa17e70894896fb79c072e3785bea3611b93c11e79f039ed5af stale.json
22cc7337f545271ebf11ca6b76e7e479d62c51278ac001a8d870b07d0fa9884e unknown.json
b06a72ff1d964efe65e996797705ec2ef72574a813aec5d8e9aff26c899f1352 valid.json
@@ -17,6 +17,7 @@
"logs.capture@1",
"profile.cpu@1",
"profile.memory@1",
"profile.memory.service@1",
"profile.threads@1",
"telemetry.record@1",
"telemetry.otlp@1",
@@ -40,6 +41,7 @@
"logs.capture@1",
"profile.cpu@1",
"profile.memory@1",
"profile.memory.service@1",
"profile.threads@1",
"telemetry.record@1",
"telemetry.otlp@1",
+1 -1
View File
@@ -1,7 +1,7 @@
a499742c06046e9cf2f781fc17d8b4cf95e018ab45fa41ee5944ba15b0e2902c auth
9b54e39584a442270ae486e8378df1d6f168c45d0c59baba9459b46bb212d2df version
159d116e965b51771f1687fc6ec9bdfbf2a4a0e6d6aa26d3f1046b3dac2697dd registration
fe1287eac16832bed88320254245ba07a985e6ae7e66e1d1570e324ffa972873 heartbeat
9b3862f5160968a2ff281afb3ea519a3d49ae1bc3d767153d70f309d2a7457a7 heartbeat
cc6c3f3bf5e6a938f2e00b8ce42d8fa7bfb974f4eb2927101d4ede4ebdc10dd5 inventory
0207458ec9b97b368e2f347da112511a3aabd27d752890cacda4fa2408e7f19e object-performance
a464d4c695d76dd985c0f2abf68c2135c080221098ad2427022f85243a7bf6ae network-performance
+9 -2
View File
@@ -607,9 +607,16 @@ impl DefaultObjectUsecase {
// Handle MetadataDirective REPLACE: replace user metadata while preserving system metadata.
// System metadata (compression, encryption) is added after this block to ensure
// it's not cleared by the REPLACE operation.
if let Some(replacement_metadata) = replacement_metadata {
if let Some(mut replacement_metadata) = replacement_metadata {
// S3 defaults an object's content type to `binary/octet-stream` when none is
// supplied. REPLACE must apply that same default instead of leaving
// content-type unset.
if !replacement_metadata.contains_key("content-type") {
replacement_metadata.insert("content-type".to_string(), "binary/octet-stream".to_string());
}
let effective_content_type = replacement_metadata.get("content-type").cloned();
user_defined = replacement_metadata;
src_info.content_type = content_type.clone();
src_info.content_type = effective_content_type;
src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage);
src_info.expires = expires_timestamp.map(OffsetDateTime::from);
} else if metadata_directive.is_some() || website_redirect_location.is_some() {
+71 -4
View File
@@ -29,6 +29,7 @@ use thiserror::Error;
use tokio_util::sync::CancellationToken;
use uuid::{Uuid, Variant, Version};
use super::profile_memory::MEMORY_PROFILE_SERVICE_CAPABILITY;
use super::{
CPU_PROFILE_CAPABILITY, DRIVE_CAPABILITY, DRIVE_SCHEMA_VERSION, DriveOutcome, DrivePerformanceError, DrivePerformanceRequest,
DriveProvenance, LocalDriveConsent, LocalNetworkConsent, LocalProfileConsent, LocalTopConsent, MAX_NETWORK_TRAFFIC_BYTES,
@@ -45,6 +46,7 @@ use crate::connect::DeviceIdentity;
const PROTOCOL_VERSION: &str = "v1";
const PROFILE_CPU_JOB_TYPE: &str = "profile.cpu";
const PROFILE_THREADS_JOB_TYPE: &str = "profile.threads";
const PROFILE_MEMORY_JOB_TYPE: &str = "profile.memory";
const PERFORMANCE_DRIVE_JOB_TYPE: &str = "performance.drive";
const PERFORMANCE_NETWORK_JOB_TYPE: &str = "performance.network";
const TOP_API_JOB_TYPE: &str = "top.api";
@@ -56,6 +58,7 @@ const MAX_FUTURE_SKEW_SECONDS: i64 = 300;
const MAX_OUTPUT_BYTES: u64 = 524_288;
const MAX_MEMORY_BYTES: u64 = 64 * 1024 * 1024;
const MAX_CPU_MILLIS: u64 = 30_000;
const MAX_MEMORY_PROFILE_CPU_MILLIS: u64 = 5_000;
const MAX_NETWORK_CPU_MILLIS: u64 = 5_000;
const MIN_NETWORK_MEMORY_BYTES: u64 = 1_048_576;
const DRIVE_TARGET_ALIAS: &str = "drive-1";
@@ -75,6 +78,7 @@ const MIN_TOP_RPC_MEMORY_BYTES: u64 = 1_048_576;
enum DiagnosticJobKind {
ProfileCpu,
ProfileThreads,
ProfileMemory,
PerformanceDrive,
PerformanceNetwork,
TopApi,
@@ -424,6 +428,11 @@ impl DiagnosticJobEnvelope {
{
return Err(DiagnosticJobError::LimitExceeded);
}
if kind == DiagnosticJobKind::ProfileMemory
&& (self.limits.max_cpu_millis > MAX_MEMORY_PROFILE_CPU_MILLIS || self.limits.max_memory_bytes != MAX_MEMORY_BYTES)
{
return Err(DiagnosticJobError::LimitExceeded);
}
match (kind, self.parameters.traffic_bytes) {
(DiagnosticJobKind::PerformanceNetwork, Some(1..=MAX_NETWORK_TRAFFIC_BYTES)) => {
if self.limits.max_cpu_millis > MAX_NETWORK_CPU_MILLIS || self.limits.max_memory_bytes < MIN_NETWORK_MEMORY_BYTES
@@ -478,6 +487,11 @@ impl DiagnosticJobEnvelope {
(PROFILE_THREADS_JOB_TYPE, [capability], PROFILE_SCHEMA_VERSION) if capability == THREAD_PROFILE_CAPABILITY => {
Ok(DiagnosticJobKind::ProfileThreads)
}
(PROFILE_MEMORY_JOB_TYPE, [capability], PROFILE_SCHEMA_VERSION)
if capability == MEMORY_PROFILE_SERVICE_CAPABILITY =>
{
Ok(DiagnosticJobKind::ProfileMemory)
}
(PERFORMANCE_DRIVE_JOB_TYPE, [capability], DRIVE_SCHEMA_VERSION) if capability == DRIVE_CAPABILITY => {
Ok(DiagnosticJobKind::PerformanceDrive)
}
@@ -518,6 +532,7 @@ pub async fn execute_diagnostic_job(
match envelope.kind()? {
DiagnosticJobKind::ProfileCpu => execute_profile_cpu_job(envelope, nonce, identity, provenance, cancel).await,
DiagnosticJobKind::ProfileThreads => execute_profile_threads_job(envelope, nonce, identity, provenance, cancel).await,
DiagnosticJobKind::ProfileMemory => execute_profile_memory_job(envelope, nonce, identity, provenance, cancel).await,
DiagnosticJobKind::PerformanceDrive => {
let Some(scratch_root) = runtime_drive_scratch_root() else {
return Ok(failed_drive_execution(&envelope.job_id, "SOURCE_UNAVAILABLE"));
@@ -742,6 +757,54 @@ async fn execute_performance_network_job(
})
}
async fn execute_profile_memory_job(
envelope: DiagnosticJobEnvelope,
nonce: [u8; 32],
identity: &DeviceIdentity,
provenance: ProfileProvenance,
cancel: &CancellationToken,
) -> Result<DiagnosticJobExecution, DiagnosticJobError> {
let expire = parse_time(&envelope.expire_time)?;
let consent_expire = parse_time(&envelope.parameters.consent_expires_at)?;
let request = ProfileCaptureRequest {
organization_name: envelope.organization_name,
cluster_name: envelope.cluster_name,
device_name: envelope.device_name,
run_uid: envelope.job_id.clone(),
artifact_uid: envelope.parameters.artifact_uid,
schema_version: envelope.schema_version,
// The service capability gates dispatch; schema-v1 exports retain the
// original allocation-aggregate capability and signed artifact format.
capability: super::MEMORY_PROFILE_CAPABILITY.to_owned(),
consent: LocalProfileConsent {
consent_uid: envelope.parameters.consent_uid,
policy_revision: envelope.parameters.consent_policy_revision,
expires_at_unix: consent_expire.timestamp(),
confirmed: true,
},
produced_at_unix: Utc::now().timestamp(),
expires_at_unix: expire.timestamp(),
nonce,
duration: Duration::from_millis(envelope.parameters.duration_millis),
sample_period: Duration::from_micros(envelope.parameters.sample_period_micros),
provenance,
};
let export = super::export_memory_profile(&request, identity, cancel)
.await
.map_err(capture_failure)?;
if export.archive_bytes.len() > usize::try_from(envelope.limits.max_output_bytes).unwrap_or(usize::MAX) {
return Err(DiagnosticJobError::LimitExceeded);
}
Ok(DiagnosticJobExecution {
job_id: envelope.job_id,
outcome: "SUCCEEDED".to_owned(),
reason: "COMPLETE".to_owned(),
artifact_uid: Some(export.artifact_uid),
artifact_sha256: Some(export.archive_sha256),
artifact_bytes: Some(export.archive_bytes),
})
}
async fn execute_profile_threads_job(
envelope: DiagnosticJobEnvelope,
nonce: [u8; 32],
@@ -1220,6 +1283,7 @@ mod tests {
("logs.capture@1", &["logs"][..]),
("profile.cpu@1", &["profile"][..]),
("profile.memory@1", &["profile"][..]),
("profile.memory.service@1", &[][..]),
("profile.threads@1", &["profile"][..]),
("telemetry.record@1", &["telemetry", "record"][..]),
("telemetry.otlp@1", &["telemetry", "otlp"][..]),
@@ -1240,13 +1304,16 @@ mod tests {
let connect = command.find_subcommand("connect").expect("connect command");
for (capability, path) in expected {
if path.is_empty() {
// Network probes use the authenticated service dispatcher and
// locally resolved peers, not a standalone CLI command.
let mut job = envelope();
job.job_type = PERFORMANCE_NETWORK_JOB_TYPE.to_owned();
let (job_type, kind) = match capability {
NETWORK_CAPABILITY => (PERFORMANCE_NETWORK_JOB_TYPE, DiagnosticJobKind::PerformanceNetwork),
MEMORY_PROFILE_SERVICE_CAPABILITY => (PROFILE_MEMORY_JOB_TYPE, DiagnosticJobKind::ProfileMemory),
_ => panic!("{capability} is missing service dispatch"),
};
job.job_type = job_type.to_owned();
job.required_capabilities = vec![capability.to_owned()];
job.schema_version = NETWORK_SCHEMA_VERSION;
assert_eq!(job.kind(), Ok(DiagnosticJobKind::PerformanceNetwork));
assert_eq!(job.kind(), Ok(kind));
continue;
}
let mut command = connect;
+1
View File
@@ -48,6 +48,7 @@ pub const CONNECT_DIAGNOSTIC_CAPABILITIES: &[&str] = &[
logs::LOGS_CAPABILITY,
profile_cpu::CPU_PROFILE_CAPABILITY,
profile_cpu::MEMORY_PROFILE_CAPABILITY,
profile_memory::MEMORY_PROFILE_SERVICE_CAPABILITY,
profile_cpu::THREAD_PROFILE_CAPABILITY,
trace_record::TELEMETRY_RECORD_CAPABILITY,
trace_record::TELEMETRY_OTLP_CAPABILITY,
@@ -34,6 +34,9 @@ use super::profile_cpu::{
const MAX_ALLOCATOR_STATS_BYTES: usize = 262_144;
/// Service-job negotiation is separate from the unchanged memory result schema.
pub const MEMORY_PROFILE_SERVICE_CAPABILITY: &str = "profile.memory.service@1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct AllocationSnapshot {
total_allocated_bytes: u64,
+295 -1
View File
@@ -380,11 +380,20 @@ async fn run_collection_schedule(
let _ = receipts.send(state.last_receipt.clone());
}
// The runtime's initial stopped value is not a received revocation. Keep the
// durable interval fenced until heartbeat supplies a policy to evaluate.
while *policies.borrow() == DiagnosticCollectionPolicy::stopped() {
let _ = status.send(DiagnosticScheduleStatus::Waiting);
if wait_for_policy(policies, shutdown).await {
return Ok(());
}
}
loop {
if shutdown.is_cancelled() {
break;
}
let policy = policies.borrow().clone();
let policy = policies.borrow_and_update().clone();
if let Err(error) = policy.validate()
&& policy.should_run()
{
@@ -692,6 +701,291 @@ mod tests {
})
}
#[tokio::test]
async fn bootstrap_stopped_policy_preserves_a_due_interval_until_fresh_policy_arrives() {
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let active = policy(7, true);
let due = (Utc::now() + chrono::Duration::seconds(300)).to_rfc3339_opts(SecondsFormat::Secs, true);
let previous = receipt(
7,
ENVIRONMENT_TOOL_ID,
now_string(),
ReceiptOutcome::Succeeded,
1,
None,
Some(b"previous"),
);
store
.write(ScheduleState {
policy_revision: Some(active.revision),
policy_fingerprint: Some(active.fingerprint().expect("fingerprint")),
next_due_at: Some(due.clone()),
active_interval_started_at: None,
last_receipt: Some(previous.clone()),
})
.await
.expect("persist scheduled interval");
let (policy_tx, policy_rx) = watch::channel(DiagnosticCollectionPolicy::stopped());
let shutdown = CancellationToken::new();
let mut runtime = spawn_schedule(store.clone(), policy_rx, shutdown.clone(), blocking_runner());
tokio::time::timeout(Duration::from_secs(2), runtime.status.changed())
.await
.expect("bootstrap waiting timeout")
.expect("bootstrap waiting notification");
assert!(matches!(*runtime.status.borrow(), DiagnosticScheduleStatus::Waiting));
let persisted = store.read().await.expect("bootstrap state");
assert_eq!(persisted.next_due_at.as_deref(), Some(due.as_str()));
assert_eq!(persisted.last_receipt, Some(previous.clone()));
assert_eq!(*runtime.receipts.borrow(), Some(previous.clone()));
policy_tx
.send(DiagnosticCollectionPolicy::stopped())
.expect("bootstrap still waiting");
tokio::time::timeout(Duration::from_secs(2), runtime.status.changed())
.await
.expect("repeated bootstrap timeout")
.expect("repeated bootstrap notification");
assert_eq!(store.read().await.expect("still scheduled").next_due_at.as_deref(), Some(due.as_str()));
policy_tx.send(active).expect("fresh unchanged policy");
assert!(
tokio::time::timeout(Duration::from_secs(2), runtime.status.changed())
.await
.is_err()
);
assert!(!runtime.task.is_finished(), "unchanged policy must not kill the scheduler");
let persisted = store.read().await.expect("resumed state");
assert_eq!(persisted.next_due_at.as_deref(), Some(due.as_str()));
assert_eq!(persisted.last_receipt, Some(previous));
policy_tx.send(policy(8, true)).expect("new revision");
wait_running(&mut runtime.status).await;
assert!(matches!(
*runtime.status.borrow(),
DiagnosticScheduleStatus::Running { policy_revision: 8, .. }
));
shutdown.cancel();
runtime.shutdown().await;
}
#[tokio::test]
async fn overdue_restart_waits_for_fresh_policy_and_keeps_the_original_interval() {
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let active = policy(7, true);
let due = Utc::now() - chrono::Duration::seconds(10);
let due_string = due.to_rfc3339_opts(SecondsFormat::Secs, true);
store
.write(ScheduleState {
policy_revision: Some(7),
policy_fingerprint: Some(active.fingerprint().expect("fingerprint")),
next_due_at: Some(due_string.clone()),
..ScheduleState::default()
})
.await
.expect("persist overdue interval");
let (policy_tx, policy_rx) = watch::channel(DiagnosticCollectionPolicy::stopped());
let shutdown = CancellationToken::new();
let mut runtime = spawn_schedule(store.clone(), policy_rx, shutdown.clone(), blocking_runner());
tokio::time::timeout(Duration::from_secs(2), runtime.status.changed())
.await
.expect("bootstrap timeout")
.expect("waiting");
assert!(matches!(*runtime.status.borrow(), DiagnosticScheduleStatus::Waiting));
let before = store.read().await.expect("before authorization");
assert_eq!(before.next_due_at, Some(due_string.clone()));
assert!(before.active_interval_started_at.is_none());
assert!(before.last_receipt.is_none());
policy_tx.send(active).expect("fresh policy");
wait_running(&mut runtime.status).await;
let running = store.read().await.expect("running original interval");
assert_eq!(running.active_interval_started_at, Some(due_string.clone()));
assert_eq!(
running.next_due_at,
Some((due + chrono::Duration::seconds(300)).to_rfc3339_opts(SecondsFormat::Secs, true))
);
shutdown.cancel();
runtime.shutdown().await;
let completed = store.read().await.expect("cancelled interval").last_receipt.expect("receipt");
assert_eq!(completed.interval_started_at, due_string);
assert_eq!(completed.outcome, ReceiptOutcome::Cancelled);
}
#[tokio::test]
async fn fresh_policy_received_before_startup_does_not_cancel_its_own_overdue_interval() {
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let active = policy(7, true);
let due = Utc::now() - chrono::Duration::seconds(10);
let due_string = due.to_rfc3339_opts(SecondsFormat::Secs, true);
store
.write(ScheduleState {
policy_revision: Some(7),
policy_fingerprint: Some(active.fingerprint().expect("fingerprint")),
next_due_at: Some(due_string.clone()),
..ScheduleState::default()
})
.await
.expect("persist overdue interval");
let (policy_tx, policy_rx) = watch::channel(DiagnosticCollectionPolicy::stopped());
policy_tx.send(active).expect("heartbeat arrives before state read completes");
let shutdown = CancellationToken::new();
let runner: Runner = Arc::new(|_| Box::pin(async { Ok(b"collected".to_vec()) }));
let mut runtime = spawn_schedule(store.clone(), policy_rx, shutdown.clone(), runner);
tokio::time::timeout(Duration::from_secs(2), runtime.receipts.changed())
.await
.expect("collection timeout")
.expect("receipt");
let receipt = runtime.receipts.borrow().clone().expect("collection receipt");
shutdown.cancel();
runtime.shutdown().await;
assert_eq!(receipt.outcome, ReceiptOutcome::Succeeded);
assert_eq!(receipt.interval_started_at, due_string);
assert_eq!(receipt.attempt_count, 1);
assert_eq!(receipt.result_bytes, Some(b"collected".len()));
let persisted = store.read().await.expect("completed interval");
assert_eq!(
persisted.next_due_at,
Some((due + chrono::Duration::seconds(300)).to_rfc3339_opts(SecondsFormat::Secs, true))
);
assert_eq!(persisted.last_receipt, Some(receipt));
}
#[tokio::test]
async fn restart_rejects_expired_policy_and_corrupt_same_revision_state() {
for invalid in ["expired policy", "missing due", "invalid due", "fingerprint mismatch"] {
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let mut active = policy(7, true);
if invalid == "expired policy" {
active.consent_expires_at = Some("2020-01-01T00:00:00Z".to_owned());
}
let mut state = ScheduleState {
policy_revision: Some(7),
policy_fingerprint: Some(active.fingerprint().expect("fingerprint")),
next_due_at: Some(now_string()),
..ScheduleState::default()
};
match invalid {
"missing due" => state.next_due_at = None,
"invalid due" => state.next_due_at = Some("not-a-timestamp".to_owned()),
"fingerprint mismatch" => state.policy_fingerprint = Some("different-policy".to_owned()),
_ => {}
}
store.write(state).await.expect("persist invalid input");
let (policy_tx, mut policy_rx) = watch::channel(DiagnosticCollectionPolicy::stopped());
let shutdown = CancellationToken::new();
let runner = blocking_runner();
let (status_tx, mut status_rx) = watch::channel(DiagnosticScheduleStatus::Waiting);
let (receipt_tx, _receipt_rx) = watch::channel(None);
let schedule = run_collection_schedule(&store, &mut policy_rx, &shutdown, &runner, &status_tx, &receipt_tx);
tokio::pin!(schedule);
tokio::select! {
result = &mut schedule => panic!("scheduler ended before fresh policy: {result:?}"),
result = tokio::time::timeout(Duration::from_secs(2), status_rx.changed()) => {
result.expect("bootstrap timeout").expect("waiting");
}
}
policy_tx.send(active).expect("fresh invalid policy");
let result = tokio::time::timeout(Duration::from_secs(2), schedule)
.await
.expect("invalid input must fail closed");
match invalid {
"expired policy" | "invalid due" => {
assert!(matches!(result, Err(DiagnosticScheduleError::Policy)), "{invalid}: {result:?}")
}
_ => assert!(matches!(result, Err(DiagnosticScheduleError::StateCorrupt)), "{invalid}: {result:?}"),
}
let persisted = store.read().await.expect("rejected state");
assert!(persisted.active_interval_started_at.is_none());
assert!(persisted.last_receipt.is_none());
}
}
#[tokio::test]
async fn fresh_disabled_or_revoked_policy_clears_due_before_a_new_revision_can_resume() {
for reason in [ReasonCode::Disabled, ReasonCode::ConsentInactive] {
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let active = policy(7, true);
store
.write(ScheduleState {
policy_revision: Some(7),
policy_fingerprint: Some(active.fingerprint().expect("fingerprint")),
next_due_at: Some(now_string()),
..ScheduleState::default()
})
.await
.expect("persist interval");
let (policy_tx, policy_rx) = watch::channel(DiagnosticCollectionPolicy::stopped());
let shutdown = CancellationToken::new();
let mut runtime = spawn_schedule(store.clone(), policy_rx, shutdown.clone(), blocking_runner());
tokio::time::timeout(Duration::from_secs(2), runtime.status.changed())
.await
.expect("bootstrap timeout")
.expect("waiting");
let mut stopped = policy(8, false);
stopped.reason_code = Some(reason);
policy_tx.send(stopped).expect("fresh stop");
tokio::time::timeout(Duration::from_secs(2), runtime.status.changed())
.await
.expect("stop timeout")
.expect("stopped policy processed");
assert!(matches!(*runtime.status.borrow(), DiagnosticScheduleStatus::Waiting));
let stopped = store.read().await.expect("revoked state");
assert!(stopped.next_due_at.is_none());
assert!(stopped.active_interval_started_at.is_none());
assert!(stopped.last_receipt.is_none());
policy_tx.send(policy(9, true)).expect("new authorization");
wait_running(&mut runtime.status).await;
assert!(matches!(
*runtime.status.borrow(),
DiagnosticScheduleStatus::Running { policy_revision: 9, .. }
));
shutdown.cancel();
runtime.shutdown().await;
}
}
#[tokio::test]
async fn interrupted_interval_is_reported_without_collecting_before_fresh_policy() {
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let active = policy(7, true);
let started = now_string();
let next = (Utc::now() + chrono::Duration::seconds(300)).to_rfc3339_opts(SecondsFormat::Secs, true);
store
.write(ScheduleState {
policy_revision: Some(7),
policy_fingerprint: Some(active.fingerprint().expect("fingerprint")),
next_due_at: Some(next.clone()),
active_interval_started_at: Some(started.clone()),
last_receipt: None,
})
.await
.expect("persist interrupted interval");
let (_policy_tx, policy_rx) = watch::channel(DiagnosticCollectionPolicy::stopped());
let shutdown = CancellationToken::new();
let mut runtime = spawn_schedule(store.clone(), policy_rx, shutdown.clone(), blocking_runner());
tokio::time::timeout(Duration::from_secs(2), runtime.receipts.changed())
.await
.expect("recovery timeout")
.expect("crash receipt");
let receipt = runtime.receipts.borrow().clone().expect("recovery receipt");
assert_eq!(receipt.policy_revision, 7);
assert_eq!(receipt.interval_started_at, started);
assert_eq!(receipt.outcome, ReceiptOutcome::Failed);
assert_eq!(receipt.reason.as_deref(), Some("producer_restarted_during_collection"));
shutdown.cancel();
runtime.shutdown().await;
let persisted = store.read().await.expect("recovered state");
assert!(persisted.active_interval_started_at.is_none());
assert_eq!(persisted.next_due_at, Some(next));
assert_eq!(persisted.last_receipt, Some(receipt));
}
#[tokio::test]
async fn disable_cancels_an_active_run_and_persists_receipt() {
let temp = tempfile::tempdir().expect("tempdir");
+9 -1
View File
@@ -111,7 +111,15 @@ impl PendingHeartbeat {
super::diagnostics::CPU_PROFILE_CAPABILITY,
]
|| self.capabilities == heartbeat_capabilities(false)
|| self.capabilities == heartbeat_capabilities(true))
|| self.capabilities == heartbeat_capabilities(true)
// RUSTFS_COMPAT_TODO(connect-894) Remove after upgrades from the pre-service-memory capability set are unsupported.
// Preserve exact pending requests; never add the capability to a retry.
|| [false, true].into_iter().any(|job_capable| {
heartbeat_capabilities(job_capable)
.iter()
.filter(|capability| capability.as_str() != "profile.memory.service@1")
.eq(self.capabilities.iter())
}))
&& self.sequence <= MAX_SEQUENCE
&& self.coarse_node_summary.is_valid()
&& is_exact_utc_seconds(&self.client_time)
+111
View File
@@ -544,6 +544,7 @@ async fn sends_only_l0_fields_and_accepts_additive_response_fields() {
"logs.capture@1",
"profile.cpu@1",
"profile.memory@1",
"profile.memory.service@1",
"profile.threads@1",
"telemetry.record@1",
"telemetry.otlp@1",
@@ -642,6 +643,116 @@ async fn restart_replays_pending_request_then_advances_sequence() {
assert_eq!(seen[1]["sequence"].as_u64(), seen[0]["sequence"].as_u64().map(|value| value + 1));
}
fn pre_service_memory_capabilities(job_capable: bool) -> Vec<&'static str> {
let mut capabilities = vec!["heartbeat", "diagnostics.policy.v1", "inventory.environment@1"];
if job_capable {
capabilities.push("jobs");
}
// Freeze the preceding release, independently of today's advertisement.
capabilities.extend([
"performance.client@1",
"performance.drive@1",
"performance.network@1",
"performance.object@1",
"performance.siteReplication@1",
"logs.capture@1",
"profile.cpu@1",
"profile.memory@1",
"profile.threads@1",
"telemetry.record@1",
"telemetry.otlp@1",
"telemetry.replay@1",
"top.api@1",
"top.disk@1",
"top.locks@1",
"top.net@1",
"top.rpc@1",
"inspect.object@1",
]);
capabilities
}
fn pending_heartbeat_with_capabilities(capabilities: &[&str]) -> Value {
json!({
"protocolVersion": "v1",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"agentVersion": format!("rustfs-agent/{}", env!("CARGO_PKG_VERSION")),
"capabilities": capabilities,
"sequence": 0,
"clientTime": "2026-08-22T01:02:03Z",
"coarseNodeSummary": {"total": 1, "healthy": 1, "degraded": 0}
})
}
#[tokio::test]
async fn restart_replays_exact_pre_service_memory_capabilities_with_and_without_jobs() {
for job_capable in [false, true] {
let pki = TestPki::new();
let server = server(&pki, vec![Reply::ok("2026-08-22T01:02:03Z")]).await;
let temp = tempfile::tempdir().expect("tempdir");
let shutdown = CancellationToken::new();
let config = config(&temp, &pki, &server);
fs::create_dir_all(config.state_path.parent().expect("state directory")).expect("create state directory");
let pending = pending_heartbeat_with_capabilities(&pre_service_memory_capabilities(job_capable));
let state = json!({"nextSequence": 0, "pending": pending});
fs::write(&config.state_path, serde_json::to_vec(&state).expect("heartbeat state JSON")).expect("write heartbeat state");
private_mode(&config.state_path);
let runtime = spawn_heartbeat_runtime(Some(config), &shutdown, summary)
.expect("start runtime")
.expect("configured runtime");
let mut status = runtime.status();
assert!(matches!(
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::Online { .. })).await,
HeartbeatStatus::Online { .. }
));
runtime.shutdown().await;
let seen = server.seen.lock().expect("seen lock");
assert_eq!(seen.len(), 1);
assert_eq!(seen[0], pending);
}
}
#[tokio::test]
async fn pre_service_memory_compatibility_does_not_accept_changed_capabilities() {
for job_capable in [false, true] {
for mutation in ["unknown", "missing", "duplicate", "reordered"] {
let pki = TestPki::new();
let server = server(&pki, vec![]).await;
let temp = tempfile::tempdir().expect("tempdir");
let shutdown = CancellationToken::new();
let config = config(&temp, &pki, &server);
fs::create_dir_all(config.state_path.parent().expect("state directory")).expect("create state directory");
let mut capabilities = pre_service_memory_capabilities(job_capable);
match mutation {
"unknown" => capabilities.push("shell.exec@1"),
"missing" => {
capabilities.pop();
}
"duplicate" => capabilities.push("profile.memory@1"),
"reordered" => capabilities.swap(5, 6),
_ => unreachable!(),
}
let state = json!({"nextSequence": 0, "pending": pending_heartbeat_with_capabilities(&capabilities)});
fs::write(&config.state_path, serde_json::to_vec(&state).expect("heartbeat state JSON"))
.expect("write heartbeat state");
private_mode(&config.state_path);
let runtime = spawn_heartbeat_runtime(Some(config), &shutdown, summary)
.expect("start runtime")
.expect("configured runtime");
let mut status = runtime.status();
assert!(
matches!(
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::Failed { .. })).await,
HeartbeatStatus::Failed { reason } if reason.contains("violates the protocol invariants")
),
"accepted altered persisted capabilities: {mutation}, jobs={job_capable}"
);
assert!(server.seen.lock().expect("seen lock").is_empty());
runtime.shutdown().await;
}
}
}
#[tokio::test]
async fn restart_sends_the_last_durable_diagnostic_receipt_outbound() {
let pki = TestPki::new();
+235 -1
View File
@@ -30,6 +30,8 @@ use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64_simd::URL_SAFE_NO_PAD;
use chrono::{SecondsFormat, Utc};
use ed25519_dalek::{Signer as _, SigningKey};
use p256::ecdsa::signature::Verifier as _;
use p256::ecdsa::{Signature, VerifyingKey};
use p256::pkcs8::DecodePublicKey as _;
@@ -37,7 +39,13 @@ use profile_cpu::{
LocalProfileConsent, MEMORY_PROFILE_CAPABILITY, ProfileCaptureRequest, ProfileError, ProfileProvenance,
save_signed_profile_export,
};
use profile_memory::{AllocationProfileSource, export_memory_profile, export_memory_profile_from, parse_allocator_stats};
use profile_memory::{
AllocationProfileSource, MEMORY_PROFILE_SERVICE_CAPABILITY, export_memory_profile, export_memory_profile_from,
parse_allocator_stats,
};
use rustfs::connect::{
DiagnosticJobEnvelope, DiagnosticJobError, DiagnosticJobTarget, TrustedDiagnosticJobSigner, execute_diagnostic_job,
};
use sha2::{Digest as _, Sha256};
use tokio::sync::Mutex as AsyncMutex;
use tokio_util::sync::CancellationToken;
@@ -117,6 +125,232 @@ fn archive_entry(archive: &mut ZipArchive<Cursor<Vec<u8>>>, name: &str) -> Vec<u
bytes
}
fn service_job() -> DiagnosticJobEnvelope {
let request = request();
let now = Utc::now();
serde_json::from_value(serde_json::json!({
"jobId": request.run_uid,
"protocolVersion": "v1",
"jobType": "profile.memory",
"schemaVersion": 1,
"organizationName": request.organization_name,
"clusterName": request.cluster_name,
"deviceName": request.device_name,
"createTime": now.to_rfc3339_opts(SecondsFormat::Secs, true),
"expireTime": (now + chrono::Duration::seconds(30)).to_rfc3339_opts(SecondsFormat::Secs, true),
"nonce": URL_SAFE_NO_PAD.encode_to_string(request.nonce),
"requiredCapabilities": [MEMORY_PROFILE_SERVICE_CAPABILITY],
"authorization": {
"actorType": "BROWSER_USER",
"actorName": "users/019e3ae0-0000-7000-8000-000000000018",
"requestId": "123e4567-e89b-42d3-a456-426614174001"
},
"limits": {
"timeoutSeconds": 30,
"maxOutputBytes": 524288,
"maxMemoryBytes": 67108864,
"maxCpuMillis": 5000
},
"parameters": {
"artifactUid": request.artifact_uid,
"consentUid": request.consent.consent_uid,
"consentPolicyRevision": request.consent.policy_revision,
"consentExpiresAt": (now + chrono::Duration::seconds(60)).to_rfc3339_opts(SecondsFormat::Secs, true),
"durationMillis": 1000,
"samplePeriodMicros": 10000
},
"signature": {"algorithm": "Ed25519", "keyId": "", "value": ""}
}))
.expect("service job fixture")
}
fn sign_service_job(job: DiagnosticJobEnvelope) -> (DiagnosticJobEnvelope, TrustedDiagnosticJobSigner) {
let key = SigningKey::from_bytes(&[0x17; 32]);
let key_id = hex(&Sha256::digest(key.verifying_key().as_bytes()));
let signer = TrustedDiagnosticJobSigner::new(key_id.clone(), *key.verifying_key().as_bytes()).expect("trusted signer");
let signature = URL_SAFE_NO_PAD.encode_to_string(key.sign(&job.signing_payload().expect("job payload")).to_bytes());
let mut value = serde_json::to_value(job).expect("job JSON");
value["signature"]["keyId"] = key_id.into();
value["signature"]["value"] = signature.into();
(serde_json::from_value(value).expect("signed job"), signer)
}
fn service_target(job: &DiagnosticJobEnvelope) -> DiagnosticJobTarget {
DiagnosticJobTarget {
organization_name: job.organization_name.clone(),
cluster_name: job.cluster_name.clone(),
device_name: job.device_name.clone(),
}
}
#[test]
fn service_job_requires_the_independently_negotiated_capability_and_signed_bounds() {
let (job, signer) = sign_service_job(service_job());
let target = service_target(&job);
signer.verify(&job, &target, Utc::now()).expect("bounded service job");
for capabilities in [
vec![MEMORY_PROFILE_CAPABILITY.to_owned()],
vec![
MEMORY_PROFILE_SERVICE_CAPABILITY.to_owned(),
MEMORY_PROFILE_CAPABILITY.to_owned(),
],
vec![],
] {
let mut candidate = job.clone();
candidate.required_capabilities = capabilities;
let (candidate, signer) = sign_service_job(candidate);
assert_eq!(signer.verify(&candidate, &target, Utc::now()), Err(DiagnosticJobError::Unsupported));
}
let mut limits = vec![job.limits.clone(); 4];
limits[0].max_cpu_millis = 5001;
limits[1].max_memory_bytes = 67108863;
limits[2].max_output_bytes = 524289;
limits[3].timeout_seconds = 31;
for limit in limits {
let mut candidate = job.clone();
candidate.limits = limit;
let (candidate, signer) = sign_service_job(candidate);
assert_eq!(signer.verify(&candidate, &target, Utc::now()), Err(DiagnosticJobError::LimitExceeded));
}
let mut tampered = job.clone();
tampered.parameters.sample_period_micros += 1;
assert_eq!(signer.verify(&tampered, &target, Utc::now()), Err(DiagnosticJobError::SignatureInvalid));
let mut wrong_target = target.clone();
wrong_target.organization_name.push('x');
assert_eq!(signer.verify(&job, &wrong_target, Utc::now()), Err(DiagnosticJobError::TargetMismatch));
wrong_target = target.clone();
wrong_target.device_name.push('x');
assert_eq!(signer.verify(&job, &wrong_target, Utc::now()), Err(DiagnosticJobError::TargetMismatch));
let mut expired = job;
expired.parameters.consent_expires_at =
(Utc::now() - chrono::Duration::seconds(1)).to_rfc3339_opts(SecondsFormat::Secs, true);
let (expired, signer) = sign_service_job(expired);
assert_eq!(signer.verify(&expired, &target, Utc::now()), Err(DiagnosticJobError::Expired));
}
#[tokio::test]
async fn signed_service_memory_job_exports_the_same_process_allocator_with_original_bindings() {
let _guard = TEST_PROFILE_LOCK.lock().await;
let (job, signer) = sign_service_job(service_job());
let verified = signer
.verify(&job, &service_target(&job), Utc::now())
.expect("verified memory job");
let identity = connect::DeviceIdentity::generate();
let provenance = rustfs::connect::ProfileProvenance::new("c".repeat(40), "d".repeat(64), "1.0.0-rc.6", vec![]);
// This invokes the service dispatcher directly in this mimalloc-using process,
// not the standalone CLI or a fixture allocation source.
let execution = execute_diagnostic_job(verified, &identity, provenance, &CancellationToken::new()).await;
#[cfg(target_os = "windows")]
{
assert_eq!(execution, Err(DiagnosticJobError::ProfileSourceUnavailable));
}
#[cfg(not(target_os = "windows"))]
{
let execution = execution.expect("service allocator capture");
assert_eq!(execution.job_id, job.job_id);
assert_eq!(execution.outcome, "SUCCEEDED");
assert_eq!(execution.reason, "COMPLETE");
assert_eq!(execution.artifact_uid.as_deref(), Some(job.parameters.artifact_uid.as_str()));
let bytes = execution.artifact_bytes.expect("signed artifact");
assert_eq!(execution.artifact_sha256, Some(hex(&Sha256::digest(&bytes))));
let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("profile archive");
let envelope_bytes = archive_entry(&mut archive, "envelope.json");
let envelope: serde_json::Value = serde_json::from_slice(&envelope_bytes).expect("envelope JSON");
let result_bytes = archive_entry(&mut archive, "result.json");
let result: serde_json::Value = serde_json::from_slice(&result_bytes).expect("result JSON");
assert_eq!(result["toolId"], "profile.memory");
assert_eq!(result["schemaVersion"], 1);
assert_eq!(result["capability"], MEMORY_PROFILE_CAPABILITY);
assert_eq!(result["runUid"], job.job_id);
assert_eq!(result["provenance"]["repository"], "rustfs/rustfs");
assert_eq!(result["provenance"]["sourceCommit"], "c".repeat(40));
assert_eq!(result["provenance"]["executableSha256"], "d".repeat(64));
assert_eq!(result["data"]["scope"], "ALLOCATION_AGGREGATES");
assert!(result["data"]["allocatedBytes"].is_u64());
assert!(result["data"]["allocationCount"].is_u64());
assert_eq!(envelope["organizationName"], job.organization_name);
assert_eq!(envelope["clusterName"], job.cluster_name);
assert_eq!(envelope["deviceName"], job.device_name);
assert_eq!(envelope["artifactUid"], job.parameters.artifact_uid);
assert_eq!(envelope["runUid"], job.job_id);
assert_eq!(envelope["consentUid"], job.parameters.consent_uid);
assert_eq!(envelope["policyRevision"], job.parameters.consent_policy_revision);
assert_eq!(envelope["classification"], "L3");
assert_eq!(envelope["nonce"], job.nonce);
assert_eq!(envelope["payload"]["sha256"], hex(&Sha256::digest(&result_bytes)));
let signature: serde_json::Value =
serde_json::from_slice(&archive_entry(&mut archive, "envelope.sig")).expect("signature JSON");
let raw = URL_SAFE_NO_PAD
.decode_to_vec(signature["value"].as_str().expect("signature"))
.expect("base64 signature");
let signature = Signature::from_slice(&raw).expect("ES256 signature");
let public = VerifyingKey::from_public_key_der(&identity.public_key_der()).expect("device public key");
let mut input = b"rustfs-diagnostic-envelope-v1\0".to_vec();
input.extend_from_slice(&envelope_bytes);
public.verify(&input, &signature).expect("original device signed artifact");
}
}
#[tokio::test]
async fn cancelled_service_memory_job_produces_no_artifact() {
let (job, signer) = sign_service_job(service_job());
let verified = signer
.verify(&job, &service_target(&job), Utc::now())
.expect("verified memory job");
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
execute_diagnostic_job(
verified,
&connect::DeviceIdentity::generate(),
rustfs::connect::ProfileProvenance::new("c".repeat(40), "d".repeat(64), "1.0.0-rc.6", vec![]),
&cancel,
)
.await,
Err(DiagnosticJobError::Cancelled)
);
}
#[tokio::test]
async fn service_memory_job_honors_cancellation_during_capture_and_the_signed_output_limit() {
let _guard = TEST_PROFILE_LOCK.lock().await;
let mut job = service_job();
job.parameters.sample_period_micros = 500_000;
let (job, signer) = sign_service_job(job);
let verified = signer
.verify(&job, &service_target(&job), Utc::now())
.expect("verified memory job");
let identity = connect::DeviceIdentity::generate();
let provenance = rustfs::connect::ProfileProvenance::new("c".repeat(40), "d".repeat(64), "1.0.0-rc.6", vec![]);
let cancel = CancellationToken::new();
let execution = execute_diagnostic_job(verified, &identity, provenance.clone(), &cancel);
let cancellation = async {
// The first join branch polls the collector through its first snapshot
// into the bounded wait before this branch cancels the same token.
cancel.cancel();
};
let (result, ()) = tokio::join!(biased; execution, cancellation);
#[cfg(not(target_os = "windows"))]
assert_eq!(result, Err(DiagnosticJobError::Cancelled));
#[cfg(target_os = "windows")]
assert_eq!(result, Err(DiagnosticJobError::ProfileSourceUnavailable));
let mut job = service_job();
job.limits.max_output_bytes = 1;
let (job, signer) = sign_service_job(job);
let verified = signer
.verify(&job, &service_target(&job), Utc::now())
.expect("bounded output memory job");
let result = execute_diagnostic_job(verified, &identity, provenance, &CancellationToken::new()).await;
#[cfg(not(target_os = "windows"))]
assert_eq!(result, Err(DiagnosticJobError::LimitExceeded));
#[cfg(target_os = "windows")]
assert_eq!(result, Err(DiagnosticJobError::ProfileSourceUnavailable));
}
#[tokio::test]
async fn real_mimalloc_profile_produces_a_signed_three_file_export() {
let _guard = TEST_PROFILE_LOCK.lock().await;
+99 -22
View File
@@ -506,6 +506,25 @@ SCHEDULED_ALERT_WORKFLOWS = tuple(
item["workflow"]
for item in json.loads((ROOT / ".github/scheduled-validations.json").read_text())
)
# Each migrated scheduled-alert workflow requires the new pin; this is not a list of pins that
# every workflow may choose between during the staged checkout update.
SCHEDULED_ALERT_CHECKOUT_OVERRIDES = {
".github/workflows/audit.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/coverage.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/e2e-replication-nightly.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/e2e-distributed.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/e2e-s3tests.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/mint.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/minio-interop.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/build.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/ci.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/fuzz.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/nightly-gnu.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/performance-ab.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/runner-hygiene.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/scheduled-validation-watchdog.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
".github/workflows/scheduled-validation-freshness.yml": "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
}
def words(value: str) -> set[str]:
@@ -1030,7 +1049,10 @@ def check_quick_checks(root: Path) -> list[str]:
yaml_scalar_continues(job, index, 4) for index in conditions
):
errors.append(f"{relative}: Quick Checks job must not add dependencies, bypass failures, or change its event condition")
checkout = workflow_step_block(job, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0")
checkout_ref = SCHEDULED_ALERT_CHECKOUT_OVERRIDES.get(
relative, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"
)
checkout = workflow_step_block(job, checkout_ref)
action = workflow_step_block(job, "./.github/actions/quick-checks")
if checkout is None or action is None:
errors.append(f"{relative}: Quick Checks requires checkout and the shared quick-checks action")
@@ -1073,8 +1095,9 @@ def alert_step_errors(
expected_action_if: str | None,
required_permissions: tuple[str, ...],
required_action_tokens: tuple[str, ...],
checkout_ref: str = "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
) -> list[str]:
checkout = workflow_step_block(job_lines, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0")
checkout = workflow_step_block(job_lines, checkout_ref)
action = workflow_step_block(job_lines, "./.github/actions/schedule-failure-issue")
errors: list[str] = []
permissions = yaml_block(job_lines, "permissions", 4)
@@ -1161,12 +1184,15 @@ def check_scheduled_alerts(root: Path) -> list[str]:
errors.append(f"{relative}: missing alert-on-failure job")
continue
job = "\n".join(line.split("#", 1)[0] for line in job_lines)
checkout_ref = SCHEDULED_ALERT_CHECKOUT_OVERRIDES.get(
relative, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"
)
required = (
"always()",
"github.event_name == 'schedule'",
"contains(needs.*.result, 'failure')",
"issues: write",
"uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
f"uses: {checkout_ref}",
"uses: ./.github/actions/schedule-failure-issue",
"github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}",
)
@@ -1176,7 +1202,10 @@ def check_scheduled_alerts(root: Path) -> list[str]:
else:
errors.extend(
f"{relative}: {error}"
for error in alert_step_errors(job_lines, None, ("issues: write",), ("github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}",))
for error in alert_step_errors(
job_lines, None, ("issues: write",),
("github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}",), checkout_ref=checkout_ref,
)
)
for (hour, minute), workflows in schedule_slots.items():
@@ -1213,13 +1242,17 @@ def check_scheduled_alerts(root: Path) -> list[str]:
errors.append(".github/workflows/scheduled-validation-watchdog.yml: missing alert-on-incomplete-run job")
return errors
watchdog_job = "\n".join(line.split("#", 1)[0] for line in watchdog_job_lines)
watchdog_checkout_ref = SCHEDULED_ALERT_CHECKOUT_OVERRIDES.get(
".github/workflows/scheduled-validation-watchdog.yml",
"actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
)
required = (
"github.event.workflow_run.event == 'schedule'",
"github.event.workflow_run.conclusion != 'success'",
"github.event.workflow_run.conclusion != 'failure'",
"actions: read",
"issues: write",
"uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
f"uses: {watchdog_checkout_ref}",
"uses: ./.github/actions/schedule-failure-issue",
"github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}",
"workflow-name: ${{ github.event.workflow_run.name }}",
@@ -1250,6 +1283,7 @@ def check_scheduled_alerts(root: Path) -> list[str]:
"source-ref-name: ${{ github.event.workflow_run.head_branch }}",
"source-sha: ${{ github.event.workflow_run.head_sha }}",
),
checkout_ref=watchdog_checkout_ref,
)
)
@@ -1264,12 +1298,16 @@ def check_scheduled_alerts(root: Path) -> list[str]:
errors.append(".github/workflows/scheduled-validation-freshness.yml: missing check-freshness job")
return errors
freshness_job = "\n".join(line.split("#", 1)[0] for line in freshness_job_lines)
freshness_checkout_ref = SCHEDULED_ALERT_CHECKOUT_OVERRIDES.get(
".github/workflows/scheduled-validation-freshness.yml",
"actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
)
required = (
"python3 scripts/check_scheduled_validation_freshness.py",
"actions: read",
"issues: write",
"if: failure()",
"uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
f"uses: {freshness_checkout_ref}",
"uses: ./.github/actions/schedule-failure-issue",
"github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}",
"details-file: ${{ runner.temp }}/scheduled-validation-freshness.md",
@@ -1290,6 +1328,7 @@ def check_scheduled_alerts(root: Path) -> list[str]:
"github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}",
"details-file: ${{ runner.temp }}/scheduled-validation-freshness.md",
),
checkout_ref=freshness_checkout_ref,
)
)
if not (root / "scripts/check_scheduled_validation_freshness.py").is_file():
@@ -3466,7 +3505,7 @@ class SelfTests(unittest.TestCase):
root = Path(tmp)
caller = (
"jobs:\n quick-checks:\n steps:\n"
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
" - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a\n"
" with:\n persist-credentials: false\n"
" - uses: ./.github/actions/quick-checks\n"
)
@@ -5850,6 +5889,23 @@ class SelfTests(unittest.TestCase):
self.assertEqual(selection.read_text(), f"sha256={digest}\n")
def test_scheduled_alerts_require_completion_watchdog(self) -> None:
old_checkout = "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"
new_checkout = "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a"
upgraded_workflows = (
".github/workflows/audit.yml",
".github/workflows/coverage.yml",
".github/workflows/e2e-replication-nightly.yml",
".github/workflows/e2e-distributed.yml",
".github/workflows/e2e-s3tests.yml",
".github/workflows/mint.yml",
".github/workflows/minio-interop.yml",
".github/workflows/build.yml",
".github/workflows/ci.yml",
".github/workflows/fuzz.yml",
".github/workflows/nightly-gnu.yml",
".github/workflows/performance-ab.yml",
".github/workflows/runner-hygiene.yml",
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
alert = (
@@ -5869,11 +5925,15 @@ class SelfTests(unittest.TestCase):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
names.append(path.stem)
checkout_ref = new_checkout if relative in upgraded_workflows else old_checkout
workflow_alert = alert.replace(old_checkout, checkout_ref)
path.write_text(
f'name: "{path.stem}"\n'
f'on:\n schedule:\n - cron: "{index} {index} * * *"\n'
f'jobs:\n{alert}'
f'jobs:\n{workflow_alert}'
)
watchdog_checkout = new_checkout
freshness_checkout = new_checkout
watchdog = root / ".github/workflows/scheduled-validation-watchdog.yml"
watchdog.write_text(
"on:\n workflow_run:\n workflows:\n"
@@ -5887,7 +5947,7 @@ class SelfTests(unittest.TestCase):
+ " actions: read\n"
+ " issues: write\n"
+ " steps:\n"
+ " - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
+ f" - uses: {watchdog_checkout}\n"
+ " - uses: ./.github/actions/schedule-failure-issue\n"
+ " with:\n"
+ " github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}\n"
@@ -5906,7 +5966,7 @@ class SelfTests(unittest.TestCase):
" actions: read\n"
" issues: write\n"
" steps:\n"
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
f" - uses: {freshness_checkout}\n"
" - run: python3 scripts/check_scheduled_validation_freshness.py\n"
" - uses: ./.github/actions/schedule-failure-issue\n"
" if: failure()\n"
@@ -5919,17 +5979,34 @@ class SelfTests(unittest.TestCase):
checker.write_text("")
self.assertEqual(check_scheduled_alerts(root), [])
# Reverting a migrated workflow must fail, just as independently
# upgrading an unmigrated one must fail: no shared two-pin allowlist.
self.assertTrue(set(upgraded_workflows).issubset(SCHEDULED_ALERT_WORKFLOWS))
for relative in SCHEDULED_ALERT_WORKFLOWS:
expected = new_checkout if relative in upgraded_workflows else old_checkout
rejected = old_checkout if relative in upgraded_workflows else new_checkout
path = root / relative
original = path.read_text()
with self.subTest(workflow=relative, rejected_pin=rejected):
self.assertIn(expected, original)
path.write_text(original.replace(expected, rejected))
errors = check_scheduled_alerts(root)
self.assertEqual(len(errors), 1)
self.assertIn(f"{relative}: alert-on-failure missing uses: {expected}", errors[0])
path.write_text(original)
first = root / SCHEDULED_ALERT_WORKFLOWS[0]
first_checkout = new_checkout if SCHEDULED_ALERT_WORKFLOWS[0] in upgraded_workflows else old_checkout
mutations = (
("contains(needs.*.result, 'failure')", "false"),
("issues: write", "issues: read"),
(
"uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
f"uses: {first_checkout}",
"uses: actions/checkout@missing",
),
(
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n",
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
f" - uses: {first_checkout}\n",
f" - uses: {first_checkout}\n"
" if: github.event_name == 'workflow_dispatch'\n",
),
(
@@ -5948,7 +6025,7 @@ class SelfTests(unittest.TestCase):
first_original = first.read_text()
real_steps = (
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
f" - uses: {first_checkout}\n"
" - uses: ./.github/actions/schedule-failure-issue\n"
" with:\n"
" github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}\n"
@@ -5958,7 +6035,7 @@ class SelfTests(unittest.TestCase):
real_steps,
" - run: |\n"
" : <<'MARKER'\n"
" uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
f" uses: {first_checkout}\n"
" MARKER\n"
" - run: |\n"
" : <<'MARKER'\n"
@@ -5974,7 +6051,7 @@ class SelfTests(unittest.TestCase):
" - uses: ./.github/actions/schedule-failure-issue\n"
" with:\n"
" github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}\n"
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n",
f" - uses: {first_checkout}\n",
)
)
self.assertEqual(len(check_scheduled_alerts(root)), 1)
@@ -5984,12 +6061,12 @@ class SelfTests(unittest.TestCase):
("actions: read", "actions: none"),
("issues: write", "issues: read"),
(
"uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
f"uses: {watchdog_checkout}",
"uses: actions/checkout@missing",
),
(
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n",
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
f" - uses: {watchdog_checkout}\n",
f" - uses: {watchdog_checkout}\n"
" if: github.event_name == 'workflow_dispatch'\n",
),
(
@@ -6092,15 +6169,15 @@ class SelfTests(unittest.TestCase):
self.assertEqual(len(check_scheduled_alerts(root)), 1)
freshness.write_text(
freshness_original.replace(
"uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
f"uses: {freshness_checkout}",
"uses: actions/checkout@missing",
)
)
self.assertEqual(len(check_scheduled_alerts(root)), 1)
freshness.write_text(
freshness_original.replace(
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n",
" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0\n"
f" - uses: {freshness_checkout}\n",
f" - uses: {freshness_checkout}\n"
" if: github.event_name == 'workflow_dispatch'\n",
)
)
+33 -8
View File
@@ -21,18 +21,28 @@ fail() {
}
if [[ $# -ne 5 ]]; then
fail "expected BUILD_TYPE SOURCE_VERSION DEV_SEQUENCE DEB_ARCH RPM_ARCH"
fail "expected BUILD_TYPE SOURCE_VERSION DEV_SEQUENCE ARCH LIBC"
fi
build_type=$1
source_version=$2
dev_sequence=$3
deb_arch=$4
rpm_arch=$5
arch=$4
libc=$5
case "${deb_arch}:${rpm_arch}" in
amd64:x86_64 | arm64:aarch64) ;;
*) fail "unsupported or mismatched architecture pair" ;;
case "$arch" in
x86_64 | aarch64) ;;
*) fail "unsupported architecture (expected x86_64 or aarch64)" ;;
esac
# The libc variant of the binary being packaged. It only distinguishes the
# package FILE names (gnu and musl builds of the same version would otherwise
# collide on the release); the dpkg/rpm package identity stays plain "rustfs"
# so the two variants remain mutually exclusive upgrades, not co-installable
# packages.
case "$libc" in
gnu | musl) ;;
*) fail "unsupported libc variant (expected gnu or musl)" ;;
esac
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
@@ -89,8 +99,23 @@ case "$build_type" in
*) fail "unsupported build type" ;;
esac
deb_file="rustfs_${deb_version}_${deb_arch}.deb"
rpm_file="rustfs-${rpm_version}-${rpm_release}.${rpm_arch}.rpm"
# Package file names mirror the binary artifact names, whose zips are named
# rustfs-linux-<arch>-<libc>-<version>.zip: all release assets of one build
# share the same stem and differ only by extension. Non-development builds
# embed the raw release tag after the 'v' marker exactly like the zips;
# development builds embed dev-<sha> (zips use the short SHA, packages the
# full one). The dpkg/rpm versions with their '~' prerelease ordering live
# in the package metadata above and are independent of the file name.
case "$build_type" in
development)
package_stem="rustfs-linux-${arch}-${libc}-dev-${source_sha}"
;;
*)
package_stem="rustfs-linux-${arch}-${libc}-v${source_version}"
;;
esac
deb_file="${package_stem}.deb"
rpm_file="${package_stem}.rpm"
# Emit only after every input and derived value has been validated. Consumers
# may append this fixed five-line protocol directly to GITHUB_OUTPUT.
+59 -47
View File
@@ -70,61 +70,73 @@ run_failure() {
sha=0123456789abcdef0123456789abcdef01234567
run_success stable-amd64 \
$'deb_version=1.2.3\nrpm_version=1.2.3\nrpm_release=1\ndeb_file=rustfs_1.2.3_amd64.deb\nrpm_file=rustfs-1.2.3-1.x86_64.rpm' \
release 1.2.3 '' amd64 x86_64
run_success alpha-arm64 \
$'deb_version=1.2.3~alpha.1\nrpm_version=1.2.3~alpha.1\nrpm_release=1\ndeb_file=rustfs_1.2.3~alpha.1_arm64.deb\nrpm_file=rustfs-1.2.3~alpha.1-1.aarch64.rpm' \
prerelease 1.2.3-alpha.1 '' arm64 aarch64
run_success beta-amd64 \
$'deb_version=1.2.3~beta.2\nrpm_version=1.2.3~beta.2\nrpm_release=1\ndeb_file=rustfs_1.2.3~beta.2_amd64.deb\nrpm_file=rustfs-1.2.3~beta.2-1.x86_64.rpm' \
prerelease 1.2.3-beta.2 '' amd64 x86_64
run_success rc-amd64 \
$'deb_version=1.2.3~rc.4\nrpm_version=1.2.3~rc.4\nrpm_release=1\ndeb_file=rustfs_1.2.3~rc.4_amd64.deb\nrpm_file=rustfs-1.2.3~rc.4-1.x86_64.rpm' \
prerelease 1.2.3-rc.4 '' amd64 x86_64
run_success preview-amd64 \
$'deb_version=1.0.0~rc.5~preview.2\nrpm_version=1.0.0~rc.5~preview.2\nrpm_release=1\ndeb_file=rustfs_1.0.0~rc.5~preview.2_amd64.deb\nrpm_file=rustfs-1.0.0~rc.5~preview.2-1.x86_64.rpm' \
preview 1.0.0-rc.5-preview.2 '' amd64 x86_64
run_success development-amd64 \
run_success stable-x86_64-gnu \
$'deb_version=1.2.3\nrpm_version=1.2.3\nrpm_release=1\ndeb_file=rustfs-linux-x86_64-gnu-v1.2.3.deb\nrpm_file=rustfs-linux-x86_64-gnu-v1.2.3.rpm' \
release 1.2.3 '' x86_64 gnu
run_success stable-x86_64-musl \
$'deb_version=1.2.3\nrpm_version=1.2.3\nrpm_release=1\ndeb_file=rustfs-linux-x86_64-musl-v1.2.3.deb\nrpm_file=rustfs-linux-x86_64-musl-v1.2.3.rpm' \
release 1.2.3 '' x86_64 musl
run_success alpha-aarch64-gnu \
$'deb_version=1.2.3~alpha.1\nrpm_version=1.2.3~alpha.1\nrpm_release=1\ndeb_file=rustfs-linux-aarch64-gnu-v1.2.3-alpha.1.deb\nrpm_file=rustfs-linux-aarch64-gnu-v1.2.3-alpha.1.rpm' \
prerelease 1.2.3-alpha.1 '' aarch64 gnu
run_success alpha-aarch64-musl \
$'deb_version=1.2.3~alpha.1\nrpm_version=1.2.3~alpha.1\nrpm_release=1\ndeb_file=rustfs-linux-aarch64-musl-v1.2.3-alpha.1.deb\nrpm_file=rustfs-linux-aarch64-musl-v1.2.3-alpha.1.rpm' \
prerelease 1.2.3-alpha.1 '' aarch64 musl
run_success beta-x86_64-gnu \
$'deb_version=1.2.3~beta.2\nrpm_version=1.2.3~beta.2\nrpm_release=1\ndeb_file=rustfs-linux-x86_64-gnu-v1.2.3-beta.2.deb\nrpm_file=rustfs-linux-x86_64-gnu-v1.2.3-beta.2.rpm' \
prerelease 1.2.3-beta.2 '' x86_64 gnu
run_success rc-x86_64-musl \
$'deb_version=1.2.3~rc.4\nrpm_version=1.2.3~rc.4\nrpm_release=1\ndeb_file=rustfs-linux-x86_64-musl-v1.2.3-rc.4.deb\nrpm_file=rustfs-linux-x86_64-musl-v1.2.3-rc.4.rpm' \
prerelease 1.2.3-rc.4 '' x86_64 musl
run_success preview-x86_64-gnu \
$'deb_version=1.0.0~rc.5~preview.2\nrpm_version=1.0.0~rc.5~preview.2\nrpm_release=1\ndeb_file=rustfs-linux-x86_64-gnu-v1.0.0-rc.5-preview.2.deb\nrpm_file=rustfs-linux-x86_64-gnu-v1.0.0-rc.5-preview.2.rpm' \
preview 1.0.0-rc.5-preview.2 '' x86_64 gnu
run_success preview-aarch64-musl \
$'deb_version=1.0.0~rc.5~preview.2\nrpm_version=1.0.0~rc.5~preview.2\nrpm_release=1\ndeb_file=rustfs-linux-aarch64-musl-v1.0.0-rc.5-preview.2.deb\nrpm_file=rustfs-linux-aarch64-musl-v1.0.0-rc.5-preview.2.rpm' \
preview 1.0.0-rc.5-preview.2 '' aarch64 musl
run_success development-x86_64-gnu \
"deb_version=0~dev.7463.${sha}
rpm_version=0
rpm_release=0.dev.7463.${sha}
deb_file=rustfs_0~dev.7463.${sha}_amd64.deb
rpm_file=rustfs-0-0.dev.7463.${sha}.x86_64.rpm" \
development "dev-${sha}" 7463 amd64 x86_64
run_success development-arm64 \
deb_file=rustfs-linux-x86_64-gnu-dev-${sha}.deb
rpm_file=rustfs-linux-x86_64-gnu-dev-${sha}.rpm" \
development "dev-${sha}" 7463 x86_64 gnu
run_success development-aarch64-musl \
"deb_version=0~dev.7463.${sha}
rpm_version=0
rpm_release=0.dev.7463.${sha}
deb_file=rustfs_0~dev.7463.${sha}_arm64.deb
rpm_file=rustfs-0-0.dev.7463.${sha}.aarch64.rpm" \
development "dev-${sha}" 7463 arm64 aarch64
deb_file=rustfs-linux-aarch64-musl-dev-${sha}.deb
rpm_file=rustfs-linux-aarch64-musl-dev-${sha}.rpm" \
development "dev-${sha}" 7463 aarch64 musl
run_failure missing-arguments
run_failure empty-build-type '' 1.2.3 '' amd64 x86_64
run_failure unknown-build-type nightly 1.2.3 '' amd64 x86_64
run_failure empty-version release '' '' amd64 x86_64
run_failure release-with-sequence release 1.2.3 1 amd64 x86_64
run_failure release-prerelease-mismatch release 1.2.3-rc.1 '' amd64 x86_64
run_failure prerelease-release-mismatch prerelease 1.2.3 '' amd64 x86_64
run_failure preview-malformed preview 1.2.3-rc.1-preview '' amd64 x86_64
run_failure preview-wrong-shape preview 1.2.3-preview.1 '' amd64 x86_64
run_failure short-semver release 1.2 '' amd64 x86_64
run_failure leading-v release v1.2.3 '' amd64 x86_64
run_failure leading-zero release 01.2.3 '' amd64 x86_64
run_failure zero-sequence development "dev-${sha}" 0 amd64 x86_64
run_failure leading-zero-sequence development "dev-${sha}" 01 amd64 x86_64
run_failure non-decimal-sequence development "dev-${sha}" seven amd64 x86_64
run_failure empty-dev-sha development dev- 1 amd64 x86_64
run_failure short-dev-sha development dev-0123456 1 amd64 x86_64
run_failure uppercase-dev-sha development dev-0123456789ABCDEF0123456789ABCDEF01234567 1 amd64 x86_64
run_failure dev-extra-suffix development "dev-${sha}-dirty" 1 amd64 x86_64
run_failure whitespace release '1.2.3 bad' '' amd64 x86_64
run_failure command-substitution release "1.2.3\$(id)" '' amd64 x86_64
run_failure backticks release "1.2.3\`id\`" '' amd64 x86_64
run_failure newline release $'1.2.3\nforged=1' '' amd64 x86_64
run_failure unsupported-deb-arch release 1.2.3 '' x86_64 x86_64
run_failure mismatched-arch release 1.2.3 '' amd64 aarch64
run_failure empty-build-type '' 1.2.3 '' x86_64 gnu
run_failure unknown-build-type nightly 1.2.3 '' x86_64 gnu
run_failure empty-version release '' '' x86_64 gnu
run_failure release-with-sequence release 1.2.3 1 x86_64 gnu
run_failure release-prerelease-mismatch release 1.2.3-rc.1 '' x86_64 gnu
run_failure prerelease-release-mismatch prerelease 1.2.3 '' x86_64 gnu
run_failure preview-malformed preview 1.2.3-rc.1-preview '' x86_64 gnu
run_failure preview-wrong-shape preview 1.2.3-preview.1 '' x86_64 gnu
run_failure short-semver release 1.2 '' x86_64 gnu
run_failure leading-v release v1.2.3 '' x86_64 gnu
run_failure leading-zero release 01.2.3 '' x86_64 gnu
run_failure zero-sequence development "dev-${sha}" 0 x86_64 gnu
run_failure leading-zero-sequence development "dev-${sha}" 01 x86_64 gnu
run_failure non-decimal-sequence development "dev-${sha}" seven x86_64 gnu
run_failure empty-dev-sha development dev- 1 x86_64 gnu
run_failure short-dev-sha development dev-0123456 1 x86_64 gnu
run_failure uppercase-dev-sha development dev-0123456789ABCDEF0123456789ABCDEF01234567 1 x86_64 gnu
run_failure dev-extra-suffix development "dev-${sha}-dirty" 1 x86_64 gnu
run_failure whitespace release '1.2.3 bad' '' x86_64 gnu
run_failure command-substitution release "1.2.3\$(id)" '' x86_64 gnu
run_failure backticks release "1.2.3\`id\`" '' x86_64 gnu
run_failure newline release $'1.2.3\nforged=1' '' x86_64 gnu
run_failure unsupported-arch release 1.2.3 '' armv7 gnu
run_failure empty-arch release 1.2.3 '' '' gnu
run_failure empty-libc release 1.2.3 '' x86_64 ''
run_failure unknown-libc release 1.2.3 '' x86_64 static
run_failure too-many-arguments release 1.2.3 '' x86_64 gnu extra
# Ordering contract shared by both package managers: every pre-release sorts
# below its final release, every preview sorts below the pre-release it