mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 13:46:05 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dddb4aa817 | |||
| a722fa80d5 | |||
| d70bc931b3 | |||
| 9ecb500cbf | |||
| 5832623e70 | |||
| e0f2d56c96 | |||
| 832c7dab9f | |||
| 6aeacdd961 | |||
| a8cecf6462 | |||
| 980f3abbd3 | |||
| e36650827b | |||
| 09c8e10d5e | |||
| 0ff03c596c | |||
| a74919db8e | |||
| e77c6f0ca5 | |||
| 3149411943 |
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
|
sha256-darwin=845feb5859c4063c38307ada8f263f4039ebaf54c510bbc9177c4ab0dba2d8a9
|
||||||
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
|
sha256-linux=22320a04e541ef27cf1d0df3670ab3fafb62c57beef9a57eaff895e84a1e8380
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
|||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||||
@echo "🧪 Checking test wiring..."
|
@echo "🧪 Checking test wiring..."
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
|
|||||||
@@ -111,10 +111,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
shell: bash
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
- name: Check io_uring lane --lib precondition
|
||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Copyright 2026 RustFS Team
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
# Reports the existing required checks for paths excluded by ci.yml.
|
|
||||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
|
||||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
|
||||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [ opened, synchronize, reopened ]
|
|
||||||
branches: [ main ]
|
|
||||||
paths:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quick-checks:
|
|
||||||
name: Quick Checks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run shared quick checks
|
|
||||||
uses: ./.github/actions/quick-checks
|
|
||||||
|
|
||||||
test-and-lint:
|
|
||||||
name: Test and Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
|
||||||
# planning-type document could be slipped in (git add -f bypasses
|
|
||||||
# .gitignore). Run the guard here so the required "Test and Lint" check
|
|
||||||
# stays meaningful for docs-only changes.
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Satisfy required check for docs-only changes
|
|
||||||
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
|
|
||||||
+90
-83
@@ -37,25 +37,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
|
|
||||||
# reports the required "Test and Lint" check for PRs skipped here.
|
|
||||||
paths-ignore:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
@@ -88,6 +69,32 @@ jobs:
|
|||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
|
|
||||||
|
classify-changes:
|
||||||
|
name: Select CI scope
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
mode: ${{ steps.scope.outputs.mode }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Select scope using the base revision's policy
|
||||||
|
id: scope
|
||||||
|
env:
|
||||||
|
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||||
|
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
|
||||||
|
else
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Base CI policy unavailable; running the full matrix."
|
||||||
|
fi
|
||||||
|
|
||||||
typos:
|
typos:
|
||||||
name: Typos
|
name: Typos
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -100,7 +107,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fail early with compile-free checks shared with docs-only CI.
|
# Fail early with compile-free checks for every pull request.
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -116,9 +123,9 @@ jobs:
|
|||||||
uses: ./.github/actions/quick-checks
|
uses: ./.github/actions/quick-checks
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Workspace Test and Lint
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -289,45 +296,6 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
# Record the reason before this job completes as FAILURE. A separate
|
|
||||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
|
||||||
# required check's failure verdict.
|
|
||||||
- name: Annotate early-stop reason
|
|
||||||
if: >-
|
|
||||||
failure() && github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "## CI early-stop"
|
|
||||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
|
||||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
|
||||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
|
||||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
|
||||||
cancel-after-test-and-lint-failure:
|
|
||||||
name: Cancel siblings after Test and Lint failure
|
|
||||||
if: >-
|
|
||||||
failure() && needs.test-and-lint.result == 'failure'
|
|
||||||
&& github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
needs: [ test-and-lint ]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
actions: write
|
|
||||||
steps:
|
|
||||||
- name: Cancel remaining jobs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||||
@@ -340,8 +308,8 @@ jobs:
|
|||||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||||
test-ilm-integration-serial:
|
test-ilm-integration-serial:
|
||||||
name: ILM Integration (serial)
|
name: ILM Integration (serial)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -408,8 +376,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -449,8 +417,8 @@ jobs:
|
|||||||
|
|
||||||
connect-short-credential-boundary:
|
connect-short-credential-boundary:
|
||||||
name: Connect Short Credential Boundary
|
name: Connect Short Credential Boundary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -507,8 +475,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
strategy:
|
strategy:
|
||||||
@@ -561,8 +529,8 @@ jobs:
|
|||||||
|
|
||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -600,7 +568,7 @@ jobs:
|
|||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
|
argv = ["python3", "scripts/e2e_binary.py", "build", "--bins", "--features", "e2e-test-hooks"]
|
||||||
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
||||||
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
||||||
if not clean_before:
|
if not clean_before:
|
||||||
@@ -634,6 +602,7 @@ jobs:
|
|||||||
name: rustfs-debug-binary
|
name: rustfs-debug-binary
|
||||||
path: |
|
path: |
|
||||||
target/debug/rustfs
|
target/debug/rustfs
|
||||||
|
target/debug/rustfs.e2e.json
|
||||||
target/debug/rustfs.e2e-startup-cas-build.json
|
target/debug/rustfs.e2e-startup-cas-build.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
@@ -666,13 +635,15 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary with rio-v2
|
- name: Build debug binary with rio-v2
|
||||||
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
run: python3 scripts/e2e_binary.py build --bins --features rio-v2,e2e-test-hooks
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-debug-binary-rio-v2
|
name: rustfs-debug-binary-rio-v2
|
||||||
path: target/debug/rustfs
|
path: |
|
||||||
|
target/debug/rustfs
|
||||||
|
target/debug/rustfs.e2e.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -684,8 +655,8 @@ jobs:
|
|||||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||||
@@ -821,7 +792,7 @@ jobs:
|
|||||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||||
--status-level all --final-status-level all --failure-output final
|
--status-level all --final-status-level all --failure-output final
|
||||||
|
|
||||||
- name: Upload e2e smoke diagnostics
|
- name: Upload e2e smoke diagnostics
|
||||||
@@ -857,7 +828,7 @@ jobs:
|
|||||||
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||||
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
||||||
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
||||||
./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -985,6 +956,7 @@ jobs:
|
|||||||
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
||||||
raise SystemExit("downloaded hooks binary identity mismatch")
|
raise SystemExit("downloaded hooks binary identity mismatch")
|
||||||
shutil.copy2(manifest_path, target / manifest_path.name)
|
shutil.copy2(manifest_path, target / manifest_path.name)
|
||||||
|
shutil.copy2(source.with_name("rustfs.e2e.json"), target / "rustfs.e2e.json")
|
||||||
binary.chmod(0o755)
|
binary.chmod(0o755)
|
||||||
PYINPUT
|
PYINPUT
|
||||||
|
|
||||||
@@ -1004,7 +976,7 @@ jobs:
|
|||||||
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||||
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||||
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
run: python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||||
|
|
||||||
- name: Upload junit
|
- name: Upload junit
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1076,7 +1048,7 @@ jobs:
|
|||||||
- name: Run end-to-end tests
|
- name: Run end-to-end tests
|
||||||
run: |
|
run: |
|
||||||
s3s-e2e --version
|
s3s-e2e --version
|
||||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
python3 scripts/e2e_binary.py run --features rio-v2,e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -1119,7 +1091,7 @@ jobs:
|
|||||||
S3_PORT="${S3_PORT}" \
|
S3_PORT="${S3_PORT}" \
|
||||||
DATA_ROOT="${RUN_ROOT}" \
|
DATA_ROOT="${RUN_ROOT}" \
|
||||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||||
./scripts/s3-tests/run.sh
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Upload s3 test artifacts
|
- name: Upload s3 test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1201,7 +1173,7 @@ jobs:
|
|||||||
S3_PORT="${S3_PORT}" \
|
S3_PORT="${S3_PORT}" \
|
||||||
DATA_ROOT="${RUN_ROOT}" \
|
DATA_ROOT="${RUN_ROOT}" \
|
||||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||||
./scripts/s3-tests/run.sh
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Upload s3 test artifacts
|
- name: Upload s3 test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1212,9 +1184,44 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
required-checks:
|
||||||
|
name: Test and Lint
|
||||||
|
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- typos
|
||||||
|
- quick-checks
|
||||||
|
- test-and-lint
|
||||||
|
- test-ilm-integration-serial
|
||||||
|
- test-and-lint-rio-v2
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- test-and-lint-protocols
|
||||||
|
- build-rustfs-debug-binary
|
||||||
|
- uring-integration
|
||||||
|
- e2e-tests
|
||||||
|
- s3-implemented-tests
|
||||||
|
- s3-lifecycle-behavior-tests
|
||||||
|
- build-rustfs-debug-binary-rio-v2
|
||||||
|
- e2e-tests-rio-v2
|
||||||
|
- e2e-full
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Require the expected result of every CI lane
|
||||||
|
env:
|
||||||
|
CI_NEEDS: ${{ toJSON(needs) }}
|
||||||
|
shell: bash
|
||||||
|
run: python3 scripts/ci_gate.py verify
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
name: Alert on scheduled failure
|
name: Alert on scheduled failure
|
||||||
needs:
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- required-checks
|
||||||
- typos
|
- typos
|
||||||
- quick-checks
|
- quick-checks
|
||||||
- test-and-lint
|
- test-and-lint
|
||||||
|
|||||||
@@ -151,8 +151,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs --bins
|
python3 scripts/e2e_binary.py build --bins
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify distributed e2e membership
|
- name: Verify distributed e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -168,9 +167,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -n "${FILTER}" ]; then
|
if [ -n "${FILTER}" ]; then
|
||||||
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||||
else
|
else
|
||||||
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload distributed e2e diagnostics
|
- name: Upload distributed e2e diagnostics
|
||||||
|
|||||||
@@ -89,14 +89,10 @@ jobs:
|
|||||||
- name: Verify awscurl
|
- name: Verify awscurl
|
||||||
run: test -x "$AWSCURL_PATH"
|
run: test -x "$AWSCURL_PATH"
|
||||||
|
|
||||||
# Build the rustfs binary once up front. The e2e tests spawn it as a
|
# Build once and carry its source/binary identity into the test invocation.
|
||||||
# child process (crates/e2e_test/src/common.rs) and will build it on
|
|
||||||
# demand otherwise, but a single explicit build avoids several parallel
|
|
||||||
# nextest test processes racing to build it at once.
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs --bins
|
python3 scripts/e2e_binary.py build --bins
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify replication e2e membership
|
- name: Verify replication e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -108,7 +104,7 @@ jobs:
|
|||||||
- name: Run replication e2e nightly suite
|
- name: Run replication e2e nightly suite
|
||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||||
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload nextest junit report
|
- name: Upload nextest junit report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -144,8 +140,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify cluster fault e2e membership
|
- name: Verify cluster fault e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -157,7 +152,7 @@ jobs:
|
|||||||
- name: Run cluster fault e2e nightly suite
|
- name: Run cluster fault e2e nightly suite
|
||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||||
run: cargo nextest run --profile e2e-nightly -p e2e_test
|
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload cluster fault diagnostics
|
- name: Upload cluster fault diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
@@ -198,6 +193,9 @@ jobs:
|
|||||||
sudo apt-get install -y -qq iproute2
|
sudo apt-get install -y -qq iproute2
|
||||||
ss -tn state CLOSE-WAIT >/dev/null
|
ss -tn state CLOSE-WAIT >/dev/null
|
||||||
|
|
||||||
|
- name: Build protocol server
|
||||||
|
run: python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"
|
||||||
|
|
||||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||||
- name: Verify protocol e2e membership
|
- name: Verify protocol e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -210,7 +208,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||||
run: >-
|
run: >-
|
||||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||||
|
|
||||||
- name: Upload protocol diagnostics
|
- name: Upload protocol diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -125,12 +125,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build current RustFS binary
|
- name: Build current RustFS binary
|
||||||
run: |
|
run: |
|
||||||
cargo build --locked -p rustfs --bin rustfs
|
python3 scripts/e2e_binary.py build
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Run upgrade compatibility test
|
- name: Run upgrade compatibility test
|
||||||
run: |
|
run: |
|
||||||
cargo test --locked -p e2e_test \
|
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||||
-- --ignored --exact --nocapture
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||||
|
|
||||||
- name: Build the RustFS binary under test
|
- name: Build the RustFS binary under test
|
||||||
run: cargo build --locked -p rustfs --bins
|
run: python3 scripts/e2e_binary.py build --bins
|
||||||
|
|
||||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
- name: Run the interop cases against MinIO
|
- name: Run the interop cases against MinIO
|
||||||
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||||
|
|
||||||
- name: Build the MinIO interop report
|
- name: Build the MinIO interop report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -251,7 +251,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the RustFS binary under test
|
- name: Build the RustFS binary under test
|
||||||
if: steps.credentials.outputs.present == 'true'
|
if: steps.credentials.outputs.present == 'true'
|
||||||
run: cargo build --locked -p rustfs --bins
|
run: python3 scripts/e2e_binary.py build --bins
|
||||||
|
|
||||||
# A filterset that matches nothing is valid, so the count is asserted
|
# A filterset that matches nothing is valid, so the count is asserted
|
||||||
# rather than inferred from a green run.
|
# rather than inferred from a green run.
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
- name: Run the three-case minimum
|
- name: Run the three-case minimum
|
||||||
if: steps.credentials.outputs.present == 'true'
|
if: steps.credentials.outputs.present == 'true'
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||||
|
|
||||||
- name: Build the ${{ matrix.provider }} interop report
|
- name: Build the ${{ matrix.provider }} interop report
|
||||||
|
|||||||
+42
-47
@@ -1,7 +1,7 @@
|
|||||||
# e2e_test
|
# e2e_test
|
||||||
|
|
||||||
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
||||||
(built on demand from the workspace) and drives it over the network with the
|
(built and identified before the test invocation) and drives it over the network with the
|
||||||
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
||||||
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
||||||
end-to-end behavior lives here, unit behavior stays in the source crates
|
end-to-end behavior lives here, unit behavior stays in the source crates
|
||||||
@@ -32,32 +32,28 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
All commands assume repo root and Python 3.9 or newer on Linux or macOS. Build the server once through the provenance entry point, then run the test command through the same script:
|
||||||
`rustfs` binary from [`src/common.rs`](src/common.rs) (`rustfs_binary_path`) on
|
|
||||||
first use — the first invocation is slow, later ones reuse the binary.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Whole crate (default = ignored tests skipped)
|
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||||
cargo nextest run -p e2e_test
|
|
||||||
|
# Whole crate (ignored tests remain skipped)
|
||||||
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test
|
||||||
|
|
||||||
# One module
|
# One module
|
||||||
cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||||
|
|
||||||
# PR smoke subset (see "CI smoke subset" below)
|
|
||||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
|
||||||
|
|
||||||
# ILM serial lane — ignored lifecycle tests, single-threaded (mirrors CI)
|
|
||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
|
||||||
|
|
||||||
|
# PR smoke subset
|
||||||
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
```
|
```
|
||||||
|
|
||||||
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
`build` records the source contents, HEAD, resolved Cargo features, profile, toolchain, and binary SHA-256 beside the executable in `rustfs.e2e.json`. `run` validates that identity before and after the command, preserves command failures, and removes its temporary run receipt on completion. The Rust harness checks that receipt before starting each server; it never compiles a server inside a test process. Source or binary changes during a run invalidate the result, even when the test command succeeds. Use an isolated worktree and keep it unchanged until the command finishes.
|
||||||
single-worker execution, feature-gated scheduling) documented in
|
|
||||||
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
The additional `--features` arguments must match between `build` and `run`; Cargo defaults remain enabled. The wrapper supplies `RUSTFS_BUILD_FEATURES` from Cargo's resolved feature list, including features enabled by `full`. Protocol helpers require a subset of that list. `CARGO_TARGET_DIR` and `--profile release` are supported. An in-workspace target directory must be Git-ignored; tracked files are always included in the source identity. `build --bins` preserves CI lanes that compile all RustFS binary targets. For a downloaded artifact, copy both the executable and its sidecar, then use `run`; do not generate a new identity for an arbitrary prebuilt binary. `CARGO_BIN_EXE_rustfs` cannot override the verified executable.
|
||||||
selects which features the spawned binary is built with; leave it unset to run
|
|
||||||
every protocol entry. Use the exact profile command under
|
Each build/run holds an exclusive `rustfs.e2e.lock` marker beside the binary; concurrent wrappers fail immediately. Use a private target directory and do not run ordinary Cargo builds against it while tests are active: Cargo does not honor this marker. Interrupted runs fail and terminate their command group. After an uncatchable kill, inspect the PID recorded in a leftover marker and remove it only after confirming its owner has stopped. Embedded file symlinks are hashed through their target; embedded directory symlinks are rejected because their contents cannot be enumerated safely by this entry point.
|
||||||
[Troubleshooting](#troubleshooting) for CI-equivalent execution.
|
|
||||||
|
The protocols suite has its own fixed-port and single-worker contract in [`src/protocols/README.md`](src/protocols/README.md). Use its command under [Troubleshooting](#troubleshooting).
|
||||||
|
|
||||||
### `#[ignore]` semantics
|
### `#[ignore]` semantics
|
||||||
|
|
||||||
@@ -123,7 +119,7 @@ via `create_s3_client(idx)` / `create_all_clients()`. See
|
|||||||
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
||||||
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
||||||
| `find_available_port` | Random free port (isolation primitive) |
|
| `find_available_port` | Random free port (isolation primitive) |
|
||||||
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
|
| `rustfs_binary_path` / `_with_features` | Verify this run's binary receipt and required feature subset |
|
||||||
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
||||||
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
||||||
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
||||||
@@ -189,35 +185,33 @@ the wiring source of truth. Committed test-ID digests under
|
|||||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
# Smoke, full, and cluster lanes share a server with fault-test hooks.
|
||||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||||
# Full single-node merge/main lane
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
cargo nextest run --profile e2e-full -p e2e_test
|
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||||
# Cluster fault nightly lane
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
|
||||||
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
|
# Distributed 4-node 4-disk lane uses the default server.
|
||||||
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
|
||||||
cargo nextest run --profile e2e-distributed -p e2e_test
|
python3 scripts/e2e_binary.py build
|
||||||
# Replication nightly lane; awscurl is required for STS paths
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
|
||||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
|
||||||
# Fixed-port protocol nightly lane
|
# Replication nightly uses the default server; awscurl is required for STS.
|
||||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
python3 scripts/e2e_binary.py build
|
||||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# ILM serial lane
|
|
||||||
|
# Protocol nightly owns fixed ports.
|
||||||
|
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||||
|
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||||
|
|
||||||
|
# The ILM serial lane does not use this server harness.
|
||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||||
# s3s-e2e black box
|
|
||||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs-e2e-data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Stale binary.** Tests build the `rustfs` binary once and reuse it. To avoid
|
The full lane also requires the startup-CAS build manifest generated by the `Build debug binary` step in `.github/workflows/ci.yml`. Preserve that binary and both sidecars as its `Preserve startup CAS binary input` step does, and use the same `RUSTFS_E2E_STARTUP_CAS_*` environment as `Run e2e full suite`. A generic local build alone does not supply that fixture evidence.
|
||||||
rebuilding while iterating on tests, `common.rs` reuses an existing binary when
|
|
||||||
running *inside* the e2e test process even if sources changed
|
**Stale or unverified binary.** Re-run the matching `build` command after changing source or features, then invoke tests through `run`. A missing receipt, copied old executable, or mismatched build identity is a prerequisite failure. Bare Cargo invocations that start a server deliberately fail; unit tests that do not start a server can still run directly.
|
||||||
(`can_reuse_inside_e2e`, [`src/common.rs`](src/common.rs) line 98). Downside: if
|
|
||||||
you changed **server** code, force a rebuild with
|
|
||||||
`cargo build -p rustfs` (or `touch` a source file outside the reuse window)
|
|
||||||
before re-running, or CI's freshly built artifact will diverge from your local
|
|
||||||
one.
|
|
||||||
|
|
||||||
**Port already in use / orphan processes.** A hard-killed run can leak a
|
**Port already in use / orphan processes.** A hard-killed run can leak a
|
||||||
`rustfs` child holding its port. Find and kill it:
|
`rustfs` child holding its port. Find and kill it:
|
||||||
@@ -249,7 +243,8 @@ spawn error. Install the pinned CI version before running their profiles.
|
|||||||
A subset of this crate runs on every PR via the `e2e-tests` job:
|
A subset of this crate runs on every PR via the `e2e-tests` job:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||||
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
```
|
```
|
||||||
|
|
||||||
The selection lives in `.config/nextest.toml` under `[profile.e2e-smoke]`
|
The selection lives in `.config/nextest.toml` under `[profile.e2e-smoke]`
|
||||||
|
|||||||
+121
-147
@@ -31,7 +31,6 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -44,7 +43,6 @@ use tokio::net::TcpStream;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
|
||||||
|
|
||||||
// Common constants for all E2E tests
|
// Common constants for all E2E tests
|
||||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||||
@@ -428,59 +426,75 @@ fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&P
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
/// Resolve the server verified by `scripts/e2e_binary.py run` for this test invocation.
|
||||||
|
/// Requested features are a required subset of the server's resolved Cargo features.
|
||||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||||
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
|
||||||
return PathBuf::from(path);
|
|
||||||
}
|
|
||||||
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
|
||||||
|
|
||||||
let workspace = workspace_root();
|
let workspace = workspace_root();
|
||||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||||
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
let binary_path = std::env::var_os("CARGO_BIN_EXE_rustfs")
|
||||||
|
.map(PathBuf::from)
|
||||||
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
.unwrap_or_else(|| resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref()));
|
||||||
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
let receipt_path = std::env::var_os("RUSTFS_E2E_BINARY_RECEIPT").map(PathBuf::from);
|
||||||
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
|
receipt_path
|
||||||
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
|
.ok_or_else(|| std::io::Error::new(ErrorKind::NotFound, "missing E2E run receipt"))
|
||||||
if source_is_newer {
|
.and_then(|receipt| verify_e2e_binary_receipt(&receipt, &workspace, &binary_path, requested_features))
|
||||||
warn!(
|
.unwrap_or_else(|error| {
|
||||||
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
|
panic!(
|
||||||
binary_path
|
"E2E server prerequisite failed: {error}. Build with `python3 scripts/e2e_binary.py build --features <features>` and run tests with `python3 scripts/e2e_binary.py run --features <features> -- cargo nextest run ...`"
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
info!("Using existing RustFS binary at {:?}", binary_path);
|
|
||||||
return binary_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Building RustFS binary to ensure it's up to date...");
|
|
||||||
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
|
||||||
|
|
||||||
info!("Using RustFS binary at {:?}", binary_path);
|
|
||||||
binary_path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
|
#[derive(serde::Deserialize)]
|
||||||
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
|
#[serde(deny_unknown_fields)]
|
||||||
return true;
|
struct E2eBinaryReceipt {
|
||||||
};
|
schema: u32,
|
||||||
let Ok(binary_modified) = binary_meta.modified() else {
|
workspace: PathBuf,
|
||||||
return true;
|
binary: PathBuf,
|
||||||
};
|
size: u64,
|
||||||
|
modified_ns: u128,
|
||||||
let workspace = workspace_root();
|
features: Vec<String>,
|
||||||
let watch_roots = [
|
|
||||||
workspace.join("Cargo.toml"),
|
|
||||||
workspace.join("Cargo.lock"),
|
|
||||||
workspace.join("rustfs"),
|
|
||||||
workspace.join("crates"),
|
|
||||||
];
|
|
||||||
|
|
||||||
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn running_inside_e2e_test_binary() -> bool {
|
fn verify_e2e_binary_receipt(
|
||||||
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
|
receipt_path: &Path,
|
||||||
|
workspace: &Path,
|
||||||
|
binary_path: &Path,
|
||||||
|
requested_features: Option<&str>,
|
||||||
|
) -> std::io::Result<PathBuf> {
|
||||||
|
let receipt: E2eBinaryReceipt = serde_json::from_slice(&stdfs::read(receipt_path)?)?;
|
||||||
|
let binary = binary_path.canonicalize()?;
|
||||||
|
let metadata = binary.metadata()?;
|
||||||
|
let modified_ns = metadata
|
||||||
|
.modified()?
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_err(std::io::Error::other)?
|
||||||
|
.as_nanos();
|
||||||
|
// The runner hashes source and binary before/after the entire suite. Each
|
||||||
|
// nextest process checks only this invocation's path, features, and file stat.
|
||||||
|
if receipt.schema != 1
|
||||||
|
|| receipt.workspace != workspace.canonicalize()?
|
||||||
|
|| receipt.binary != binary
|
||||||
|
|| !metadata.is_file()
|
||||||
|
|| receipt.size != metadata.len()
|
||||||
|
|| receipt.modified_ns != modified_ns
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"E2E server differs from this run's verified binary",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(requested) = requested_features.and_then(normalize_rustfs_build_features)
|
||||||
|
&& requested
|
||||||
|
.split(',')
|
||||||
|
.any(|feature| !receipt.features.iter().any(|actual| actual == feature))
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
"E2E server is missing a requested build feature",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn requested_rustfs_build_features() -> Option<String> {
|
pub fn requested_rustfs_build_features() -> Option<String> {
|
||||||
@@ -510,96 +524,6 @@ pub fn rustfs_build_feature_enabled(requested_features: Option<&str>, required_f
|
|||||||
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
|
|
||||||
binary_path.with_extension("features")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
|
|
||||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
|
||||||
let recorded = stdfs::read_to_string(stamp_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| normalize_rustfs_build_features(&value));
|
|
||||||
let requested = requested_features.and_then(normalize_rustfs_build_features);
|
|
||||||
|
|
||||||
match requested.as_deref() {
|
|
||||||
Some(features) => recorded.as_deref() == Some(features),
|
|
||||||
None => recorded.is_none(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
|
|
||||||
if path.is_file() {
|
|
||||||
return std::fs::metadata(path)
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.map(|modified| modified > binary_modified)
|
|
||||||
.unwrap_or(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !path.is_dir() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WalkDir::new(path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_entry(|entry| {
|
|
||||||
let name = entry.file_name();
|
|
||||||
name != OsStr::new("target") && name != OsStr::new(".git")
|
|
||||||
})
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.filter(|entry| entry.file_type().is_file())
|
|
||||||
.any(|entry| {
|
|
||||||
std::fs::metadata(entry.path())
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.map(|modified| modified > binary_modified)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the RustFS binary using cargo
|
|
||||||
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
|
||||||
let workspace = workspace_root();
|
|
||||||
info!("Building RustFS binary from workspace: {:?}", workspace);
|
|
||||||
|
|
||||||
let _profile = if cfg!(debug_assertions) {
|
|
||||||
info!("Building in debug mode");
|
|
||||||
"dev"
|
|
||||||
} else {
|
|
||||||
info!("Building in release mode");
|
|
||||||
"release"
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cmd = Command::new("cargo");
|
|
||||||
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
|
|
||||||
|
|
||||||
if let Some(features) = requested_features {
|
|
||||||
cmd.arg("--features").arg(features);
|
|
||||||
info!("Building with features: {}", features);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg!(debug_assertions) {
|
|
||||||
cmd.arg("--release");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Executing: cargo build --bin rustfs {}",
|
|
||||||
if cfg!(debug_assertions) { "" } else { "--release" }
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = cmd.output().expect("Failed to execute cargo build command");
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
panic!("Failed to build RustFS binary. Error: {stderr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
|
||||||
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
|
||||||
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("✅ RustFS binary built successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn awscurl_binary_path() -> PathBuf {
|
fn awscurl_binary_path() -> PathBuf {
|
||||||
std::env::var_os("AWSCURL_PATH")
|
std::env::var_os("AWSCURL_PATH")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -2229,16 +2153,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn binary_feature_stamp_matching_uses_normalized_features() {
|
fn explicit_binary_without_run_receipt_is_rejected() {
|
||||||
let binary_path = std::env::temp_dir().join(format!("rustfs-feature-stamp-test-{}", Uuid::new_v4()));
|
const CHILD_ENV: &str = "RUSTFS_E2E_RECEIPT_TEST_CHILD";
|
||||||
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
if std::env::var_os(CHILD_ENV).is_some() {
|
||||||
|
rustfs_binary_path_with_features(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let executable = std::env::current_exe().expect("locate isolated test process");
|
||||||
|
let output = Command::new(&executable)
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"common::tests::explicit_binary_without_run_receipt_is_rejected",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env(CHILD_ENV, "1")
|
||||||
|
.env("CARGO_BIN_EXE_rustfs", &executable)
|
||||||
|
.env_remove("RUSTFS_E2E_BINARY_RECEIPT")
|
||||||
|
.output()
|
||||||
|
.expect("run the missing-receipt scenario with isolated environment variables");
|
||||||
|
assert!(!output.status.success(), "an explicit binary must not bypass run verification");
|
||||||
|
assert!(String::from_utf8_lossy(&output.stderr).contains("missing E2E run receipt"));
|
||||||
|
}
|
||||||
|
|
||||||
stdfs::write(&stamp_path, " SFTP, ftps ").expect("write feature stamp");
|
#[test]
|
||||||
assert!(binary_features_match(&binary_path, Some("sftp,ftps")));
|
fn e2e_run_receipt_rejects_replaced_binary_and_missing_features() {
|
||||||
assert!(binary_features_match(&binary_path, Some(" SFTP, FTPS ")));
|
let directory = std::env::temp_dir().join(format!("rustfs-e2e-receipt-test-{}", Uuid::new_v4()));
|
||||||
assert!(!binary_features_match(&binary_path, Some("sftp")));
|
stdfs::create_dir(&directory).expect("create receipt fixture");
|
||||||
|
let binary = directory.join("rustfs");
|
||||||
stdfs::remove_file(stamp_path).ok();
|
let receipt = directory.join("receipt.json");
|
||||||
|
stdfs::write(&binary, "server").expect("write fixture binary");
|
||||||
|
let metadata = binary.metadata().expect("stat fixture binary");
|
||||||
|
let record = serde_json::json!({
|
||||||
|
"schema": 1,
|
||||||
|
"workspace": directory.canonicalize().expect("canonical workspace"),
|
||||||
|
"binary": binary.canonicalize().expect("canonical binary"),
|
||||||
|
"size": metadata.len(),
|
||||||
|
"modified_ns": metadata.modified().expect("modified time").duration_since(std::time::UNIX_EPOCH).expect("positive timestamp").as_nanos(),
|
||||||
|
"features": ["default", "full", "ftps", "webdav", "sftp"]
|
||||||
|
});
|
||||||
|
stdfs::write(&receipt, serde_json::to_vec(&record).expect("serialize receipt")).expect("write receipt");
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("sftp,webdav")).expect("resolved feature subset");
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("full")).expect("full was actually requested");
|
||||||
|
assert_eq!(
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("rio-v2"))
|
||||||
|
.expect_err("full does not enable rio-v2")
|
||||||
|
.kind(),
|
||||||
|
ErrorKind::InvalidInput
|
||||||
|
);
|
||||||
|
let other = directory.join("old-server");
|
||||||
|
stdfs::write(&other, "server").expect("write alternate binary");
|
||||||
|
assert!(verify_e2e_binary_receipt(&receipt, &directory, &other, None).is_err());
|
||||||
|
stdfs::write(&binary, "different server").expect("replace fixture binary");
|
||||||
|
assert!(verify_e2e_binary_receipt(&receipt, &directory, &binary, None).is_err());
|
||||||
|
stdfs::remove_file(&receipt).expect("remove expired receipt");
|
||||||
|
assert_eq!(
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, None)
|
||||||
|
.expect_err("expired receipt")
|
||||||
|
.kind(),
|
||||||
|
ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
stdfs::remove_dir_all(directory).expect("remove receipt fixture");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
||||||
|
|||||||
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
|
|||||||
let miss = env.raw_get(bucket, miss_key).await?;
|
let miss = env.raw_get(bucket, miss_key).await?;
|
||||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||||
}
|
}
|
||||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
let (listed, _, _) = tokio::try_join!(
|
||||||
|
env.wait_local_listed(bucket, hit_key, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
|
||||||
|
)?;
|
||||||
|
assert!(listed);
|
||||||
|
|
||||||
let status = env.status_json(bucket).await?;
|
let status = env.status_json(bucket).await?;
|
||||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||||
|
|||||||
@@ -17,15 +17,13 @@ Use the canonical CI-equivalent protocol command in the parent
|
|||||||
For targeted debugging of the core suite only:
|
For targeted debugging of the core suite only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||||
|
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
This targeted command does not cover the full `e2e-protocols` profile.
|
This targeted command does not cover the full `e2e-protocols` profile.
|
||||||
|
|
||||||
`RUSTFS_BUILD_FEATURES` controls which features the test rustfs binary is
|
`e2e_binary.py` supplies `RUSTFS_BUILD_FEATURES` from the verified server's resolved Cargo features. The protocol runner schedules only entries present in that feature list; helpers check that their required features are available without rebuilding the server.
|
||||||
built with. When this variable is set, the protocol test runner schedules
|
|
||||||
only entries whose protocol is present in the requested feature list. Leave
|
|
||||||
it unset to run every protocol entry.
|
|
||||||
`--test-threads=1` is required because every entry spawns a rustfs server
|
`--test-threads=1` is required because every entry spawns a rustfs server
|
||||||
on fixed bind ports.
|
on fixed bind ports.
|
||||||
|
|
||||||
|
|||||||
@@ -627,13 +627,7 @@ impl From<tokio::task::JoinError> for DiskError {
|
|||||||
impl Clone for DiskError {
|
impl Clone for DiskError {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
DiskError::Io(io_error) => DiskError::Io(
|
DiskError::Io(io_error) => DiskError::Io(std::io::Error::new(io_error.kind(), io_error.to_string())),
|
||||||
rustfs_rio::clone_internode_http_io_error(io_error)
|
|
||||||
.and_then(std::io::Error::into_inner)
|
|
||||||
// The helper derives a kind from the source; Clone must retain the original outer kind.
|
|
||||||
.map(|source| std::io::Error::new(io_error.kind(), source))
|
|
||||||
.unwrap_or_else(|| std::io::Error::new(io_error.kind(), io_error.to_string())),
|
|
||||||
),
|
|
||||||
DiskError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
DiskError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
||||||
DiskError::Unexpected => DiskError::Unexpected,
|
DiskError::Unexpected => DiskError::Unexpected,
|
||||||
DiskError::CorruptedFormat => DiskError::CorruptedFormat,
|
DiskError::CorruptedFormat => DiskError::CorruptedFormat,
|
||||||
@@ -1271,49 +1265,6 @@ mod tests {
|
|||||||
assert!(!bad_request.is_retryable_internode_write_failure());
|
assert!(!bad_request.is_retryable_internode_write_failure());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_internode_http_clone_preserves_retryability_status_and_context() {
|
|
||||||
use http::StatusCode;
|
|
||||||
use rustfs_rio::InternodeHttpErrorKind::{ConnectionRefused, ConnectionReset, HttpStatus, Unknown};
|
|
||||||
|
|
||||||
for (kind, retryable) in [
|
|
||||||
(ConnectionRefused, true),
|
|
||||||
(ConnectionReset, true),
|
|
||||||
(HttpStatus(StatusCode::TOO_MANY_REQUESTS), true),
|
|
||||||
(HttpStatus(StatusCode::SERVICE_UNAVAILABLE), true),
|
|
||||||
(HttpStatus(StatusCode::CONFLICT), true),
|
|
||||||
(Unknown, false),
|
|
||||||
(HttpStatus(StatusCode::BAD_REQUEST), false),
|
|
||||||
(HttpStatus(StatusCode::INTERNAL_SERVER_ERROR), false),
|
|
||||||
] {
|
|
||||||
let original = DiskError::from(rustfs_rio::new_test_internode_http_io_error(kind));
|
|
||||||
assert_eq!(original.internode_http_error_kind(), Some(kind));
|
|
||||||
assert_eq!(original.is_retryable_internode_write_failure(), retryable);
|
|
||||||
|
|
||||||
let cloned = original.clone();
|
|
||||||
assert_eq!(cloned, original, "clone must preserve the error bucket for {kind:?}");
|
|
||||||
assert_eq!(
|
|
||||||
cloned.is_retryable_internode_write_failure(),
|
|
||||||
retryable,
|
|
||||||
"clone changed retryability for {kind:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(cloned.internode_http_error_kind(), Some(kind));
|
|
||||||
if let HttpStatus(status) = kind {
|
|
||||||
assert!(cloned.is_internode_http_status(status.as_u16()));
|
|
||||||
}
|
|
||||||
let DiskError::Io(io_error) = &cloned else {
|
|
||||||
panic!("unmarked internode error must remain Io: {cloned:?}");
|
|
||||||
};
|
|
||||||
let source = io_error
|
|
||||||
.get_ref()
|
|
||||||
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
|
|
||||||
.expect("clone must retain the structured internode error");
|
|
||||||
assert_eq!(source.context().method(), "PUT");
|
|
||||||
assert_eq!(source.context().target(), "/rustfs/rpc/put_file_stream");
|
|
||||||
assert_eq!(source.context().operation(), Some(INTERNODE_OPERATION_PUT_FILE_STREAM));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_stream_conflict_is_not_a_retryable_put_file_failure() {
|
async fn read_stream_conflict_is_not_a_retryable_put_file_failure() {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -1358,57 +1309,11 @@ mod tests {
|
|||||||
!error.is_retryable_internode_write_failure(),
|
!error.is_retryable_internode_write_failure(),
|
||||||
"read-operation 409 must not trigger put-file retry"
|
"read-operation 409 must not trigger put-file retry"
|
||||||
);
|
);
|
||||||
let cloned = error.clone();
|
|
||||||
let reduced = crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(error)], &[], 1)
|
|
||||||
.expect("the read conflict must remain the dominant error");
|
|
||||||
for preserved in [&cloned, &reduced] {
|
|
||||||
assert!(
|
|
||||||
!preserved.is_retryable_internode_write_failure(),
|
|
||||||
"cloning or reducing a read conflict must not turn it into a PUT retry"
|
|
||||||
);
|
|
||||||
assert!(preserved.is_internode_http_status(409));
|
|
||||||
let DiskError::Io(io_error) = preserved else {
|
|
||||||
panic!("read conflict must remain Io: {preserved:?}");
|
|
||||||
};
|
|
||||||
let source = io_error
|
|
||||||
.get_ref()
|
|
||||||
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
|
|
||||||
.expect("read conflict must retain its request context");
|
|
||||||
assert_eq!(source.context().method(), "GET");
|
|
||||||
assert_eq!(source.context().target(), "/rustfs/rpc/read_file_stream");
|
|
||||||
assert_eq!(
|
|
||||||
source.context().operation(),
|
|
||||||
Some(rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_READ_FILE_STREAM)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("isolated read-conflict test must finish within its budget");
|
.expect("isolated read-conflict test must finish within its budget");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_internode_http_clone_preserves_outer_io_kind_and_message() {
|
|
||||||
let source = rustfs_rio::new_test_internode_http_io_error(InternodeHttpErrorKind::ConnectionReset)
|
|
||||||
.into_inner()
|
|
||||||
.expect("the internode helper must provide a typed source");
|
|
||||||
let original_io = io::Error::new(io::ErrorKind::InvalidData, source);
|
|
||||||
let message = original_io.to_string();
|
|
||||||
let original = DiskError::from(original_io);
|
|
||||||
assert_eq!(original.internode_http_error_kind(), Some(InternodeHttpErrorKind::ConnectionReset));
|
|
||||||
assert!(original.is_retryable_internode_write_failure());
|
|
||||||
|
|
||||||
let cloned = original.clone();
|
|
||||||
let reduced = crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(original)], &[], 1)
|
|
||||||
.expect("the wrapped internode error must remain the dominant error");
|
|
||||||
for preserved in [&cloned, &reduced] {
|
|
||||||
let DiskError::Io(io_error) = preserved else {
|
|
||||||
panic!("the wrapped error must remain Io: {preserved:?}");
|
|
||||||
};
|
|
||||||
assert_eq!(io_error.kind(), io::ErrorKind::InvalidData);
|
|
||||||
assert_eq!(io_error.to_string(), message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_internode_missing_errors_preserve_disk_error_types() {
|
fn test_internode_missing_errors_preserve_disk_error_types() {
|
||||||
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
|
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
|
||||||
@@ -1420,17 +1325,6 @@ mod tests {
|
|||||||
assert_eq!(file_missing, DiskError::FileNotFound);
|
assert_eq!(file_missing, DiskError::FileNotFound);
|
||||||
assert_eq!(volume_missing, DiskError::VolumeNotFound);
|
assert_eq!(volume_missing, DiskError::VolumeNotFound);
|
||||||
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
|
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
|
||||||
for missing in [file_missing, volume_missing] {
|
|
||||||
assert_eq!(missing.clone(), missing);
|
|
||||||
assert_eq!(
|
|
||||||
crate::disk::error_reduce::reduce_write_quorum_errs(
|
|
||||||
&[Some(missing.clone()), Some(missing.clone()), None],
|
|
||||||
&[],
|
|
||||||
2
|
|
||||||
),
|
|
||||||
Some(missing)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -226,78 +226,6 @@ mod tests {
|
|||||||
assert_eq!(res, Some(quorum_err));
|
assert_eq!(res, Some(quorum_err));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_write_quorum_reduction_preserves_internode_http_identity() {
|
|
||||||
use http::StatusCode;
|
|
||||||
use rustfs_rio::InternodeHttpErrorKind::{ConnectionRefused, HttpStatus, Unknown};
|
|
||||||
|
|
||||||
for (kind, retryable) in [
|
|
||||||
(ConnectionRefused, true),
|
|
||||||
(HttpStatus(StatusCode::SERVICE_UNAVAILABLE), true),
|
|
||||||
(HttpStatus(StatusCode::CONFLICT), true),
|
|
||||||
(Unknown, false),
|
|
||||||
(HttpStatus(StatusCode::BAD_REQUEST), false),
|
|
||||||
] {
|
|
||||||
// Construct both producer errors independently: the reducer owns the first clone.
|
|
||||||
let first = Error::from(rustfs_rio::new_test_internode_http_io_error(kind));
|
|
||||||
let second = Error::from(rustfs_rio::new_test_internode_http_io_error(kind));
|
|
||||||
assert_eq!(first.internode_http_error_kind(), Some(kind));
|
|
||||||
assert_eq!(second.internode_http_error_kind(), Some(kind));
|
|
||||||
assert_eq!(first.is_retryable_internode_write_failure(), retryable);
|
|
||||||
let errors = [Some(first), Some(second), None];
|
|
||||||
let reduced = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, 2)
|
|
||||||
.expect("two equal producer errors must dominate one successful write");
|
|
||||||
|
|
||||||
assert_eq!(Some(&reduced), errors[0].as_ref());
|
|
||||||
assert_eq!(
|
|
||||||
reduced.is_retryable_internode_write_failure(),
|
|
||||||
retryable,
|
|
||||||
"quorum reduction changed retryability for {kind:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(reduced.internode_http_error_kind(), Some(kind));
|
|
||||||
if let HttpStatus(status) = kind {
|
|
||||||
assert!(reduced.is_internode_http_status(status.as_u16()));
|
|
||||||
}
|
|
||||||
let Error::Io(io_error) = &reduced else {
|
|
||||||
panic!("the dominant error must remain Io: {reduced:?}");
|
|
||||||
};
|
|
||||||
let source = io_error
|
|
||||||
.get_ref()
|
|
||||||
.and_then(|source| source.downcast_ref::<rustfs_rio::InternodeHttpError>())
|
|
||||||
.expect("quorum reduction must retain the structured internode error");
|
|
||||||
assert_eq!(source.context().method(), "PUT");
|
|
||||||
assert_eq!(source.context().target(), "/rustfs/rpc/put_file_stream");
|
|
||||||
assert_eq!(
|
|
||||||
source.context().operation(),
|
|
||||||
Some(rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_PUT_FILE_STREAM)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_clone_and_write_quorum_do_not_promote_non_retryable_errors() {
|
|
||||||
use http::StatusCode;
|
|
||||||
use rustfs_rio::InternodeHttpErrorKind::{HttpStatus, Unknown};
|
|
||||||
|
|
||||||
for original in [
|
|
||||||
Error::from(rustfs_rio::new_test_internode_http_io_error(Unknown)),
|
|
||||||
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::BAD_REQUEST))),
|
|
||||||
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::FORBIDDEN))),
|
|
||||||
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::NOT_FOUND))),
|
|
||||||
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
))),
|
|
||||||
err_io("internode connection reset: PUT /rustfs/rpc/put_file_stream"),
|
|
||||||
] {
|
|
||||||
assert!(!original.is_retryable_internode_write_failure());
|
|
||||||
let cloned = original.clone();
|
|
||||||
let reduced =
|
|
||||||
reduce_write_quorum_errs(&[Some(original)], &[], 1).expect("a non-retryable error must remain an error");
|
|
||||||
assert!(!cloned.is_retryable_internode_write_failure());
|
|
||||||
assert!(!reduced.is_retryable_internode_write_failure());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_count_errs() {
|
fn test_count_errs() {
|
||||||
let e1 = err_io("a");
|
let e1 = err_io("a");
|
||||||
|
|||||||
+22
-20
@@ -3,7 +3,7 @@
|
|||||||
**Use this when:** a check is red and you need to know whether it blocks the merge, which workflow and job produced it, and how to reproduce it locally.
|
**Use this when:** a check is red and you need to know whether it blocks the merge, which workflow and job produced it, and how to reproduce it locally.
|
||||||
**Source of truth:** the live `main` ruleset (command below) for required status; `.github/workflows/<file>.yml` for triggers, `paths`, `timeout-minutes`, and cron; `.config/nextest.toml` for e2e profile filters; `.github/scheduled-validations.json` for the freshness-watchdog list.
|
**Source of truth:** the live `main` ruleset (command below) for required status; `.github/workflows/<file>.yml` for triggers, `paths`, `timeout-minutes`, and cron; `.config/nextest.toml` for e2e profile filters; `.github/scheduled-validations.json` for the freshness-watchdog list.
|
||||||
|
|
||||||
A job blocks a merge only when its exact check name is in the live `main` ruleset. A workflow name, a `merge_group` trigger, or a red PR check does not make a job required by itself.
|
A job blocks a merge when its exact check name is required by the live `main` ruleset, or when its result is required by the `Test and Lint` aggregate. A workflow name, a `merge_group` trigger, or an unrelated red PR check does not make a job required by itself.
|
||||||
|
|
||||||
## Required merge checks
|
## Required merge checks
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@ The `main` ruleset (`6436880`) requires exactly these contexts, with `strict_req
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CLA Check` | `cla.yml` | Contributor agreement |
|
| `CLA Check` | `cla.yml` | Contributor agreement |
|
||||||
| `Quick Checks` | `ci.yml` job `quick-checks` | Formatting and repository guard scripts |
|
| `Quick Checks` | `ci.yml` job `quick-checks` | Formatting and repository guard scripts |
|
||||||
| `Test and Lint` | `ci.yml` job `test-and-lint` | Clippy, workspace nextest (`ci` profile, excluding `e2e_test`), doctests, migration-gate count (`scripts/check_migration_gate_count.sh`) |
|
| `Test and Lint` | `ci.yml` job `required-checks` | Exact expected results for every CI validation job, including workspace checks, critical E2E, feature lanes, and event-specific full suites |
|
||||||
|
|
||||||
For PRs limited to the `paths-ignore` list in `ci.yml`, `ci-docs-only.yml` reports `Quick Checks` and `Test and Lint` under the same names; it runs the quick checks and `scripts/check_no_planning_docs.sh`, not a Rust build or tests. `scripts/check_ci_paths_sync.sh` keeps the two path lists aligned.
|
Every PR enters `ci.yml`. The `classify-changes` job uses the base revision of `scripts/ci_gate.py` to select a conservative documentation-only path: root Markdown/licenses, `AGENTS.md`, Markdown under `docs/` or `.agents/skills/`, and documentation images. Unknown paths, unavailable Git history, an empty diff, or a missing base policy select the full matrix. Renames include their deleted source path. Documentation-only PRs still run Quick Checks and Typos; the aggregate requires the expensive jobs to be skipped exactly as selected.
|
||||||
|
|
||||||
|
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
|
||||||
|
|
||||||
Verify the live rule before changing merge policy:
|
Verify the live rule before changing merge policy:
|
||||||
|
|
||||||
@@ -24,25 +26,25 @@ gh api repos/rustfs/rustfs/rulesets/6436880 \
|
|||||||
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
||||||
```
|
```
|
||||||
|
|
||||||
Promotion rule: never promote a report-only lane to required from one green run. Require at least 14 days and 30 representative PRs with at least 99% complete execution, then update the ruleset and this file together.
|
The aggregate requires the validation lanes already selected by `ci.yml`; this closes the gap where a failing critical lane left the required workspace check green. Independent workflows remain report-only unless separately required. Before adding a new expensive lane or moving existing PR coverage to a schedule, collect representative execution and regression evidence, establish ownership and a working scheduled replacement, and update this reference with the resulting policy.
|
||||||
|
|
||||||
## Pull request and merge matrix
|
## Pull request and merge matrix
|
||||||
|
|
||||||
"Report-only" means visible and actionable but not in the required list. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
|
"Via aggregate" means a wrong result fails the required `Test and Lint` check. "Report-only" means visible and actionable but outside both the required list and aggregate. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
|
||||||
|
|
||||||
| Event | Check name | Workflow / job | Merge status | Reproduce |
|
| Event | Check name | Workflow / job | Merge status | Reproduce |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
|
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
|
||||||
| PR, non-doc change | `Test and Lint` | `ci.yml` `test-and-lint` | Required | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
|
| PR, non-doc change | `Workspace Test and Lint` | `ci.yml` `test-and-lint` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
|
||||||
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Report-only | `typos` |
|
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
|
||||||
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Report-only | exact command in the job |
|
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Via aggregate | exact command in the job |
|
||||||
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Report-only | `cargo nextest run` with the job's `--features` |
|
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate | `cargo nextest run` with the job's `--features` |
|
||||||
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Report-only | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Via aggregate | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
||||||
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Report-only; prerequisite for the black-box jobs | `cargo build -p rustfs --bins` |
|
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `cargo build -p rustfs --bins --features e2e-test-hooks` |
|
||||||
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Report-only | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Report-only | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Report-only | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||||
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
||||||
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
||||||
@@ -52,8 +54,8 @@ Promotion rule: never promote a report-only lane to required from one green run.
|
|||||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||||
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
||||||
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
||||||
| PR limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
| PR, documentation-only selection | `Quick Checks`, `Typos`, `Test and Lint` | `ci.yml` `quick-checks`, `typos`, `required-checks` | Required directly or via aggregate | Quick Checks commands; `python3 scripts/ci_gate.py --self-test` |
|
||||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||||
|
|
||||||
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
||||||
|
|
||||||
@@ -67,11 +69,11 @@ the serialized cluster fault-domain suites for scheduled soak signal.
|
|||||||
|
|
||||||
## Scheduled validation
|
## Scheduled validation
|
||||||
|
|
||||||
Scheduled lanes never block a PR. Their workflow-local gate fails the run, scheduled failures route to the shared failure-issue action, and `scheduled-validation-freshness.yml` fails when a workflow listed in `.github/scheduled-validations.json` has not run within its `max_age_hours` (a `never_ran_grace_until` entry covers the window before a newly enabled cron's first slot). Cadence is qualitative here; the cron lives in each workflow's `on.schedule`.
|
Scheduled lanes never block a PR. Their workflow-local gate fails the run, scheduled failures route to the shared failure-issue action, and `scheduled-validation-freshness.yml` fails when a workflow listed in `.github/scheduled-validations.json` has no recent attempt or completed successful scheduled run within its `max_age_hours` (a `never_ran_grace_until` entry covers the window before a newly enabled cron's first slot). Cadence is qualitative here; the cron lives in each workflow's `on.schedule`.
|
||||||
|
|
||||||
| Workflow (cadence) | Jobs | Verdict and artifacts | In freshness list | Reproduce |
|
| Workflow (cadence) | Jobs | Verdict and artifacts | In freshness list | Reproduce |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | per-job | yes | dispatch `ci.yml` |
|
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | strict aggregate; the full E2E lane runs on dispatch, merge groups, and main pushes | yes | dispatch `ci.yml` |
|
||||||
| `build.yml` (weekly) | `build-rustfs` over the six-target platform matrix in `prepare-platform-matrix` (four Linux, macOS aarch64, Windows x86_64) | build/package integrity | yes | dispatch `build.yml` with an exact platform set |
|
| `build.yml` (weekly) | `build-rustfs` over the six-target platform matrix in `prepare-platform-matrix` (four Linux, macOS aarch64, Windows x86_64) | build/package integrity | yes | dispatch `build.yml` with an exact platform set |
|
||||||
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
||||||
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, then `cargo nextest run --profile e2e-distributed -p e2e_test` |
|
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, then `cargo nextest run --profile e2e-distributed -p e2e_test` |
|
||||||
@@ -88,7 +90,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
|||||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
||||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
||||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on missing or stale attempts or completed successes | n/a | dispatch |
|
||||||
|
|
||||||
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
|
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci.yml's pull_request paths-ignore and ci-docs-only.yml's paths must be equal.
|
|
||||||
#
|
|
||||||
# ci-docs-only.yml exists to report the required checks for pull requests that
|
|
||||||
# ci.yml skips. The two lists are the complement of each other, so any drift
|
|
||||||
# breaks one of two ways, both silent:
|
|
||||||
#
|
|
||||||
# - an entry only in ci.yml's paths-ignore: a PR touching only those files
|
|
||||||
# triggers neither workflow, nobody reports "Test and Lint" or "Quick
|
|
||||||
# Checks", and the PR waits on a required check forever;
|
|
||||||
# - an entry only in ci-docs-only.yml's paths: both workflows run, which is
|
|
||||||
# merely wasteful — but it also means the lists no longer describe the same
|
|
||||||
# intent, and the next edit is made against a wrong assumption.
|
|
||||||
#
|
|
||||||
# The push paths-ignore in ci.yml is deliberately NOT compared: no required
|
|
||||||
# check is reported for push events, so it does not have to pair with anything.
|
|
||||||
#
|
|
||||||
# Also asserts ci-docs-only.yml still declares both companion job names, since a
|
|
||||||
# rename there produces exactly the permanent-pending failure above.
|
|
||||||
#
|
|
||||||
# Usage: scripts/check_ci_paths_sync.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
CI=".github/workflows/ci.yml"
|
|
||||||
DOCS=".github/workflows/ci-docs-only.yml"
|
|
||||||
|
|
||||||
# Print the quoted list items that follow $2 within the block introduced by $1.
|
|
||||||
# Both files keep these as a flat list of quoted scalars, so no YAML parser is
|
|
||||||
# needed and the script stays dependency-free like its check_* siblings.
|
|
||||||
extract() {
|
|
||||||
local file="$1" event="$2" key="$3"
|
|
||||||
awk -v event="$event" -v key="$key" '
|
|
||||||
$0 ~ "^ " event ":[[:space:]]*$" { in_event = 1; next }
|
|
||||||
in_event && /^ [a-z_]+:[[:space:]]*$/ { in_event = 0 }
|
|
||||||
in_event && $0 ~ "^ " key ":[[:space:]]*$" { in_list = 1; next }
|
|
||||||
in_list {
|
|
||||||
if ($0 ~ /^ - /) {
|
|
||||||
item = $0
|
|
||||||
sub(/^ - /, "", item)
|
|
||||||
gsub(/^"|"$/, "", item)
|
|
||||||
print item
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if ($0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*$/) in_list = 0
|
|
||||||
}
|
|
||||||
' "$file" | sort
|
|
||||||
}
|
|
||||||
|
|
||||||
ci_list="$(extract "$CI" "pull_request" "paths-ignore")"
|
|
||||||
docs_list="$(extract "$DOCS" "pull_request" "paths")"
|
|
||||||
|
|
||||||
if [ -z "$ci_list" ] || [ -z "$docs_list" ]; then
|
|
||||||
echo "ERROR: could not read one of the path lists — did the file structure change?" >&2
|
|
||||||
echo " $CI pull_request.paths-ignore: $(printf '%s' "$ci_list" | grep -c . || true) entries" >&2
|
|
||||||
echo " $DOCS pull_request.paths: $(printf '%s' "$docs_list" | grep -c . || true) entries" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
status=0
|
|
||||||
|
|
||||||
if ! diff_out="$(diff <(printf '%s\n' "$ci_list") <(printf '%s\n' "$docs_list"))"; then
|
|
||||||
echo "ERROR: $CI pull_request paths-ignore and $DOCS paths have drifted." >&2
|
|
||||||
echo " '<' is only in $CI, '>' is only in $DOCS:" >&2
|
|
||||||
printf '%s\n' "$diff_out" | sed 's/^/ /' >&2
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for job_name in "Test and Lint" "Quick Checks"; do
|
|
||||||
if ! grep -q "name: ${job_name}\$" "$DOCS"; then
|
|
||||||
echo "ERROR: $DOCS no longer declares a job named '${job_name}'." >&2
|
|
||||||
echo " It is a required status check; without a companion job here, a" >&2
|
|
||||||
echo " docs-only PR waits on it forever." >&2
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$status" -ne 0 ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "OK: ci.yml and ci-docs-only.yml path lists agree ($(printf '%s\n' "$ci_list" | wc -l | tr -d ' ') entries)"
|
|
||||||
@@ -542,7 +542,7 @@ def yaml_scalar_continues(lines: list[str], index: int, indent: int) -> bool:
|
|||||||
def check_quick_checks(root: Path) -> list[str]:
|
def check_quick_checks(root: Path) -> list[str]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
bypass_key = r'''(?:if|continue-on-error|needs|"if"|"continue-on-error"|"needs"|'if'|'continue-on-error'|'needs')\s*:'''
|
bypass_key = r'''(?:if|continue-on-error|needs|"if"|"continue-on-error"|"needs"|'if'|'continue-on-error'|'needs')\s*:'''
|
||||||
for name in ("ci.yml", "ci-docs-only.yml"):
|
for name in ("ci.yml",):
|
||||||
relative = f".github/workflows/{name}"
|
relative = f".github/workflows/{name}"
|
||||||
path = root / relative
|
path = root / relative
|
||||||
job = yaml_block(path.read_text().splitlines(), "quick-checks", 2) if path.is_file() else None
|
job = yaml_block(path.read_text().splitlines(), "quick-checks", 2) if path.is_file() else None
|
||||||
@@ -1165,7 +1165,6 @@ class SelfTests(unittest.TestCase):
|
|||||||
".github/workflows/ci.yml": caller.replace(
|
".github/workflows/ci.yml": caller.replace(
|
||||||
" steps:", " if: github.event_name != 'pull_request' || github.event.action != 'closed'\n steps:"
|
" steps:", " if: github.event_name != 'pull_request' || github.event.action != 'closed'\n steps:"
|
||||||
),
|
),
|
||||||
".github/workflows/ci-docs-only.yml": caller,
|
|
||||||
".github/actions/quick-checks/action.yml": action,
|
".github/actions/quick-checks/action.yml": action,
|
||||||
}
|
}
|
||||||
for relative, source in sources.items():
|
for relative, source in sources.items():
|
||||||
@@ -1173,7 +1172,7 @@ class SelfTests(unittest.TestCase):
|
|||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(source)
|
path.write_text(source)
|
||||||
self.assertEqual(check_quick_checks(root), [])
|
self.assertEqual(check_quick_checks(root), [])
|
||||||
for relative in (".github/workflows/ci.yml", ".github/workflows/ci-docs-only.yml"):
|
for relative in (".github/workflows/ci.yml",):
|
||||||
source = sources[relative]
|
source = sources[relative]
|
||||||
mutations = {
|
mutations = {
|
||||||
"different action": source.replace("./.github/actions/quick-checks", "./.github/actions/other"),
|
"different action": source.replace("./.github/actions/quick-checks", "./.github/actions/other"),
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Select safe documentation-only CI and verify the complete required job set."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ALWAYS_JOBS = ("classify-changes", "typos", "quick-checks")
|
||||||
|
CODE_JOBS = (
|
||||||
|
"test-and-lint", "test-ilm-integration-serial", "test-and-lint-rio-v2",
|
||||||
|
"connect-short-credential-boundary", "test-and-lint-protocols",
|
||||||
|
"build-rustfs-debug-binary", "uring-integration", "e2e-tests",
|
||||||
|
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
|
||||||
|
)
|
||||||
|
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full")
|
||||||
|
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_path(path: str) -> bool:
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
if not parts or path.startswith("/") or any(part in (".", "..") for part in parts) or any(ord(c) < 32 for c in path):
|
||||||
|
return False
|
||||||
|
if parts[-1] == "AGENTS.md":
|
||||||
|
return True
|
||||||
|
if len(parts) == 1 and (path.endswith(".md") or path == "LICENSE" or path.startswith("LICENSE-")):
|
||||||
|
return True
|
||||||
|
if path.startswith(("docs/", ".agents/skills/")) and path.endswith(".md"):
|
||||||
|
return True
|
||||||
|
return path.startswith("docs/") and path.endswith((".png", ".jpg", ".svg"))
|
||||||
|
|
||||||
|
|
||||||
|
def select_mode(event: str, base: str, head: str, root: Path) -> str:
|
||||||
|
if event != "pull_request" or not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)):
|
||||||
|
return "full"
|
||||||
|
try:
|
||||||
|
changed = subprocess.check_output(
|
||||||
|
["git", "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", base, head, "--"],
|
||||||
|
cwd=root, stderr=subprocess.PIPE,
|
||||||
|
).decode("utf-8")
|
||||||
|
except (subprocess.CalledProcessError, UnicodeError):
|
||||||
|
return "full"
|
||||||
|
paths = changed.rstrip("\0").split("\0") if changed else []
|
||||||
|
return "docs" if paths and all(documentation_path(path) for path in paths) else "full"
|
||||||
|
|
||||||
|
|
||||||
|
def expected_results(mode: str, event: str, ref: str) -> dict[str, str]:
|
||||||
|
if event not in ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch"):
|
||||||
|
raise ValueError(f"unsupported CI event: {event!r}")
|
||||||
|
if mode not in ("docs", "full") or (mode == "docs" and event != "pull_request"):
|
||||||
|
raise ValueError(f"invalid CI selection: {mode!r} for {event!r}")
|
||||||
|
expected = {job: "success" for job in ALWAYS_JOBS}
|
||||||
|
expected.update({job: "success" if mode == "full" else "skipped" for job in CODE_JOBS})
|
||||||
|
rio = mode == "full" and event in ("schedule", "workflow_dispatch")
|
||||||
|
expected.update({job: "success" if rio else "skipped" for job in OPTIONAL_JOBS[:2]})
|
||||||
|
full = mode == "full" and (event in ("merge_group", "workflow_dispatch") or (event == "push" and ref == "refs/heads/main"))
|
||||||
|
expected["e2e-full"] = "success" if full else "skipped"
|
||||||
|
return expected
|
||||||
|
|
||||||
|
|
||||||
|
def verify_results(needs: object, event: str, ref: str) -> list[str]:
|
||||||
|
if not isinstance(needs, dict):
|
||||||
|
return ["needs must be a job-result object"]
|
||||||
|
selection = needs.get("classify-changes", {})
|
||||||
|
outputs = selection.get("outputs", {}) if isinstance(selection, dict) else {}
|
||||||
|
mode = outputs.get("mode") if isinstance(outputs, dict) else None
|
||||||
|
try:
|
||||||
|
expected = expected_results(mode, event, ref)
|
||||||
|
except ValueError as error:
|
||||||
|
return [str(error)]
|
||||||
|
errors = []
|
||||||
|
if set(needs) != set(expected):
|
||||||
|
errors.append(f"job set differs: missing={sorted(set(expected) - set(needs))}, unexpected={sorted(set(needs) - set(expected))}")
|
||||||
|
for job, required in expected.items():
|
||||||
|
result = needs.get(job, {})
|
||||||
|
actual = result.get("result") if isinstance(result, dict) else None
|
||||||
|
if actual != required:
|
||||||
|
errors.append(f"{job}: expected {required}, got {actual!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def check_workflow(root: Path) -> list[str]:
|
||||||
|
# Reuse the repository's canonical-indentation checker; actionlint validates YAML syntax.
|
||||||
|
from check_test_wiring import yaml_block, yaml_scalar_continues
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
lines = (root / ".github/workflows/ci.yml").read_text().splitlines()
|
||||||
|
jobs = yaml_block(lines, "jobs", 0) or []
|
||||||
|
names = set()
|
||||||
|
for index, line in enumerate(jobs):
|
||||||
|
if not re.match(r"^ \S", line) or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
header = re.fullmatch(r''' (["']?)([A-Za-z_][A-Za-z0-9_-]*)\1\s*:\s*(?:#.*)?''', line)
|
||||||
|
if header is None:
|
||||||
|
errors.append("CI job declarations must use single-line job IDs")
|
||||||
|
continue
|
||||||
|
name = header[2]
|
||||||
|
if name in names:
|
||||||
|
errors.append(f"duplicate CI job ID: {name}")
|
||||||
|
names.add(name)
|
||||||
|
jobs[index] = f" {name}:"
|
||||||
|
required = set(ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS)
|
||||||
|
if names - NON_VALIDATION_JOBS != required:
|
||||||
|
errors.append("CI verification jobs and the required gate contract differ")
|
||||||
|
for job in required:
|
||||||
|
block = yaml_block(jobs, job, 2) or []
|
||||||
|
if any(re.match(r"\s+(?:- )?[\"']?continue-on-error[\"']?\s*:", line) for line in block):
|
||||||
|
errors.append(f"{job} cannot convert a validation failure into success")
|
||||||
|
gate = yaml_block(jobs, "required-checks", 2) or []
|
||||||
|
def scalar(block, key, indent):
|
||||||
|
prefix = " " * indent + key + ": "
|
||||||
|
matches = [index for index, line in enumerate(block) if line.startswith(prefix)]
|
||||||
|
if len(matches) != 1:
|
||||||
|
return None
|
||||||
|
index = matches[0]
|
||||||
|
if yaml_scalar_continues(block, index, indent):
|
||||||
|
return None
|
||||||
|
return block[index][len(prefix):]
|
||||||
|
|
||||||
|
display_names = {}
|
||||||
|
for job in names:
|
||||||
|
block = [re.sub(r'''^ (?:'name'|"name")\s*:\s*''', " name: ", line)
|
||||||
|
for line in yaml_block(jobs, job, 2) or []]
|
||||||
|
value = scalar(block, "name", 4)
|
||||||
|
display = re.fullmatch(r'''(?:"([^"\\]*)"|'([^']*)'|([^'"#][^#]*?))(?:\s+#.*)?\s*''', (value or "").strip())
|
||||||
|
if display is None or (display[3] is not None and display[3].startswith(tuple("|>*&!{[?"))):
|
||||||
|
errors.append(f"{job} must use a verifiable single-line display name")
|
||||||
|
continue
|
||||||
|
name = next(value for value in display.groups() if value is not None)
|
||||||
|
if "${{" in name and (job != "test-and-lint-protocols" or name != "Test and Lint (${{ matrix.features.name }})"):
|
||||||
|
errors.append(f"{job} has an unverifiable dynamic display name")
|
||||||
|
display_names[job] = name
|
||||||
|
|
||||||
|
dependencies = yaml_block(gate, "needs", 4) or []
|
||||||
|
declared = [line.strip().removeprefix("- ") for line in dependencies if line.strip()]
|
||||||
|
if set(declared) != required or len(declared) != len(required):
|
||||||
|
errors.append("required-checks must directly depend on every verification job exactly once")
|
||||||
|
if display_names.get("required-checks") != "Test and Lint" or list(display_names.values()).count("Test and Lint") != 1:
|
||||||
|
errors.append("Test and Lint must uniquely name the aggregate gate")
|
||||||
|
if scalar(gate, "if", 4) != "always() && (github.event_name != 'pull_request' || github.event.action != 'closed')":
|
||||||
|
errors.append("required-checks must run after failed or skipped dependencies")
|
||||||
|
if scalar(gate, "shell", 8) != "bash" or scalar(gate, "run", 8) != "python3 scripts/ci_gate.py verify" or scalar(gate, "CI_NEEDS", 10) != "${{ toJSON(needs) }}":
|
||||||
|
errors.append("required-checks must verify the actual needs results")
|
||||||
|
if any(re.match(r'''\s+(?:- )?(?:["']?continue-on-error["']?\s*:|["']?if["']?\s*:)''', line) and not line.startswith(" if:") for line in gate):
|
||||||
|
errors.append("required-checks cannot ignore failures")
|
||||||
|
pr = yaml_block(lines, "pull_request", 2) or []
|
||||||
|
if any(line.strip().startswith(("paths:", "paths-ignore:")) for line in pr):
|
||||||
|
errors.append("all pull requests must enter the single CI workflow")
|
||||||
|
if (root / ".github/workflows/ci-docs-only.yml").exists():
|
||||||
|
errors.append("the duplicate required-status companion must be removed")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
class SelfTests(unittest.TestCase):
|
||||||
|
def test_documentation_paths_do_not_hide_build_or_fixture_changes(self):
|
||||||
|
for path in ("README.md", "AGENTS.md", "crates/utils/AGENTS.md", "docs/testing/README.md", "docs/diagram.svg", ".agents/skills/example/SKILL.md"):
|
||||||
|
self.assertTrue(documentation_path(path), path)
|
||||||
|
for path in ("", "src/lib.rs", "crates/foo/tests/fixtures/data.md", "Cargo.lock", "build.rs", "deploy/chart.yaml", ".github/workflows/ci.yml", "scripts/dev_build.sh", "assets/logo.png", "docs/test.rs", "README.md\n", "../README.md"):
|
||||||
|
self.assertFalse(documentation_path(path), path)
|
||||||
|
|
||||||
|
def test_git_range_includes_deleted_source_and_rename_origins(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.check_output(["git", "-c", "core.hooksPath=/dev/null", "-c", "user.name=CI Test", "-c", "user.email=ci@example.invalid", *args], cwd=root, stderr=subprocess.PIPE).decode().strip()
|
||||||
|
git("init", "-q")
|
||||||
|
(root / "server.rs").write_text("fn main() {}\n")
|
||||||
|
(root / "README.md").write_text("old\n")
|
||||||
|
git("add", "."); git("commit", "-qm", "base")
|
||||||
|
base = git("rev-parse", "HEAD")
|
||||||
|
(root / "README.md").write_text("new\n")
|
||||||
|
git("add", "."); git("commit", "-qm", "docs")
|
||||||
|
docs = git("rev-parse", "HEAD")
|
||||||
|
self.assertEqual(select_mode("pull_request", base, docs, root), "docs")
|
||||||
|
(root / "server.rs").rename(root / "server.md")
|
||||||
|
git("add", "-A"); git("commit", "-qm", "rename source")
|
||||||
|
head = git("rev-parse", "HEAD")
|
||||||
|
self.assertEqual(select_mode("pull_request", base, head, root), "full")
|
||||||
|
self.assertEqual(select_mode("pull_request", docs, docs, root), "full")
|
||||||
|
self.assertEqual(select_mode("pull_request", "0" * 40, head, root), "full")
|
||||||
|
self.assertEqual(select_mode("pull_request", "--output=bad", head, root), "full")
|
||||||
|
self.assertEqual(select_mode("merge_group", base, docs, root), "full")
|
||||||
|
|
||||||
|
def test_event_contract_requires_complete_candidate_and_optional_lanes(self):
|
||||||
|
ordinary = expected_results("full", "pull_request", "refs/pull/1/merge")
|
||||||
|
self.assertEqual({job for job, state in ordinary.items() if state == "skipped"}, set(OPTIONAL_JOBS))
|
||||||
|
docs = expected_results("docs", "pull_request", "refs/pull/1/merge")
|
||||||
|
self.assertEqual({job for job, state in docs.items() if state == "success"}, set(ALWAYS_JOBS))
|
||||||
|
for event in ("schedule", "workflow_dispatch", "merge_group", "push"):
|
||||||
|
result = expected_results("full", event, "refs/heads/main")
|
||||||
|
self.assertEqual(result["e2e-full"], "skipped" if event == "schedule" else "success")
|
||||||
|
self.assertEqual(result["e2e-tests-rio-v2"], "success" if event in ("schedule", "workflow_dispatch") else "skipped")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
expected_results("docs", event, "refs/heads/main")
|
||||||
|
|
||||||
|
def test_every_wrong_result_missing_job_or_selection_fails_closed(self):
|
||||||
|
for mode, event in (("full", "pull_request"), ("docs", "pull_request"), ("full", "schedule"), ("full", "workflow_dispatch"), ("full", "merge_group")):
|
||||||
|
good = {job: {"result": value} for job, value in expected_results(mode, event, "refs/heads/main").items()}
|
||||||
|
good["classify-changes"]["outputs"] = {"mode": mode}
|
||||||
|
self.assertEqual(verify_results(good, event, "refs/heads/main"), [])
|
||||||
|
for job in good:
|
||||||
|
for value in ("success", "skipped", "failure", "cancelled", "neutral", "", None):
|
||||||
|
if value == good[job]["result"]:
|
||||||
|
continue
|
||||||
|
with self.subTest(mode=mode, event=event, job=job, result=value):
|
||||||
|
bad = {**good, job: {**good[job], "result": value}}
|
||||||
|
self.assertTrue(verify_results(bad, event, "refs/heads/main"))
|
||||||
|
self.assertTrue(verify_results({key: value for key, value in good.items() if key != job}, event, "refs/heads/main"))
|
||||||
|
missing_result = {key: value for key, value in good[job].items() if key != "result"}
|
||||||
|
self.assertTrue(verify_results({**good, job: missing_result}, event, "refs/heads/main"))
|
||||||
|
self.assertTrue(verify_results({**good, "unknown-job": {"result": "success"}}, event, "refs/heads/main"))
|
||||||
|
for selection in ({}, {"mode": ""}, {"mode": True}, []):
|
||||||
|
bad = {**good, "classify-changes": {"result": "success", "outputs": selection}}
|
||||||
|
self.assertTrue(verify_results(bad, event, "refs/heads/main"))
|
||||||
|
|
||||||
|
def test_repository_wiring_and_missing_dependency_regression(self):
|
||||||
|
self.assertEqual(check_workflow(ROOT), [])
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / ".github/workflows").mkdir(parents=True)
|
||||||
|
source = (ROOT / ".github/workflows/ci.yml").read_text()
|
||||||
|
path = root / ".github/workflows/ci.yml"
|
||||||
|
for job in ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS:
|
||||||
|
before, gate = source.split(" required-checks:\n", 1)
|
||||||
|
path.write_text(before + " required-checks:\n" + gate.replace(f" - {job}\n", "", 1))
|
||||||
|
self.assertTrue(check_workflow(root), job)
|
||||||
|
for old, new in (
|
||||||
|
("run: python3 scripts/ci_gate.py verify", "run: python3 scripts/ci_gate.py verify || true"),
|
||||||
|
("run: python3 scripts/ci_gate.py verify", "run: python3 scripts/ci_gate.py verify\n || true"),
|
||||||
|
("CI_NEEDS: ${{ toJSON(needs) }}", "CI_NEEDS: '{}'"),
|
||||||
|
("name: Test and Lint\n", "name: Unrequired result\n"),
|
||||||
|
(" shell: bash\n run: python3 scripts/ci_gate.py verify", " shell: echo {0}\n run: python3 scripts/ci_gate.py verify"),
|
||||||
|
(" shell: bash\n run: python3 scripts/ci_gate.py verify", " run: python3 scripts/ci_gate.py verify"),
|
||||||
|
(" run: python3 scripts/ci_gate.py verify", ' "if": false\n run: python3 scripts/ci_gate.py verify'),
|
||||||
|
):
|
||||||
|
path.write_text(source.replace(old, new))
|
||||||
|
self.assertTrue(check_workflow(root), new)
|
||||||
|
for job in ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS:
|
||||||
|
for field in ("continue-on-error", '"continue-on-error"', "'continue-on-error'"):
|
||||||
|
path.write_text(source.replace(f" {job}:\n", f" {job}:\n {field}: true\n", 1))
|
||||||
|
self.assertTrue(check_workflow(root), (job, field))
|
||||||
|
before, block = source.split(f" {job}:\n", 1)
|
||||||
|
block = block.replace(" - name:", f" - {field}: true\n name:", 1)
|
||||||
|
path.write_text(before + f" {job}:\n" + block)
|
||||||
|
self.assertTrue(check_workflow(root), (job, field, "step"))
|
||||||
|
path.write_text(source + "\n cancel-after-test-and-lint-failure:\n runs-on: ubuntu-latest\n")
|
||||||
|
self.assertTrue(check_workflow(root))
|
||||||
|
|
||||||
|
def test_job_ids_and_display_names_cannot_hide_validation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / ".github/workflows").mkdir(parents=True)
|
||||||
|
source = (ROOT / ".github/workflows/ci.yml").read_text()
|
||||||
|
path = root / ".github/workflows/ci.yml"
|
||||||
|
for header in ("typos", "'typos'", '"typos"'):
|
||||||
|
path.write_text(source.replace(" typos:\n", f" {header}: # spelling\n"))
|
||||||
|
self.assertEqual(check_workflow(root), [], header)
|
||||||
|
for name in ("Test and Lint # required", "'Test and Lint'", '"Test and Lint" # required'):
|
||||||
|
path.write_text(source.replace(" name: Test and Lint\n", f" name: {name}\n"))
|
||||||
|
self.assertEqual(check_workflow(root), [], name)
|
||||||
|
for key in ("'name'", '"name"'):
|
||||||
|
path.write_text(source.replace(" name: Typos\n", f" {key}: Typos\n"))
|
||||||
|
self.assertEqual(check_workflow(root), [], key)
|
||||||
|
for header in ("new_test", "NewTest", "_new_test", "'new_test'", '"new_test"', '"new\\u005ftest"'):
|
||||||
|
path.write_text(source + f"\n {header}:\n name: New test\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n")
|
||||||
|
self.assertTrue(check_workflow(root), header)
|
||||||
|
path.write_text(source + "\n 'typos':\n name: Duplicate\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n")
|
||||||
|
self.assertIn("duplicate CI job ID: typos", check_workflow(root))
|
||||||
|
for name in (
|
||||||
|
"Test and Lint", "Test and Lint # duplicate", "'Test and Lint'",
|
||||||
|
'"Test and Lint" # duplicate', '"Test\\u0020and Lint"',
|
||||||
|
">-\n Test and Lint", "|-\n Test and Lint", "Test and\n Lint",
|
||||||
|
"*required_name", "&required_name Test and Lint", "!!str Test and Lint",
|
||||||
|
"${{ 'Test and Lint' }}", '"${{ github.event.inputs.check_name }}"',
|
||||||
|
):
|
||||||
|
path.write_text(source.replace(" name: Typos\n", f" name: {name}\n"))
|
||||||
|
self.assertTrue(check_workflow(root), name)
|
||||||
|
path.write_text(source.replace(" name: Typos\n", ""))
|
||||||
|
self.assertIn("typos must use a verifiable single-line display name", check_workflow(root))
|
||||||
|
|
||||||
|
def test_verify_command_preserves_failures(self):
|
||||||
|
good = {job: {"result": value} for job, value in expected_results("full", "pull_request", "refs/pull/1/merge").items()}
|
||||||
|
good["classify-changes"]["outputs"] = {"mode": "full"}
|
||||||
|
failed = {**good, "e2e-tests": {"result": "failure"}}
|
||||||
|
for needs, code in ((json.dumps(good), 0), (json.dumps(failed), 1), ("{}", 1), ("{", 1)):
|
||||||
|
with self.subTest(needs=needs):
|
||||||
|
env = dict(os.environ, CI_NEEDS=needs, GITHUB_EVENT_NAME="pull_request", GITHUB_REF="refs/pull/1/merge")
|
||||||
|
result = subprocess.run([sys.executable, str(Path(__file__).resolve()), "verify"], env=env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, code, result.stderr)
|
||||||
|
self.assertIn("ERROR:" if code else "CI contract passed", result.stderr if code else result.stdout)
|
||||||
|
|
||||||
|
def test_actual_selector_bootstrap_uses_base_policy_and_fails_closed(self):
|
||||||
|
from check_test_wiring import yaml_block
|
||||||
|
jobs = yaml_block((ROOT / ".github/workflows/ci.yml").read_text().splitlines(), "jobs", 0)
|
||||||
|
selector = yaml_block(jobs, "classify-changes", 2)
|
||||||
|
body = "\n".join(line[10:] for line in selector[selector.index(" run: |") + 1:])
|
||||||
|
for event, changed, base_sha, available, broken, expected in (
|
||||||
|
("pull_request", "README.md", "b" * 40, True, False, "docs"),
|
||||||
|
("pull_request", "src/server.rs", "b" * 40, True, False, "full"),
|
||||||
|
("pull_request", "README.md", "b" * 40, False, False, "full"),
|
||||||
|
("merge_group", "README.md", "b" * 40, False, False, "full"),
|
||||||
|
("pull_request", "README.md", "b" * 40, True, True, None),
|
||||||
|
("pull_request", "README.md", "", True, True, "full"),
|
||||||
|
):
|
||||||
|
with self.subTest(event=event, changed=changed, available=available, broken=broken), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "scripts").mkdir()
|
||||||
|
(root / "scripts/ci_gate.py").write_text("raise SystemExit(71)\n")
|
||||||
|
(root / "python3").symlink_to(sys.executable)
|
||||||
|
base = root / "base-policy.py"
|
||||||
|
base.write_text("raise SystemExit(29)\n" if broken else Path(__file__).read_text())
|
||||||
|
git = root / "git"
|
||||||
|
git.write_text('''#!/bin/sh
|
||||||
|
if [ "$1" = show ]; then
|
||||||
|
[ "$2" = "$CI_BASE_SHA:scripts/ci_gate.py" ] || exit 19
|
||||||
|
[ "$BASE_AVAILABLE" = yes ] || exit 128
|
||||||
|
cat "$BASE_POLICY"
|
||||||
|
elif [ "$1" = diff ]; then
|
||||||
|
printf '%s\\0' "$CHANGED_PATH"
|
||||||
|
else
|
||||||
|
exit 20
|
||||||
|
fi
|
||||||
|
''')
|
||||||
|
git.chmod(0o755)
|
||||||
|
output = root / "output"
|
||||||
|
output.touch()
|
||||||
|
env = dict(os.environ, GITHUB_EVENT_NAME=event, CI_BASE_SHA=base_sha, GITHUB_SHA="c" * 40,
|
||||||
|
RUNNER_TEMP=str(root), GITHUB_OUTPUT=str(output), BASE_POLICY=str(base),
|
||||||
|
BASE_AVAILABLE="yes" if available else "no", CHANGED_PATH=changed,
|
||||||
|
PATH=f"{root}{os.pathsep}{os.environ['PATH']}")
|
||||||
|
result = subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", body], cwd=root, env=env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 29 if expected is None else 0, result.stderr)
|
||||||
|
self.assertEqual(output.read_text(), "" if expected is None else f"mode={expected}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if sys.argv[1:] == ["--self-test"]:
|
||||||
|
return not unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(SelfTests)).wasSuccessful()
|
||||||
|
if sys.argv[1:] == ["select"]:
|
||||||
|
mode = select_mode(os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("CI_BASE_SHA", ""), os.environ.get("GITHUB_SHA", ""), Path.cwd())
|
||||||
|
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||||||
|
output.write(f"mode={mode}\n")
|
||||||
|
print(f"CI selection: {mode}")
|
||||||
|
return 0
|
||||||
|
if sys.argv[1:] == ["verify"]:
|
||||||
|
try:
|
||||||
|
errors = verify_results(json.loads(os.environ["CI_NEEDS"]), os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("GITHUB_REF", ""))
|
||||||
|
except (KeyError, ValueError) as error:
|
||||||
|
errors = [str(error)]
|
||||||
|
elif sys.argv[1:] == ["--check-workflow"]:
|
||||||
|
errors = check_workflow(ROOT)
|
||||||
|
else:
|
||||||
|
print("usage: ci_gate.py {select|verify|--check-workflow|--self-test}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
for error in errors:
|
||||||
|
print(f"ERROR: {error}", file=sys.stderr)
|
||||||
|
if not errors:
|
||||||
|
print("CI contract passed")
|
||||||
|
return bool(errors)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build an identified E2E server and verify it around one test invocation."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RECEIPT_ENV = "RUSTFS_E2E_BINARY_RECEIPT"
|
||||||
|
|
||||||
|
|
||||||
|
def feature_set(value):
|
||||||
|
return sorted(set(part.strip() for part in value.split(",") if part.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def source_identity():
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
|
||||||
|
tracked = subprocess.check_output(["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT)
|
||||||
|
paths = set(tracked.decode("utf-8").rstrip("\0").split("\0")) - {""}
|
||||||
|
# RustEmbed consumes ignored console assets as well as tracked Rust sources.
|
||||||
|
static_dir = ROOT / "rustfs/static"
|
||||||
|
if static_dir.is_symlink():
|
||||||
|
raise ValueError("The embedded static directory must not be a symlink")
|
||||||
|
if static_dir.is_dir():
|
||||||
|
for path in static_dir.rglob("*"):
|
||||||
|
if path.is_symlink() and path.is_dir():
|
||||||
|
raise ValueError(f"Unsupported embedded directory symlink: {path}")
|
||||||
|
if not path.is_dir():
|
||||||
|
paths.add(str(path.relative_to(ROOT)))
|
||||||
|
elif static_dir.exists():
|
||||||
|
paths.add("rustfs/static")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
digest.update(b"static-present\0" if static_dir.is_dir() else b"static-absent\0")
|
||||||
|
for name in sorted(paths):
|
||||||
|
path = ROOT / name
|
||||||
|
digest.update(name.encode("utf-8") + b"\0")
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
digest.update(b"deleted\0")
|
||||||
|
continue
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
digest.update(b"symlink\0" + os.fsencode(os.readlink(path)) + b"\0")
|
||||||
|
if path.is_dir():
|
||||||
|
target = path.resolve()
|
||||||
|
if ROOT not in target.parents:
|
||||||
|
raise ValueError(f"Directory link escapes the source inventory: {name}")
|
||||||
|
# Directory aliases such as .claude/skills share already-hashed inputs.
|
||||||
|
for child in target.rglob("*"):
|
||||||
|
if child.is_dir() and not child.is_symlink():
|
||||||
|
continue
|
||||||
|
if child.is_dir() or str(child.relative_to(ROOT)) not in paths:
|
||||||
|
raise ValueError(f"Directory link contains an unrecorded input: {child}")
|
||||||
|
digest.update(b"directory\0" + str(target.relative_to(ROOT)).encode("utf-8") + b"\0")
|
||||||
|
continue
|
||||||
|
elif not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise ValueError(f"Unsupported build input: {name}")
|
||||||
|
digest.update(str(metadata.st_mode & 0o111).encode() + b"\0")
|
||||||
|
digest.update(file_hash(path).encode() + b"\0")
|
||||||
|
return {"head": head, "sha256": digest.hexdigest()}
|
||||||
|
|
||||||
|
|
||||||
|
def sidecar_path(binary):
|
||||||
|
return binary.with_name(binary.name + ".e2e.json")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_target_directory(target_dir):
|
||||||
|
if target_dir == ROOT or target_dir in ROOT.parents:
|
||||||
|
raise ValueError("CARGO_TARGET_DIR must not contain the source workspace")
|
||||||
|
if ROOT in target_dir.parents:
|
||||||
|
ignored = subprocess.run(["git", "check-ignore", "--quiet", "--no-index", str(target_dir.relative_to(ROOT))], cwd=ROOT)
|
||||||
|
if ignored.returncode != 0:
|
||||||
|
raise ValueError("An in-workspace CARGO_TARGET_DIR must be Git-ignored; use target/ or an external directory")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def exclusive_binary(binary):
|
||||||
|
marker = binary.with_name(binary.name + ".e2e.lock")
|
||||||
|
try:
|
||||||
|
descriptor = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
|
except FileExistsError as error:
|
||||||
|
raise ValueError(f"Another E2E build/run owns {marker}; do not share a target directory between concurrent runs") from error
|
||||||
|
try:
|
||||||
|
identity = os.fstat(descriptor)
|
||||||
|
with os.fdopen(descriptor, "w") as lock:
|
||||||
|
lock.write(f"pid={os.getpid()}\n")
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
current = marker.stat()
|
||||||
|
if (current.st_dev, current.st_ino) != (identity.st_dev, identity.st_ino):
|
||||||
|
raise ValueError("The E2E ownership marker changed during the command")
|
||||||
|
marker.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_command(process):
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def build(binary, target_dir, profile, requested, all_bins):
|
||||||
|
sidecar = sidecar_path(binary)
|
||||||
|
sidecar.unlink(missing_ok=True)
|
||||||
|
before = source_identity()
|
||||||
|
command = ["cargo", "build", "--locked", "-p", "rustfs", "--target-dir", str(target_dir), "--message-format=json-render-diagnostics"]
|
||||||
|
command.extend(["--bins"] if all_bins else ["--bin", "rustfs"])
|
||||||
|
if requested:
|
||||||
|
command.extend(["--features", ",".join(requested)])
|
||||||
|
if profile == "release":
|
||||||
|
command.append("--release")
|
||||||
|
artifact = None
|
||||||
|
with subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, text=True, start_new_session=True) as process:
|
||||||
|
try:
|
||||||
|
for line in process.stdout:
|
||||||
|
message = json.loads(line)
|
||||||
|
if message.get("reason") == "compiler-message":
|
||||||
|
print(message["message"].get("rendered", ""), end="", file=sys.stderr)
|
||||||
|
if message.get("reason") == "compiler-artifact" and message.get("target", {}).get("name") == "rustfs" and "bin" in message.get("target", {}).get("kind", []):
|
||||||
|
artifact = message
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise ValueError("RustFS build failed; no E2E identity was recorded")
|
||||||
|
except BaseException:
|
||||||
|
terminate_command(process)
|
||||||
|
raise
|
||||||
|
if not artifact or Path(artifact.get("executable", "")).resolve() != binary:
|
||||||
|
raise ValueError("Cargo did not produce the requested RustFS executable")
|
||||||
|
if source_identity() != before:
|
||||||
|
raise ValueError("Build inputs changed during compilation; finish preparing embedded assets and rebuild in an isolated worktree")
|
||||||
|
record = {
|
||||||
|
"schema": 1,
|
||||||
|
"source": before,
|
||||||
|
"requested_features": requested,
|
||||||
|
"features": sorted(artifact["features"]),
|
||||||
|
"profile": profile,
|
||||||
|
"rustc": subprocess.check_output(["rustc", "-Vv"], text=True),
|
||||||
|
"binary_sha256": file_hash(binary),
|
||||||
|
}
|
||||||
|
sidecar.write_text(json.dumps(record, sort_keys=True) + "\n")
|
||||||
|
print(f"Built E2E server: {binary}\nIdentity: {sidecar}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(binary, profile, requested):
|
||||||
|
record = json.loads(sidecar_path(binary).read_text())
|
||||||
|
if not isinstance(record, dict) or set(record) != {"schema", "source", "requested_features", "features", "profile", "rustc", "binary_sha256"} or type(record["schema"]) is not int or record["schema"] != 1:
|
||||||
|
raise ValueError("Missing or unsupported E2E binary identity; run the build command")
|
||||||
|
if not isinstance(record["rustc"], str) or not record["rustc"].strip():
|
||||||
|
raise ValueError("Missing E2E build toolchain identity")
|
||||||
|
if record["requested_features"] != requested or record["profile"] != profile:
|
||||||
|
raise ValueError("E2E binary build features/profile differ from this test invocation")
|
||||||
|
if not isinstance(record["features"], list) or not all(isinstance(item, str) for item in record["features"]) or not set(requested) <= set(record["features"]):
|
||||||
|
raise ValueError("Invalid resolved E2E binary features")
|
||||||
|
if record["source"] != source_identity():
|
||||||
|
raise ValueError("E2E binary was built from different inputs; rebuild before testing")
|
||||||
|
if record["binary_sha256"] != file_hash(binary):
|
||||||
|
raise ValueError("E2E binary content differs from its build identity")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def run(binary, profile, requested, command):
|
||||||
|
if not command:
|
||||||
|
raise ValueError("run requires a test command after --")
|
||||||
|
override = os.environ.get("CARGO_BIN_EXE_rustfs")
|
||||||
|
if override and Path(override).resolve() != binary:
|
||||||
|
raise ValueError("CARGO_BIN_EXE_rustfs selects a different server; use --binary explicitly")
|
||||||
|
record = verify(binary, profile, requested)
|
||||||
|
metadata = binary.stat()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="rustfs-e2e-receipt-") as directory:
|
||||||
|
receipt = Path(directory) / "receipt.json"
|
||||||
|
receipt.write_text(json.dumps({
|
||||||
|
"schema": 1,
|
||||||
|
"workspace": str(ROOT),
|
||||||
|
"binary": str(binary),
|
||||||
|
"size": metadata.st_size,
|
||||||
|
"modified_ns": metadata.st_mtime_ns,
|
||||||
|
"features": record["features"],
|
||||||
|
}))
|
||||||
|
env = dict(os.environ, CARGO_BIN_EXE_rustfs=str(binary), RUSTFS_BUILD_FEATURES=",".join(record["features"]))
|
||||||
|
env[RECEIPT_ENV] = str(receipt)
|
||||||
|
with subprocess.Popen(command, cwd=ROOT, env=env, start_new_session=True) as process:
|
||||||
|
try:
|
||||||
|
status = process.wait()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
terminate_command(process)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
if verify(binary, profile, requested) != record:
|
||||||
|
raise ValueError("E2E build identity changed during testing")
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
print(f"E2E validation invalidated: {error}", file=sys.stderr)
|
||||||
|
return status if status else 1
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("mode", choices=("build", "run"))
|
||||||
|
parser.add_argument("--features", default="", help="additional Cargo features; defaults remain enabled")
|
||||||
|
parser.add_argument("--profile", choices=("debug", "release"), default="debug")
|
||||||
|
parser.add_argument("--binary", type=Path, help="prebuilt server path for run")
|
||||||
|
parser.add_argument("--bins", action="store_true", help="build all RustFS binary targets, preserving the CI build matrix")
|
||||||
|
# Parse the child command separately so its options are never interpreted here.
|
||||||
|
args = sys.argv[1:]
|
||||||
|
separator = args.index("--") if "--" in args else len(args)
|
||||||
|
command = args[separator + 1:] if separator < len(args) else []
|
||||||
|
options = parser.parse_args(args[:separator])
|
||||||
|
target_dir = Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target")).resolve()
|
||||||
|
binary = (options.binary or target_dir / options.profile / ("rustfs.exe" if os.name == "nt" else "rustfs")).resolve()
|
||||||
|
try:
|
||||||
|
validate_target_directory(target_dir)
|
||||||
|
requested = feature_set(options.features)
|
||||||
|
if options.mode == "build":
|
||||||
|
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with exclusive_binary(binary):
|
||||||
|
if options.mode == "build":
|
||||||
|
if options.binary or command:
|
||||||
|
raise ValueError("build does not accept --binary or a child command")
|
||||||
|
build(binary, target_dir, options.profile, requested, options.bins)
|
||||||
|
return 0
|
||||||
|
if options.bins:
|
||||||
|
raise ValueError("--bins is a build option")
|
||||||
|
return run(binary, options.profile, requested, command)
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
print(f"E2E prerequisite failed: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -14,7 +14,12 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
TARGET_DIR="$PROJECT_ROOT/target/debug"
|
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$PROJECT_ROOT/target}"
|
||||||
|
if [[ "$CARGO_TARGET_DIR" != /* ]]; then
|
||||||
|
CARGO_TARGET_DIR="$PROJECT_ROOT/$CARGO_TARGET_DIR"
|
||||||
|
fi
|
||||||
|
export CARGO_TARGET_DIR
|
||||||
|
TARGET_DIR="$CARGO_TARGET_DIR/debug"
|
||||||
RUSTFS_BINARY="$TARGET_DIR/rustfs"
|
RUSTFS_BINARY="$TARGET_DIR/rustfs"
|
||||||
DATA_DIR="$TARGET_DIR/rustfs_test_data"
|
DATA_DIR="$TARGET_DIR/rustfs_test_data"
|
||||||
RUSTFS_PID=""
|
RUSTFS_PID=""
|
||||||
@@ -94,7 +99,7 @@ build_rustfs() {
|
|||||||
print_info "Building RustFS..."
|
print_info "Building RustFS..."
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
if ! cargo build --bin rustfs --features "$RUSTFS_BUILD_FEATURES"; then
|
if ! python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"; then
|
||||||
print_error "Failed to build RustFS"
|
print_error "Failed to build RustFS"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -115,6 +120,10 @@ check_dependencies() {
|
|||||||
missing_tools+=("curl")
|
missing_tools+=("curl")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
missing_tools+=("python3")
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
missing_tools+=("cargo")
|
missing_tools+=("cargo")
|
||||||
fi
|
fi
|
||||||
@@ -203,7 +212,7 @@ run_tests() {
|
|||||||
|
|
||||||
print_info "Test command: ${test_cmd[*]}"
|
print_info "Test command: ${test_cmd[*]}"
|
||||||
|
|
||||||
if "${test_cmd[@]}"; then
|
if python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- "${test_cmd[@]}"; then
|
||||||
print_success "All tests passed!"
|
print_success "All tests passed!"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -243,9 +243,10 @@ run_quick_e2e_steps() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_step "e2e-reliability-disk-fault" cargo test --package e2e_test reliability_disk_fault_test -- --nocapture
|
run_step "build-e2e-server" python3 scripts/e2e_binary.py build
|
||||||
run_step "e2e-heal-erasure-disk-rebuild" cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture
|
run_step "e2e-reliability-disk-fault" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test reliability_disk_fault_test -- --nocapture
|
||||||
run_step "e2e-namespace-lock-quorum" cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture
|
run_step "e2e-heal-erasure-disk-rebuild" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture
|
||||||
|
run_step "e2e-namespace-lock-quorum" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture
|
||||||
}
|
}
|
||||||
|
|
||||||
run_quick_profile() {
|
run_quick_profile() {
|
||||||
@@ -313,15 +314,15 @@ write_blackbox_matrix() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
printf 'profile\tscenario\tgate\tcommand\tfixture_env\tstatus\n'
|
printf 'profile\tscenario\tgate\tcommand\tfixture_env\tstatus\n'
|
||||||
printf 'quick\tsingle-node disk fault read/write\tblack-box\tcargo test --package e2e_test reliability_disk_fault_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\tsingle-node disk fault read/write\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test reliability_disk_fault_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tcargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tcargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
|
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
|
||||||
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
|
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
|
||||||
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
|
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
|
||||||
printf 'destructive\tdistributed cluster concurrency\tblack-box\tcargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tdistributed cluster concurrency\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tcargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
printf 'destructive\tdelete marker migration semantics\tblack-box\tcargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tdelete marker migration semantics\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
} >"$BLACKBOX_MATRIX"
|
} >"$BLACKBOX_MATRIX"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,9 +567,9 @@ run_destructive_profile() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_step "e2e-cluster-concurrency" cargo test --package e2e_test cluster_concurrency_test -- --nocapture
|
run_step "e2e-cluster-concurrency" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test cluster_concurrency_test -- --nocapture
|
||||||
run_step "e2e-stale-multipart-cleanup-cluster" cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture
|
run_step "e2e-stale-multipart-cleanup-cluster" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture
|
||||||
run_step "e2e-delete-marker-migration-semantics" cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture
|
run_step "e2e-delete-marker-migration-semantics" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture
|
||||||
}
|
}
|
||||||
|
|
||||||
run_fuzz_profile() {
|
run_fuzz_profile() {
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise the E2E build/run boundary without compiling RustFS."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class BinaryProvenanceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
(self.root / "scripts").mkdir()
|
||||||
|
shutil.copy(Path(__file__).with_name("e2e_binary.py"), self.root / "scripts/e2e_binary.py")
|
||||||
|
(self.root / "Cargo.toml").write_text("[workspace]\n")
|
||||||
|
(self.root / "source.rs").write_text("original source\n")
|
||||||
|
(self.root / ".gitignore").write_text("/target/\n/rustfs/static/\n")
|
||||||
|
(self.root / ".agents/skills").mkdir(parents=True)
|
||||||
|
(self.root / ".agents/skills/SKILL.md").write_text("tracked instructions\n")
|
||||||
|
(self.root / ".claude").mkdir()
|
||||||
|
(self.root / ".claude/skills").symlink_to("../.agents/skills", target_is_directory=True)
|
||||||
|
subprocess.run(["git", "init", "-q", str(self.root)], check=True)
|
||||||
|
for args in (["add", "."], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"]):
|
||||||
|
subprocess.run(["git", "-C", str(self.root), *args], check=True)
|
||||||
|
self.commands = self.root / "target/commands"
|
||||||
|
self.commands.mkdir(parents=True)
|
||||||
|
cargo = self.commands / "cargo"
|
||||||
|
cargo.write_text(f"#!{sys.executable}\n" + '''import json, os, pathlib, sys
|
||||||
|
if os.environ.get("FAKE_BUILD_FAIL"):
|
||||||
|
raise SystemExit(23)
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args[:2] == ["nextest", "run"]:
|
||||||
|
receipt = json.loads(pathlib.Path(os.environ["RUSTFS_E2E_BINARY_RECEIPT"]).read_text())
|
||||||
|
assert pathlib.Path(receipt["binary"]) == pathlib.Path(os.environ["CARGO_BIN_EXE_rustfs"]).resolve()
|
||||||
|
if os.environ.get("RUSTFS_E2E_STARTUP_CAS_BINARY"):
|
||||||
|
assert pathlib.Path(receipt["binary"]) == pathlib.Path(os.environ["RUSTFS_E2E_STARTUP_CAS_BINARY"]).resolve()
|
||||||
|
pathlib.Path("target/nextest-command.json").write_text(json.dumps(args))
|
||||||
|
raise SystemExit(int(os.environ.get("FAKE_TEST_EXIT", "0")))
|
||||||
|
target = pathlib.Path(args[args.index("--target-dir") + 1])
|
||||||
|
binary = target / ("release" if "--release" in args else "debug") / "rustfs"
|
||||||
|
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
binary.write_text("#!/bin/sh\\nexit 0\\n")
|
||||||
|
binary.chmod(0o755)
|
||||||
|
features = ["default", "ftps", "webdav"]
|
||||||
|
if "--features" in args:
|
||||||
|
features.extend(args[args.index("--features") + 1].split(","))
|
||||||
|
if "full" in features:
|
||||||
|
features.extend(["sftp", "swift", "metrics-gpu", "pyroscope"])
|
||||||
|
print(json.dumps({"reason": "compiler-artifact", "target": {"name": "rustfs", "kind": ["bin"]}, "executable": str(binary), "features": sorted(set(features))}))
|
||||||
|
if os.environ.get("FAKE_BUILD_MUTATE"):
|
||||||
|
pathlib.Path("source.rs").write_text("changed during build")
|
||||||
|
''')
|
||||||
|
cargo.chmod(0o755)
|
||||||
|
rustc = self.commands / "rustc"
|
||||||
|
rustc.write_text("#!/bin/sh\nprintf 'rustc fixture\\nhost: fixture\\n'\n")
|
||||||
|
rustc.chmod(0o755)
|
||||||
|
self.env = dict(os.environ, PATH=f"{self.commands}{os.pathsep}{os.environ['PATH']}")
|
||||||
|
for name in ("CARGO_TARGET_DIR", "CARGO_BIN_EXE_rustfs", "RUSTFS_BUILD_FEATURES", "RUSTFS_E2E_BINARY_RECEIPT"):
|
||||||
|
self.env.pop(name, None)
|
||||||
|
self.binary = self.root / "target/debug/rustfs"
|
||||||
|
self.sidecar = self.binary.with_name("rustfs.e2e.json")
|
||||||
|
|
||||||
|
def invoke(self, *args, env=None):
|
||||||
|
return subprocess.run([sys.executable, str(self.root / "scripts/e2e_binary.py"), *args], cwd=self.root, env=env or self.env, text=True, capture_output=True)
|
||||||
|
|
||||||
|
def build(self, features=""):
|
||||||
|
result = self.invoke("build", "--features", features)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def run_code(self, code="pass", features="", env=None):
|
||||||
|
return self.invoke("run", "--features", features, "--", sys.executable, "-c", code, env=env)
|
||||||
|
|
||||||
|
def test_build_run_and_receipt_cleanup(self):
|
||||||
|
self.build("full,e2e-test-hooks")
|
||||||
|
result = self.run_code("import os,pathlib; print(os.environ['RUSTFS_E2E_BINARY_RECEIPT']); assert pathlib.Path(os.environ['CARGO_BIN_EXE_rustfs']).is_file(); assert 'sftp' in os.environ['RUSTFS_BUILD_FEATURES']", "e2e-test-hooks,full")
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertFalse(Path(result.stdout.strip()).exists(), "run receipts must not survive their command")
|
||||||
|
self.assertIn("sftp", json.loads(self.sidecar.read_text())["features"])
|
||||||
|
|
||||||
|
def test_source_changes_are_not_hidden_by_timestamps_or_head(self):
|
||||||
|
self.build()
|
||||||
|
path = self.root / "source.rs"
|
||||||
|
old = path.stat()
|
||||||
|
path.write_text("different bytes\n")
|
||||||
|
os.utime(path, ns=(old.st_atime_ns, old.st_mtime_ns))
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
|
||||||
|
def test_deleted_untracked_and_ignored_embedded_inputs(self):
|
||||||
|
for mutation in ("delete", "untracked", "static"):
|
||||||
|
with self.subTest(mutation=mutation):
|
||||||
|
self.build()
|
||||||
|
path = self.root / "source.rs"
|
||||||
|
if mutation == "delete":
|
||||||
|
path.unlink()
|
||||||
|
elif mutation == "untracked":
|
||||||
|
(self.root / "new.rs").write_text("new source")
|
||||||
|
else:
|
||||||
|
static = self.root / "rustfs/static"
|
||||||
|
static.mkdir(parents=True)
|
||||||
|
(static / "index.html").write_text("embedded content")
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
path.write_text("original source\n")
|
||||||
|
|
||||||
|
def test_wrong_binary_features_and_manifest_fail_closed(self):
|
||||||
|
self.build("sftp")
|
||||||
|
self.assertNotEqual(self.run_code(features="webdav").returncode, 0)
|
||||||
|
self.binary.write_text("old server")
|
||||||
|
self.assertNotEqual(self.run_code(features="sftp").returncode, 0)
|
||||||
|
self.sidecar.write_text("{}")
|
||||||
|
self.assertNotEqual(self.run_code(features="sftp").returncode, 0)
|
||||||
|
self.sidecar.unlink()
|
||||||
|
self.assertNotEqual(self.run_code(features="sftp").returncode, 0)
|
||||||
|
|
||||||
|
def test_build_failure_or_source_race_does_not_leave_a_receipt(self):
|
||||||
|
for failure in ("FAKE_BUILD_FAIL", "FAKE_BUILD_MUTATE"):
|
||||||
|
self.build()
|
||||||
|
result = self.invoke("build", env=dict(self.env, **{failure: "1"}))
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertFalse(self.sidecar.exists())
|
||||||
|
|
||||||
|
def test_child_failure_and_changes_during_run_fail(self):
|
||||||
|
self.build()
|
||||||
|
failed = self.run_code("raise SystemExit(37)")
|
||||||
|
self.assertEqual(failed.returncode, 37, failed.stderr)
|
||||||
|
for code in ("import pathlib; pathlib.Path('source.rs').write_text('changed while testing')", "import pathlib; pathlib.Path('target/debug/rustfs').write_text('different server')"):
|
||||||
|
self.build()
|
||||||
|
self.assertNotEqual(self.run_code(code).returncode, 0)
|
||||||
|
|
||||||
|
def test_override_cannot_select_an_unverified_server(self):
|
||||||
|
self.build()
|
||||||
|
result = self.run_code(env=dict(self.env, CARGO_BIN_EXE_rustfs="/some/old/server"))
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
|
||||||
|
def test_artifact_moves_between_clean_checkouts(self):
|
||||||
|
self.build()
|
||||||
|
with tempfile.TemporaryDirectory() as destination:
|
||||||
|
clone = Path(destination) / "clone"
|
||||||
|
subprocess.run(["git", "clone", "-q", str(self.root), str(clone)], check=True)
|
||||||
|
(clone / "target/debug").mkdir(parents=True)
|
||||||
|
shutil.copy2(self.binary, clone / "target/debug/rustfs")
|
||||||
|
shutil.copy2(self.sidecar, clone / "target/debug/rustfs.e2e.json")
|
||||||
|
result = subprocess.run([sys.executable, str(clone / "scripts/e2e_binary.py"), "run", "--", sys.executable, "-c", "pass"], cwd=clone, env=self.env, text=True, capture_output=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_ci_build_preserves_both_manifests_and_runs_the_copied_server(self):
|
||||||
|
from check_test_wiring import yaml_block
|
||||||
|
from test_security_workflow import named_steps, shell_body
|
||||||
|
|
||||||
|
source = (Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml").read_text().splitlines()
|
||||||
|
build_steps = named_steps(yaml_block(source, "build-rustfs-debug-binary", 2))
|
||||||
|
run_steps = named_steps(yaml_block(source, "e2e-full", 2))
|
||||||
|
(self.root / "Cargo.lock").write_text("fixture lock\n")
|
||||||
|
subprocess.run(["git", "add", "Cargo.lock"], cwd=self.root, check=True)
|
||||||
|
subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "lock"], cwd=self.root, check=True)
|
||||||
|
copied = self.root / "target/startup-cas-input/rustfs"
|
||||||
|
env = dict(self.env, STARTUP_CAS_INPUT=str(copied.parent), RUSTFS_E2E_STARTUP_CAS_BINARY=str(copied))
|
||||||
|
for step in (build_steps["Build debug binary"], run_steps["Preserve startup CAS binary input"]):
|
||||||
|
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", shell_body(step)], cwd=self.root, env=env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
for name in ("rustfs.e2e.json", "rustfs.e2e-startup-cas-build.json"):
|
||||||
|
self.assertIn(" target/debug/" + name, build_steps["Upload debug binary"])
|
||||||
|
self.assertEqual((self.binary.parent / name).read_bytes(), (copied.parent / name).read_bytes())
|
||||||
|
manifest = json.loads(copied.with_name("rustfs.e2e-startup-cas-build.json").read_text())
|
||||||
|
self.assertEqual(manifest["argv"], ["python3", "scripts/e2e_binary.py", "build", "--bins", "--features", "e2e-test-hooks"])
|
||||||
|
self.assertTrue(manifest["clean_before"] and manifest["clean_after"])
|
||||||
|
body = next(line.removeprefix(" run: ") for line in run_steps["Run e2e full suite"] if line.startswith(" run: "))
|
||||||
|
for status in (0, 23):
|
||||||
|
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], cwd=self.root, env=dict(env, FAKE_TEST_EXIT=str(status)), capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, status, result.stderr)
|
||||||
|
copied.write_text("replaced preserved binary")
|
||||||
|
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], cwd=self.root, env=env, capture_output=True, text=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
|
||||||
|
def test_distributed_workflow_runs_both_filter_branches_with_receipts(self):
|
||||||
|
from check_test_wiring import yaml_block
|
||||||
|
from test_security_workflow import named_steps, shell_body
|
||||||
|
|
||||||
|
source = (Path(__file__).resolve().parents[1] / ".github/workflows/e2e-distributed.yml").read_text().splitlines()
|
||||||
|
steps = named_steps(yaml_block(source, "distributed", 2))
|
||||||
|
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", shell_body(steps["Build rustfs binary"])], cwd=self.root, env=self.env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
for selected in ("", "test(distributed::s3_basic)"):
|
||||||
|
for status in (0, 23):
|
||||||
|
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", shell_body(steps["Run distributed 4-node e2e suite"])], cwd=self.root, env=dict(self.env, FILTER=selected, FAKE_TEST_EXIT=str(status)), capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, status, result.stderr)
|
||||||
|
argv = json.loads((self.root / "target/nextest-command.json").read_text())
|
||||||
|
self.assertEqual(argv, ["nextest", "run", "--profile", "e2e-distributed", "-p", "e2e_test", *(["-E", selected] if selected else ["--no-tests=fail"])])
|
||||||
|
|
||||||
|
def test_target_directory_and_profile_are_explicit(self):
|
||||||
|
env = dict(self.env, CARGO_TARGET_DIR="target/custom")
|
||||||
|
built = self.invoke("build", "--profile", "release", env=env)
|
||||||
|
self.assertEqual(built.returncode, 0, built.stderr)
|
||||||
|
run = self.invoke("run", "--profile", "release", "--", sys.executable, "-c", "pass", env=env)
|
||||||
|
self.assertEqual(run.returncode, 0, run.stderr)
|
||||||
|
self.assertNotEqual(self.invoke("run", "--", sys.executable, "-c", "pass", env=env).returncode, 0)
|
||||||
|
|
||||||
|
def test_target_directory_cannot_hide_source_inputs(self):
|
||||||
|
for target in (str(self.root), str(self.root / "crates"), str(self.root.parent)):
|
||||||
|
with self.subTest(target=target):
|
||||||
|
result = self.invoke("build", env=dict(self.env, CARGO_TARGET_DIR=target))
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("CARGO_TARGET_DIR", result.stderr)
|
||||||
|
tracked = self.root / "target/tracked.rs"
|
||||||
|
tracked.write_text("tracked build input")
|
||||||
|
subprocess.run(["git", "add", "-f", "target/tracked.rs"], cwd=self.root, check=True)
|
||||||
|
self.build()
|
||||||
|
tracked.write_text("changed tracked build input")
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
|
||||||
|
def test_unsupported_embedded_directory_links_fail_closed(self):
|
||||||
|
self.build()
|
||||||
|
destination = self.root / "target/embedded-assets"
|
||||||
|
destination.mkdir()
|
||||||
|
(destination / "index.html").write_text("untracked embedded input")
|
||||||
|
static = self.root / "rustfs/static"
|
||||||
|
static.mkdir(parents=True)
|
||||||
|
(static / "linked-assets").symlink_to(destination, target_is_directory=True)
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
|
||||||
|
def test_directory_aliases_cannot_hide_unrecorded_inputs(self):
|
||||||
|
self.build()
|
||||||
|
target = self.root / ".agents/skills/SKILL.md"
|
||||||
|
target.write_text("changed instructions\n")
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
self.build()
|
||||||
|
(target.parent / ".gitignore").write_text("hidden.rs\n")
|
||||||
|
(target.parent / "hidden.rs").write_text("ignored build input\n")
|
||||||
|
result = self.invoke("build")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("unrecorded input", result.stderr)
|
||||||
|
alias = self.root / ".claude/skills"
|
||||||
|
alias.unlink()
|
||||||
|
with tempfile.TemporaryDirectory() as external:
|
||||||
|
alias.symlink_to(external, target_is_directory=True)
|
||||||
|
result = self.invoke("build")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("escapes the source inventory", result.stderr)
|
||||||
|
|
||||||
|
def test_directory_alias_indirection_is_part_of_the_identity(self):
|
||||||
|
for name in ("first", "second"):
|
||||||
|
directory = self.root / name
|
||||||
|
directory.mkdir()
|
||||||
|
(directory / "input.rs").write_text(name)
|
||||||
|
selection = self.root / "target/selection"
|
||||||
|
selection.symlink_to(self.root / "first", target_is_directory=True)
|
||||||
|
(self.root / "source-alias").symlink_to("target/selection", target_is_directory=True)
|
||||||
|
self.build()
|
||||||
|
selection.unlink()
|
||||||
|
selection.symlink_to(self.root / "second", target_is_directory=True)
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
|
||||||
|
def test_existing_embedded_files_and_symlink_targets_are_hashed(self):
|
||||||
|
static = self.root / "rustfs/static"
|
||||||
|
static.mkdir(parents=True)
|
||||||
|
index = static / "index.html"
|
||||||
|
index.write_text("embedded version one")
|
||||||
|
external = self.root / "target/embedded-file"
|
||||||
|
external.write_text("linked version one")
|
||||||
|
(static / "linked.html").symlink_to(external)
|
||||||
|
self.build()
|
||||||
|
index.write_text("embedded version two")
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
self.build()
|
||||||
|
external.write_text("linked version two")
|
||||||
|
self.assertNotEqual(self.run_code().returncode, 0)
|
||||||
|
|
||||||
|
def test_each_run_hashes_binary_twice_and_never_calls_cargo(self):
|
||||||
|
script = self.root / "scripts/e2e_binary.py"
|
||||||
|
script.write_text(script.read_text().replace("def file_hash(path):\n", "def file_hash(path):\n if path.name == 'rustfs':\n with (ROOT / 'target/hash-count').open('a') as count:\n count.write('hash\\n')\n"))
|
||||||
|
self.build()
|
||||||
|
count = self.root / "target/hash-count"
|
||||||
|
count.write_text("")
|
||||||
|
result = self.run_code(env=dict(self.env, FAKE_BUILD_FAIL="1"))
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(count.read_text().splitlines(), ["hash", "hash"])
|
||||||
|
|
||||||
|
def test_concurrent_build_or_run_is_rejected(self):
|
||||||
|
self.build()
|
||||||
|
command = [sys.executable, str(self.root / "scripts/e2e_binary.py"), "run", "--", sys.executable, "-c", "print('ready', flush=True); input()"]
|
||||||
|
with subprocess.Popen(command, cwd=self.root, env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as process:
|
||||||
|
self.assertEqual(process.stdout.readline().strip(), "ready")
|
||||||
|
try:
|
||||||
|
for args in (("build", "--features", "sftp"), ("run", "--", sys.executable, "-c", "pass")):
|
||||||
|
rejected = self.invoke(*args)
|
||||||
|
self.assertNotEqual(rejected.returncode, 0)
|
||||||
|
self.assertIn("Another E2E build/run", rejected.stderr)
|
||||||
|
finally:
|
||||||
|
output, error = process.communicate("\n", timeout=10)
|
||||||
|
self.assertEqual(process.returncode, 0, error + output)
|
||||||
|
self.assertFalse(self.binary.with_name("rustfs.e2e.lock").exists())
|
||||||
|
|
||||||
|
def test_interruption_cleans_receipt_and_releases_ownership(self):
|
||||||
|
self.build()
|
||||||
|
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
command = [sys.executable, str(self.root / "scripts/e2e_binary.py"), "run", "--", sys.executable, "-c", "import os; print(os.environ['RUSTFS_E2E_BINARY_RECEIPT'], flush=True); input()"]
|
||||||
|
with subprocess.Popen(command, cwd=self.root, env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as process:
|
||||||
|
receipt = Path(process.stdout.readline().strip())
|
||||||
|
self.assertTrue(receipt.is_file())
|
||||||
|
process.send_signal(signum)
|
||||||
|
process.communicate(timeout=10)
|
||||||
|
self.assertNotEqual(process.returncode, 0)
|
||||||
|
self.assertFalse(receipt.exists())
|
||||||
|
self.assertFalse(self.binary.with_name("rustfs.e2e.lock").exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user