Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
lima-vm
GitHub Repository: lima-vm/lima
Path: blob/master/cmd/limactl/copy.go
2613 views
1
// SPDX-FileCopyrightText: Copyright The Lima Authors
2
// SPDX-License-Identifier: Apache-2.0
3
4
package main
5
6
import (
7
"context"
8
"errors"
9
"fmt"
10
"os"
11
"os/exec"
12
"path/filepath"
13
"runtime"
14
"strings"
15
16
"github.com/coreos/go-semver/semver"
17
"github.com/sirupsen/logrus"
18
"github.com/spf13/cobra"
19
20
"github.com/lima-vm/lima/v2/pkg/ioutilx"
21
"github.com/lima-vm/lima/v2/pkg/limatype"
22
"github.com/lima-vm/lima/v2/pkg/sshutil"
23
"github.com/lima-vm/lima/v2/pkg/store"
24
)
25
26
const copyHelp = `Copy files between host and guest
27
28
Prefix guest filenames with the instance name and a colon.
29
30
Backends:
31
auto - Automatically selects the best available backend (rsync preferred, falls back to scp)
32
rsync - Uses rsync for faster transfers with resume capability (requires rsync on both host and guest)
33
scp - Uses scp for reliable transfers (always available)
34
35
Not to be confused with 'limactl clone'.
36
`
37
38
const copyExample = `
39
# Copy file from guest to host (auto backend)
40
limactl copy default:/etc/os-release .
41
42
# Copy file from host to guest with verbose output
43
limactl copy -v myfile.txt default:/tmp/
44
45
# Copy directory recursively using rsync backend
46
limactl copy --backend=rsync -r ./mydir default:/tmp/
47
48
# Copy using scp backend specifically
49
limactl copy --backend=scp default:/var/log/app.log ./logs/
50
51
# Copy multiple files
52
limactl copy file1.txt file2.txt default:/tmp/
53
`
54
55
type copyTool string
56
57
const (
58
rsync copyTool = "rsync"
59
scp copyTool = "scp"
60
auto copyTool = "auto"
61
)
62
63
type copyPath struct {
64
instanceName string
65
path string
66
isRemote bool
67
instance *limatype.Instance
68
}
69
70
func newCopyCommand() *cobra.Command {
71
copyCommand := &cobra.Command{
72
Use: "copy SOURCE ... TARGET",
73
Aliases: []string{"cp"},
74
Short: "Copy files between host and guest",
75
Long: copyHelp,
76
Example: copyExample,
77
Args: WrapArgsError(cobra.MinimumNArgs(2)),
78
RunE: copyAction,
79
GroupID: advancedCommand,
80
}
81
82
copyCommand.Flags().BoolP("recursive", "r", false, "Copy directories recursively")
83
copyCommand.Flags().BoolP("verbose", "v", false, "Enable verbose output")
84
copyCommand.Flags().String("backend", "auto", "Copy backend (scp|rsync|auto)")
85
86
return copyCommand
87
}
88
89
func copyAction(cmd *cobra.Command, args []string) error {
90
ctx := cmd.Context()
91
recursive, err := cmd.Flags().GetBool("recursive")
92
if err != nil {
93
return err
94
}
95
96
verbose, err := cmd.Flags().GetBool("verbose")
97
if err != nil {
98
return err
99
}
100
101
debug, err := cmd.Flags().GetBool("debug")
102
if err != nil {
103
return err
104
}
105
106
if debug {
107
verbose = true
108
}
109
110
copyPaths, err := parseCopyArgs(ctx, args)
111
if err != nil {
112
return err
113
}
114
115
backend, err := cmd.Flags().GetString("backend")
116
if err != nil {
117
return err
118
}
119
120
cpTool, toolPath, err := selectCopyTool(ctx, copyPaths, backend)
121
if err != nil {
122
return err
123
}
124
125
logrus.Debugf("using copy tool %q", toolPath)
126
127
var copyCmd *exec.Cmd
128
switch cpTool {
129
case scp:
130
copyCmd, err = scpCommand(ctx, toolPath, copyPaths, verbose, recursive)
131
case rsync:
132
copyCmd, err = rsyncCommand(ctx, toolPath, copyPaths, verbose, recursive)
133
default:
134
err = fmt.Errorf("invalid copy tool %q", cpTool)
135
}
136
if err != nil {
137
return err
138
}
139
140
copyCmd.Stdin = cmd.InOrStdin()
141
copyCmd.Stdout = cmd.OutOrStdout()
142
copyCmd.Stderr = cmd.ErrOrStderr()
143
logrus.Debugf("executing %v (may take a long time)", copyCmd)
144
145
// TODO: use syscall.Exec directly (results in losing tty?)
146
return copyCmd.Run()
147
}
148
149
func parseCopyArgs(ctx context.Context, args []string) ([]*copyPath, error) {
150
var copyPaths []*copyPath
151
152
for _, arg := range args {
153
cp := &copyPath{}
154
155
if runtime.GOOS == "windows" {
156
if filepath.IsAbs(arg) {
157
var err error
158
arg, err = ioutilx.WindowsSubsystemPath(ctx, arg)
159
if err != nil {
160
return nil, err
161
}
162
} else {
163
arg = filepath.ToSlash(arg)
164
}
165
}
166
167
parts := strings.SplitN(arg, ":", 2)
168
switch len(parts) {
169
case 1:
170
cp.path = arg
171
cp.isRemote = false
172
case 2:
173
cp.instanceName = parts[0]
174
cp.path = parts[1]
175
cp.isRemote = true
176
177
inst, err := store.Inspect(ctx, cp.instanceName)
178
if err != nil {
179
if errors.Is(err, os.ErrNotExist) {
180
return nil, fmt.Errorf("instance %q does not exist, run `limactl create %s` to create a new instance", cp.instanceName, cp.instanceName)
181
}
182
return nil, err
183
}
184
if inst.Status == limatype.StatusStopped {
185
return nil, fmt.Errorf("instance %q is stopped, run `limactl start %s` to start the instance", cp.instanceName, cp.instanceName)
186
}
187
cp.instance = inst
188
default:
189
return nil, fmt.Errorf("path %q contains multiple colons", arg)
190
}
191
192
copyPaths = append(copyPaths, cp)
193
}
194
195
return copyPaths, nil
196
}
197
198
func selectCopyTool(ctx context.Context, copyPaths []*copyPath, backend string) (copyTool, string, error) {
199
switch copyTool(backend) {
200
case scp:
201
scpPath, err := exec.LookPath("scp")
202
if err != nil {
203
return "", "", fmt.Errorf("scp not found on host: %w", err)
204
}
205
return scp, scpPath, nil
206
case rsync:
207
rsyncPath, err := exec.LookPath("rsync")
208
if err != nil {
209
return "", "", fmt.Errorf("rsync not found on host: %w", err)
210
}
211
if !rsyncAvailableOnGuests(ctx, copyPaths) {
212
return "", "", errors.New("rsync not available on guest(s)")
213
}
214
return rsync, rsyncPath, nil
215
case auto:
216
if rsyncPath, err := exec.LookPath("rsync"); err == nil {
217
if rsyncAvailableOnGuests(ctx, copyPaths) {
218
return rsync, rsyncPath, nil
219
}
220
logrus.Debugf("rsync not available on guest(s), falling back to scp")
221
} else {
222
logrus.Debugf("rsync not found on host, falling back to scp: %v", err)
223
}
224
225
scpPath, err := exec.LookPath("scp")
226
if err != nil {
227
return "", "", fmt.Errorf("neither rsync nor scp found on host: %w", err)
228
}
229
return scp, scpPath, nil
230
default:
231
return "", "", fmt.Errorf("invalid backend %q, must be one of: scp, rsync, auto", backend)
232
}
233
}
234
235
func rsyncAvailableOnGuests(ctx context.Context, copyPaths []*copyPath) bool {
236
instances := make(map[string]*limatype.Instance)
237
238
for _, cp := range copyPaths {
239
if cp.isRemote {
240
instances[cp.instanceName] = cp.instance
241
}
242
}
243
244
for instName, inst := range instances {
245
if !checkRsyncOnGuest(ctx, inst) {
246
logrus.Debugf("rsync not available on instance %q", instName)
247
return false
248
}
249
}
250
251
return true
252
}
253
254
func checkRsyncOnGuest(ctx context.Context, inst *limatype.Instance) bool {
255
sshExe, err := sshutil.NewSSHExe()
256
if err != nil {
257
logrus.Debugf("failed to create SSH executable: %v", err)
258
return false
259
}
260
sshOpts, err := sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false)
261
if err != nil {
262
logrus.Debugf("failed to get SSH options for rsync check: %v", err)
263
return false
264
}
265
266
sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
267
checkCmd := exec.CommandContext(ctx, "ssh")
268
checkCmd.Args = append(checkCmd.Args, sshArgs...)
269
checkCmd.Args = append(checkCmd.Args,
270
"-p", fmt.Sprintf("%d", inst.SSHLocalPort),
271
fmt.Sprintf("%[email protected]", *inst.Config.User.Name),
272
"command -v rsync >/dev/null 2>&1",
273
)
274
275
err = checkCmd.Run()
276
return err == nil
277
}
278
279
func scpCommand(ctx context.Context, command string, copyPaths []*copyPath, verbose, recursive bool) (*exec.Cmd, error) {
280
instances := make(map[string]*limatype.Instance)
281
scpFlags := []string{}
282
scpArgs := []string{}
283
284
if verbose {
285
scpFlags = append(scpFlags, "-v")
286
} else {
287
scpFlags = append(scpFlags, "-q")
288
}
289
290
if recursive {
291
scpFlags = append(scpFlags, "-r")
292
}
293
294
// this assumes that ssh and scp come from the same place, but scp has no -V
295
sshExeForVersion, err := sshutil.NewSSHExe()
296
if err != nil {
297
return nil, err
298
}
299
legacySSH := sshutil.DetectOpenSSHVersion(ctx, sshExeForVersion).LessThan(*semver.New("8.0.0"))
300
301
for _, cp := range copyPaths {
302
if cp.isRemote {
303
if legacySSH {
304
scpFlags = append(scpFlags, "-P", fmt.Sprintf("%d", cp.instance.SSHLocalPort))
305
scpArgs = append(scpArgs, fmt.Sprintf("%[email protected]:%s", *cp.instance.Config.User.Name, cp.path))
306
} else {
307
scpArgs = append(scpArgs, fmt.Sprintf("scp://%[email protected]:%d/%s", *cp.instance.Config.User.Name, cp.instance.SSHLocalPort, cp.path))
308
}
309
instances[cp.instanceName] = cp.instance
310
} else {
311
scpArgs = append(scpArgs, cp.path)
312
}
313
}
314
315
if legacySSH && len(instances) > 1 {
316
return nil, errors.New("more than one (instance) host is involved in this command, this is only supported for openSSH v8.0 or higher")
317
}
318
319
scpFlags = append(scpFlags, "-3", "--")
320
scpArgs = append(scpFlags, scpArgs...)
321
322
var sshOpts []string
323
if len(instances) == 1 {
324
// Only one (instance) host is involved; we can use the instance-specific
325
// arguments such as ControlPath. This is preferred as we can multiplex
326
// sessions without re-authenticating (MaxSessions permitting).
327
for _, inst := range instances {
328
sshExe, err := sshutil.NewSSHExe()
329
if err != nil {
330
return nil, err
331
}
332
sshOpts, err = sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false)
333
if err != nil {
334
return nil, err
335
}
336
}
337
} else {
338
// Copying among multiple hosts; we can't pass in host-specific options.
339
sshExe, err := sshutil.NewSSHExe()
340
if err != nil {
341
return nil, err
342
}
343
sshOpts, err = sshutil.CommonOpts(ctx, sshExe, false)
344
if err != nil {
345
return nil, err
346
}
347
}
348
sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
349
350
return exec.CommandContext(ctx, command, append(sshArgs, scpArgs...)...), nil
351
}
352
353
func rsyncCommand(ctx context.Context, command string, copyPaths []*copyPath, verbose, recursive bool) (*exec.Cmd, error) {
354
rsyncFlags := []string{"-a"}
355
356
if verbose {
357
rsyncFlags = append(rsyncFlags, "-v", "--progress")
358
} else {
359
rsyncFlags = append(rsyncFlags, "-q")
360
}
361
362
if recursive {
363
rsyncFlags = append(rsyncFlags, "-r")
364
}
365
366
rsyncArgs := make([]string, 0, len(rsyncFlags)+len(copyPaths))
367
rsyncArgs = append(rsyncArgs, rsyncFlags...)
368
369
var sshCmd string
370
var remoteInstance *limatype.Instance
371
372
for _, cp := range copyPaths {
373
if cp.isRemote {
374
if remoteInstance == nil {
375
remoteInstance = cp.instance
376
sshExe, err := sshutil.NewSSHExe()
377
if err != nil {
378
return nil, err
379
}
380
sshOpts, err := sshutil.SSHOpts(ctx, sshExe, cp.instance.Dir, *cp.instance.Config.User.Name, false, false, false, false)
381
if err != nil {
382
return nil, err
383
}
384
385
sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
386
sshCmd = fmt.Sprintf("ssh -p %d %s", cp.instance.SSHLocalPort, strings.Join(sshArgs, " "))
387
}
388
}
389
}
390
391
if sshCmd != "" {
392
rsyncArgs = append(rsyncArgs, "-e", sshCmd)
393
}
394
395
// Handle trailing slash for directory copies to keep consistent behavior with scp,
396
// which was the original implementation of `limactl copy -r`.
397
// https://github.com/lima-vm/lima/issues/4468
398
if recursive {
399
for i, cp := range copyPaths {
400
//nolint:modernize // stringscutprefix: HasSuffix + TrimSuffix can be simplified to CutSuffix
401
if strings.HasSuffix(cp.path, "/") {
402
if cp.isRemote {
403
for j, cp2 := range copyPaths {
404
if i != j {
405
cp2.path = strings.TrimSuffix(cp2.path, "/")
406
}
407
}
408
} else {
409
cp.path = strings.TrimSuffix(cp.path, "/")
410
}
411
} else {
412
cp.path += "/"
413
}
414
}
415
}
416
417
for _, cp := range copyPaths {
418
if cp.isRemote {
419
rsyncArgs = append(rsyncArgs, fmt.Sprintf("%[email protected]:%s", *cp.instance.Config.User.Name, cp.path))
420
} else {
421
rsyncArgs = append(rsyncArgs, cp.path)
422
}
423
}
424
425
return exec.CommandContext(ctx, command, rsyncArgs...), nil
426
}
427
428