Skip to content

Commit dc34fe0

Browse files
authored
feat(admin): add service control and site replication commands (#257)
1 parent 7783efe commit dc34fe0

10 files changed

Lines changed: 968 additions & 4 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ rc admin rebalance start local
280280
rc admin rebalance status local
281281
rc admin rebalance stop local
282282

283+
# Site replication across clusters (peer sites given as alias names)
284+
rc admin replicate add site1 site2
285+
rc admin replicate info site1
286+
rc admin replicate status site1
287+
rc admin replicate remove site1 --all
288+
289+
# Service control (restart/stop perform a graceful shutdown;
290+
# a process manager such as systemd relaunches after restart)
291+
rc admin service restart local
292+
rc admin service stop local
293+
283294
# JSON output
284295
rc admin info cluster local --json
285296
rc admin heal status local --json

crates/cli/src/commands/admin/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ mod info;
1212
mod policy;
1313
mod pool;
1414
mod rebalance;
15+
mod replicate;
16+
mod service;
1517
mod service_account;
1618
mod user;
1719

@@ -68,6 +70,14 @@ pub enum AdminCommands {
6870
/// Inspect access key identities
6971
#[command(name = "access-key", subcommand)]
7072
AccessKey(access_key::AccessKeyCommands),
73+
74+
/// Control the server process (restart, stop, freeze, unfreeze)
75+
#[command(subcommand)]
76+
Service(service::ServiceCommands),
77+
78+
/// Manage site replication across clusters
79+
#[command(subcommand)]
80+
Replicate(replicate::ReplicateCommands),
7181
}
7282

7383
/// Execute an admin subcommand
@@ -92,6 +102,10 @@ pub async fn execute(cmd: AdminCommands, output_config: OutputConfig) -> ExitCod
92102
AdminCommands::AccessKey(access_key_cmd) => {
93103
access_key::execute(access_key_cmd, &formatter).await
94104
}
105+
AdminCommands::Service(service_cmd) => service::execute(service_cmd, &formatter).await,
106+
AdminCommands::Replicate(replicate_cmd) => {
107+
replicate::execute(replicate_cmd, &formatter).await
108+
}
95109
}
96110
}
97111

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
//! Site replication commands (`rc admin replicate`).
2+
//!
3+
//! Mirrors `mc admin replicate` against the RustFS native admin API
4+
//! (`/rustfs/admin/v3/site-replication/*`). Peer sites are given as
5+
//! configured alias names; their endpoints and credentials are resolved
6+
//! from the local alias store.
7+
8+
use clap::Subcommand;
9+
10+
use super::get_admin_client;
11+
use crate::exit_code::ExitCode;
12+
use crate::output::Formatter;
13+
use rc_core::AliasManager;
14+
use rc_core::admin::{AdminApi, PeerSiteSpec, SiteRemoveSpec, SiteStatusOptions};
15+
16+
/// Site replication subcommands
17+
#[derive(Subcommand, Debug)]
18+
pub enum ReplicateCommands {
19+
/// Add sites (given as alias names) to a site replication cluster
20+
Add(AddArgs),
21+
22+
/// Show site replication configuration
23+
Info(InfoArgs),
24+
25+
/// Show site replication status
26+
Status(StatusArgs),
27+
28+
/// Remove sites from the site replication cluster
29+
#[command(alias = "rm")]
30+
Remove(RemoveArgs),
31+
}
32+
33+
#[derive(clap::Args, Debug)]
34+
pub struct AddArgs {
35+
/// Alias names of the sites to link (first is the request target)
36+
#[arg(required = true, num_args = 2..)]
37+
pub aliases: Vec<String>,
38+
}
39+
40+
#[derive(clap::Args, Debug)]
41+
pub struct InfoArgs {
42+
/// Alias name of the server
43+
pub alias: String,
44+
}
45+
46+
#[derive(clap::Args, Debug)]
47+
pub struct StatusArgs {
48+
/// Alias name of the server
49+
pub alias: String,
50+
51+
/// Include bucket replication status
52+
#[arg(long)]
53+
pub buckets: bool,
54+
55+
/// Include user replication status
56+
#[arg(long)]
57+
pub users: bool,
58+
59+
/// Include group replication status
60+
#[arg(long)]
61+
pub groups: bool,
62+
63+
/// Include policy replication status
64+
#[arg(long)]
65+
pub policies: bool,
66+
67+
/// Include replication metrics
68+
#[arg(long)]
69+
pub metrics: bool,
70+
}
71+
72+
#[derive(clap::Args, Debug)]
73+
pub struct RemoveArgs {
74+
/// Alias name of the server to send the request to
75+
pub alias: String,
76+
77+
/// Site names to remove from the cluster
78+
#[arg(long = "site", conflicts_with = "all")]
79+
pub sites: Vec<String>,
80+
81+
/// Remove all sites (dissolve the cluster)
82+
#[arg(long)]
83+
pub all: bool,
84+
}
85+
86+
/// Execute a site replication subcommand
87+
pub async fn execute(cmd: ReplicateCommands, formatter: &Formatter) -> ExitCode {
88+
match cmd {
89+
ReplicateCommands::Add(args) => execute_add(args, formatter).await,
90+
ReplicateCommands::Info(args) => execute_info(args, formatter).await,
91+
ReplicateCommands::Status(args) => execute_status(args, formatter).await,
92+
ReplicateCommands::Remove(args) => execute_remove(args, formatter).await,
93+
}
94+
}
95+
96+
fn resolve_peer_sites(
97+
aliases: &[String],
98+
formatter: &Formatter,
99+
) -> Result<Vec<PeerSiteSpec>, ExitCode> {
100+
let alias_manager = match AliasManager::new() {
101+
Ok(am) => am,
102+
Err(e) => {
103+
formatter.error(&format!("Failed to load aliases: {e}"));
104+
return Err(ExitCode::GeneralError);
105+
}
106+
};
107+
108+
let mut sites = Vec::with_capacity(aliases.len());
109+
for name in aliases {
110+
let alias = match alias_manager.get(name) {
111+
Ok(a) => a,
112+
Err(e) => {
113+
formatter.error(&format!("Unknown alias `{name}`: {e}"));
114+
return Err(ExitCode::GeneralError);
115+
}
116+
};
117+
if alias.anonymous || alias.access_key.is_empty() {
118+
formatter.error(&format!(
119+
"Alias `{name}` has no credentials; site replication requires credentialed aliases"
120+
));
121+
return Err(ExitCode::GeneralError);
122+
}
123+
sites.push(PeerSiteSpec {
124+
name: alias.name.clone(),
125+
endpoint: alias.endpoint.clone(),
126+
access_key: alias.access_key.clone(),
127+
secret_key: alias.secret_key.clone(),
128+
skip_tls_verify: alias.insecure,
129+
ca_cert_pem: String::new(),
130+
});
131+
}
132+
Ok(sites)
133+
}
134+
135+
async fn execute_add(args: AddArgs, formatter: &Formatter) -> ExitCode {
136+
let sites = match resolve_peer_sites(&args.aliases, formatter) {
137+
Ok(s) => s,
138+
Err(code) => return code,
139+
};
140+
141+
let client = match get_admin_client(&args.aliases[0], formatter) {
142+
Ok(c) => c,
143+
Err(code) => return code,
144+
};
145+
146+
match client.site_replication_add(&sites).await {
147+
Ok(result) => {
148+
if formatter.is_json() {
149+
formatter.json(&result);
150+
} else {
151+
formatter.success(&format!(
152+
"Site replication configured across: {}",
153+
args.aliases.join(", ")
154+
));
155+
if let Some(status) = result.get("status").and_then(|v| v.as_str()) {
156+
formatter.println(&format!(" Status: {status}"));
157+
}
158+
}
159+
ExitCode::Success
160+
}
161+
Err(e) => {
162+
formatter.error(&format!("Failed to add site replication: {e}"));
163+
ExitCode::GeneralError
164+
}
165+
}
166+
}
167+
168+
async fn execute_info(args: InfoArgs, formatter: &Formatter) -> ExitCode {
169+
let client = match get_admin_client(&args.alias, formatter) {
170+
Ok(c) => c,
171+
Err(code) => return code,
172+
};
173+
174+
match client.site_replication_info().await {
175+
Ok(info) => {
176+
if formatter.is_json() {
177+
formatter.json(&info);
178+
} else {
179+
let enabled = info
180+
.get("enabled")
181+
.and_then(|v| v.as_bool())
182+
.unwrap_or(false);
183+
if !enabled {
184+
formatter.println("Site replication is not configured.");
185+
} else {
186+
if let Some(name) = info.get("name").and_then(|v| v.as_str()) {
187+
formatter.println(&format!("Cluster: {name}"));
188+
}
189+
if let Some(sites) = info.get("sites").and_then(|v| v.as_array()) {
190+
formatter.println("Sites:");
191+
for site in sites {
192+
let name = site.get("name").and_then(|v| v.as_str()).unwrap_or("-");
193+
let endpoint =
194+
site.get("endpoint").and_then(|v| v.as_str()).unwrap_or("-");
195+
formatter.println(&format!(" {name} {endpoint}"));
196+
}
197+
}
198+
}
199+
}
200+
ExitCode::Success
201+
}
202+
Err(e) => {
203+
formatter.error(&format!("Failed to get site replication info: {e}"));
204+
ExitCode::GeneralError
205+
}
206+
}
207+
}
208+
209+
async fn execute_status(args: StatusArgs, formatter: &Formatter) -> ExitCode {
210+
let client = match get_admin_client(&args.alias, formatter) {
211+
Ok(c) => c,
212+
Err(code) => return code,
213+
};
214+
215+
// Default to the summary views when no specific flag is requested,
216+
// matching `mc admin replicate status` behavior.
217+
let none_requested =
218+
!(args.buckets || args.users || args.groups || args.policies || args.metrics);
219+
let options = SiteStatusOptions {
220+
buckets: args.buckets || none_requested,
221+
users: args.users || none_requested,
222+
groups: args.groups || none_requested,
223+
policies: args.policies || none_requested,
224+
metrics: args.metrics,
225+
peer_state: false,
226+
ilm_expiry_rules: false,
227+
};
228+
229+
match client.site_replication_status(&options).await {
230+
Ok(status) => {
231+
if formatter.is_json() {
232+
formatter.json(&status);
233+
} else {
234+
print_replication_status(&status, formatter);
235+
}
236+
ExitCode::Success
237+
}
238+
Err(e) => {
239+
formatter.error(&format!("Failed to get site replication status: {e}"));
240+
ExitCode::GeneralError
241+
}
242+
}
243+
}
244+
245+
fn print_replication_status(status: &serde_json::Value, formatter: &Formatter) {
246+
let enabled = status
247+
.get("enabled")
248+
.and_then(|v| v.as_bool())
249+
.unwrap_or(false);
250+
if !enabled {
251+
formatter.println("Site replication is not configured.");
252+
return;
253+
}
254+
255+
if let Some(sites) = status.get("Sites").and_then(|v| v.as_object()) {
256+
formatter.println("Sites:");
257+
for site in sites.values() {
258+
let name = site.get("name").and_then(|v| v.as_str()).unwrap_or("-");
259+
let endpoint = site.get("endpoint").and_then(|v| v.as_str()).unwrap_or("-");
260+
formatter.println(&format!(" {name} {endpoint}"));
261+
}
262+
}
263+
264+
let count = |key: &str| status.get(key).and_then(|v| v.as_u64()).unwrap_or_default();
265+
formatter.println(&format!(
266+
"Replicated entities: buckets={} users={} groups={} policies={}",
267+
count("MaxBuckets"),
268+
count("MaxUsers"),
269+
count("MaxGroups"),
270+
count("MaxPolicies"),
271+
));
272+
273+
if let Some(errors) = status.get("PeerErrors").and_then(|v| v.as_object())
274+
&& !errors.is_empty()
275+
{
276+
formatter.println(&format!(
277+
"Peer errors reported by {} site(s):",
278+
errors.len()
279+
));
280+
for (site, error) in errors {
281+
formatter.println(&format!(" {site}: {error}"));
282+
}
283+
}
284+
}
285+
286+
async fn execute_remove(args: RemoveArgs, formatter: &Formatter) -> ExitCode {
287+
if !args.all && args.sites.is_empty() {
288+
formatter.error("Specify --site <name> (repeatable) or --all");
289+
return ExitCode::UsageError;
290+
}
291+
292+
let client = match get_admin_client(&args.alias, formatter) {
293+
Ok(c) => c,
294+
Err(code) => return code,
295+
};
296+
297+
let spec = SiteRemoveSpec {
298+
site_names: args.sites.clone(),
299+
remove_all: args.all,
300+
};
301+
302+
match client.site_replication_remove(&spec).await {
303+
Ok(result) => {
304+
if formatter.is_json() {
305+
formatter.json(&result);
306+
} else {
307+
formatter.success("Site replication removal requested.");
308+
if let Some(status) = result.get("status").and_then(|v| v.as_str()) {
309+
formatter.println(&format!(" Status: {status}"));
310+
}
311+
}
312+
ExitCode::Success
313+
}
314+
Err(e) => {
315+
formatter.error(&format!("Failed to remove site replication: {e}"));
316+
ExitCode::GeneralError
317+
}
318+
}
319+
}

0 commit comments

Comments
 (0)