test(ecstore): pin persisted metadata key literals and bucket config goldens (#5904)

This commit is contained in:
Zhengchao An
2026-08-10 06:12:26 +08:00
committed by GitHub
parent b4b891afad
commit be0cea83b7
18 changed files with 2044 additions and 15 deletions
+4
View File
@@ -35,6 +35,10 @@ path = "src/lib.rs"
name = "rustfs"
path = "src/main.rs"
[[bin]]
name = "rustfs-cli"
path = "src/bin/rustfs-cli.rs"
[features]
default = ["ftps", "webdav"]
metrics-gpu = ["rustfs-obs/gpu"]
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Offline and diagnostic RustFS command-line entry point.
//!
//! This binary shares RustFS's existing subcommand dispatcher and provides the
//! documented entry point for offline tooling such as `inspect bucket-meta`.
fn main() {
rustfs::startup_entrypoint::run_process();
}
+95 -3
View File
@@ -16,7 +16,7 @@
//!
//! This module contains the command-line interface definitions including:
//! - `Cli`: Main CLI parser
//! - `Commands`: Subcommands (Server, Info, Tls)
//! - `Commands`: Top-level server and diagnostic subcommands
//! - `ServerOpts`: Server subcommand options
//! - `InfoOpts`: Info subcommand options
//! - `TlsOpts`: TLS diagnostic subcommand options
@@ -56,7 +56,7 @@ pub(super) const LONG_VERSION: &str = concat!(
);
/// Known subcommands. When the first arg matches one of these, it is treated as a subcommand.
pub const KNOWN_SUBCOMMANDS: &[&str] = &["server", "info", "tls", "diagnose"];
pub const KNOWN_SUBCOMMANDS: &[&str] = &["server", "info", "tls", "diagnose", "inspect"];
/// Preprocess argv for legacy compatibility: `rustfs <volume>` and `rustfs --address ...` are
/// treated as `rustfs server <volume>` and `rustfs server --address ...` respectively.
@@ -116,6 +116,48 @@ pub enum Commands {
Tls(TlsOpts),
/// Analyze RustFS log files and report probable failure causes
Diagnose(DiagnoseOpts),
/// Offline, read-only inspection of on-disk data (no server required)
Inspect(InspectOpts),
}
/// Offline inspection subcommand options
#[derive(Args, Clone)]
pub struct InspectOpts {
#[command(subcommand)]
pub command: InspectCommands,
}
/// Offline inspection subcommands
#[derive(Subcommand, Clone)]
pub enum InspectCommands {
/// Export a bucket's persisted configuration bytes straight from drive roots
/// (works even when the config XML no longer parses)
BucketMeta(InspectBucketMetaOpts),
}
/// `inspect bucket-meta` options
#[derive(Args, Clone)]
#[command(
after_help = "IMPORTANT: Mount every source drive read-only for forensic use. Read-only application calls cannot prevent filesystem atime updates or path replacement races on writable mounts."
)]
pub struct InspectBucketMetaOpts {
/// Drive root path(s). Repeat for multi-drive nodes: erasure-coded metadata
/// needs enough drives for write quorum. Source media must be mounted
/// read-only for strict forensic use.
#[arg(long = "path", required = true, value_parser = NonEmptyStringValueParser::new())]
pub paths: Vec<String>,
/// Bucket whose metadata to inspect
#[arg(long, value_parser = NonEmptyStringValueParser::new())]
pub bucket: String,
/// Write the raw `.metadata.bin` blob and each stored config's exact bytes to --out
#[arg(long)]
pub raw: bool,
/// New output directory for --raw (default: ./bucket-meta-<bucket>)
#[arg(long, requires = "raw")]
pub out: Option<std::path::PathBuf>,
}
/// Diagnose report output format
@@ -368,6 +410,8 @@ pub enum CommandResult {
Tls(TlsOpts),
/// Diagnose command with options
Diagnose(DiagnoseOpts),
/// Inspect command with options
Inspect(InspectOpts),
}
/// Create default ServerOpts from environment variables
@@ -407,7 +451,7 @@ pub fn default_server_opts() -> ServerOpts {
#[cfg(test)]
mod tests {
use super::{Cli, preprocess_args_for_legacy};
use super::{Cli, Commands, InspectCommands, preprocess_args_for_legacy};
use clap::Parser;
use clap::error::ErrorKind;
@@ -423,4 +467,52 @@ mod tests {
};
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
}
#[test]
fn inspect_bucket_meta_parses_repeated_drive_paths() {
let cli = Cli::try_parse_from([
"rustfs",
"inspect",
"bucket-meta",
"--path",
"/data/drive-1",
"--path",
"/data/drive-2",
"--bucket",
"example-bucket",
"--raw",
"--out",
"/tmp/export",
])
.expect("inspect arguments should parse");
let Some(Commands::Inspect(inspect)) = cli.command else {
panic!("inspect command expected");
};
let InspectCommands::BucketMeta(opts) = inspect.command;
assert_eq!(opts.paths, ["/data/drive-1", "/data/drive-2"]);
assert_eq!(opts.bucket, "example-bucket");
assert!(opts.raw);
assert_eq!(opts.out.as_deref(), Some(std::path::Path::new("/tmp/export")));
}
#[test]
fn inspect_bucket_meta_rejects_out_without_raw() {
let err = match Cli::try_parse_from([
"rustfs",
"inspect",
"bucket-meta",
"--path",
"/data/drive-1",
"--bucket",
"example-bucket",
"--out",
"/tmp/export",
]) {
Ok(_) => panic!("--out without --raw must be rejected"),
Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
}
+26 -1
View File
@@ -15,7 +15,7 @@
#[cfg(test)]
#[allow(unsafe_op_in_unsafe_fn)]
mod tests {
use crate::config::cli::default_server_opts;
use crate::config::cli::{InspectCommands, default_server_opts};
use crate::config::{CommandResult, Config, Opt, TlsCommands};
use crate::storage_api::config_test::DisksLayout;
use rustfs_config::{DEFAULT_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ENABLE, DEFAULT_OBS_ENDPOINT, RUSTFS_REGION};
@@ -101,6 +101,31 @@ mod tests {
}
}
#[test]
#[serial]
fn test_inspect_subcommand_survives_legacy_preprocessing() {
let result = Opt::parse_command([
"rustfs",
"inspect",
"bucket-meta",
"--path",
"/data/drive-1",
"--bucket",
"example-bucket",
])
.expect("inspect command should survive legacy preprocessing");
match result {
CommandResult::Inspect(opts) => match opts.command {
InspectCommands::BucketMeta(opts) => {
assert_eq!(opts.paths, ["/data/drive-1"]);
assert_eq!(opts.bucket, "example-bucket");
}
},
_ => panic!("expected inspect command result"),
}
}
#[test]
#[serial]
fn test_parse_from_non_server_commands_falls_back_without_panicking() {
+1
View File
@@ -52,6 +52,7 @@ mod config_test;
// Re-export public types
pub use cli::{CommandResult, InfoOpts, InfoType};
pub use cli::{DiagnoseFormat, DiagnoseOpts};
pub use cli::{InspectBucketMetaOpts, InspectCommands, InspectOpts};
pub use cli::{TlsCommands, TlsInspectOpts, TlsOpts};
pub use config_struct::Config;
pub use info::execute_info;
+3 -2
View File
@@ -98,7 +98,7 @@ impl Opt {
let cli = Cli::parse_from(args);
match cli.command {
Some(Commands::Server(opts)) => Self::from_server_opts(*opts),
Some(Commands::Info(_)) | Some(Commands::Tls(_)) | Some(Commands::Diagnose(_)) => {
Some(Commands::Info(_)) | Some(Commands::Tls(_)) | Some(Commands::Diagnose(_)) | Some(Commands::Inspect(_)) => {
Self::from_server_opts(default_server_opts())
}
None => {
@@ -134,6 +134,7 @@ impl Opt {
Some(Commands::Info(opts)) => Ok(CommandResult::Info(opts)),
Some(Commands::Tls(opts)) => Ok(CommandResult::Tls(opts)),
Some(Commands::Diagnose(opts)) => Ok(CommandResult::Diagnose(opts)),
Some(Commands::Inspect(opts)) => Ok(CommandResult::Inspect(opts)),
Some(Commands::Server(opts)) => Self::server_command_result(Self::from_server_opts(*opts)),
None => {
// Default to server with empty volumes (will be filled from env)
@@ -162,7 +163,7 @@ impl Opt {
let cli = Cli::try_parse_from(args)?;
match cli.command {
Some(Commands::Server(opts)) => Ok(Self::from_server_opts(*opts)),
Some(Commands::Info(_)) | Some(Commands::Tls(_)) | Some(Commands::Diagnose(_)) => {
Some(Commands::Info(_)) | Some(Commands::Tls(_)) | Some(Commands::Diagnose(_)) | Some(Commands::Inspect(_)) => {
Err(clap::Error::new(clap::error::ErrorKind::DisplayHelp))
}
None => {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -84,6 +84,7 @@ pub mod diagnose;
pub mod embedded;
pub mod error;
pub mod init;
pub mod inspect;
pub(crate) mod kms_deletion_gate;
pub mod license;
pub mod memory_observability;
+3
View File
@@ -87,6 +87,9 @@ async fn async_main() -> Result<()> {
// Diagnose short-circuits before observability init on purpose:
// the report goes to stdout and must not be wrapped by the JSON logger.
CommandResult::Diagnose(opts) => return crate::diagnose::execute_diagnose(&opts),
// Inspect is offline like diagnose: read-only against drive paths, output
// to stdout/--out, and must run before any observability/storage init.
CommandResult::Inspect(opts) => return crate::inspect::execute_inspect(&opts).await,
CommandResult::Server(config) => config,
};
+9 -1
View File
@@ -543,7 +543,15 @@ pub(crate) mod ecstore_test_support {
}
pub(crate) mod ecstore_set_disk {
pub(crate) use rustfs_ecstore::api::set_disk::{DEFAULT_READ_BUFFER_SIZE, get_lock_acquire_timeout, is_valid_storage_class};
pub(crate) use rustfs_ecstore::api::set_disk::{
DEFAULT_READ_BUFFER_SIZE, file_info_quorum_hash, get_lock_acquire_timeout, is_valid_storage_class,
};
}
/// Offline erasure primitives for `rustfs inspect bucket-meta` (backlog#1733):
/// shard bitrot verification and reconstruction without a running store.
pub(crate) mod ecstore_erasure {
pub(crate) use rustfs_ecstore::api::erasure::{BitrotReader, Erasure};
}
pub(crate) mod ecstore_storage {
+10
View File
@@ -23,6 +23,16 @@ pub(crate) mod capacity {
}
}
/// Offline bucket-metadata inspection (`rustfs inspect bucket-meta`,
/// backlog#1733): read-only shard verification and reconstruction without a
/// running store.
pub(crate) mod inspect {
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata as bucket_metadata;
pub(crate) use crate::storage::storage_api::ecstore_bucket::utils::check_valid_bucket_name_strict;
pub(crate) use crate::storage::storage_api::ecstore_erasure::{BitrotReader, Erasure};
pub(crate) use crate::storage::storage_api::ecstore_set_disk::file_info_quorum_hash;
}
pub(crate) mod cluster {
pub(crate) mod contract {
pub(crate) mod capability {
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! End-to-end coverage for the offline inspect command dispatcher.
use std::process::Command;
use rustfs_ecstore::api::bucket::metadata::BucketMetadata;
fn decode_hex(source: &str) -> Vec<u8> {
let digits = source
.chars()
.filter(|character| character.is_ascii_hexdigit())
.collect::<String>();
digits
.as_bytes()
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16).expect("fixture hex byte"))
.collect()
}
#[test]
fn inspect_subcommand_reaches_the_offline_executor() {
let drive = tempfile::tempdir().expect("drive tempdir");
let output = Command::new(env!("CARGO_BIN_EXE_rustfs-cli"))
.args([
"inspect",
"bucket-meta",
"--path",
drive.path().to_string_lossy().as_ref(),
"--bucket",
"interop",
])
.output()
.expect("run rustfs-cli inspect");
assert!(!output.status.success(), "missing metadata must reach the executor and fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("mount source drives read-only"), "executor warning missing: {stderr}");
assert!(stderr.contains("no drive yielded a readable shard"), "executor error missing: {stderr}");
}
#[test]
fn inspect_success_reports_persisted_header_and_all_config_timestamps() {
let drive = tempfile::tempdir().expect("drive tempdir");
let export = tempfile::tempdir().expect("export tempdir");
let out = export.path().join("raw");
let object_dir = drive.path().join(".rustfs.sys/buckets/interop/.metadata.bin");
std::fs::create_dir_all(&object_dir).expect("create metadata object directory");
let fixture = decode_hex(include_str!("../../crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex"));
std::fs::write(object_dir.join("xl.meta"), fixture).expect("write metadata fixture");
let drive_path = drive.path().to_string_lossy().into_owned();
let out_path = out.to_string_lossy().into_owned();
let output = Command::new(env!("CARGO_BIN_EXE_rustfs-cli"))
.args([
"inspect",
"bucket-meta",
"--path",
drive_path.as_str(),
"--bucket",
"interop",
"--raw",
"--out",
out_path.as_str(),
])
.output()
.expect("run rustfs-cli inspect");
assert!(output.status.success(), "valid metadata inspection failed");
let stdout = String::from_utf8_lossy(&output.stdout);
let expected_lines = [
"bucket : interop",
"format : 1",
"version : 1",
"created : 2026-07-07T15:58:57.210712Z",
"notification.xml 231 2026-07-07T15:58:57.614429Z",
"lifecycle.xml 344 2026-07-07T15:58:57.337595Z",
"object-lock.xml 184 2026-07-07T15:58:57.293303Z",
"versioning.xml 123 2026-07-07T15:58:57.254109Z",
"bucket-encryption.xml 240 2026-07-07T15:58:57.554116Z",
"tagging.xml 128 2026-07-07T15:58:57.378177Z",
"replication.xml 639 2026-07-07T15:59:20.148526Z",
"cors.xml 0 -",
"logging.xml 0 -",
"website.xml 0 -",
"accelerate.xml 0 -",
"request-payment.xml 0 -",
"public-access-block.xml 0 -",
];
for expected in expected_lines {
assert!(stdout.lines().any(|line| line == expected), "missing stdout line {expected:?}:\n{stdout}");
}
let expected_blob = decode_hex(include_str!("../../crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex"));
assert_eq!(std::fs::read_dir(&out).expect("read raw output").count(), 14);
assert_eq!(std::fs::read(out.join(".metadata.bin")).expect("read raw metadata"), expected_blob);
let metadata = BucketMetadata::unmarshal(&expected_blob[4..]).expect("unmarshal expected metadata");
let expected_configs = [
("notification.xml", metadata.notification_config_xml.as_slice()),
("lifecycle.xml", metadata.lifecycle_config_xml.as_slice()),
("object-lock.xml", metadata.object_lock_config_xml.as_slice()),
("versioning.xml", metadata.versioning_config_xml.as_slice()),
("bucket-encryption.xml", metadata.encryption_config_xml.as_slice()),
("tagging.xml", metadata.tagging_config_xml.as_slice()),
("replication.xml", metadata.replication_config_xml.as_slice()),
("cors.xml", metadata.cors_config_xml.as_slice()),
("logging.xml", metadata.logging_config_xml.as_slice()),
("website.xml", metadata.website_config_xml.as_slice()),
("accelerate.xml", metadata.accelerate_config_xml.as_slice()),
("request-payment.xml", metadata.request_payment_config_xml.as_slice()),
("public-access-block.xml", metadata.public_access_block_config_xml.as_slice()),
];
for (name, expected_bytes) in expected_configs {
assert_eq!(
std::fs::read(out.join(name)).unwrap_or_else(|error| panic!("read {name}: {error}")),
expected_bytes,
"{name} must preserve exact persisted bytes"
);
}
}