Compare commits

..

1 Commits

Author SHA1 Message Date
cxymds 1dea00ffa7 ci: repair e2e selection and verify tool downloads via API (#9240)
* ci: align full e2e selection with retired catalog test

* ci: verify pinned tools through GitHub release asset API

* ci: fetch pinned typos through the release asset API

* ci: download pinned actionlint through the release asset API

* ci: ensure GitHub CLI exists before pinned mc download

* ci: budget full quick checks after tool installation
2026-11-08 14:09:26 +00:00
21 changed files with 276 additions and 236 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=219ac11e720b3f54dfee4d5d5e95171785ba159b2fe01db3037a8f5061c1ad32
sha256-linux=2d34698cc8ec52d5b6c37ac612919a1d21f36cd56a1af539a7522a4eea518e26
sha256-darwin=9901a4950099b94d3d2618524d540f1ed4bf6023044903886f98b6c4bf5f30fb
sha256-linux=09fd5f48f5556c9fc576357222dccd7ebb0b90b790798f1834db2ec5d67b4952
+1
View File
@@ -54,6 +54,7 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_download_release_asset.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
+4 -3
View File
@@ -28,11 +28,12 @@ runs:
- name: Install actionlint
shell: bash
env:
GH_TOKEN: ${{ github.token }}
ASSET_DOWNLOADER: ${{ github.action_path }}/../setup/download_release_asset.py
run: |
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
curl --fail --location --silent --show-error \
--output "$actionlint_dir/actionlint.tar.gz" \
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
python3 "$ASSET_DOWNLOADER" --repository rhysd/actionlint --tag v1.7.12 --asset actionlint_1.7.12_linux_amd64.tar.gz --output "$actionlint_dir/actionlint.tar.gz" --sha256 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
rm "$actionlint_dir/actionlint.tar.gz"
+23 -5
View File
@@ -66,10 +66,7 @@ inputs:
runs:
using: "composite"
steps:
# protobuf-compiler is deliberately absent: the setup-protoc step below
# installs 35.1 into the tool cache and prepends it to PATH, so the apt
# build (older, and never version-matched) was shadowed on every run and
# simply never used.
# Use the pinned code generators rather than Ubuntu's older packages.
- name: Install system dependencies (Ubuntu)
if: runner.os == 'Linux'
shell: bash
@@ -79,7 +76,9 @@ runs:
build-essential \
pkg-config \
libssl-dev \
ripgrep
ripgrep \
python3 \
gh
# musl-gcc is needed by the native musl release leg, and zip/unzip by the
# release packaging steps. No CI test lane touches any of them.
@@ -88,13 +87,32 @@ runs:
shell: bash
run: sudo apt-get install -y musl-tools zip unzip
- name: Install pinned code generators through GitHub API (Linux x64)
if: runner.os == 'Linux' && runner.arch == 'X64'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
ASSET_DOWNLOADER: ${{ github.action_path }}/download_release_asset.py
run: |
python3 "$ASSET_DOWNLOADER" --repository protocolbuffers/protobuf --tag v35.1 --asset protoc-35.1-linux-x86_64.zip --output "$RUNNER_TEMP/protoc.zip"
python3 "$ASSET_DOWNLOADER" --repository google/flatbuffers --tag v25.12.19 --asset 'Linux.flatc.binary.g++-13.zip' --output "$RUNNER_TEMP/flatc.zip"
python3 -m zipfile -e "$RUNNER_TEMP/protoc.zip" "$RUNNER_TEMP/protoc"
python3 -m zipfile -e "$RUNNER_TEMP/flatc.zip" "$RUNNER_TEMP/flatc"
chmod +x "$RUNNER_TEMP/protoc/bin/protoc" "$RUNNER_TEMP/flatc/flatc"
"$RUNNER_TEMP/protoc/bin/protoc" --version
"$RUNNER_TEMP/flatc/flatc" --version
echo "$RUNNER_TEMP/protoc/bin" >> "$GITHUB_PATH"
echo "$RUNNER_TEMP/flatc" >> "$GITHUB_PATH"
- name: Install protoc
if: runner.os != 'Linux' || runner.arch != 'X64'
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
with:
version: "35.1"
repo-token: ${{ github.token }}
- name: Install flatc
if: runner.os != 'Linux' || runner.arch != 'X64'
uses: Nugine/setup-flatc@698800de72a96bfb22cf60431dc21a2ff9a7e07b # v1
with:
version: "25.12.19"
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Download a named GitHub release asset through its API and verify its digest."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from urllib.parse import quote
def download(repository, tag, name, output, expected_sha256=None):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", repository):
raise ValueError("invalid release repository")
if not tag or not name or "/" in name or "\\" in name:
raise ValueError("invalid release tag or asset name")
if expected_sha256 is not None and not re.fullmatch(r"[0-9a-f]{64}", expected_sha256):
raise ValueError("invalid pinned asset digest")
result = subprocess.run(
["gh", "api", "--hostname", "github.com", f"repos/{repository}/releases/tags/{quote(tag, safe='')}"],
check=True, capture_output=True, text=True, timeout=60,
)
release = json.loads(result.stdout)
if not isinstance(release, dict) or release.get("tag_name") != tag:
raise ValueError("release metadata does not match the requested tag")
assets = release.get("assets")
if not isinstance(assets, list) or any(not isinstance(asset, dict) for asset in assets):
raise ValueError("invalid release asset inventory")
matches = [asset for asset in assets if asset.get("name") == name]
if len(matches) != 1:
raise ValueError("release must contain exactly one named asset")
asset = matches[0]
asset_id, size, digest = asset.get("id"), asset.get("size"), asset.get("digest")
if type(asset_id) is not int or asset_id <= 0 or type(size) is not int or not 0 < size <= 512 * 1024 * 1024:
raise ValueError("invalid release asset identity or size")
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
raise ValueError("release asset has no valid SHA-256 digest")
sha256 = digest.removeprefix("sha256:")
if expected_sha256 is not None and expected_sha256 != sha256:
raise ValueError("release digest differs from the pinned digest")
output = Path(output)
output.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=output.parent, prefix=".release-asset-", delete=False) as stream:
temporary = Path(stream.name)
# gh authenticates to the API; its HTTP client strips credentials on
# cross-host redirects. No browser URL or token is supplied in argv.
subprocess.run(
["gh", "api", "--hostname", "github.com", "--header", "Accept: application/octet-stream", f"repos/{repository}/releases/assets/{asset_id}"],
check=True, stdout=stream, timeout=300,
)
with temporary.open("rb") as stream:
actual = hashlib.file_digest(stream, "sha256").hexdigest()
if temporary.stat().st_size != size or actual != sha256:
raise ValueError("downloaded release asset failed size or SHA-256 verification")
temporary.replace(output)
print(f"Verified release asset: {repository} {tag} {name} sha256={actual}")
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--asset", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--sha256")
args = parser.parse_args()
try:
download(args.repository, args.tag, args.asset, args.output, args.sha256)
except (ValueError, KeyError, TypeError, json.JSONDecodeError, subprocess.SubprocessError, OSError) as error:
print(f"Release asset download failed: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+16 -3
View File
@@ -142,15 +142,23 @@ jobs:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install pinned typos through GitHub API
env:
GH_TOKEN: ${{ github.token }}
TYPOS_VERSION: 1.50.2
run: |
python3 .github/actions/setup/download_release_asset.py --repository crate-ci/typos --tag "v${TYPOS_VERSION}" --asset "typos-v${TYPOS_VERSION}-x86_64-unknown-linux-musl.tar.gz" --output "$RUNNER_TEMP/typos.tar.gz"
mkdir -p "$RUNNER_TEMP/typos"
tar -xzf "$RUNNER_TEMP/typos.tar.gz" -C "$RUNNER_TEMP/typos"
- name: Typos check with custom config file
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
run: '"$RUNNER_TEMP/typos/typos" .'
# Fail early with compile-free checks for every pull request.
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -1137,11 +1145,16 @@ jobs:
- name: Install mc
env:
GH_TOKEN: ${{ github.token }}
MC_VERSION: RELEASE.2025-08-13T08-35-41Z
MC_SHA256: 01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891
run: |
if ! command -v gh >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y gh
fi
MC_BINARY="mc.linux-amd64.${MC_VERSION}"
curl -fsSLo "$RUNNER_TEMP/mc" "https://github.com/minio/mc/releases/download/${MC_VERSION}/${MC_BINARY}"
python3 .github/actions/setup/download_release_asset.py --repository minio/mc --tag "$MC_VERSION" --asset "$MC_BINARY" --output "$RUNNER_TEMP/mc" --sha256 "$MC_SHA256"
echo "${MC_SHA256} $RUNNER_TEMP/mc" | sha256sum --check --status
chmod +x "$RUNNER_TEMP/mc"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
-4
View File
@@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Notifications and audit
- Notify and audit targets that keep the default `queue_dir` now work on native Windows: the defaults `/opt/rustfs/events` and `/opt/rustfs/audit` resolve per node, to `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit` on Windows, instead of failing validation as non-absolute paths. The new `RUSTFS_TARGET_STORE_DIR` (Helm `config.rustfs.target_store_dir`) relocates both default queues on any platform. Linux and macOS behavior is unchanged when it is unset, persisted configuration is unchanged, and pending entries are not migrated when the root changes. See `docs/operations/target-queue-storage.md`.
### Deployment
- Existing volumes created by older root-running container images must be writable by the non-root runtime identity before upgrading. The default container UID/GID is `10001:10001`; stop the affected instances, verify host mount paths, and for ordinary bind mounts run `sudo chown -R 10001:10001 /actual/data /actual/logs` on each RustFS data/log mount. Account for ACLs, NFS, Kubernetes, and rootless UID mappings. Image-build ownership changes do not migrate mounted data. See the Docker Quick Start in [README.md](README.md).
+1 -1
View File
@@ -199,7 +199,7 @@ docker run -d --name rustfs -p 9000:9000 \
Notes:
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout, or set `RUSTFS_TARGET_STORE_DIR` to move every default queue; native Windows defaults to `%ProgramData%\RustFS`. See [Target queue storage](docs/operations/target-queue-storage.md).
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout.
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
-7
View File
@@ -276,13 +276,6 @@ Drive timeout profile preset:
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
## Target queue directory
- `RUSTFS_TARGET_STORE_DIR`
- absolute root for notify and audit target queues whose `queue_dir` is the default `/opt/rustfs/events` or `/opt/rustfs/audit`; they open `<root>/events` and `<root>/audit` on this node.
- unset keeps those paths on Linux and macOS; native Windows uses `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit`.
- explicit `queue_dir` values are never relocated, and the persisted target configuration is unchanged. Changing the root does not migrate pending entries. See `docs/operations/target-queue-storage.md`.
## Startup filesystem boundary policy
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
-6
View File
@@ -144,12 +144,6 @@ pub const POSTGRES_QUEUE_LIMIT: &str = "queue_limit";
/// Applies to both notify and audit target queue stores.
pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS";
/// Environment variable that relocates the root of the default notify and audit queue directories.
///
/// It applies only to targets whose `queue_dir` is the logical default (`EVENT_DEFAULT_DIR` or
/// `AUDIT_DEFAULT_DIR`); their queues move to `<root>/events` and `<root>/audit` on this node.
pub const ENV_TARGET_STORE_DIR: &str = "RUSTFS_TARGET_STORE_DIR";
/// Queue-store compression is enabled by default to reduce disk footprint.
pub const DEFAULT_TARGET_STORE_COMPRESS: bool = true;
+1 -1
View File
@@ -159,7 +159,7 @@ pub(crate) fn notify_configuration_hint() -> String {
format!(
"No notify targets configured. Check {}=true and instance-scoped target env vars (for example {webhook_enable_primary} + {webhook_endpoint_primary} for arn:rustfs:sqs::primary:webhook). If using default queue_dir, ensure {} is writable.",
rustfs_config::ENV_NOTIFY_ENABLE,
rustfs_targets::store::resolve_queue_dir(rustfs_config::EVENT_DEFAULT_DIR),
rustfs_config::EVENT_DEFAULT_DIR,
)
}
+4 -9
View File
@@ -13,7 +13,6 @@
// limitations under the License.
use crate::TargetError;
use crate::store::resolve_queue_dir;
use crate::target::nats::validate_jetstream_settings;
use crate::target::pulsar::validate_pulsar_broker;
use async_nats::ServerAddr;
@@ -30,12 +29,6 @@ use std::path::Path;
use std::str::FromStr;
use url::Url;
/// Return the queue directory this node opens for a target, with logical defaults resolved.
pub(super) fn queue_dir_or_default(config: &KVS, key: &str, default_queue_dir: &str) -> String {
let queue_dir = config.lookup(key).unwrap_or_else(|| default_queue_dir.to_string());
resolve_queue_dir(&queue_dir).into_owned()
}
pub(super) fn split_env_field_and_instance(rest: &str, valid_fields: &HashSet<String>) -> Option<(String, String)> {
let normalized = rest.to_lowercase();
if valid_fields.contains(&normalized) {
@@ -131,7 +124,7 @@ pub(super) fn validate_nats_server_config(server: &ServerAddr, config: &KVS, def
));
}
let queue_dir = queue_dir_or_default(config, NATS_QUEUE_DIR, default_queue_dir);
let queue_dir = config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string()));
}
@@ -211,7 +204,9 @@ pub(super) fn validate_pulsar_broker_config(broker: &str, config: &KVS, default_
));
}
let queue_dir = queue_dir_or_default(config, PULSAR_QUEUE_DIR, default_queue_dir);
let queue_dir = config
.lookup(PULSAR_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
}
+26 -12
View File
@@ -13,8 +13,8 @@
// limitations under the License.
use super::common::{
parse_jetstream_ack_timeout_secs, parse_jetstream_enable, parse_target_bool, parse_url, queue_dir_or_default,
validate_nats_server_config, validate_outbound_http_url, validate_pulsar_broker_config,
parse_jetstream_ack_timeout_secs, parse_jetstream_enable, parse_target_bool, parse_url, validate_nats_server_config,
validate_outbound_http_url, validate_pulsar_broker_config,
};
use crate::error::TargetError;
use crate::target::{
@@ -118,7 +118,7 @@ pub fn build_amqp_args(config: &KVS, default_queue_dir: &str, target_type: Targe
tls_ca: config.lookup(AMQP_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(AMQP_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(AMQP_TLS_CLIENT_KEY).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, AMQP_QUEUE_DIR, default_queue_dir),
queue_dir: config.lookup(AMQP_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(AMQP_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -146,7 +146,9 @@ pub fn build_webhook_args(config: &KVS, default_queue_dir: &str, target_type: Ta
enable: true,
endpoint: endpoint_url,
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, WEBHOOK_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(WEBHOOK_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -178,7 +180,9 @@ pub fn validate_webhook_config(config: &KVS, default_queue_dir: &str) -> Result<
));
}
let queue_dir = queue_dir_or_default(config, WEBHOOK_QUEUE_DIR, default_queue_dir);
let queue_dir = config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
}
@@ -230,7 +234,7 @@ pub fn build_mqtt_args(config: &KVS, default_queue_dir: &str, target_type: Targe
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?,
queue_dir: queue_dir_or_default(config, MQTT_QUEUE_DIR, default_queue_dir),
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(MQTT_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -307,7 +311,7 @@ pub fn build_nats_args(config: &KVS, default_queue_dir: &str, target_type: Targe
tls_client_cert: config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default(),
tls_required: parse_target_bool(config.lookup(NATS_TLS_REQUIRED).as_deref()).unwrap_or(false),
queue_dir: queue_dir_or_default(config, NATS_QUEUE_DIR, default_queue_dir),
queue_dir: config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(NATS_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -349,7 +353,9 @@ pub fn build_pulsar_args(config: &KVS, default_queue_dir: &str, target_type: Tar
tls_ca: config.lookup(PULSAR_TLS_CA).unwrap_or_default(),
tls_allow_insecure: parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false),
tls_hostname_verification: parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true),
queue_dir: queue_dir_or_default(config, PULSAR_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(PULSAR_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(PULSAR_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -399,7 +405,9 @@ pub fn build_redis_args(
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(15)),
queue_dir: queue_dir_or_default(config, REDIS_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(REDIS_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(REDIS_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -455,7 +463,9 @@ pub fn build_postgres_args(config: &KVS, default_queue_dir: &str, target_type: T
tls_ca: config.lookup(POSTGRES_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(POSTGRES_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(POSTGRES_TLS_CLIENT_KEY).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, POSTGRES_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(POSTGRES_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(POSTGRES_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -519,7 +529,9 @@ pub fn build_kafka_args(config: &KVS, default_queue_dir: &str, target_type: Targ
},
sasl_username,
sasl_password,
queue_dir: queue_dir_or_default(config, KAFKA_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(KAFKA_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(KAFKA_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -557,7 +569,9 @@ pub fn build_mysql_args(config: &KVS, default_queue_dir: &str, target_type: Targ
tls_ca: config.lookup(MYSQL_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(MYSQL_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(MYSQL_TLS_CLIENT_KEY).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, MYSQL_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(MYSQL_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(MYSQL_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
+2 -93
View File
@@ -15,13 +15,12 @@
use crate::error::StoreError;
use rustfs_config::notify::{COMPRESS_EXT, DEFAULT_EXT};
use rustfs_config::{
AUDIT_DEFAULT_DIR, DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, DEFAULT_TARGET_STORE_MAX_BYTES, ENV_TARGET_STORE_COMPRESS,
ENV_TARGET_STORE_DIR, ENV_TARGET_STORE_MAX_BYTES, EVENT_DEFAULT_DIR, EnableState,
DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, DEFAULT_TARGET_STORE_MAX_BYTES, ENV_TARGET_STORE_COMPRESS,
ENV_TARGET_STORE_MAX_BYTES, EnableState,
};
use serde::{Serialize, de::DeserializeOwned};
use snap::raw::{Decoder, Encoder};
use std::{
borrow::Cow,
collections::HashMap,
fs::File,
io::Write,
@@ -156,46 +155,6 @@ fn queue_store_compression_enabled() -> bool {
resolve_queue_store_compression_from_env_value(value.as_deref())
}
/// Map a configured target `queue_dir` to the directory this node opens.
///
/// `EVENT_DEFAULT_DIR` and `AUDIT_DEFAULT_DIR` are persisted cluster-wide as logical
/// defaults, so every node resolves them locally. `RUSTFS_TARGET_STORE_DIR` relocates
/// their root on any platform. Without it, Windows uses `%ProgramData%\RustFS`, because
/// the Unix literal has no drive prefix and is not an absolute path there. Every other
/// value, including an explicit operator path, is returned unchanged.
pub fn resolve_queue_dir(queue_dir: &str) -> Cow<'_, str> {
let store_root = std::env::var(ENV_TARGET_STORE_DIR).ok();
resolve_queue_dir_with(queue_dir, store_root.as_deref(), platform_queue_root().as_deref())
}
#[cfg(windows)]
fn platform_queue_root() -> Option<PathBuf> {
std::env::var_os("ProgramData")
.filter(|root| !root.is_empty())
.map(|root| PathBuf::from(root).join("RustFS"))
}
#[cfg(not(windows))]
fn platform_queue_root() -> Option<PathBuf> {
None
}
fn resolve_queue_dir_with<'a>(queue_dir: &'a str, store_root: Option<&str>, platform_root: Option<&Path>) -> Cow<'a, str> {
let leaf = match queue_dir {
EVENT_DEFAULT_DIR => "events",
AUDIT_DEFAULT_DIR => "audit",
_ => return Cow::Borrowed(queue_dir),
};
let root = match store_root.map(str::trim).filter(|root| !root.is_empty()) {
Some(root) => Path::new(root),
None => match platform_root {
Some(root) => root,
None => return Cow::Borrowed(queue_dir),
},
};
Cow::Owned(root.join(leaf).to_string_lossy().into_owned())
}
/// Represents a key for an entry in the store
#[derive(Debug, Clone)]
pub struct Key {
@@ -1406,56 +1365,6 @@ mod tests {
thread,
};
#[test]
fn logical_default_queue_dirs_resolve_under_the_configured_root() {
let root = Path::new("/srv/rustfs-queues");
let platform = Path::new("/platform/RustFS");
assert_eq!(
resolve_queue_dir_with(EVENT_DEFAULT_DIR, Some("/srv/rustfs-queues"), Some(platform)),
root.join("events").to_string_lossy()
);
assert_eq!(
resolve_queue_dir_with(AUDIT_DEFAULT_DIR, Some(" /srv/rustfs-queues "), None),
root.join("audit").to_string_lossy()
);
assert_eq!(
resolve_queue_dir_with(EVENT_DEFAULT_DIR, Some(" "), Some(platform)),
platform.join("events").to_string_lossy(),
"a blank override must fall back to the platform root"
);
}
#[test]
fn explicit_and_unmapped_queue_dirs_are_preserved() {
let platform = Path::new("/platform/RustFS");
for explicit in [
"/var/lib/rustfs/events",
"/opt/rustfs/events/",
"/opt/rustfs",
"",
"relative/queue",
] {
assert_eq!(
resolve_queue_dir_with(explicit, Some("/srv/rustfs-queues"), Some(platform)),
explicit,
"only the exact logical defaults may be relocated"
);
}
assert_eq!(resolve_queue_dir_with(EVENT_DEFAULT_DIR, None, None), EVENT_DEFAULT_DIR);
assert_eq!(resolve_queue_dir_with(AUDIT_DEFAULT_DIR, None, None), AUDIT_DEFAULT_DIR);
}
#[cfg(windows)]
#[test]
fn windows_logical_default_queue_dirs_are_absolute_without_an_override() {
let platform = platform_queue_root().expect("Windows defines ProgramData");
for default in [EVENT_DEFAULT_DIR, AUDIT_DEFAULT_DIR] {
let resolved = resolve_queue_dir_with(default, None, Some(&platform));
assert!(Path::new(resolved.as_ref()).is_absolute(), "{default} resolved to {resolved}");
assert!(resolved.contains("RustFS"), "{default} resolved to {resolved}");
}
}
fn temp_store_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rustfs-targets-{name}-{}", Uuid::new_v4()))
}
+1 -19
View File
@@ -1,22 +1,4 @@
# Target queue storage
## Queue directory location
A target's `queue_dir` is stored in the cluster-wide target configuration. The built-in defaults `/opt/rustfs/events` and `/opt/rustfs/audit` are logical defaults: each node resolves them to a local directory when it validates or opens a queue. The stock notify and audit default tables both persist `/opt/rustfs/events`; `/opt/rustfs/audit` applies only to an audit target whose configuration has no `queue_dir` key. Any other `queue_dir`, including an explicit operator path, is used as written and must be absolute.
| Node setting | `/opt/rustfs/events` resolves to | `/opt/rustfs/audit` resolves to |
|---|---|---|
| `RUSTFS_TARGET_STORE_DIR=<root>` (any platform) | `<root>/events` | `<root>/audit` |
| Windows, variable unset | `%ProgramData%\RustFS\events` | `%ProgramData%\RustFS\audit` |
| Linux/macOS, variable unset | `/opt/rustfs/events` | `/opt/rustfs/audit` |
`RUSTFS_TARGET_STORE_DIR` must be an absolute path; a relative value makes target validation fail with the queue-directory error. The queue store creates missing directories when it opens, so the resolved directory only needs to be creatable by the RustFS process. An admin request that names a default path explicitly is checked at its resolved location and, as for any explicit `queue_dir`, that directory must already exist.
The official container image links `/opt/rustfs/events` and `/opt/rustfs/audit` into `/data/.rustfs`, so its queues survive container recreation without this variable. Windows previously rejected targets that kept the default because the Unix literal is not an absolute Windows path; those targets now validate and queue under `%ProgramData%\RustFS`.
Setting or changing `RUSTFS_TARGET_STORE_DIR` on an existing node moves where its default queues are opened. Entries still pending in the previous directory are not migrated: let them drain first, or move the directory contents while RustFS is stopped. Rolling back to a version without this variable returns default queues to the previous location.
## Queue size limits
# Target queue storage limits
Disk queues for notification and audit targets enforce both their configured entry limit and a shared byte limit for each queue directory. `RUSTFS_TARGET_STORE_MAX_BYTES` sets that byte limit; the default is 1 GiB. The value must be a positive integer in bytes. Missing, zero, invalid, and overflowing values use the default.
-1
View File
@@ -157,7 +157,6 @@ behavior.
| config.rustfs.obs_environment | string | `"development"` | |
| config.rustfs.obs_log_directory | string | `"/logs"` | Log directory inside the RustFS container. Set to `""` to disable log PVCs and mounts. |
| config.rustfs.region | string | `"us-east-1"` | |
| config.rustfs.target_store_dir | string | `""` | Absolute root for notify and audit target queues that keep the default `queue_dir`, set as `RUSTFS_TARGET_STORE_DIR`; queues move to `<root>/events` and `<root>/audit`. Empty keeps the image layout, where `/opt/rustfs/events` and `/opt/rustfs/audit` link into `/data/.rustfs`. |
| config.rustfs.volumes | string | `""` | Explicit distributed volume topology. When empty, the chart generates the topology and normally injects `RUSTFS_LOCAL_ENDPOINT_HOST`; custom topologies must configure local endpoint identity explicitly when needed. |
| config.rustfs.log_rotation.size | int | `"100"` | Default log rotation size mb for rustfs. |
| config.rustfs.log_rotation.time | string | `"hour"` | Default log rotation time for rustfs. |
-3
View File
@@ -21,9 +21,6 @@ data:
{{- if .region }}
RUSTFS_REGION: {{ .region | quote }}
{{- end }}
{{- if .target_store_dir }}
RUSTFS_TARGET_STORE_DIR: {{ .target_store_dir | quote }}
{{- end }}
{{- if .domains }}
RUSTFS_SERVER_DOMAINS: {{ include "rustfs.serverDomains" $ | quote }}
{{- end }}
-3
View File
@@ -143,9 +143,6 @@ config:
console_enable: "true"
console_address: ":9001"
region: "us-east-1"
# Root for notify/audit target queues that keep the default queue_dir; queues use
# <root>/events and <root>/audit. Empty keeps the image layout (/opt/rustfs -> /data/.rustfs).
target_store_dir: ""
# Optionally enable support for virtual-hosted-style requests.
# See more information: https://docs.rustfs.com/integration/virtual.html
domains: "" # e.g. "example.com"
@@ -560,8 +560,6 @@ where
}
pub(crate) async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
let queue_dir = rustfs_targets::store::resolve_queue_dir(queue_dir);
let queue_dir = queue_dir.as_ref();
if !queue_dir.is_empty() {
if !Path::new(queue_dir).is_absolute() {
return Err(s3_error!(InvalidArgument, "queue_dir must be an absolute path"));
@@ -1076,58 +1074,3 @@ mod instance_runtime_status_tests {
assert!(statuses.contains_key(&("PRIMARY".into(), "custom-two".into())));
}
}
#[cfg(test)]
mod queue_dir_resolution_tests {
use super::validate_queue_dir;
use rustfs_config::server_config::KVS;
use rustfs_config::{AUDIT_DEFAULT_DIR, ENV_TARGET_STORE_DIR, EVENT_DEFAULT_DIR, REDIS_QUEUE_DIR, REDIS_URL};
use rustfs_targets::{config::build_redis_args, target::TargetType};
use serial_test::serial;
fn redis_config() -> KVS {
let mut config = KVS::new();
config.insert(REDIS_URL.to_string(), "redis://127.0.0.1:6379/".to_string());
config
}
#[tokio::test]
#[serial]
async fn default_queue_dirs_follow_the_target_store_root() {
let root = tempfile::tempdir().expect("queue store root");
let root_text = root.path().to_str().expect("UTF-8 queue store root").to_owned();
temp_env::async_with_vars([(ENV_TARGET_STORE_DIR, Some(root_text.as_str()))], async {
let notify = build_redis_args(&redis_config(), EVENT_DEFAULT_DIR, "events", TargetType::NotifyEvent)
.expect("default notify queue");
assert_eq!(notify.queue_dir, root.path().join("events").to_string_lossy());
let audit =
build_redis_args(&redis_config(), AUDIT_DEFAULT_DIR, "audit", TargetType::AuditLog).expect("default audit queue");
assert_eq!(audit.queue_dir, root.path().join("audit").to_string_lossy());
let explicit = root.path().join("operator-queue").to_string_lossy().into_owned();
let mut config = redis_config();
config.insert(REDIS_QUEUE_DIR.to_string(), explicit.clone());
let args = build_redis_args(&config, EVENT_DEFAULT_DIR, "events", TargetType::NotifyEvent).expect("explicit queue");
assert_eq!(args.queue_dir, explicit, "an operator path must not be relocated");
})
.await;
}
#[tokio::test]
#[serial]
async fn explicit_logical_default_is_validated_at_its_resolved_directory() {
let root = tempfile::tempdir().expect("queue store root");
let root_text = root.path().to_str().expect("UTF-8 queue store root").to_owned();
temp_env::async_with_vars([(ENV_TARGET_STORE_DIR, Some(root_text.as_str()))], async {
let missing = validate_queue_dir(EVENT_DEFAULT_DIR)
.await
.expect_err("the resolved queue directory does not exist yet");
assert!(missing.message().is_some_and(|message| message.contains("does not exist")));
std::fs::create_dir(root.path().join("events")).expect("resolved queue directory");
validate_queue_dir(EVENT_DEFAULT_DIR)
.await
.expect("the logical default must be checked where this node stores it");
})
.await;
}
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Exercise the release download entry point without fetching external tools."""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / ".github/actions/setup/download_release_asset.py"
class ReleaseAssetTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.body = b"verified tool fixture"
self.sha256 = hashlib.sha256(self.body).hexdigest()
self.asset = {"name": "tool.zip", "id": 17, "size": len(self.body), "digest": "sha256:" + self.sha256}
self.metadata = {"tag_name": "v1.0", "assets": [self.asset]}
self.output = self.root / "tool.zip"
self.output.write_bytes(b"previous file")
self.calls = self.root / "calls.jsonl"
gh = self.root / "gh"
gh.write_text(f"#!{sys.executable}\n" + '''import json,os,pathlib,sys
args=sys.argv[1:]
with pathlib.Path(os.environ['CALLS']).open('a') as out:out.write(json.dumps(args)+'\\n')
if '/releases/tags/' in args[-1]:
if os.environ.get('FAIL_METADATA'):raise SystemExit(19)
print(os.environ['METADATA'])
else:
if os.environ.get('FAIL_DOWNLOAD'):raise SystemExit(23)
sys.stdout.buffer.write(bytes.fromhex(os.environ['BODY']))
''')
gh.chmod(0o755)
self.env = dict(os.environ, PATH=str(self.root) + os.pathsep + os.environ["PATH"], CALLS=str(self.calls), GH_TOKEN="synthetic-do-not-log-token")
def invoke(self, extra=(), **env):
environment = dict(self.env, METADATA=json.dumps(self.metadata), BODY=self.body.hex(), **env)
return subprocess.run([sys.executable, str(SCRIPT), "--repository", "owner/repo", "--tag", "v1.0", "--asset", "tool.zip", "--output", str(self.output), *extra], env=environment, capture_output=True, text=True)
def assert_failed_atomically(self, result):
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.output.read_bytes(), b"previous file")
self.assertEqual(list(self.root.glob(".release-asset-*")), [])
self.assertNotIn(self.env["GH_TOKEN"], result.stdout + result.stderr)
def test_valid_asset_uses_api_and_installs_only_verified_bytes(self):
result = self.invoke(("--sha256", self.sha256))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.output.read_bytes(), self.body)
calls = [json.loads(line) for line in self.calls.read_text().splitlines()]
self.assertEqual(calls[0], ["api", "--hostname", "github.com", "repos/owner/repo/releases/tags/v1.0"])
self.assertEqual(calls[1], ["api", "--hostname", "github.com", "--header", "Accept: application/octet-stream", "repos/owner/repo/releases/assets/17"])
self.assertNotIn(self.env["GH_TOKEN"], json.dumps(calls) + result.stdout + result.stderr)
def test_download_failure_preserves_destination(self):
self.assert_failed_atomically(self.invoke(FAIL_DOWNLOAD="1"))
def test_missing_gh_fails_without_replacing_destination(self):
(self.root / "gh").unlink()
self.env["PATH"] = str(self.root)
self.assert_failed_atomically(self.invoke())
def test_metadata_failure_preserves_destination(self):
self.assert_failed_atomically(self.invoke(FAIL_METADATA="1"))
def test_corrupt_download_fails_digest_validation(self):
self.body = b"modified tool fixture"
self.assert_failed_atomically(self.invoke())
def test_truncated_download_fails_size_validation(self):
self.asset["size"] += 1
self.assert_failed_atomically(self.invoke())
def test_pinned_digest_cannot_be_replaced_by_release_metadata(self):
self.assert_failed_atomically(self.invoke(("--sha256", "0" * 64)))
self.assertEqual(len(self.calls.read_text().splitlines()), 1)
def test_ambiguous_or_missing_assets_fail_before_download(self):
for assets in ([], [self.asset, self.asset]):
with self.subTest(assets=len(assets)):
self.metadata["assets"] = assets
self.assert_failed_atomically(self.invoke())
def test_metadata_tag_must_match_requested_version(self):
self.metadata["tag_name"] = "v2.0"
self.assert_failed_atomically(self.invoke())
def test_malformed_release_inventory_fails_before_download(self):
for metadata in ([], {"tag_name": "v1.0", "assets": None}, {"tag_name": "v1.0", "assets": [None]}):
with self.subTest(metadata=metadata):
self.metadata = metadata
self.assert_failed_atomically(self.invoke())
def test_missing_digest_or_invalid_identity_fails_before_download(self):
for field, value in [("digest", None), ("digest", "sha256:invalid"), ("id", True), ("id", 0), ("size", 0), ("size", 513 * 1024 * 1024)]:
with self.subTest(field=field, value=value):
self.asset[field] = value
self.assert_failed_atomically(self.invoke())
self.asset = {"name": "tool.zip", "id": 17, "size": len(self.body), "digest": "sha256:" + self.sha256}
self.metadata["assets"] = [self.asset]
if __name__ == "__main__":
unittest.main()
-7
View File
@@ -206,13 +206,6 @@ grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_VERSIONS: "100"' <<<"$scanner_config_output
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_VERSION_SIZE: "1099511627776"' <<<"$scanner_config_output"
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_FOLDERS: "65538"' <<<"$scanner_config_output"
target_store_output=$(render_chart --set config.rustfs.target_store_dir=/data/rustfs0/.rustfs)
grep -q 'RUSTFS_TARGET_STORE_DIR: "/data/rustfs0/.rustfs"' <<<"$target_store_output"
if grep -q 'RUSTFS_TARGET_STORE_DIR' <<<"$(render_chart)"; then
echo "RUSTFS_TARGET_STORE_DIR must not be rendered by default" >&2
exit 1
fi
# Fail-closed credential checks. Rendering must fail when no credentials,
# existingSecret, or allowInsecureDefaults override is supplied.
default_render_status=0