feat(admin): add on-demand migration bucket admin API (#7076)

* feat(ecstore): add on-demand migration bucket config model

Introduce OnDemandMigrationConfig (deny_unknown_fields, version 1) with typed validation, credential redaction, a secret-free Debug impl, and the OnceLock publish hook the runtime registers into. Exported through the api facade.

* feat(ecstore): persist on-demand migration config in bucket metadata

Store the config as a RustFS extension entry (on-demand-migration.json) with its update time in .metadata.bin, add the typed BucketMetadataSys accessor, and publish the config through the hook on every cache-install path alongside the durability sync.

* refactor(ecstore): extract shared remote S3 client builder

Move the aws_sdk_s3 client construction out of bucket_target_sys into
bucket/remote_s3_client.rs: endpoint assembly, credential provider,
path-style selection, custom CA / skip-TLS transports and the outbound
SSRF gate now build from a neutral RemoteS3EndpointSpec so replication
targets and the upcoming on-demand migration source client share one
policy. Replication builds its client through From<&BucketTarget>; the
gate keeps its relaxed semantics (private allowed, loopback only behind
RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET) verbatim. The builder also
gains optional connect/read timeouts and a User-Agent suffix
interceptor, both unset for replication.

Refs rustfs/backlog#2149

* feat(ecstore): add on-demand migration SourceClient

Add bucket/on_demand_migration/source_client.rs on top of the shared
remote S3 builder: HEAD, ranged streaming GET, ListObjectsV2 with
source-prefix mapping, GetObjectTagging and an admin probe. Every request
carries the x-rustfs-/x-minio-source-proxy-request anti-loop markers and
a RustFS-OnDemandMigration/<version> User-Agent suffix; SSE-C source
objects are rejected as unsupported. SourceError classifies SDK failures
(not found, access denied, throttled, timeout, connect, server error)
with retryability and a stable metrics label. Debug output redacts
credentials.

Refs rustfs/backlog#2149

* docs(operations): point outbound policy at shared remote S3 client builder

* chore: integrate ODM-01 and ODM-02 as B1 base (fix facade merge)

* feat(admin): add on-demand migration bucket admin API

Add the management plane for On-Demand Migration (ODM-07,
rustfs/backlog#2154): PUT/GET/DELETE /v3/on-demand-migration/{bucket},
PUT ?dry-run=true, and a GET .../status skeleton.

- PUT authorizes SetBucketOnDemandMigration, checks the bucket, the
  RUSTFS_ON_DEMAND_MIGRATION_ENABLED switch and the license, validates the
  ODM-01 config against local endpoints and replication targets, probes the
  source with SourceClient::probe(), then persists through the incarnation
  gate and asks peers to reload. Responses carry the redacted config and a
  probe summary; probe failures name only the error class.
- GET answers 404 NoSuchConfiguration when unset; DELETE is idempotent (204).
- New AdminAction variants admin:SetBucketOnDemandMigration and
  admin:GetBucketOnDemandMigration, route policy matrix rows, registration
  and MinIO alias coverage, and a doc row for the extra handler gates.
- rustfs-madmin gains on_demand_migration wire types and client methods;
  golden fixtures under crates/madmin/fixtures/on_demand_migration/ are
  asserted byte-for-byte by both the handler and the client tests.

Anonymous sources still map to a 400 naming source.credentials until the
runtime slice adds the credential-less path.

* refactor(admin): route on-demand migration handler errors through the s3 facade
This commit is contained in:
Zhengchao An
2026-09-03 01:58:49 +08:00
committed by GitHub
parent a23d4b05a3
commit a5bde8b0af
17 changed files with 2106 additions and 142 deletions
+5
View File
@@ -49,6 +49,7 @@ mod notify_runtime_access;
pub mod object_data_cache;
pub mod object_zip_download;
pub mod oidc;
pub mod on_demand_migration;
pub mod plugins_catalog;
pub mod plugins_instances;
pub mod policies;
@@ -129,6 +130,10 @@ mod tests {
let _list_extension_instances = extensions::ListExtensionInstancesHandler {};
let _get_plugin_catalog = plugins_catalog::GetPluginCatalogHandler {};
let _create_object_zip_download = object_zip_download::CreateObjectZipDownloadHandler {};
let _set_on_demand_migration = on_demand_migration::SetBucketOnDemandMigrationHandler {};
let _get_on_demand_migration = on_demand_migration::GetBucketOnDemandMigrationHandler {};
let _delete_on_demand_migration = on_demand_migration::DeleteBucketOnDemandMigrationHandler {};
let _on_demand_migration_status = on_demand_migration::GetBucketOnDemandMigrationStatusHandler {};
let _list_plugin_instances = plugins_instances::ListPluginInstancesHandler {};
let _get_plugin_instance = plugins_instances::GetPluginInstanceHandler {};
let _put_plugin_instance = plugins_instances::PutPluginInstanceHandler {};
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -36,9 +36,9 @@ mod route_registration_test;
use handlers::{
account, audit, batch_job, bucket_meta, cluster_snapshot, config_admin, diagnostics, durability as durability_handler,
extensions, heal, health, idp_compat, ilm_transition, inspect_archive, kms, mfa, module_switch, object_data_cache,
object_zip_download, oidc, plugins_catalog, plugins_instances, pools, profile_admin, quota as quota_handler, rebalance,
replication as replication_handler, scanner, site_replication, sts, system, table_catalog, tier, tls_debug, usage_prefix,
user,
object_zip_download, oidc, on_demand_migration, plugins_catalog, plugins_instances, pools, profile_admin,
quota as quota_handler, rebalance, replication as replication_handler, scanner, site_replication, sts, system, table_catalog,
tier, tls_debug, usage_prefix, user,
};
use router::{AdminOperation, S3Router};
use s3s::route::S3Route;
@@ -77,6 +77,7 @@ fn register_admin_routes(r: &mut S3Router<AdminOperation>) -> std::io::Result<()
quota_handler::register_quota_route(r)?;
durability_handler::register_durability_route(r)?;
on_demand_migration::register_on_demand_migration_route(r)?;
bucket_meta::register_bucket_meta_route(r)?;
config_admin::register_config_route(r)?;
scanner::register_scanner_route(r)?;
+57
View File
@@ -38,6 +38,7 @@ const EXPORT_BUCKET_METADATA: AdminActionRef = AdminActionRef::new("ExportBucket
const EXPORT_IAM: AdminActionRef = AdminActionRef::new("ExportIAMAction");
const FORCE_UNLOCK: AdminActionRef = AdminActionRef::new("ForceUnlockAdminAction");
const GET_BUCKET_TARGET: AdminActionRef = AdminActionRef::new("GetBucketTargetAction");
const GET_BUCKET_ON_DEMAND_MIGRATION: AdminActionRef = AdminActionRef::new("GetBucketOnDemandMigrationAction");
const GET_GROUP: AdminActionRef = AdminActionRef::new("GetGroupAdminAction");
const GET_USER: AdminActionRef = AdminActionRef::new("GetUserAdminAction");
const GET_METRICS: AdminActionRef = AdminActionRef::new("GetMetricsAction");
@@ -88,6 +89,7 @@ const SERVER_INFO: AdminActionRef = AdminActionRef::new("ServerInfoAdminAction")
const SERVER_UPDATE: AdminActionRef = AdminActionRef::new("ServerUpdateAdminAction");
const SET_BUCKET_QUOTA: AdminActionRef = AdminActionRef::new("SetBucketQuotaAdminAction");
const SET_BUCKET_TARGET: AdminActionRef = AdminActionRef::new("SetBucketTargetAction");
const SET_BUCKET_ON_DEMAND_MIGRATION: AdminActionRef = AdminActionRef::new("SetBucketOnDemandMigrationAction");
const SET_TABLE: AdminActionRef = AdminActionRef::new("SetTableAction");
const SET_TABLE_BUCKET: AdminActionRef = AdminActionRef::new("SetTableBucketAction");
const SET_TABLE_LIFECYCLE: AdminActionRef = AdminActionRef::new("SetTableLifecycleAction");
@@ -390,6 +392,30 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
CONFIG_UPDATE,
RouteRiskLevel::High,
),
admin(
HttpMethod::Put,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
SET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
GET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Delete,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
SET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}/status",
GET_BUCKET_ON_DEMAND_MIGRATION,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/rustfs/admin/export-bucket-metadata",
@@ -2163,6 +2189,37 @@ mod tests {
assert_action(HttpMethod::Get, "/rustfs/admin/v3/metrics", GET_METRICS);
}
#[test]
fn route_policy_splits_on_demand_migration_into_set_and_get_actions() {
assert_action(
HttpMethod::Put,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
SET_BUCKET_ON_DEMAND_MIGRATION,
);
assert_action(
HttpMethod::Delete,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
SET_BUCKET_ON_DEMAND_MIGRATION,
);
assert_action(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
GET_BUCKET_ON_DEMAND_MIGRATION,
);
assert_action(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}/status",
GET_BUCKET_ON_DEMAND_MIGRATION,
);
// Reads never require the write action, and the routes are not bucket-target routes.
assert_not_action(
HttpMethod::Get,
"/rustfs/admin/v3/on-demand-migration/{bucket}",
SET_BUCKET_ON_DEMAND_MIGRATION,
);
assert_not_action(HttpMethod::Put, "/rustfs/admin/v3/on-demand-migration/{bucket}", SET_BUCKET_TARGET);
}
#[test]
fn route_policy_requires_dedicated_inspect_action_for_encrypted_archive() {
assert_action(HttpMethod::Post, "/rustfs/admin/v4/inspect/archive", INSPECT_DATA);
@@ -240,6 +240,14 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
admin_route_sample(Method::PUT, "/v3/bucket-durability/{bucket}", "/v3/bucket-durability/test-bucket"),
admin_route_sample(Method::GET, "/v3/bucket-durability/{bucket}", "/v3/bucket-durability/test-bucket"),
admin_route_sample(Method::DELETE, "/v3/bucket-durability/{bucket}", "/v3/bucket-durability/test-bucket"),
admin_route_sample(Method::PUT, "/v3/on-demand-migration/{bucket}", "/v3/on-demand-migration/test-bucket"),
admin_route_sample(Method::GET, "/v3/on-demand-migration/{bucket}", "/v3/on-demand-migration/test-bucket"),
admin_route_sample(Method::DELETE, "/v3/on-demand-migration/{bucket}", "/v3/on-demand-migration/test-bucket"),
admin_route_sample(
Method::GET,
"/v3/on-demand-migration/{bucket}/status",
"/v3/on-demand-migration/test-bucket/status",
),
admin_route(Method::GET, "/export-bucket-metadata"),
admin_route(Method::GET, "/v3/export-bucket-metadata"),
admin_route(Method::PUT, "/import-bucket-metadata"),
@@ -1274,6 +1282,10 @@ fn test_register_routes_cover_representative_admin_paths() {
assert_route(&router, Method::PUT, &admin_path("/v3/bucket-durability/test-bucket"));
assert_route(&router, Method::GET, &admin_path("/v3/bucket-durability/test-bucket"));
assert_route(&router, Method::DELETE, &admin_path("/v3/bucket-durability/test-bucket"));
assert_route(&router, Method::PUT, &admin_path("/v3/on-demand-migration/test-bucket"));
assert_route(&router, Method::GET, &admin_path("/v3/on-demand-migration/test-bucket"));
assert_route(&router, Method::DELETE, &admin_path("/v3/on-demand-migration/test-bucket"));
assert_route(&router, Method::GET, &admin_path("/v3/on-demand-migration/test-bucket/status"));
assert_route(&router, Method::GET, &admin_path("/export-bucket-metadata"));
assert_route(&router, Method::GET, &admin_path("/v3/export-bucket-metadata"));
@@ -1403,6 +1415,10 @@ fn test_admin_alias_paths_match_existing_admin_routes() {
(Method::POST, compat_admin_alias_path("/v3/scanner/cycle-state/reset")),
(Method::POST, compat_admin_alias_path("/v3/scanner/usage-state/reset")),
(Method::GET, compat_admin_alias_path("/v3/ilm/expiry/status")),
(Method::PUT, compat_admin_alias_path("/v3/on-demand-migration/b")),
(Method::GET, compat_admin_alias_path("/v3/on-demand-migration/b")),
(Method::DELETE, compat_admin_alias_path("/v3/on-demand-migration/b")),
(Method::GET, compat_admin_alias_path("/v3/on-demand-migration/b/status")),
] {
assert!(
router.contains_compatible_route(method.clone(), &path),
+35 -3
View File
@@ -20,8 +20,8 @@ use time::OffsetDateTime;
mod ecstore_bucket {
pub(crate) use crate::storage::storage_api::ecstore_bucket::{
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, object_lock, quota, replication, target,
utils, versioning, versioning_sys,
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, object_lock, on_demand_migration, quota,
remote_s3_client, replication, target, utils, versioning, versioning_sys,
};
}
@@ -269,6 +269,7 @@ pub(crate) mod metadata {
pub(crate) const BUCKET_TARGETS_FILE: &str = super::ecstore_bucket::metadata::BUCKET_TARGETS_FILE;
pub(crate) const BUCKET_VERSIONING_CONFIG: &str = super::ecstore_bucket::metadata::BUCKET_VERSIONING_CONFIG;
pub(crate) const BUCKET_DURABILITY_CONFIG: &str = super::ecstore_bucket::metadata::BUCKET_DURABILITY_CONFIG;
pub(crate) const BUCKET_ON_DEMAND_MIGRATION_CONFIG: &str = super::ecstore_bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG;
pub(crate) const OBJECT_LOCK_CONFIG: &str = super::ecstore_bucket::metadata::OBJECT_LOCK_CONFIG;
pub(crate) type BucketMetadata = super::ecstore_bucket::metadata::BucketMetadata;
@@ -282,6 +283,29 @@ pub(crate) mod durability {
pub(crate) type BucketDurabilityConfig = super::ecstore_bucket::durability::BucketDurabilityConfig;
}
pub(crate) mod on_demand_migration {
pub(crate) type OnDemandMigrationConfig = super::ecstore_bucket::on_demand_migration::OnDemandMigrationConfig;
pub(crate) type OnDemandMigrationConfigError = super::ecstore_bucket::on_demand_migration::OnDemandMigrationConfigError;
pub(crate) type PathStyle = super::ecstore_bucket::on_demand_migration::PathStyle;
pub(crate) type Provider = super::ecstore_bucket::on_demand_migration::Provider;
pub(crate) type ValidationContext<'a> = super::ecstore_bucket::on_demand_migration::ValidationContext<'a>;
pub(crate) mod source_client {
pub(crate) type SourceClient = super::super::ecstore_bucket::on_demand_migration::source_client::SourceClient;
pub(crate) type SourceClientSpec = super::super::ecstore_bucket::on_demand_migration::source_client::SourceClientSpec;
pub(crate) type SourceError = super::super::ecstore_bucket::on_demand_migration::source_client::SourceError;
pub(crate) type SourceProbe = super::super::ecstore_bucket::on_demand_migration::source_client::SourceProbe;
pub(crate) type SourceProvider = super::super::ecstore_bucket::on_demand_migration::source_client::SourceProvider;
pub(crate) type SourceTimeouts = super::super::ecstore_bucket::on_demand_migration::source_client::SourceTimeouts;
}
}
pub(crate) mod remote_s3_client {
pub(crate) type PathStyle = super::ecstore_bucket::remote_s3_client::PathStyle;
pub(crate) type RemoteCredentials = super::ecstore_bucket::remote_s3_client::RemoteCredentials;
pub(crate) type RemoteS3ClientError = super::ecstore_bucket::remote_s3_client::RemoteS3ClientError;
}
pub(crate) mod metadata_sys {
use std::sync::Arc;
@@ -417,6 +441,12 @@ pub(crate) mod metadata_sys {
super::ecstore_bucket::metadata_sys::get_durability_config(bucket).await
}
pub(crate) async fn get_on_demand_migration_config(
bucket: &str,
) -> Result<Option<(super::on_demand_migration::OnDemandMigrationConfig, OffsetDateTime)>> {
super::ecstore_bucket::metadata_sys::get_on_demand_migration_config(bucket).await
}
pub(crate) async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
super::ecstore_bucket::metadata_sys::get_quota_config(bucket).await
}
@@ -869,7 +899,9 @@ pub(crate) mod bucket {
pub(crate) use super::lifecycle;
pub(crate) use super::metadata;
pub(crate) use super::metadata_sys;
pub(crate) use super::on_demand_migration;
pub(crate) use super::quota;
pub(crate) use super::remote_s3_client;
pub(crate) use super::replication;
pub(crate) use super::target;
pub(crate) use super::versioning_sys;
@@ -969,7 +1001,7 @@ pub(crate) mod runtime {
}
pub(crate) mod s3 {
pub(crate) use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, header};
pub(crate) use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, auth, header};
/// Build an `S3Error` without reaching for the `s3s` error macro.
///
+1 -1
View File
@@ -408,7 +408,7 @@ pub(crate) mod ecstore_bucket {
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::tier_delete_journal::test_util::install_all_v6_fleet_capability_proof;
pub(crate) use rustfs_ecstore::api::bucket::{
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, migration, object_lock, on_demand_migration,
policy_sys, replication, tagging, target, utils,
policy_sys, remote_s3_client, replication, tagging, target, utils,
};
pub(crate) use rustfs_ecstore::api::bucket::{quota, versioning, versioning_sys};
}