diff --git a/crates/object-capacity/Cargo.toml b/crates/object-capacity/Cargo.toml index b37d58f3b..32834acb0 100644 --- a/crates/object-capacity/Cargo.toml +++ b/crates/object-capacity/Cargo.toml @@ -65,7 +65,7 @@ rustfs-config = { workspace = true, features = ["constants"] } rustfs-io-metrics = { workspace = true } rustfs-utils = { workspace = true, features = ["os"] } futures = { workspace = true } -tokio = { workspace = true, features = ["sync", "time", "fs", "rt-multi-thread"] } +tokio = { workspace = true, features = ["sync", "time", "fs", "rt-multi-thread","macros"] } tokio-util = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/crates/scanner-metrics/Cargo.toml b/crates/scanner-metrics/Cargo.toml index 61ced250d..cfc0d3e19 100644 --- a/crates/scanner-metrics/Cargo.toml +++ b/crates/scanner-metrics/Cargo.toml @@ -34,7 +34,7 @@ metrics = { workspace = true } rmp-serde = { workspace = true } rustfs-heal-contracts = { workspace = true } serde = { workspace = true, features = ["derive"] } -tokio = { workspace = true, features = ["sync"] } +tokio = { workspace = true, features = ["sync","rt"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/docs/README.md b/docs/README.md index 0a9e7a3ca..305eb6048 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,3 +36,7 @@ drive ownership, snapshot freshness, counter queries, and rolling upgrades. For optional shard commitments, see [Independent shard integrity rollout](operations/shard-integrity-rollout.md): activation, legacy repair results, multipart mode changes, and rollback limits. + +For crates.io publication of workspace crates, see +[Workspace Cargo Publish](operations/cargo-publish-workspace.md): dependency +ordering, dry-run, publish, and failure handling. diff --git a/docs/operations/cargo-publish-workspace.md b/docs/operations/cargo-publish-workspace.md new file mode 100644 index 000000000..2d752ad3b --- /dev/null +++ b/docs/operations/cargo-publish-workspace.md @@ -0,0 +1,164 @@ +# Workspace Cargo Publish Runbook + +This runbook covers crates.io publication for the RustFS Cargo workspace. It is separate from the GitHub tag, binary asset, Docker, Helm, and package release pipeline. + +Use [`scripts/cargo_publish_workspace.sh`](../../scripts/cargo_publish_workspace.sh) as the source of truth for the live publish order. The script derives the order from `cargo metadata`, skips workspace members marked `publish = false`, fails on dependency cycles, and runs crates before their workspace dependents. + +## Scope + +- Publish workspace crates with `cargo publish -p ` in dependency order. +- Validate the package graph before any registry operation. +- Check whether each crate version already exists in the target registry before dry-run or publish work. +- Support an offline plan mode and an online dry-run mode before the final publish. +- Keep the publish operation on the exact reviewed commit. + +Out of scope: + +- RustFS version-file bumping. +- GitHub preview or final tag publication. +- GitHub Release asset verification. +- Docker, Helm, DEB, RPM, or latest-channel publication. + +## Current Branch Snapshot + +On this branch, `cargo metadata --format-version 1 --no-deps` reports 51 workspace members. None of the workspace package manifests set `publish = false`, so every member is eligible from Cargo's manifest perspective. + +The script always filters out the test-only `e2e_test` package, leaving 50 packages in the default publish plan. If another test-only or internal crate should not be published, set `publish = false` in that crate's manifest before publication. The script also supports `--exclude `, but it will fail if any selected publishable crate still depends on the excluded package. + +The publish order below is the current topological order of workspace path dependencies: + +1. `rustfs-checksums` +2. `rustfs-common` +3. `rustfs-config` +4. `rustfs-credentials` +5. `rustfs-crypto` +6. `rustfs-extension-schema` +7. `rustfs-heal-contracts` +8. `rustfs-license` +9. `rustfs-log-analyzer` +10. `rustfs-object-data-cache` +11. `rustfs-replication` +12. `rustfs-s3-types` +13. `rustfs-security-governance` +14. `rustfs-utils` +15. `rustfs-tls-runtime` +16. `rustfs-policy` +17. `rustfs-scanner-metrics` +18. `rustfs-s3-ops` +19. `rustfs-filemeta` +20. `rustfs-kms` +21. `rustfs-signer` +22. `rustfs-trusted-proxies` +23. `rustfs-targets` +24. `rustfs-keystone` +25. `rustfs-io-metrics` +26. `rustfs-data-usage` +27. `rustfs-storage-api` +28. `rustfs-madmin` +29. `rustfs-audit` +30. `rustfs-io-core` +31. `rustfs-lock` +32. `rustfs-object-capacity` +33. `rustfs-protos` +34. `rustfs-rio` +35. `rustfs-lifecycle` +36. `rustfs-concurrency` +37. `rustfs-rio-v2` +38. `rustfs-s3-client` +39. `rustfs-zip` +40. `rustfs-ecstore` +41. `rustfs-notify` +42. `rustfs-test-utils` +43. `rustfs-heal` +44. `rustfs-iam` +45. `rustfs-s3select-api` +46. `rustfs-scanner` +47. `rustfs-obs` +48. `rustfs-protocols` +49. `rustfs-s3select-query` +50. `rustfs` + +Regenerate this order before a real publication: + +```bash +scripts/cargo_publish_workspace.sh --mode plan +``` + +## Preflight + +1. Start from the exact commit that should be published. +2. Confirm the worktree is clean: + +```bash +git status --short +``` + +3. Confirm the branch contains the intended version numbers: + +```bash +cargo metadata --format-version 1 --no-deps +``` + +4. Confirm crates.io authentication without printing tokens: + +```bash +cargo login +``` + +5. Run the repository validation appropriate to the version change before publishing. At minimum, a real release should not rely only on `cargo publish --dry-run`; dry-run verifies packaging, not RustFS runtime acceptance. + +## Dry Run + +Run the complete ordered packaging check. The default registry is `crates-io`, and the script skips any crate version that already exists there. The script passes `--registry crates-io` explicitly so local source replacement does not redirect the publish command to a mirror. + +```bash +scripts/cargo_publish_workspace.sh --mode dry-run +``` + +For an alternate registry: + +```bash +scripts/cargo_publish_workspace.sh --mode dry-run --registry +``` + +Dry-run may need network access because the script checks whether versions already exist and Cargo validates registry dependency resolution. If the existence check cannot confirm the registry state, the script fails closed instead of publishing or dry-running that package. If dry-run fails for a workspace dependency, do not skip forward; fix the manifest/version state or publish the missing dependency first. + +## Publish + +The script requires an explicit environment confirmation for publish mode. The default registry is `crates-io`; crate versions that already exist in the registry are reported and skipped, not republished. Missing versions continue to `cargo publish --registry crates-io` by default. + +```bash +RUSTFS_CARGO_PUBLISH_CONFIRM=publish \ + scripts/cargo_publish_workspace.sh --mode publish --wait-seconds 60 +``` + +If the reviewed release/version bump is intentionally still uncommitted when packaging or publishing, pass `--allow-dirty`. The script then passes `--allow-dirty` through to `cargo publish`; without it, both the script and Cargo keep the dirty-worktree protection enabled. + +For an alternate registry: + +```bash +RUSTFS_CARGO_PUBLISH_CONFIRM=publish \ + scripts/cargo_publish_workspace.sh --mode publish --registry --wait-seconds 60 +``` + +The wait between crates gives the registry index time to expose each newly published crate version before dependents are published. Increase `--wait-seconds` if dependents fail because the registry cannot resolve a just-published workspace crate. + +## Failure Handling + +- If a crate fails during dry-run, stop and fix the package before retrying from the beginning. +- If the script reports that a crate version already exists, it skips that crate and continues with the next selected package. +- If the registry existence check fails for network, service, authentication, or unexpected-response reasons, stop and rerun after the registry state can be verified. +- If a dependent crate cannot resolve a just-published internal dependency, wait for registry propagation and retry the same package. +- Do not bump versions, retag, or publish GitHub release assets as a workaround for a partial cargo publish. Treat that as a separate release decision. + +## Post-Publish Checks + +After the script finishes, spot-check the registry state: + +```bash +cargo info rustfs +cargo info rustfs-ecstore +cargo info rustfs-utils +``` + +Record the final commit, crate version, script mode, registry, and any retry decisions in the release handoff. diff --git a/flake.nix b/flake.nix index f26bc39d7..84a747aea 100644 --- a/flake.nix +++ b/flake.nix @@ -73,7 +73,7 @@ rustc = rustToolchain; }; - clientVersion = "0.1.32"; + clientVersion = "0.1.36"; rustfs = rustPlatform.buildRustPackage { pname = "rustfs"; diff --git a/scripts/README.md b/scripts/README.md index 9a74bb4e9..a802401a9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -137,6 +137,7 @@ their issue closes. | `manual_transition_nightly_stress_runbook.sh` | dev-tool | Nightly stress entrypoint with failure snapshot templates | — | | `install-flatc.sh` | dev-tool | Local flatc installer (macOS) | — | | `install-protoc.sh` | dev-tool | Local protoc installer (macOS/Linux) | — | +| `cargo_publish_workspace.sh` | dev-tool | Generates the workspace crate publish order, checks existing registry versions, and optionally runs ordered `cargo publish` dry-runs or publication | `docs/operations/cargo-publish-workspace.md` | | `makefile-header.sh` | dev-tool | Generates the `## —— section ——` header lines used in `.config/make/*.mak` | — | | `tls_gen.md` | dev-tool (doc) | Notes on generating local TLS certificates | — | diff --git a/scripts/cargo_publish_workspace.sh b/scripts/cargo_publish_workspace.sh new file mode 100755 index 000000000..00846ab64 --- /dev/null +++ b/scripts/cargo_publish_workspace.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/cargo_publish_workspace.sh [options] [-- extra cargo publish args] + +Generate and optionally execute the RustFS workspace cargo publish order. +The test-only e2e_test package is always excluded. + +Options: + --mode plan|dry-run|publish Action to perform. Defaults to plan. + --registry NAME Registry to check and publish to. Defaults to crates-io. + --wait-seconds N Delay between successful publishes. Defaults to 60. + --allow-dirty Allow a dirty git worktree and pass --allow-dirty to cargo publish. + --only PACKAGE Publish only PACKAGE and its workspace dependencies. Repeatable. + --exclude PACKAGE Exclude PACKAGE from the plan. Repeatable. + -h, --help Show this help. + +Publish mode requires RUSTFS_CARGO_PUBLISH_CONFIRM=publish. +USAGE +} + +mode="plan" +registry="crates-io" +wait_seconds="60" +allow_dirty=0 +only_packages=() +exclude_packages=() +extra_cargo_args=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + [[ $# -ge 2 ]] || { echo "--mode requires a value" >&2; exit 2; } + mode="$2" + shift 2 + ;; + --registry) + [[ $# -ge 2 ]] || { echo "--registry requires a value" >&2; exit 2; } + registry="$2" + shift 2 + ;; + --wait-seconds) + [[ $# -ge 2 ]] || { echo "--wait-seconds requires a value" >&2; exit 2; } + wait_seconds="$2" + shift 2 + ;; + --allow-dirty) + allow_dirty=1 + shift + ;; + --only) + [[ $# -ge 2 ]] || { echo "--only requires a package name" >&2; exit 2; } + only_packages+=("$2") + shift 2 + ;; + --exclude) + [[ $# -ge 2 ]] || { echo "--exclude requires a package name" >&2; exit 2; } + exclude_packages+=("$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + extra_cargo_args+=("$@") + break + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "$mode" in + plan|dry-run|publish) ;; + *) + echo "--mode must be one of: plan, dry-run, publish" >&2 + exit 2 + ;; +esac + +if ! [[ "$wait_seconds" =~ ^[0-9]+$ ]]; then + echo "--wait-seconds must be a non-negative integer" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +cd "$repo_root" + +if [[ "$allow_dirty" -eq 0 ]] && [[ -n "$(git status --porcelain)" ]]; then + echo "worktree is dirty; commit, stash, or pass --allow-dirty when this is intentional" >&2 + exit 1 +fi + +if [[ "$mode" == "publish" && "${RUSTFS_CARGO_PUBLISH_CONFIRM:-}" != "publish" ]]; then + echo "publish mode requires RUSTFS_CARGO_PUBLISH_CONFIRM=publish" >&2 + exit 2 +fi + +order_args=() +if [[ "${#only_packages[@]}" -gt 0 ]]; then + for package in "${only_packages[@]}"; do + order_args+=(--only "$package") + done +fi +if [[ "${#exclude_packages[@]}" -gt 0 ]]; then + for package in "${exclude_packages[@]}"; do + order_args+=(--exclude "$package") + done +fi + +publish_plan_file="$(mktemp "${TMPDIR:-/tmp}/rustfs-cargo-publish-plan.XXXXXX")" +trap 'rm -f "$publish_plan_file"' EXIT + +python_args=(-) +if [[ "${#order_args[@]}" -gt 0 ]]; then + python_args+=("${order_args[@]}") +fi + +python3 "${python_args[@]}" >"$publish_plan_file" <<'PY' +import argparse +import collections +import json +import subprocess +import sys + +parser = argparse.ArgumentParser() +parser.add_argument("--only", action="append", default=[]) +parser.add_argument("--exclude", action="append", default=[]) +args = parser.parse_args() +default_excluded = {"e2e_test"} + +metadata = json.loads( + subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]) +) +workspace_members = set(metadata["workspace_members"]) +workspace_root = metadata["workspace_root"] + "/" +packages_by_id = { + package["id"]: package + for package in metadata["packages"] + if package["id"] in workspace_members +} +packages_by_name = {package["name"]: package for package in packages_by_id.values()} + +unknown = sorted((set(args.only) | set(args.exclude)) - set(packages_by_name)) +if unknown: + print(f"unknown workspace package(s): {', '.join(unknown)}", file=sys.stderr) + sys.exit(2) + +publishable = { + name + for name, package in packages_by_name.items() + if package.get("publish") != [] +} + +if args.only: + selected = set() + visiting = set() + + def include_with_dependencies(name): + if name in selected: + return + if name in visiting: + print(f"dependency cycle while selecting {name}", file=sys.stderr) + sys.exit(1) + visiting.add(name) + package = packages_by_name[name] + for dependency in package["dependencies"]: + if dependency.get("source") is None and dependency["name"] in packages_by_name: + include_with_dependencies(dependency["name"]) + visiting.remove(name) + selected.add(name) + + for name in args.only: + include_with_dependencies(name) +else: + selected = set(publishable) + +selected -= default_excluded +selected -= set(args.exclude) +selected &= publishable + +for name in sorted(selected): + package = packages_by_name[name] + for dependency in package["dependencies"]: + dependency_name = dependency["name"] + if dependency.get("source") is None and dependency_name in packages_by_name: + if dependency_name not in publishable: + print( + f"{name} depends on non-publishable workspace package {dependency_name}", + file=sys.stderr, + ) + sys.exit(1) + if dependency_name not in selected: + print( + f"{name} depends on excluded workspace package {dependency_name}", + file=sys.stderr, + ) + sys.exit(1) + +edges = collections.defaultdict(set) +indegree = {name: 0 for name in selected} +for name in selected: + package = packages_by_name[name] + for dependency in package["dependencies"]: + dependency_name = dependency["name"] + if dependency.get("source") is None and dependency_name in selected: + if name not in edges[dependency_name]: + edges[dependency_name].add(name) + indegree[name] += 1 + +ready = collections.deque(sorted(name for name, degree in indegree.items() if degree == 0)) +ordered = [] +while ready: + name = ready.popleft() + ordered.append(name) + for dependent in sorted(edges[name]): + indegree[dependent] -= 1 + if indegree[dependent] == 0: + ready.append(dependent) + +if len(ordered) != len(selected): + remaining = ", ".join(sorted(name for name, degree in indegree.items() if degree > 0)) + print(f"workspace dependency cycle blocks publish order: {remaining}", file=sys.stderr) + sys.exit(1) + +for name in ordered: + package = packages_by_name[name] + manifest_path = package["manifest_path"].removeprefix(workspace_root) + print(f"{name}\t{package['version']}\t{manifest_path}") +PY + +publish_plan=() +while IFS= read -r line; do + publish_plan+=("$line") +done <"$publish_plan_file" + +if [[ "${#publish_plan[@]}" -eq 0 ]]; then + echo "no publishable workspace packages selected" >&2 + exit 1 +fi + +printf 'Cargo publish plan (%s package(s)):\n' "${#publish_plan[@]}" +index=1 +for entry in "${publish_plan[@]}"; do + IFS=$'\t' read -r package version manifest_path <<<"$entry" + printf '%02d. %s %s (%s)\n' "$index" "$package" "$version" "$manifest_path" + index=$((index + 1)) +done + +if [[ "$mode" == "plan" ]]; then + exit 0 +fi + +registry_display="$registry" + +crate_version_exists() { + local package="$1" + local version="$2" + + if [[ "$registry" == "crates-io" ]]; then + python3 - "$package" "$version" <<'PY' +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + +package = sys.argv[1] +version = sys.argv[2] +url = "https://crates.io/api/v1/crates/{}/{}".format( + urllib.parse.quote(package, safe=""), + urllib.parse.quote(version, safe=""), +) +request = urllib.request.Request( + url, + headers={"User-Agent": "rustfs-cargo-publish-workspace"}, +) + +try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) +except urllib.error.HTTPError as exc: + if exc.code == 404: + sys.exit(1) + print(f"failed to verify {package} {version} on crates-io: HTTP {exc.code}", file=sys.stderr) + sys.exit(2) +except Exception as exc: + print(f"failed to verify {package} {version} on crates-io: {exc}", file=sys.stderr) + sys.exit(2) + +crate = payload.get("version", {}).get("crate") +num = payload.get("version", {}).get("num") +if crate == package and num == version: + sys.exit(0) + +print(f"unexpected crates-io response while verifying {package} {version}", file=sys.stderr) +sys.exit(2) +PY + return "$?" + fi + + local output + if output="$(cargo info "$package@$version" --registry "$registry" 2>&1)"; then + return 0 + fi + + if [[ "$output" == *"could not find"* || "$output" == *"no matching package"* || "$output" == *"failed to find"* ]]; then + return 1 + fi + + printf '%s\n' "$output" >&2 + return 2 +} + +cargo_publish_base=(cargo publish --registry "$registry") +if [[ "$allow_dirty" -eq 1 ]]; then + cargo_publish_base+=(--allow-dirty) +fi + +index=1 +total="${#publish_plan[@]}" +for entry in "${publish_plan[@]}"; do + IFS=$'\t' read -r package version manifest_path <<<"$entry" + printf '\n[%s/%s] %s %s (%s)\n' "$index" "$total" "$package" "$version" "$registry_display" + + set +e + crate_version_exists "$package" "$version" + exists_status="$?" + set -e + + case "$exists_status" in + 0) + printf 'skip: %s %s already exists on %s\n' "$package" "$version" "$registry_display" + index=$((index + 1)) + continue + ;; + 1) + printf 'publish check: %s %s is not present on %s\n' "$package" "$version" "$registry_display" + ;; + *) + printf 'error: could not verify whether %s %s exists on %s\n' "$package" "$version" "$registry_display" >&2 + exit 1 + ;; + esac + + if [[ "$mode" == "dry-run" ]]; then + cargo_publish_cmd=("${cargo_publish_base[@]}" -p "$package" --dry-run) + else + cargo_publish_cmd=("${cargo_publish_base[@]}" -p "$package") + fi + + if [[ "${#extra_cargo_args[@]}" -gt 0 ]]; then + cargo_publish_cmd+=("${extra_cargo_args[@]}") + fi + + "${cargo_publish_cmd[@]}" + + if [[ "$mode" == "publish" ]]; then + if [[ "$index" -lt "$total" && "$wait_seconds" -gt 0 ]]; then + sleep "$wait_seconds" + fi + fi + + index=$((index + 1)) +done