Skip to content

Commit aa69828

Browse files
authored
fix: improve RustFS compatibility for cp, mv, and quota parsing (#37)
* feat(phase-2): improve rustfs compatibility for transfer and mv * feat(phase-2): address review feedback on mv and quota defaults * feat(phase-2): strengthen integration smoke coverage on rustfs latest
1 parent 34dab44 commit aa69828

File tree

6 files changed

+416
-63
lines changed

6 files changed

+416
-63
lines changed

.github/workflows/integration.yml

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,96 @@
11
name: Integration Tests
22

33
on:
4-
push:
5-
branches: [ main ]
64
pull_request:
7-
branches: [ main ]
8-
workflow_dispatch: # Allow manual triggering
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
workflow_dispatch:
9+
schedule:
10+
- cron: "0 2 * * *"
911

1012
env:
1113
CARGO_TERM_COLOR: always
1214
RUST_BACKTRACE: 1
15+
TEST_S3_ENDPOINT: http://localhost:9000
16+
TEST_S3_ACCESS_KEY: accesskey
17+
TEST_S3_SECRET_KEY: secretkey
1318

1419
jobs:
15-
integration:
16-
name: Integration Tests
20+
smoke-latest:
21+
name: Smoke (RustFS latest)
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 30
24+
steps:
25+
- uses: actions/checkout@v6
26+
- uses: dtolnay/rust-toolchain@stable
27+
- uses: Swatinem/rust-cache@v2
28+
29+
- name: Start RustFS latest
30+
run: |
31+
docker run -d --name rustfs \
32+
-p 9000:9000 \
33+
-p 9001:9001 \
34+
-v rustfs-data:/data \
35+
-e RUSTFS_ROOT_USER=accesskey \
36+
-e RUSTFS_ROOT_PASSWORD=secretkey \
37+
-e RUSTFS_ACCESS_KEY=accesskey \
38+
-e RUSTFS_SECRET_KEY=secretkey \
39+
-e RUSTFS_VOLUMES=/data \
40+
-e RUSTFS_ADDRESS=":9000" \
41+
-e RUSTFS_CONSOLE_ENABLE="true" \
42+
-e RUSTFS_CONSOLE_ADDRESS=":9001" \
43+
rustfs/rustfs:latest
44+
45+
- name: Wait for RustFS
46+
run: |
47+
for i in {1..60}; do
48+
if curl -sf http://localhost:9000/health > /dev/null 2>&1; then
49+
echo "RustFS is ready"
50+
exit 0
51+
fi
52+
sleep 1
53+
done
54+
echo "RustFS failed to start"
55+
docker logs rustfs
56+
exit 1
57+
58+
- name: Run smoke compatibility tests
59+
run: |
60+
set -euo pipefail
61+
TESTS=(
62+
"object_operations::test_upload_and_download_small_file"
63+
"object_operations::test_move_recursive_prefix_s3_to_s3"
64+
"quota_operations::test_bucket_quota_set_info_clear"
65+
)
66+
67+
for test_name in "${TESTS[@]}"; do
68+
echo "==> Running $test_name"
69+
cargo test \
70+
--package rustfs-cli \
71+
--test integration \
72+
--features integration \
73+
"$test_name" \
74+
-- \
75+
--exact \
76+
--test-threads=1
77+
done
78+
79+
- name: Show RustFS logs on failure
80+
if: failure()
81+
run: docker logs rustfs 2>&1 | tail -200
82+
83+
full-latest:
84+
name: Full Integration (RustFS latest)
1785
runs-on: ubuntu-latest
18-
# Don't block PR merges - integration tests are supplementary
19-
continue-on-error: true
86+
timeout-minutes: 90
87+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
2088
steps:
2189
- uses: actions/checkout@v6
2290
- uses: dtolnay/rust-toolchain@stable
2391
- uses: Swatinem/rust-cache@v2
2492

25-
- name: Start RustFS
93+
- name: Start RustFS latest
2694
run: |
2795
docker run -d --name rustfs \
2896
-p 9000:9000 \
@@ -36,41 +104,32 @@ jobs:
36104
-e RUSTFS_ADDRESS=":9000" \
37105
-e RUSTFS_CONSOLE_ENABLE="true" \
38106
-e RUSTFS_CONSOLE_ADDRESS=":9001" \
39-
rustfs/rustfs:1.0.0-alpha.81
107+
rustfs/rustfs:latest
40108
41109
- name: Wait for RustFS
42110
run: |
43-
echo "Waiting for RustFS to start..."
44-
sleep 3
45-
for i in {1..30}; do
46-
# Try health endpoint
111+
for i in {1..60}; do
47112
if curl -sf http://localhost:9000/health > /dev/null 2>&1; then
48-
echo "RustFS is ready!"
113+
echo "RustFS is ready"
49114
exit 0
50115
fi
51-
echo "Waiting for RustFS... ($i/30)"
52-
sleep 2
116+
sleep 1
53117
done
54118
echo "RustFS failed to start"
55119
docker logs rustfs
56120
exit 1
57121
58-
- name: Run integration tests
122+
- name: Run full integration suite
59123
run: cargo test --package rustfs-cli --test integration --features integration -- --test-threads=1
60-
env:
61-
TEST_S3_ENDPOINT: http://localhost:9000
62-
TEST_S3_ACCESS_KEY: accesskey
63-
TEST_S3_SECRET_KEY: secretkey
64124

65125
- name: Show RustFS logs on failure
66126
if: failure()
67-
run: docker logs rustfs 2>&1 | tail -100
127+
run: docker logs rustfs 2>&1 | tail -200
68128

69129
golden:
70130
name: Golden Tests
71131
runs-on: ubuntu-latest
72-
# Don't block PR merges
73-
continue-on-error: true
132+
timeout-minutes: 20
74133
steps:
75134
- uses: actions/checkout@v6
76135
- uses: dtolnay/rust-toolchain@stable
@@ -79,5 +138,4 @@ jobs:
79138
- name: Run golden tests
80139
run: cargo test --package rustfs-cli --test golden --features golden
81140
env:
82-
# Golden tests use isolated config dir, no real S3 needed for alias tests
83141
RC_CONFIG_DIR: ${{ runner.temp }}/rc-test-config

crates/cli/src/commands/mv.rs

Lines changed: 158 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Moves objects between locations (copy + delete).
44
55
use clap::Args;
6-
use rc_core::{AliasManager, ObjectStore as _, ParsedPath, RemotePath, parse_path};
6+
use rc_core::{AliasManager, ListOptions, ObjectStore as _, ParsedPath, RemotePath, parse_path};
77
use rc_s3::S3Client;
88
use serde::Serialize;
99
use std::path::{Path, PathBuf};
@@ -273,39 +273,168 @@ async fn move_s3_to_s3(
273273
return ExitCode::Success;
274274
}
275275

276-
// Copy
277-
match client.copy_object(src, dst).await {
278-
Ok(info) => {
279-
// Delete source
280-
if let Err(e) = client.delete_object(src).await {
281-
formatter.error(&format!("Copied but failed to delete source: {e}"));
282-
return ExitCode::GeneralError;
283-
}
276+
// Recursive move for prefix/directory semantics.
277+
if args.recursive {
278+
let mut continuation_token: Option<String> = None;
279+
let mut moved_count = 0usize;
280+
let mut error_count = 0usize;
281+
let src_prefix = src.key.clone();
282+
283+
loop {
284+
let list_opts = ListOptions {
285+
recursive: true,
286+
continuation_token: continuation_token.clone(),
287+
..Default::default()
288+
};
289+
290+
let list_result = match client.list_objects(src, list_opts).await {
291+
Ok(result) => result,
292+
Err(e) => {
293+
formatter.error(&format!("Failed to list source objects: {e}"));
294+
return ExitCode::NetworkError;
295+
}
296+
};
297+
298+
for item in &list_result.items {
299+
if item.is_dir {
300+
continue;
301+
}
302+
303+
let relative = if src_prefix.is_empty() {
304+
item.key.clone()
305+
} else if let Some(rest) = item.key.strip_prefix(&src_prefix) {
306+
rest.trim_start_matches('/').to_string()
307+
} else {
308+
item.key.clone()
309+
};
284310

285-
if formatter.is_json() {
286-
let output = MvOutput {
287-
status: "success",
288-
source: src_display,
289-
target: dst_display,
290-
size_bytes: info.size_bytes,
311+
let target_key = if dst.key.is_empty() {
312+
relative.clone()
313+
} else if dst.key.ends_with('/') {
314+
format!("{}{}", dst.key, relative)
315+
} else {
316+
format!("{}/{}", dst.key, relative)
291317
};
292-
formatter.json(&output);
293-
} else {
294-
formatter.println(&format!(
295-
"{src_display} -> {dst_display} ({})",
296-
info.size_human.unwrap_or_default()
297-
));
318+
319+
let src_obj = RemotePath::new(&src.alias, &src.bucket, &item.key);
320+
let dst_obj = RemotePath::new(&dst.alias, &dst.bucket, &target_key);
321+
let src_obj_display = src_obj.to_string();
322+
let dst_obj_display = dst_obj.to_string();
323+
324+
match client.copy_object(&src_obj, &dst_obj).await {
325+
Ok(_) => match client.delete_object(&src_obj).await {
326+
Ok(()) => {
327+
moved_count += 1;
328+
if !formatter.is_json() {
329+
formatter
330+
.println(&format!("{src_obj_display} -> {dst_obj_display}"));
331+
}
332+
}
333+
Err(e) => {
334+
error_count += 1;
335+
formatter.error(&format!(
336+
"Copied but failed to delete source '{src_obj_display}': {e}"
337+
));
338+
if !args.continue_on_error {
339+
return ExitCode::GeneralError;
340+
}
341+
}
342+
},
343+
Err(e) => {
344+
error_count += 1;
345+
formatter.error(&format!(
346+
"Failed to move '{src_obj_display}' -> '{dst_obj_display}': {e}"
347+
));
348+
if !args.continue_on_error {
349+
return ExitCode::NetworkError;
350+
}
351+
}
352+
}
353+
}
354+
355+
if !list_result.truncated {
356+
break;
357+
}
358+
continuation_token = match list_result.continuation_token.clone() {
359+
Some(token) => Some(token),
360+
None => {
361+
formatter.error(
362+
"Backend indicated truncated results but did not provide a continuation token; stopping to avoid an infinite loop.",
363+
);
364+
return ExitCode::GeneralError;
365+
}
366+
};
367+
}
368+
369+
if formatter.is_json() {
370+
#[derive(Serialize)]
371+
struct MvRecursiveOutput {
372+
status: &'static str,
373+
source: String,
374+
target: String,
375+
moved: usize,
376+
errors: usize,
298377
}
378+
379+
formatter.json(&MvRecursiveOutput {
380+
status: if error_count == 0 {
381+
"success"
382+
} else {
383+
"partial"
384+
},
385+
source: src_display,
386+
target: dst_display,
387+
moved: moved_count,
388+
errors: error_count,
389+
});
390+
} else if error_count == 0 {
391+
formatter.println(&format!("Moved {moved_count} object(s)."));
392+
} else {
393+
formatter.println(&format!(
394+
"Moved {moved_count} object(s), {error_count} failed."
395+
));
396+
}
397+
398+
if error_count == 0 {
299399
ExitCode::Success
400+
} else {
401+
ExitCode::GeneralError
300402
}
301-
Err(e) => {
302-
let err_str = e.to_string();
303-
if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
304-
formatter.error(&format!("Source not found: {src_display}"));
305-
ExitCode::NotFound
306-
} else {
307-
formatter.error(&format!("Failed to move: {e}"));
308-
ExitCode::NetworkError
403+
} else {
404+
// Copy
405+
match client.copy_object(src, dst).await {
406+
Ok(info) => {
407+
// Delete source
408+
if let Err(e) = client.delete_object(src).await {
409+
formatter.error(&format!("Copied but failed to delete source: {e}"));
410+
return ExitCode::GeneralError;
411+
}
412+
413+
if formatter.is_json() {
414+
let output = MvOutput {
415+
status: "success",
416+
source: src_display,
417+
target: dst_display,
418+
size_bytes: info.size_bytes,
419+
};
420+
formatter.json(&output);
421+
} else {
422+
formatter.println(&format!(
423+
"{src_display} -> {dst_display} ({})",
424+
info.size_human.unwrap_or_default()
425+
));
426+
}
427+
ExitCode::Success
428+
}
429+
Err(e) => {
430+
let err_str = e.to_string();
431+
if err_str.contains("NotFound") || err_str.contains("NoSuchKey") {
432+
formatter.error(&format!("Source not found: {src_display}"));
433+
ExitCode::NotFound
434+
} else {
435+
formatter.error(&format!("Failed to move: {e}"));
436+
ExitCode::NetworkError
437+
}
309438
}
310439
}
311440
}

0 commit comments

Comments
 (0)