Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
lima-vm
GitHub Repository: lima-vm/lima
Path: blob/master/cmd/limactl/shell.go
2608 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
"regexp"
14
"runtime"
15
"strconv"
16
"strings"
17
18
"al.essio.dev/pkg/shellescape"
19
"github.com/coreos/go-semver/semver"
20
"github.com/lima-vm/sshocker/pkg/ssh"
21
"github.com/mattn/go-isatty"
22
"github.com/sirupsen/logrus"
23
"github.com/spf13/cobra"
24
25
"github.com/lima-vm/lima/v2/pkg/autostart"
26
"github.com/lima-vm/lima/v2/pkg/envutil"
27
"github.com/lima-vm/lima/v2/pkg/instance"
28
"github.com/lima-vm/lima/v2/pkg/ioutilx"
29
"github.com/lima-vm/lima/v2/pkg/limatype"
30
"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
31
"github.com/lima-vm/lima/v2/pkg/sshutil"
32
"github.com/lima-vm/lima/v2/pkg/store"
33
"github.com/lima-vm/lima/v2/pkg/uiutil"
34
)
35
36
const shellHelp = `Execute shell in Lima
37
38
lima command is provided as an alias for limactl shell $LIMA_INSTANCE. $LIMA_INSTANCE defaults to "` + DefaultInstanceName + `".
39
40
By default, the first 'ssh' executable found in the host's PATH is used to connect to the Lima instance.
41
A custom ssh alias can be used instead by setting the $` + sshutil.EnvShellSSH + ` environment variable.
42
43
Environment Variables:
44
--preserve-env: Propagates host environment variables to the guest instance.
45
Use LIMA_SHELLENV_ALLOW to specify which variables to allow.
46
Use LIMA_SHELLENV_BLOCK to specify which variables to block (extends default blocklist with +).
47
48
Hint: try --debug to show the detailed logs, if it seems hanging (mostly due to some SSH issue).
49
`
50
51
func newShellCommand() *cobra.Command {
52
shellCmd := &cobra.Command{
53
Use: "shell [flags] INSTANCE [COMMAND...]",
54
SuggestFor: []string{"ssh"},
55
Short: "Execute shell in Lima",
56
Long: shellHelp,
57
Args: WrapArgsError(cobra.MinimumNArgs(1)),
58
RunE: shellAction,
59
ValidArgsFunction: shellBashComplete,
60
SilenceErrors: true,
61
GroupID: basicCommand,
62
}
63
64
shellCmd.Flags().SetInterspersed(false)
65
66
shellCmd.Flags().String("shell", "", "Shell interpreter, e.g. /bin/bash")
67
shellCmd.Flags().String("workdir", "", "Working directory")
68
shellCmd.Flags().Bool("reconnect", false, "Reconnect to the SSH session")
69
shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
70
shellCmd.Flags().Bool("start", false, "Start the instance if it is not already running")
71
shellCmd.Flags().String("sync", "", "Copy a host directory to the guest and vice-versa upon exit")
72
73
return shellCmd
74
}
75
76
// Depth of "/Users/USER" is 3.
77
const rsyncMinimumSrcDirDepth = 4
78
79
func shellAction(cmd *cobra.Command, args []string) error {
80
ctx := cmd.Context()
81
flags := cmd.Flags()
82
tty, err := flags.GetBool("tty")
83
if err != nil {
84
return err
85
}
86
// simulate the behavior of double dash
87
newArg := []string{}
88
if len(args) >= 2 && args[1] == "--" {
89
newArg = append(newArg, args[:1]...)
90
newArg = append(newArg, args[2:]...)
91
args = newArg
92
}
93
instName := args[0]
94
95
if len(args) >= 2 {
96
switch args[1] {
97
case "create", "start", "delete", "shell":
98
// `lima start` (alias of `limactl $LIMA_INSTANCE start`) is probably a typo of `limactl start`
99
logrus.Warnf("Perhaps you meant `limactl %s`?", strings.Join(args[1:], " "))
100
}
101
}
102
103
inst, err := store.Inspect(ctx, instName)
104
if err != nil {
105
if errors.Is(err, os.ErrNotExist) {
106
return fmt.Errorf("instance %q does not exist, run `limactl create %s` to create a new instance", instName, instName)
107
}
108
return err
109
}
110
if inst.Config == nil {
111
if len(inst.Errors) > 0 {
112
return fmt.Errorf("instance %q has configuration errors: %w", instName, errors.Join(inst.Errors...))
113
}
114
return fmt.Errorf("instance %q has no configuration", instName)
115
}
116
if inst.Status == limatype.StatusStopped {
117
startNow, err := flags.GetBool("start")
118
if err != nil {
119
return err
120
}
121
122
if tty && !flags.Changed("start") {
123
startNow, err = askWhetherToStart(cmd)
124
if err != nil {
125
return err
126
}
127
}
128
129
if !startNow {
130
return nil
131
}
132
133
// Network reconciliation will be performed by the process launched by the autostart manager
134
if registered, err := autostart.IsRegistered(ctx, inst); err != nil && !errors.Is(err, autostart.ErrNotSupported) {
135
return fmt.Errorf("failed to check if the autostart entry for instance %q is registered: %w", inst.Name, err)
136
} else if !registered {
137
err = reconcile.Reconcile(ctx, inst.Name)
138
if err != nil {
139
return err
140
}
141
}
142
143
err = instance.Start(instance.WithLaunchingShell(ctx), inst, false, false)
144
if err != nil {
145
return err
146
}
147
148
inst, err = store.Inspect(ctx, instName)
149
if err != nil {
150
return err
151
}
152
}
153
154
restart, err := cmd.Flags().GetBool("reconnect")
155
if err != nil {
156
return err
157
}
158
if restart && sshutil.IsControlMasterExisting(inst.Dir) {
159
logrus.Infof("Exiting ssh session for the instance %q", instName)
160
161
sshConfig := &ssh.SSHConfig{
162
ConfigFile: inst.SSHConfigFile,
163
Persist: false,
164
AdditionalArgs: []string{},
165
}
166
167
if err := ssh.ExitMaster(inst.Hostname, inst.SSHLocalPort, sshConfig); err != nil {
168
return err
169
}
170
}
171
172
syncDirVal, err := flags.GetString("sync")
173
if err != nil {
174
return fmt.Errorf("failed to get sync flag: %w", err)
175
}
176
syncHostWorkdir := syncDirVal != ""
177
if syncHostWorkdir && len(inst.Config.Mounts) > 0 {
178
return errors.New("cannot use `--sync` when the instance has host mounts configured, start the instance with `--mount-none` to disable mounts")
179
}
180
181
// When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
182
//
183
// changeDirCmd := "cd workDir || exit 1" if workDir != ""
184
// := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
185
var changeDirCmd string
186
var hostCurrentDir string
187
if syncDirVal != "" {
188
hostCurrentDir, err = filepath.Abs(syncDirVal)
189
if err == nil && runtime.GOOS == "windows" {
190
hostCurrentDir, err = mountDirFromWindowsDir(ctx, inst, hostCurrentDir)
191
}
192
} else {
193
hostCurrentDir, err = hostCurrentDirectory(ctx, inst)
194
}
195
196
if err != nil {
197
changeDirCmd = "false"
198
logrus.WithError(err).Warn("failed to get the current directory")
199
}
200
if syncHostWorkdir {
201
if _, err := exec.LookPath("rsync"); err != nil {
202
return fmt.Errorf("rsync is required for `--sync` but not found: %w", err)
203
}
204
205
srcWdDepth := len(strings.Split(hostCurrentDir, string(os.PathSeparator)))
206
if srcWdDepth < rsyncMinimumSrcDirDepth {
207
return fmt.Errorf("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)",
208
hostCurrentDir, rsyncMinimumSrcDirDepth, srcWdDepth, "cd to a deeper directory")
209
}
210
}
211
212
var destRsyncDir string
213
workDir, err := cmd.Flags().GetString("workdir")
214
if err != nil {
215
return err
216
}
217
switch {
218
case workDir != "":
219
changeDirCmd = fmt.Sprintf("cd %s || exit 1", shellescape.Quote(workDir))
220
// FIXME: check whether y.Mounts contains the home, not just len > 0
221
case len(inst.Config.Mounts) > 0 || inst.VMType == limatype.WSL2:
222
changeDirCmd = fmt.Sprintf("cd %s", shellescape.Quote(hostCurrentDir))
223
hostHomeDir, err := os.UserHomeDir()
224
if err == nil && runtime.GOOS == "windows" {
225
hostHomeDir, err = mountDirFromWindowsDir(ctx, inst, hostHomeDir)
226
}
227
if err == nil {
228
changeDirCmd = fmt.Sprintf("%s || cd %s", changeDirCmd, shellescape.Quote(hostHomeDir))
229
} else {
230
logrus.WithError(err).Warn("failed to get the home directory")
231
}
232
case syncHostWorkdir:
233
destRsyncDir = *inst.Config.User.Home + hostCurrentDir
234
changeDirCmd = fmt.Sprintf("cd %s", shellescape.Quote(destRsyncDir))
235
default:
236
logrus.Debug("the host home does not seem mounted, so the guest shell will have a different cwd")
237
}
238
239
if changeDirCmd == "" {
240
changeDirCmd = "false"
241
}
242
logrus.Debugf("changeDirCmd=%q", changeDirCmd)
243
244
shell, err := cmd.Flags().GetString("shell")
245
if err != nil {
246
return err
247
}
248
if shell == "" {
249
shell = `"$SHELL"`
250
} else {
251
shell = shellescape.Quote(shell)
252
}
253
// Handle environment variable propagation
254
var envPrefix string
255
preserveEnv, err := cmd.Flags().GetBool("preserve-env")
256
if err != nil {
257
return err
258
}
259
if preserveEnv {
260
filteredEnv := envutil.FilterEnvironment()
261
if len(filteredEnv) > 0 {
262
envPrefix = "env "
263
for _, envVar := range filteredEnv {
264
envPrefix += shellescape.Quote(envVar) + " "
265
}
266
}
267
}
268
269
script := fmt.Sprintf("%s ; exec %s%s --login", changeDirCmd, envPrefix, shell)
270
if len(args) > 1 {
271
quotedArgs := make([]string, len(args[1:]))
272
parsingEnv := true
273
for i, arg := range args[1:] {
274
if parsingEnv && isEnv(arg) {
275
quotedArgs[i] = quoteEnv(arg)
276
} else {
277
parsingEnv = false
278
quotedArgs[i] = shellescape.Quote(arg)
279
}
280
}
281
script += fmt.Sprintf(
282
" -c %s",
283
shellescape.Quote(strings.Join(quotedArgs, " ")),
284
)
285
}
286
287
sshExe, err := sshutil.NewSSHExe()
288
if err != nil {
289
return err
290
}
291
292
sshOpts, err := sshutil.SSHOpts(
293
ctx,
294
sshExe,
295
inst.Dir,
296
*inst.Config.User.Name,
297
*inst.Config.SSH.LoadDotSSHPubKeys,
298
*inst.Config.SSH.ForwardAgent,
299
*inst.Config.SSH.ForwardX11,
300
*inst.Config.SSH.ForwardX11Trusted)
301
if err != nil {
302
return err
303
}
304
if runtime.GOOS == "windows" {
305
// Remove ControlMaster, ControlPath, and ControlPersist options,
306
// because Cygwin-based SSH clients do not support multiplexing when executing commands.
307
// References:
308
// https://inbox.sourceware.org/cygwin/[email protected]/T/
309
// https://stackoverflow.com/questions/20959792/is-ssh-controlmaster-with-cygwin-on-windows-actually-possible
310
// By removing these options:
311
// - Avoids execution failures when the control master is not yet available.
312
// - Prevents error messages such as:
313
// > mux_client_request_session: read from master failed: Connection reset by peer
314
// > ControlSocket ....sock already exists, disabling multiplexing
315
// Only remove these options when writing the SSH config file and executing `limactl shell`, since multiplexing seems to work with port forwarding.
316
sshOpts = sshutil.SSHOptsRemovingControlPath(sshOpts)
317
}
318
sshArgs := append([]string{}, sshExe.Args...)
319
sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...)
320
321
var sshExecForRsync *exec.Cmd
322
if syncHostWorkdir {
323
logrus.Infof("Syncing host current directory(%s) to guest instance...", hostCurrentDir)
324
sshExecForRsync = exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
325
326
// Create the destination directory in the guest instance,
327
// we could have done this by using `--rsync-path` but it's more
328
// complex to quote properly.
329
if err := executeSSHForRsync(ctx, *sshExecForRsync, inst.SSHLocalPort, inst.SSHAddress, fmt.Sprintf("mkdir -p %s", shellescape.Quote(destRsyncDir))); err != nil {
330
return fmt.Errorf("failed to create the synced workdir in guest instance: %w", err)
331
}
332
333
// The macOS release of rsync (the latest being 2.6.9) does not support shell escaping of destination path but other versions do.
334
rsyncVer, err := rsyncVersion(ctx)
335
if err != nil {
336
return fmt.Errorf("failed to get rsync version: %w", err)
337
}
338
if rsyncVer.LessThan(*semver.New("3.0.0")) {
339
destRsyncDir = shellescape.Quote(destRsyncDir)
340
}
341
342
if err := rsyncDirectory(ctx, cmd, sshExecForRsync, hostCurrentDir+"/", fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, destRsyncDir)); err != nil {
343
return fmt.Errorf("failed to sync host working directory to guest instance: %w", err)
344
}
345
logrus.Infof("Successfully synced host current directory to guest(%s) instance.", destRsyncDir)
346
}
347
348
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
349
// required for showing the shell prompt: https://stackoverflow.com/a/626574
350
sshArgs = append(sshArgs, "-t")
351
}
352
if _, present := os.LookupEnv("COLORTERM"); present {
353
// SendEnv config is cumulative, with already existing options in ssh_config
354
sshArgs = append(sshArgs, "-o", "SendEnv=COLORTERM")
355
}
356
logLevel := "ERROR"
357
// For versions older than OpenSSH 8.9p, LogLevel=QUIET was needed to
358
// avoid the "Shared connection to 127.0.0.1 closed." message with -t.
359
olderSSH := sshutil.DetectOpenSSHVersion(ctx, sshExe).LessThan(*semver.New("8.9.0"))
360
if olderSSH {
361
logLevel = "QUIET"
362
}
363
sshArgs = append(sshArgs, []string{
364
"-o", fmt.Sprintf("LogLevel=%s", logLevel),
365
"-p", strconv.Itoa(inst.SSHLocalPort),
366
inst.SSHAddress,
367
"--",
368
script,
369
}...)
370
sshCmd := exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
371
sshCmd.Stdin = os.Stdin
372
sshCmd.Stdout = os.Stdout
373
sshCmd.Stderr = os.Stderr
374
logrus.Debugf("executing ssh (may take a long)): %+v", sshCmd.Args)
375
376
// TODO: use syscall.Exec directly (results in losing tty?)
377
if err := sshCmd.Run(); err != nil {
378
return err
379
}
380
381
// Once the shell command finishes, rsync back the changes from guest workdir
382
// to the host and delete the guest synced workdir only if the user
383
// confirms the changes.
384
if syncHostWorkdir {
385
tty, err := flags.GetBool("tty")
386
if err != nil {
387
return err
388
}
389
return askUserForRsyncBack(ctx, cmd, inst, sshExecForRsync, hostCurrentDir, destRsyncDir, tty)
390
}
391
return nil
392
}
393
394
func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype.Instance, sshCmd *exec.Cmd, hostCurrentDir, destRsyncDir string, tty bool) error {
395
remoteSource := fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, destRsyncDir)
396
clean := filepath.Clean(hostCurrentDir)
397
parts := strings.Split(clean, string(filepath.Separator))
398
if len(parts) < 2 {
399
return fmt.Errorf("invalid host current directory: %s", hostCurrentDir)
400
}
401
dirForCleanup := shellescape.Quote(fmt.Sprintf("%s/", *inst.Config.User.Home) + parts[1])
402
403
rsyncBackAndCleanup := func() error {
404
if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, filepath.Dir(hostCurrentDir)); err != nil {
405
return fmt.Errorf("failed to sync back the changes from guest instance to host: %w", err)
406
}
407
408
// Clean up the guest synced workdir
409
if err := executeSSHForRsync(ctx, *sshCmd, inst.SSHLocalPort, inst.SSHAddress, fmt.Sprintf("rm -rf %s", dirForCleanup)); err != nil {
410
logrus.WithError(err).Warn("Failed to clean up guest synced workdir")
411
}
412
logrus.Info("Successfully synced back the changes to host.")
413
414
return nil
415
}
416
417
if !tty {
418
return rsyncBackAndCleanup()
419
}
420
421
message := "⚠️ Accept the changes?"
422
options := []string{
423
"Yes",
424
"No",
425
"View the changed contents",
426
}
427
428
hostTmpDest, err := os.MkdirTemp("", "lima-guest-synced-*")
429
if err != nil {
430
return err
431
}
432
defer func() {
433
if err := os.RemoveAll(hostTmpDest); err != nil {
434
logrus.WithError(err).Warnf("Failed to clean up temporary directory %s", hostTmpDest)
435
}
436
}()
437
rsyncToTempDir := false
438
439
for {
440
ans, err := uiutil.Select(message, options)
441
if err != nil {
442
return fmt.Errorf("failed to open TUI: %w", err)
443
}
444
445
switch ans {
446
case 0: // Yes
447
return rsyncBackAndCleanup()
448
case 1: // No
449
// Clean up the guest synced workdir
450
if err := executeSSHForRsync(ctx, *sshCmd, inst.SSHLocalPort, inst.SSHAddress, fmt.Sprintf("rm -rf %s", dirForCleanup)); err != nil {
451
logrus.WithError(err).Warn("Failed to clean up guest synced workdir")
452
}
453
logrus.Info("Skipping syncing back the changes to host.")
454
return nil
455
case 2: // View the changed contents
456
if !rsyncToTempDir {
457
if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, hostTmpDest); err != nil {
458
return fmt.Errorf("failed to sync back the changes from guest instance to host temporary directory: %w", err)
459
}
460
rsyncToTempDir = true
461
}
462
diffCmd := exec.CommandContext(ctx, "diff", "-ru", "--color=always", hostCurrentDir, filepath.Join(hostTmpDest, filepath.Base(hostCurrentDir)))
463
pager := os.Getenv("PAGER")
464
pager = strings.TrimSpace(pager)
465
if pager == "" {
466
pager = "less"
467
}
468
pagerArgs := strings.Fields(pager)
469
lessCmd := exec.CommandContext(ctx, pagerArgs[0], pagerArgs[1:]...)
470
471
pipeIn, err := lessCmd.StdinPipe()
472
if err != nil {
473
return fmt.Errorf("failed to create pipe for less: %w", err)
474
}
475
diffCmd.Stdout = pipeIn
476
lessCmd.Stdout = cmd.OutOrStdout()
477
lessCmd.Stderr = cmd.OutOrStderr()
478
479
if err := lessCmd.Start(); err != nil {
480
return fmt.Errorf("failed to start less: %w", err)
481
}
482
if err := diffCmd.Run(); err != nil {
483
// Command `diff` returns exit code 1 when files differ.
484
var exitErr *exec.ExitError
485
if errors.As(err, &exitErr) && exitErr.ExitCode() >= 2 {
486
_ = pipeIn.Close()
487
return fmt.Errorf("failed to run diff command: %w", err)
488
}
489
}
490
491
_ = pipeIn.Close()
492
493
if err := lessCmd.Wait(); err != nil {
494
return fmt.Errorf("failed to wait for less command: %w", err)
495
}
496
}
497
}
498
}
499
500
func executeSSHForRsync(ctx context.Context, sshCmd exec.Cmd, sshLocalPort int, sshAddress, command string) error {
501
sshCmd.Args = append(sshCmd.Args,
502
"-p", strconv.Itoa(sshLocalPort),
503
sshAddress,
504
)
505
506
// Skip Args[0] (program name) to avoid duplication
507
sshRmCmd := exec.CommandContext(ctx, sshCmd.Path, append(sshCmd.Args[1:], command)...)
508
if err := sshRmCmd.Run(); err != nil {
509
return err
510
}
511
return nil
512
}
513
514
func hostCurrentDirectory(ctx context.Context, inst *limatype.Instance) (string, error) {
515
hostCurrentDir, err := os.Getwd()
516
if err == nil && runtime.GOOS == "windows" {
517
hostCurrentDir, err = mountDirFromWindowsDir(ctx, inst, hostCurrentDir)
518
}
519
return hostCurrentDir, err
520
}
521
522
func rsyncVersion(ctx context.Context) (*semver.Version, error) {
523
out, err := exec.CommandContext(ctx, "rsync", "--version").Output()
524
if err != nil {
525
return nil, err
526
}
527
528
// `rsync version 3.2.7 protocol version 31`
529
re := regexp.MustCompile(`version (\d+\.\d+\.\d+)`)
530
matches := re.FindSubmatch(out)
531
if len(matches) < 2 {
532
return nil, errors.New("failed to parse rsync version")
533
}
534
return semver.NewVersion(string(matches[1]))
535
}
536
537
// Syncs a directory from host to guest and vice-versa. It creates a directory
538
// named "synced-workdir" in the guest's home directory and copies the contents
539
// of the host's current working directory into it. SSHArgs should not contain
540
// the port and address, rsync handles it separately.
541
func rsyncDirectory(ctx context.Context, cmd *cobra.Command, sshCmd *exec.Cmd, source, destination string) error {
542
sshCmdParts := make([]string, len(sshCmd.Args))
543
for i, arg := range sshCmd.Args {
544
sshCmdParts[i] = shellescape.Quote(arg)
545
}
546
sshCmdStr := strings.Join(sshCmdParts, " ")
547
548
rsyncArgs := []string{
549
"-ah",
550
"--delete",
551
"-e", sshCmdStr,
552
source,
553
destination,
554
}
555
rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...)
556
rsyncCmd.Stdout = cmd.OutOrStdout()
557
rsyncCmd.Stderr = cmd.OutOrStderr()
558
logrus.Debugf("executing rsync: %+v", rsyncCmd.Args)
559
return rsyncCmd.Run()
560
}
561
562
func mountDirFromWindowsDir(ctx context.Context, inst *limatype.Instance, dir string) (string, error) {
563
if inst.VMType == limatype.WSL2 {
564
distroName := "lima-" + inst.Name
565
return ioutilx.WindowsSubsystemPathForLinux(ctx, dir, distroName)
566
}
567
return ioutilx.WindowsSubsystemPath(ctx, dir)
568
}
569
570
func shellBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
571
return bashCompleteInstanceNames(cmd)
572
}
573
574
func isEnv(arg string) bool {
575
return len(strings.Split(arg, "=")) > 1
576
}
577
578
func quoteEnv(arg string) string {
579
env := strings.SplitN(arg, "=", 2)
580
env[1] = shellescape.Quote(env[1])
581
return strings.Join(env, "=")
582
}
583
584