Compare commits

...

2 Commits

Author SHA1 Message Date
weile321 88587a8f94 fix(s3select-api): clamp overflowing u64 GetRange values to i64::MAX (#9118) 2026-11-02 14:05:07 +00:00
hector 8c8b5da63f ci(nightly): dispatch nightly-candidate-ready after asset publish (#9131)
After the nightly packages are on the auto-testing nightly release and
the schema 2 R2 candidate exists, post repository_dispatch to
rustfs/auto-testing. Vault lanes stay parallel, and the workflow_run
functional chain is left in place.

Co-authored-by: hector <majinghe@users.noreply.github.com>
2026-11-02 23:05:07 +08:00
3 changed files with 255 additions and 9 deletions
+88 -4
View File
@@ -346,12 +346,12 @@ jobs:
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
# Publish the deb/rpm pair to the auto-testing repo's `assets` branch so
# engineers can download and install the nightly directly. The branch is
# a single-commit orphan rewritten on every build, which keeps the repo
# small while the latest files stay reachable at stable raw URLs.
# Publish the deb/rpm pair as assets of the rolling `nightly` release on
# rustfs/auto-testing (see scripts/release/publish_nightly_assets.sh).
# The dispatch below runs only after this upload and the immutable R2
# candidate both succeed. It does not replace rustfs-functional-chain.yml,
# which still starts from this workflow's workflow_run completion.
# kms-vault-lane and kms-vault-ha-failover stay parallel with this job.
- name: Publish packages to auto-testing release assets
env:
ASSETS_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
@@ -360,6 +360,90 @@ jobs:
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
BUILD_REF: ${{ env.NIGHTLY_BUILD_REF }}
run: bash scripts/release/publish_nightly_assets.sh
# Cross-repo repository_dispatch cannot use GITHUB_TOKEN. The secret is a
# PAT or GitHub App token for rustfs/auto-testing: classic `repo` scope,
# or fine-grained Contents write (the permission GitHub checks for
# POST /repos/{owner}/{repo}/dispatches). A dispatch failure fails this
# step, and therefore this job, so the scheduled failure alert fires.
# GitHub allows at most 10 top-level client_payload properties, so the
# schema 2 candidate object is nested under `candidate` and the release
# fields sit beside it.
- name: Dispatch nightly candidate to auto-testing
if: ${{ steps.publish.outputs.candidate_file != '' }}
env:
DISPATCH_TOKEN: ${{ secrets.AUTO_TESTING_DISPATCH_TOKEN }}
CANDIDATE_FILE: ${{ steps.publish.outputs.candidate_file }}
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
SOURCE_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
: "${DISPATCH_TOKEN:?AUTO_TESTING_DISPATCH_TOKEN is required to dispatch rustfs/auto-testing}"
: "${CANDIDATE_FILE:?nightly candidate manifest is required}"
: "${DEB_DATE:?DEB_DATE is required}"
[[ "${DEB_DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]
[[ "${SOURCE_REPO}" == "rustfs/rustfs" ]]
payload="${RUNNER_TEMP}/nightly-candidate-dispatch-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --slurpfile candidate "${CANDIDATE_FILE}" \
--arg event_type "nightly-candidate-ready" \
--arg source_repo "${SOURCE_REPO}" \
--arg latest_alias_url "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb" \
--arg auto_testing_release "https://github.com/rustfs/auto-testing/releases/tag/nightly" \
--arg channel "nightly" \
--arg deb_date "${DEB_DATE}" \
--arg nightly_workflow ".github/workflows/nightly-gnu.yml" \
'{
event_type: $event_type,
source_repo: $source_repo,
latest_alias_url: $latest_alias_url,
auto_testing_release: $auto_testing_release,
channel: $channel,
deb_date: $deb_date,
nightly_workflow: $nightly_workflow,
candidate: $candidate[0]
}' > "${payload}"
if ! jq -e '
.event_type == "nightly-candidate-ready"
and .source_repo == "rustfs/rustfs"
and .channel == "nightly"
and .deb_date == env.DEB_DATE
and .nightly_workflow == ".github/workflows/nightly-gnu.yml"
and .latest_alias_url == "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb"
and .auto_testing_release == "https://github.com/rustfs/auto-testing/releases/tag/nightly"
and .candidate.schema == 2
and ((.candidate | keys | sort) == ["build_run_attempt","build_run_id","package_sha256","package_url","schema","source_ref","source_sha","workflow_sha"])
and ((keys | sort) == ["auto_testing_release","candidate","channel","deb_date","event_type","latest_alias_url","nightly_workflow","source_repo"])
' "${payload}" >/dev/null; then
echo "nightly dispatch payload does not match the nightly-candidate-ready contract" >&2
exit 1
fi
body="${RUNNER_TEMP}/nightly-candidate-dispatch-body-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --slurpfile client_payload "${payload}" \
'{event_type: "nightly-candidate-ready", client_payload: $client_payload[0]}' > "${body}"
response="${RUNNER_TEMP}/nightly-candidate-dispatch-response-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt"
status="$(curl -sS --retry 3 --connect-timeout 15 --max-time 60 \
-o "${response}" -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json" \
--data-binary @"${body}" \
https://api.github.com/repos/rustfs/auto-testing/dispatches)" || {
echo "repository_dispatch request to rustfs/auto-testing failed before an HTTP status" >&2
exit 1
}
if [[ "${status}" != "204" ]]; then
echo "repository_dispatch to rustfs/auto-testing failed: HTTP ${status}" >&2
if [[ -s "${response}" ]]; then
cat "${response}" >&2
fi
exit 1
fi
echo "dispatched nightly-candidate-ready to rustfs/auto-testing (deb_date=${DEB_DATE}, run=${GITHUB_RUN_ID}, attempt=${GITHUB_RUN_ATTEMPT})"
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
#
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
+47 -5
View File
@@ -640,12 +640,12 @@ fn http_range_spec_from_get_range(range: &GetRange) -> HTTPRangeSpec {
GetRange::Bounded(range) => http_range_spec_from_range(range.clone()),
GetRange::Offset(start) => HTTPRangeSpec {
is_suffix_length: false,
start: *start as i64,
start: i64::try_from(*start).unwrap_or(i64::MAX),
end: -1,
},
GetRange::Suffix(length) => HTTPRangeSpec {
is_suffix_length: true,
start: *length as i64,
start: i64::try_from(*length).unwrap_or(i64::MAX),
end: -1,
},
}
@@ -654,15 +654,15 @@ fn http_range_spec_from_get_range(range: &GetRange) -> HTTPRangeSpec {
fn http_range_spec_from_range(range: Range<u64>) -> HTTPRangeSpec {
HTTPRangeSpec {
is_suffix_length: false,
start: range.start as i64,
end: range.end.saturating_sub(1) as i64,
start: i64::try_from(range.start).unwrap_or(i64::MAX),
end: i64::try_from(range.end.saturating_sub(1)).unwrap_or(i64::MAX),
}
}
fn http_range_spec_from_start(start: u64) -> HTTPRangeSpec {
HTTPRangeSpec {
is_suffix_length: false,
start: start as i64,
start: i64::try_from(start).unwrap_or(i64::MAX),
end: -1,
}
}
@@ -2476,6 +2476,48 @@ mod test {
assert_eq!(range.end, 19);
}
#[test]
fn test_get_range_conversion_for_suffix_ranges() {
// Normal suffix: last 100 bytes.
let range = http_range_spec_from_get_range(&GetRange::Suffix(100));
assert!(range.is_suffix_length);
assert_eq!(range.start, 100);
assert_eq!(range.end, -1);
// Overflowing suffix: `u64::MAX` previously wrapped to `-1` via `as i64`
// and was misread as "last 1 byte". It now clamps to `i64::MAX`, which
// `get_length` treats as "the whole object".
let range = http_range_spec_from_get_range(&GetRange::Suffix(u64::MAX));
assert!(range.is_suffix_length);
assert_eq!(range.start, i64::MAX);
assert_eq!(range.end, -1);
}
#[test]
fn test_get_range_conversion_for_offset_overflow() {
// Normal offset.
let range = http_range_spec_from_get_range(&GetRange::Offset(42));
assert!(!range.is_suffix_length);
assert_eq!(range.start, 42);
assert_eq!(range.end, -1);
// Overflowing offset clamps to `i64::MAX`, which is then rejected as
// out-of-range (416) by `get_offset_length`.
let range = http_range_spec_from_get_range(&GetRange::Offset(u64::MAX));
assert!(!range.is_suffix_length);
assert_eq!(range.start, i64::MAX);
assert_eq!(range.end, -1);
}
#[test]
fn test_get_range_conversion_for_bounded_overflow() {
// Overflowing bounded range clamps start/end to `i64::MAX`.
let range = http_range_spec_from_get_range(&GetRange::Bounded(u64::MAX - 10..u64::MAX));
assert!(!range.is_suffix_length);
assert_eq!(range.start, i64::MAX);
assert_eq!(range.end, i64::MAX);
}
#[tokio::test]
async fn test_scan_range_output_can_convert_field_delimiter() {
let chunks = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from_static(b"a&&1\nb&&2\n"))]);
+120
View File
@@ -220,6 +220,126 @@ SH
self.assertNotIn(" continue-on-error: true", self.publish)
self.assertNotIn(" overwrite: true", upload)
def dispatch_shell(self):
step = self.steps["Dispatch nightly candidate to auto-testing"]
start = step.index(" run: |") + 1
return "\n".join(line[10:] for line in step[start:] if not line.strip() or line.startswith(" "))
def candidate_manifest(self, **overrides):
digest = overrides.pop("package_sha256", self.digest)
workflow_sha = overrides.pop("workflow_sha", self.sha)
manifest = {
"schema": 2,
"workflow_sha": workflow_sha,
"source_ref": "main",
"source_sha": self.sha,
"build_run_id": 12345,
"build_run_attempt": 1,
"package_sha256": digest,
"package_url": f"https://dl.rustfs.com/artifacts/rustfs/packages/nightly/runs/12345/1/{digest}/rustfs.deb",
}
manifest.update(overrides)
return manifest
def run_dispatch(self, manifest=None, **overrides):
manifest = self.candidate_manifest() if manifest is None else manifest
candidate = self.root / "candidate.json"
candidate.write_text(json.dumps(manifest))
shim = self.root / "dispatch-curl.sh"
shim.write_text(r'''curl() {
printf '%s\0' "$@" > "$FAKE_CURL_ARGS"
local outfile="" data=""
local args=("$@")
local i=0
while [[ $i -lt ${#args[@]} ]]; do
case "${args[$i]}" in
-o) outfile="${args[$((i+1))]}"; i=$((i+2));;
--data-binary)
data="${args[$((i+1))]}"
cp "${data#@}" "$FAKE_DISPATCH_BODY"
i=$((i+2));;
*) i=$((i+1));;
esac
done
[[ "$FAKE_DISPATCH_STATUS" != "curl-failure" ]] || return 28
if [[ -n "$outfile" ]]; then printf '%s' "$FAKE_DISPATCH_RESPONSE" > "$outfile"; fi
printf '%s' "$FAKE_DISPATCH_STATUS"
}
''')
env = dict(os.environ, BASH_ENV=str(shim), CANDIDATE_FILE=str(candidate), DEB_DATE="2026-09-06",
SOURCE_REPO="rustfs/rustfs", DISPATCH_TOKEN="test-dispatch-token", RUNNER_TEMP=str(self.root),
GITHUB_RUN_ID="12345", GITHUB_RUN_ATTEMPT="1", GITHUB_SHA=manifest["workflow_sha"],
FAKE_CURL_ARGS=str(self.root / "curl-args"), FAKE_DISPATCH_BODY=str(self.root / "dispatch-body.json"),
FAKE_DISPATCH_STATUS="204", FAKE_DISPATCH_RESPONSE="")
env.update(overrides)
return subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", self.dispatch_shell()],
cwd=self.root, env=env, capture_output=True, text=True)
def test_dispatch_follows_release_publish_without_blocking_vault_lanes(self):
names = list(self.steps)
self.assertLess(names.index("Publish packages to auto-testing release assets"), names.index("Dispatch nightly candidate to auto-testing"))
step = "\n".join(self.steps["Dispatch nightly candidate to auto-testing"])
self.assertIn(" if: ${{ steps.publish.outputs.candidate_file != '' }}", step)
self.assertIn(" DISPATCH_TOKEN: ${{ secrets.AUTO_TESTING_DISPATCH_TOKEN }}", step)
self.assertNotIn("secrets.GITHUB_TOKEN", step)
self.assertNotIn("github.token", step)
self.assertNotIn("continue-on-error", step)
self.assertIn("https://api.github.com/repos/rustfs/auto-testing/dispatches", step)
self.assertIn("nightly-candidate-ready", step)
lines = WORKFLOW.read_text().splitlines()
for lane in ("kms-vault-lane", "kms-vault-ha-failover"):
job = yaml_block(lines, lane, 2)
needs = next(line for line in job if line.strip().startswith("needs:"))
self.assertEqual(needs.strip(), "needs: resolve-source")
self.assertNotIn("Dispatch nightly candidate", "\n".join(job))
alert = "\n".join(yaml_block(lines, "alert-on-failure", 2))
self.assertIn("needs: [build, kms-vault-lane, kms-vault-ha-failover]", alert)
chain = (ROOT / ".github/workflows/rustfs-functional-chain.yml").read_text()
self.assertIn('workflows: ["Nightly GNU Build"]', chain)
self.assertIn("types: [completed]", chain)
def test_dispatch_sends_schema2_candidate_without_the_token(self):
result = self.run_dispatch()
self.assertEqual(result.returncode, 0, result.stderr)
body = json.loads((self.root / "dispatch-body.json").read_text())
self.assertEqual(body["event_type"], "nightly-candidate-ready")
payload = body["client_payload"]
self.assertLessEqual(len(payload), 10)
self.assertEqual(set(payload), {"event_type", "source_repo", "latest_alias_url", "auto_testing_release", "channel", "deb_date", "nightly_workflow", "candidate"})
self.assertEqual(payload["candidate"], self.candidate_manifest())
self.assertEqual(payload["latest_alias_url"], "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb")
self.assertEqual(payload["auto_testing_release"], "https://github.com/rustfs/auto-testing/releases/tag/nightly")
self.assertEqual(payload["channel"], "nightly")
self.assertEqual(payload["deb_date"], "2026-09-06")
self.assertEqual(payload["nightly_workflow"], ".github/workflows/nightly-gnu.yml")
self.assertEqual(payload["source_repo"], "rustfs/rustfs")
validate_manifest(payload["candidate"], {"id": 12345, "run_attempt": 1, "head_sha": self.sha})
args = (self.root / "curl-args").read_bytes().split(b"\0")
self.assertIn(b"https://api.github.com/repos/rustfs/auto-testing/dispatches", args)
self.assertIn(b"Authorization: Bearer test-dispatch-token", args)
self.assertNotIn("test-dispatch-token", json.dumps(body))
self.assertIn("dispatched nightly-candidate-ready", result.stdout)
def test_dispatch_failure_stops_before_treating_the_candidate_as_notified(self):
for status, response in (("422", '{"message":"No more than 10 properties are allowed"}'), ("500", "unavailable"), ("curl-failure", "")):
with self.subTest(status=status):
result = self.run_dispatch(FAKE_DISPATCH_STATUS=status, FAKE_DISPATCH_RESPONSE=response)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("repository_dispatch", result.stderr)
if response:
self.assertIn(response, result.stderr)
self.assertNotIn("dispatched nightly-candidate-ready", result.stdout)
for marker in ("curl-args", "dispatch-body.json"):
(self.root / marker).unlink(missing_ok=True)
missing = self.run_dispatch(DISPATCH_TOKEN="")
self.assertNotEqual(missing.returncode, 0)
self.assertIn("AUTO_TESTING_DISPATCH_TOKEN is required", missing.stderr)
self.assertFalse((self.root / "curl-args").exists())
rejected = self.run_dispatch(manifest=self.candidate_manifest(extra="nope"))
self.assertNotEqual(rejected.returncode, 0, rejected.stdout)
self.assertIn("nightly-candidate-ready contract", rejected.stderr)
self.assertFalse((self.root / "dispatch-body.json").exists())
class ValidationPackageTests(unittest.TestCase):
def setUp(self):