From b49d2979a5a29d17cd457ae9e4e0a1f975932c47 Mon Sep 17 00:00:00 2001 From: ychampion Date: Sat, 11 Jul 2026 21:55:34 +0000 Subject: [PATCH 1/2] Support namespaced replication destinations Constraint: API 1.21 exposes destination prefixes and removes the deprecated replication each_s field. Rejected: Keep reduct-rs 1.20 from crates.io | its replication model cannot serialize the new prefix. Confidence: high Scope-risk: moderate Directive: Return reduct-rs to the registry release after the API 1.21 SDK is published. Tested: 192 locked tests against reduct/store:main; cargo build --locked; cargo fmt --all -- --check; cargo package --locked --allow-dirty --no-verify; built CLI create/show/update smoke Not-tested: Cross-target release builds; strict Clippy is already non-clean across untouched code --- CHANGELOG.md | 5 +++ Cargo.lock | 8 ++--- Cargo.toml | 2 +- README.md | 8 ++++- src/cmd/replica.rs | 11 +++++- src/cmd/replica/create.rs | 61 +++++++++++++++++++++++++++++---- src/cmd/replica/show.rs | 9 +++++ src/cmd/replica/update.rs | 71 ++++++++++++++++++++++++++++++++++----- 8 files changed, 151 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c22543..b247d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Add destination prefixes to replica create, update, and show commands. + ### Changed - Pin the repo Rust toolchain to `1.91.0`, require locked Cargo resolution in CI/pre-commit/install examples/publish flows, and replace the tag-version `cargo-get` install step with repo-local `cargo metadata` verification. +- Align replica commands with API v1.21 by removing the deprecated `--each-s` option. ## 0.12.2 - 2026-06-18 diff --git a/Cargo.lock b/Cargo.lock index 8d3e645..35daeb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,9 +1471,8 @@ dependencies = [ [[package]] name = "reduct-base" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28e078e920f0f0c0b3a3ed25d96304e464aa59cf97224eb2e3126725c76499a7" +version = "1.21.0" +source = "git+https://github.com/reductstore/reductstore.git?branch=main#b62d5778fb93841c7493ad49ff60ad50d587b7b9" dependencies = [ "bytes", "chrono", @@ -1520,8 +1519,7 @@ dependencies = [ [[package]] name = "reduct-rs" version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c93f90aaeeb459b37bb0274398cc7da0f24fe5b7527125b7a7544779101284" +source = "git+https://github.com/reductstore/reduct-rs?branch=main#914ac80463c0d09949a07fd3953afc3a754d58d1" dependencies = [ "async-channel", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 90b624d..c5d16a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ documentation = "https://reduct.store/docs/guides" description = "A CLI client for ReductStore written in Rust" [dependencies] -reduct-rs = { version = "1.20.0"} +reduct-rs = { version = "1.20.0", git = "https://github.com/reductstore/reduct-rs", branch = "main" } clap = { version = "4.6.1", features = ["cargo"] } dirs = "6.0.0" toml = "1.0.6" diff --git a/README.md b/README.md index 364c387..fc5b386 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ blob data. ## Features -* Support for ReductStore API v1.20 +* Support for ReductStore API v1.21 * Easy management of buckets, tokens, replications and lifecycle policies * Ability to check the status of a storage engine * Aliases for storing server credentials @@ -66,6 +66,12 @@ reduct-cli cp play/datasets ./datasets --limit 100 reduct-cli cp play/* backup ``` +Replication destinations can be namespaced with `--prefix`: + +```shell +reduct-cli replica create local/edge source remote/archive --prefix robot-1 +``` + For more examples, see the [Guides](https://www.reduct.store/docs/guides) section in the ReductStore documentation. diff --git a/src/cmd/replica.rs b/src/cmd/replica.rs index 0e8486d..6679e81 100644 --- a/src/cmd/replica.rs +++ b/src/cmd/replica.rs @@ -19,9 +19,17 @@ use crate::cmd::replica::mode::{ use crate::cmd::replica::rm::{rm_replica_cmd, rm_replica_handler}; use crate::cmd::replica::show::{show_replica_cmd, show_replica_handler}; use crate::cmd::replica::update::{update_replica_cmd, update_replica_handler}; -use clap::Command; +use clap::{Arg, Command}; use create::{create_replica, create_replica_cmd}; +fn make_prefix_arg() -> Arg { + Arg::new("prefix") + .long("prefix") + .value_name("PREFIX") + .help("Prefix added to destination entry names") + .required(false) +} + pub(crate) fn replication_cmd() -> Command { Command::new("replica") .about("Manage replication tasks") @@ -75,6 +83,7 @@ mod tests { .dst_host("http://localhost:8383") .src_bucket(bucket) .dst_bucket(bucket2) + .dst_prefix("robot-1") .send() .await?; diff --git a/src/cmd/replica/create.rs b/src/cmd/replica/create.rs index 1903bce..29415c5 100644 --- a/src/cmd/replica/create.rs +++ b/src/cmd/replica/create.rs @@ -3,9 +3,10 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use crate::cmd::replica::make_prefix_arg; use crate::cmd::RESOURCE_PATH_HELP; use crate::io::reduct::{build_client, parse_url_and_token}; -use crate::parse::widely_used_args::{make_each_n, make_each_s, make_entries_arg, make_when_arg}; +use crate::parse::widely_used_args::{make_each_n, make_entries_arg, make_when_arg}; use crate::parse::{Resource, ResourcePathParser}; use clap::{Arg, Command}; use reduct_rs::ReplicationSettings; @@ -33,7 +34,7 @@ pub(super) fn create_replica_cmd() -> Command { ) .arg(make_entries_arg()) .arg(make_each_n()) - .arg(make_each_s()) + .arg(make_prefix_arg()) .arg(make_when_arg()) } @@ -59,7 +60,7 @@ pub(super) async fn create_replica( .collect::>(); let each_n = args.get_one::("each-n"); - let each_s = args.get_one::("each-s"); + let prefix = args.get_one::("prefix"); let when = args.get_one::("when"); let client = build_client(ctx, &alias_or_url).await?; @@ -72,7 +73,7 @@ pub(super) async fn create_replica( settings.dst_token = Some(token); settings.entries = entries_filter; settings.each_n = each_n.copied(); - settings.each_s = each_s.copied(); + settings.dst_prefix = prefix.cloned().unwrap_or_default(); if let Some(when) = when { settings.when = Some(serde_json::from_str(&when)?); @@ -119,8 +120,8 @@ mod tests { "entry2", "--each-n", "10", - "--each-s", - "0.5", + "--prefix", + "robot-1", "--when", r#"{"&label": {"$gt": 10}}"#, ]); @@ -136,7 +137,7 @@ mod tests { "Keep compatibility with v1.16" ); assert_eq!(replica.settings.each_n, Some(10)); - assert_eq!(replica.settings.each_s, Some(0.5)); + assert_eq!(replica.settings.dst_prefix, "robot-1"); assert_eq!( replica.settings.when.unwrap(), json!({"&label": {"$gt": 10}}) @@ -154,4 +155,50 @@ mod tests { ]); assert!(args.is_err()); } + + #[rstest] + #[tokio::test] + async fn test_create_replica_without_prefix( + context: crate::context::CliContext, + #[future] replica: String, + #[future] bucket: String, + #[future] bucket2: String, + ) { + let test_replica = replica.await; + let bucket = bucket.await; + let bucket2 = bucket2.await; + + let client = build_client(&context, "local").await.unwrap(); + client.create_bucket(&bucket).send().await.unwrap(); + client.create_bucket(&bucket2).send().await.unwrap(); + + let args = create_replica_cmd() + .try_get_matches_from([ + "create".to_string(), + format!("local/{test_replica}"), + bucket, + format!("local/{bucket2}"), + ]) + .unwrap(); + create_replica(&context, &args).await.unwrap(); + + let replica = client.get_replication(&test_replica).await.unwrap(); + assert!(replica.settings.dst_prefix.is_empty()); + } + + #[test] + fn test_create_replica_with_prefix() { + let args = create_replica_cmd() + .try_get_matches_from([ + "create", + "local/test_replica", + "source", + "local/destination", + "--prefix", + "robot-1", + ]) + .unwrap(); + + assert_eq!(args.get_one::("prefix").unwrap(), "robot-1"); + } } diff --git a/src/cmd/replica/show.rs b/src/cmd/replica/show.rs index 8a452ba..ad0fd3f 100644 --- a/src/cmd/replica/show.rs +++ b/src/cmd/replica/show.rs @@ -80,6 +80,14 @@ pub(super) async fn show_replica_handler( labeled_cell("Source Bucket", replica.settings.src_bucket.clone()), labeled_cell("Destination Bucket", replica.settings.dst_bucket.clone()), labeled_cell("Destination Server", replica.settings.dst_host.clone()), + labeled_cell( + "Destination Prefix", + if replica.settings.dst_prefix.is_empty() { + "None".to_string() + } else { + replica.settings.dst_prefix.clone() + }, + ), labeled_cell("Entries", format!("{:?}", replica.settings.entries)), ]; @@ -154,6 +162,7 @@ mod tests { labeled_cell("Source Bucket", "test_bucket"), labeled_cell("Destination Bucket", "test_bucket_2"), labeled_cell("Destination Server", "http://localhost:8383"), + labeled_cell("Destination Prefix", "robot-1"), labeled_cell("Entries", "[]"), "When: None".to_string(), ]); diff --git a/src/cmd/replica/update.rs b/src/cmd/replica/update.rs index 46e4e59..12700f9 100644 --- a/src/cmd/replica/update.rs +++ b/src/cmd/replica/update.rs @@ -3,10 +3,11 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use crate::cmd::replica::make_prefix_arg; use crate::cmd::RESOURCE_PATH_HELP; use crate::context::CliContext; use crate::io::reduct::{build_client, parse_url_and_token}; -use crate::parse::widely_used_args::{make_each_n, make_each_s, make_entries_arg, make_when_arg}; +use crate::parse::widely_used_args::{make_each_n, make_entries_arg, make_when_arg}; use crate::parse::{Resource, ResourcePathParser}; use clap::{Arg, ArgMatches, Command}; @@ -37,7 +38,7 @@ pub(super) fn update_replica_cmd() -> Command { ) .arg(make_entries_arg()) .arg(make_each_n()) - .arg(make_each_s()) + .arg(make_prefix_arg()) .arg(make_when_arg()) } @@ -80,7 +81,7 @@ fn update_replication_settings( .map(|s| s.map(|s| s.to_string()).collect::>()); let each_n = args.get_one::("each-n"); - let each_s = args.get_one::("each-s"); + let prefix = args.get_one::("prefix"); let when = args.get_one::("when"); let (dest_url, token) = parse_url_and_token(ctx, &dest_alias_or_url)?; @@ -100,8 +101,8 @@ fn update_replication_settings( current_settings.each_n = Some(*each_n); } - if let Some(each_s) = each_s { - current_settings.each_s = Some(*each_s); + if let Some(prefix) = prefix { + current_settings.dst_prefix = prefix.clone(); } if let Some(when) = when { @@ -142,8 +143,8 @@ mod tests { format!("local/{}", &bucket2).as_str(), "--each-n", "10", - "--each-s", - "0.5", + "--prefix", + "robot-2", ]) .unwrap(); @@ -160,7 +161,22 @@ mod tests { ); assert!(replica.settings.entries.is_empty()); assert_eq!(replica.settings.each_n, Some(10)); - assert_eq!(replica.settings.each_s, Some(0.5)); + assert_eq!(replica.settings.dst_prefix, "robot-2"); + } + + #[test] + fn test_update_replica_with_prefix() { + let args = update_replica_cmd() + .try_get_matches_from([ + "update", + "local/test_replica", + "local/destination", + "--prefix", + "robot-2", + ]) + .unwrap(); + + assert_eq!(args.get_one::("prefix").unwrap(), "robot-2"); } mod update_settings { @@ -260,6 +276,43 @@ mod tests { ); } + #[rstest] + fn override_prefix(context: CliContext, current_settings: ReplicationSettings) { + let args = update_replica_cmd() + .try_get_matches_from([ + "update", + "local/test_replica", + "local/bucket2", + "--prefix", + "robot-2", + ]) + .unwrap(); + + let new_settings = + update_replication_settings(&context, &args, current_settings.clone()).unwrap(); + assert_eq!( + new_settings, + ReplicationSettings { + dst_prefix: "robot-2".to_string(), + ..current_settings + } + ); + } + + #[rstest] + fn preserve_prefix_when_omitted( + context: CliContext, + current_settings: ReplicationSettings, + ) { + let args = update_replica_cmd() + .try_get_matches_from(["update", "local/test_replica", "local/bucket2"]) + .unwrap(); + + let new_settings = + update_replication_settings(&context, &args, current_settings).unwrap(); + assert_eq!(new_settings.dst_prefix, "robot-1"); + } + #[fixture] fn current_settings(current_token: String) -> ReplicationSettings { ReplicationSettings { @@ -267,10 +320,10 @@ mod tests { dst_bucket: "bucket2".to_string(), dst_host: "http://localhost:8383/".to_string(), dst_token: Some(current_token), + dst_prefix: "robot-1".to_string(), include: Labels::from_iter(vec![("key1".to_string(), "value1".to_string())]), exclude: Labels::from_iter(vec![("key2".to_string(), "value2".to_string())]), each_n: None, - each_s: None, entries: vec!["entry1".to_string(), "entry2".to_string()], when: None, mode: Default::default(), From 2eb019d7483bfa6df9e8d0ccc8a9a42082a6b197 Mon Sep 17 00:00:00 2001 From: ychampion Date: Mon, 13 Jul 2026 13:27:17 +0000 Subject: [PATCH 2/2] docs: apply replication prefix review feedback --- CHANGELOG.md | 2 +- README.md | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b247d9f..595f5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add destination prefixes to replica create, update, and show commands. +- Add destination prefixes to replica create, update, and show commands, [PR-247](https://github.com/reductstore/reduct-cli/pull/247) by @ychampion ### Changed diff --git a/README.md b/README.md index fc5b386..33c82a5 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,6 @@ reduct-cli cp play/datasets ./datasets --limit 100 reduct-cli cp play/* backup ``` -Replication destinations can be namespaced with `--prefix`: - -```shell -reduct-cli replica create local/edge source remote/archive --prefix robot-1 -``` - For more examples, see the [Guides](https://www.reduct.store/docs/guides) section in the ReductStore documentation.