11package cmd
22
33import (
4+ "bufio"
45 "context"
56 "fmt"
7+ "os"
8+ "strings"
69
710 "github.com/bacchus-snu/sgs-cli/internal/cleanup"
811 "github.com/bacchus-snu/sgs-cli/internal/client"
912 "github.com/bacchus-snu/sgs-cli/internal/volume"
1013 "github.com/spf13/cobra"
1114)
1215
16+ var cpForce bool
17+
1318var cpCmd = & cobra.Command {
14- Use : "cp <source-node>/<source-volume> <dest-node>/<dest-volume >" ,
15- Short : "Copy a volume to another location " ,
16- Long : `Copy the contents of a source volume to a new destination volume .
19+ Use : "cp <source> <destination >" ,
20+ Short : "Copy volumes or files/directories between volumes " ,
21+ Long : `Copy volumes or files/directories between volumes .
1722
18- The destination volume will be created automatically with the same size and type
19- as the source volume. If the source is an OS volume, the destination will also
20- be marked as an OS volume.
23+ Volume copy (entire volume):
24+ sgs cp <node>/<volume> <node>/<volume>
25+ - Copies entire volume contents
26+ - Destination volume is created automatically
27+ - Requires confirmation (use --force to skip)
2128
22- The source volume must not have an active session. The destination volume must
23- not already exist.
29+ File/directory copy (specific paths):
30+ sgs cp <node>/<volume>:<path> <node>/<volume>:<path>
31+ - Copies specific files or directories within volumes
32+ - Both source and destination volumes must exist
33+ - For OS volumes, paths are relative to the rootfs (handled internally)
2434
25- For same-node copies, this uses a single temporary pod that mounts both volumes.
26- For cross-node copies, this streams data via tar between two temporary pods.
35+ Note: Source and destination must BOTH have paths (file/directory copy) or NEITHER have paths (volume copy).
2736
2837Examples:
29- # Copy a volume on the same node
30- sgs cp ferrari/my-volume ferrari/my-volume-backup
38+ # Copy entire volume (creates destination)
39+ sgs cp ferrari/os-vol porsche/os-vol-backup
40+
41+ # Copy entire volume, skip confirmation
42+ sgs cp --force ferrari/os-vol porsche/os-vol-backup
43+
44+ # Copy specific directory between data volumes
45+ sgs cp ferrari/data:/datasets/mnist porsche/data:/datasets/mnist
3146
32- # Copy a volume to a different node
33- sgs cp ferrari/my-volume porsche/my-volume
47+ # Copy from OS volume to data volume
48+ sgs cp ferrari/os-vol:/home/user/code ferrari/data:/backup/code
3449
35- # Copy an OS volume to create a fresh copy
36- sgs cp bentley/os-volume ford/os-volume ` ,
50+ # Copy between different nodes
51+ sgs cp ferrari/data:/models porsche/data:/models ` ,
3752 Args : cobra .ExactArgs (2 ),
3853 Run : runCp ,
3954}
4055
4156func init () {
4257 rootCmd .AddCommand (cpCmd )
58+ cpCmd .Flags ().BoolVarP (& cpForce , "force" , "f" , false , "Skip confirmation prompt (volume copy only)" )
4359}
4460
4561func runCp (cmd * cobra.Command , args []string ) {
@@ -49,31 +65,73 @@ func runCp(cmd *cobra.Command, args []string) {
4965 ctx , cancel := cleanup .InterruptibleContext (context .Background ())
5066 defer cancel ()
5167
52- // Parse source
53- srcNode , srcVolume , err := volume .ParseVolumePath (args [0 ])
68+ // Parse source and destination using the parser that handles paths
69+ srcPath , err := volume .ParseCopyPath (args [0 ])
5470 if err != nil {
5571 exitWithError (fmt .Sprintf ("invalid source: %s" , args [0 ]), err )
5672 }
5773
58- // Parse destination
59- dstNode , dstVolume , err := volume .ParseVolumePath (args [1 ])
74+ dstPath , err := volume .ParseCopyPath (args [1 ])
6075 if err != nil {
6176 exitWithError (fmt .Sprintf ("invalid destination: %s" , args [1 ]), err )
6277 }
6378
79+ // Validate that both have paths or neither has paths
80+ srcHasPath := srcPath .IsFineGrained ()
81+ dstHasPath := dstPath .IsFineGrained ()
82+
83+ if srcHasPath != dstHasPath {
84+ exitWithError ("invalid copy format: source and destination must both have paths (file/directory copy) or neither have paths (volume copy)" , nil )
85+ }
86+
6487 k8sClient , err := client .New ()
6588 if err != nil {
6689 exitWithError ("failed to create client" , err )
6790 }
6891
69- fmt .Printf ("Copying %s/%s to %s/%s...\n " , srcNode , srcVolume , dstNode , dstVolume )
92+ opts := volume.CopyOptions {
93+ SrcNode : srcPath .NodeName ,
94+ SrcVolume : srcPath .VolumeName ,
95+ SrcPath : srcPath .Path ,
96+ DstNode : dstPath .NodeName ,
97+ DstVolume : dstPath .VolumeName ,
98+ DstPath : dstPath .Path ,
99+ }
100+
101+ if srcHasPath {
102+ // File/directory copy: both volumes must exist
103+ fmt .Printf ("Copying %s/%s:%s to %s/%s:%s...\n " ,
104+ srcPath .NodeName , srcPath .VolumeName , srcPath .Path ,
105+ dstPath .NodeName , dstPath .VolumeName , dstPath .Path )
106+
107+ err = volume .CopyFiles (ctx , k8sClient , opts )
108+ } else {
109+ // Volume copy: destination will be created
110+ srcVolPath := fmt .Sprintf ("%s/%s" , srcPath .NodeName , srcPath .VolumeName )
111+ dstVolPath := fmt .Sprintf ("%s/%s" , dstPath .NodeName , dstPath .VolumeName )
112+
113+ // Require confirmation unless --force is set
114+ if ! cpForce {
115+ fmt .Printf ("This will copy volume '%s' to new volume '%s'.\n " , srcVolPath , dstVolPath )
116+ fmt .Printf ("Type the destination volume path to confirm: " )
117+
118+ reader := bufio .NewReader (os .Stdin )
119+ input , err := reader .ReadString ('\n' )
120+ if err != nil {
121+ exitWithError ("failed to read input" , err )
122+ }
123+
124+ input = strings .TrimSpace (input )
125+ if input != dstVolPath {
126+ fmt .Println ("Aborted: confirmation does not match" )
127+ os .Exit (1 )
128+ }
129+ }
130+
131+ fmt .Printf ("Copying %s to %s...\n " , srcVolPath , dstVolPath )
132+ err = volume .Copy (ctx , k8sClient , opts )
133+ }
70134
71- err = volume .Copy (ctx , k8sClient , volume.CopyOptions {
72- SrcNode : srcNode ,
73- SrcVolume : srcVolume ,
74- DstNode : dstNode ,
75- DstVolume : dstVolume ,
76- })
77135 if err != nil {
78136 // If context was cancelled (interrupt), signal handler already cleaned up and will exit
79137 // Just return to let it handle things
0 commit comments