diff --git a/cmd/unikraft/help_test.go b/cmd/unikraft/help_test.go index c4fad13c..1279c00a 100644 --- a/cmd/unikraft/help_test.go +++ b/cmd/unikraft/help_test.go @@ -126,6 +126,8 @@ func instancesHelpTests(t *testing.T, unikraftPath string) { []string{"unikraft", "instance", "restart", "--help"}, []string{"unikraft", "instance", "tunnel", "--help"}, []string{"unikraft", "instance", "history", "--help"}, + []string{"unikraft", "instance", "shell", "--help"}, + []string{"unikraft", "instance", "exec", "--help"}, ) } diff --git a/cmd/unikraft/integration/sandbox_test.go b/cmd/unikraft/integration/sandbox_test.go new file mode 100644 index 00000000..c42686d1 --- /dev/null +++ b/cmd/unikraft/integration/sandbox_test.go @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/containerd/continuity/fs/fstest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cloud/plugins/sandbox" + + integ "unikraft.com/cli/internal/integration" +) + +const ( + sandboxPlugin = sandbox.PluginName + + sandboxPluginRom = "plugins/sandbox:staging" + + sandboxKraftfile = ` +spec: v0.7 +name: sandbox-e2e +runtime: base-compat:latest +rootfs: + format: erofs + source: ./Dockerfile +cmd: ["tail", "-f", "/dev/null"] +` +) + +// newSandboxInstance builds the fixture image, creates a running instance +// serving the sandbox plugin on it, and returns the instance's name. +func newSandboxInstance(t *testing.T, r *integ.TestEnv) string { + t.Helper() + + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("Dockerfile", []byte("FROM busybox:latest\n"), 0o644), + fstest.CreateFile("Kraftfile", []byte(sandboxKraftfile), 0o644), + ).Apply(dir)) + + // The image is registered in the test's resource sandbox, which deletes it + // when the test ends. + image := r.Config.Profile.Organization + "/sandbox-e2e:" + uniq() + r.Run(t, []string{"unikraft", "build", ".", "--output", image}, integ.WithWorkDir(dir)) + + name := "test-" + uniq() + r.Run(t, []string{ + "unikraft", "instance", "create", + "--output", "quiet", + "--name", name, + "--metro", r.Config.MetroName, + "--image", image, + "--plugin", "name=" + sandboxPlugin + ",rom=" + sandboxPluginRom, + "--memory", "512", + "--vcpus", "1", + "--autostart", + }) + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==running", name}) + + return name +} + +// remoteContents reads remote off the instance and returns its contents, +// through the plugin rather than through a shell on the instance. +func remoteContents(t *testing.T, r *integ.TestEnv, instName, remote string) string { + t.Helper() + + dir := t.TempDir() + r.Run(t, []string{"unikraft", "instance", "read", instName, remote, "./fetched"}, integ.WithWorkDir(dir)) + data, err := os.ReadFile(filepath.Join(dir, "fetched")) + require.NoError(t, err) + + return string(data) +} + +// shell runs one line through the shell's non-interactive mode, which drives +// the same engine and builtins the prompt does. +func shell(t *testing.T, r *integ.TestEnv, instName, line string, opts ...integ.CmdOption) string { + t.Helper() + + return r.Run(t, []string{"unikraft", "instance", "shell", instName, "-c", line}, opts...) +} + +func TestSandbox(t *testing.T) { + // One instance covers all of these: they only run commands on it. + t.Run("exec", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + + // Both output streams come back, stdout and stderr alike. + out := r.Run(t, []string{"unikraft", "instance", "exec", instName, "--", "sh", "-c", "echo to-stdout; echo to-stderr >&2"}) + assert.Contains(t, out, "to-stdout") + assert.Contains(t, out, "to-stderr") + + // The command is a command line by the time it reaches the plugin, so + // arguments the shell would otherwise split or expand are quoted first. + out = r.Run(t, []string{"unikraft", "instance", "exec", instName, "--", "echo", "two words", "*"}) + assert.Contains(t, out, "two words *") + + // Naming the plugin explicitly addresses the same one the default does. + out = r.Run(t, []string{"unikraft", "instance", "exec", instName, "--plugin", sandboxPlugin, "--", "echo", "named-plugin"}) + assert.Contains(t, out, "named-plugin") + + // --dir runs the command from that directory, and --env is the whole + // environment it sees. + out = r.Run(t, []string{"unikraft", "instance", "exec", instName, "--dir", "/etc", "--", "pwd"}) + assert.Contains(t, out, "/etc") + + out = r.Run(t, []string{"unikraft", "instance", "exec", instName, "--env", "GREETING=hi,WHO=exec", "--", "sh", "-c", "echo $GREETING-$WHO"}) + assert.Contains(t, out, "hi-exec") + + // Local standard input is fed to the remote command, and closing it is + // what lets a command reading to EOF finish. + out = r.Run(t, []string{"unikraft", "instance", "exec", instName, "--", "cat"}, integ.WithStdin("fed-by-stdin\n")) + assert.Contains(t, out, "fed-by-stdin") + + // --detach reports the command's id and leaves it running, so the + // output has to be collected afterwards. + detached := strings.TrimSpace(r.Run(t, []string{ + "unikraft", "instance", "exec", instName, "--detach", "--", + "sh", "-c", "sleep 2; echo ran-detached", + })) + assert.NotEmpty(t, detached) + assert.NotContains(t, detached, "ran-detached", "--detach must not wait for the output") + + out = r.Run(t, []string{"unikraft", "instance", "shell", instName, "-c", ":wait " + detached}) + assert.Contains(t, out, "exit 0") + out = r.Run(t, []string{"unikraft", "instance", "shell", instName, "-c", ":logs " + detached}) + assert.Contains(t, out, "ran-detached") + + // A file written by the shell is the same file the plugin reads, so both + // see one filesystem. + remote := "/sb-exec-" + uniq() + ".txt" + r.Run(t, []string{"unikraft", "instance", "exec", instName, "--", "sh", "-c", "echo written-by-the-shell > " + remote}) + assert.Equal(t, "written-by-the-shell\n", remoteContents(t, r, instName, remote)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("mkdir", func(t *testing.T) { + t.Run("create", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + dir := "/sb-mkdir-" + uniq() + local := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("in a created directory\n"), 0o644), + ).Apply(local)) + + out := r.Run(t, []string{"unikraft", "instance", "mkdir", instName, dir}) + assert.Contains(t, out, `created directory "`+dir+`"`) + + // A write into it only lands if the directory is really there. + r.Run(t, []string{"unikraft", "instance", "write", instName, "./payload.txt", dir + "/payload.txt"}, integ.WithWorkDir(local)) + assert.Equal(t, "in a created directory\n", remoteContents(t, r, instName, dir+"/payload.txt")) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("parents", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + dir := "/sb-mkdir-" + uniq() + "/nested/deep" + local := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("nested\n"), 0o644), + ).Apply(local)) + + r.Run(t, []string{"unikraft", "instance", "mkdir", instName, dir, "--parents"}) + + r.Run(t, []string{"unikraft", "instance", "write", instName, "./payload.txt", dir + "/payload.txt"}, integ.WithWorkDir(local)) + assert.Equal(t, "nested\n", remoteContents(t, r, instName, dir+"/payload.txt")) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + }) + + t.Run("write-read", func(t *testing.T) { + t.Run("roundtrip", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-write-" + uniq() + ".txt" + body := "written by the integration suite\n" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte(body), 0o644), + ).Apply(dir)) + + out := r.Run(t, []string{"unikraft", "instance", "write", instName, "./payload.txt", remote}, integ.WithWorkDir(dir)) + assert.Regexp(t, `file written`, out) + + out = r.Run(t, []string{"unikraft", "instance", "read", instName, remote, "./fetched.txt"}, integ.WithWorkDir(dir)) + assert.Regexp(t, `file read`, out) + + fetched, err := os.ReadFile(filepath.Join(dir, "fetched.txt")) + require.NoError(t, err) + assert.Equal(t, body, string(fetched)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("append", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-append-" + uniq() + ".txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("first.txt", []byte("first line\n"), 0o644), + fstest.CreateFile("second.txt", []byte("second line\n"), 0o644), + ).Apply(dir)) + + r.Run(t, []string{"unikraft", "instance", "write", instName, "./first.txt", remote}, integ.WithWorkDir(dir)) + r.Run(t, []string{"unikraft", "instance", "write", instName, "./second.txt", remote, "--append"}, integ.WithWorkDir(dir)) + assert.Equal(t, "first line\nsecond line\n", remoteContents(t, r, instName, remote)) + + // Without --append the next write replaces the file. + r.Run(t, []string{"unikraft", "instance", "write", instName, "./second.txt", remote}, integ.WithWorkDir(dir)) + assert.Equal(t, "second line\n", remoteContents(t, r, instName, remote)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("parents", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-write-" + uniq() + "/nested/payload.txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("nested\n"), 0o644), + ).Apply(dir)) + + r.Run(t, []string{"unikraft", "instance", "write", instName, "./payload.txt", remote, "--parents"}, integ.WithWorkDir(dir)) + assert.Equal(t, "nested\n", remoteContents(t, r, instName, remote)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + // A read defaults to the remote file's base name, and --force overwrites + // an existing local file. + t.Run("local-destination", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-read-" + uniq() + ".txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("remote contents\n"), 0o644), + ).Apply(dir)) + + r.Run(t, []string{"unikraft", "instance", "write", instName, "./payload.txt", remote}, integ.WithWorkDir(dir)) + + // No local path given: the remote base name, in the working directory. + r.Run(t, []string{"unikraft", "instance", "read", instName, remote}, integ.WithWorkDir(dir)) + fetched, err := os.ReadFile(filepath.Join(dir, filepath.Base(remote))) + require.NoError(t, err) + assert.Equal(t, "remote contents\n", string(fetched)) + + r.Run(t, []string{"unikraft", "instance", "read", instName, remote, "--force"}, integ.WithWorkDir(dir)) + + // A local path naming a directory is written into. + require.NoError(t, os.Mkdir(filepath.Join(dir, "into"), 0o755)) + r.Run(t, []string{"unikraft", "instance", "read", instName, remote, "./into"}, integ.WithWorkDir(dir)) + fetched, err = os.ReadFile(filepath.Join(dir, "into", filepath.Base(remote))) + require.NoError(t, err) + assert.Equal(t, "remote contents\n", string(fetched)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + }) + + t.Run("copy", func(t *testing.T) { + t.Run("upload-download", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-copy-" + uniq() + ".txt" + body := "copied by the integration suite\n" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte(body), 0o644), + ).Apply(dir)) + + out := r.Run(t, []string{"unikraft", "instance", "copy", "./payload.txt", instName + ":" + remote}, integ.WithWorkDir(dir)) + assert.Regexp(t, `file written`, out) + + out = r.Run(t, []string{"unikraft", "instance", "copy", instName + ":" + remote, "./fetched.txt"}, integ.WithWorkDir(dir)) + assert.Regexp(t, `file read`, out) + + fetched, err := os.ReadFile(filepath.Join(dir, "fetched.txt")) + require.NoError(t, err) + assert.Equal(t, body, string(fetched)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + // cp is the alias the command is reached by in scp's spelling. + t.Run("cp-alias", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-cp-" + uniq() + ".txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("via cp\n"), 0o644), + ).Apply(dir)) + + r.Run(t, []string{"unikraft", "instance", "cp", "./payload.txt", instName + ":" + remote}, integ.WithWorkDir(dir)) + assert.Equal(t, "via cp\n", remoteContents(t, r, instName, remote)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("into-local-directory", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-copy-" + uniq() + ".txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("into a directory\n"), 0o644), + ).Apply(dir)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "logs"), 0o755)) + + r.Run(t, []string{"unikraft", "instance", "copy", "./payload.txt", instName + ":" + remote}, integ.WithWorkDir(dir)) + r.Run(t, []string{"unikraft", "instance", "copy", instName + ":" + remote, "./logs/"}, integ.WithWorkDir(dir)) + + fetched, err := os.ReadFile(filepath.Join(dir, "logs", filepath.Base(remote))) + require.NoError(t, err) + assert.Equal(t, "into a directory\n", string(fetched)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("remote-parents", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + remote := "/sb-copy-" + uniq() + "/nested/payload.txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("nested copy\n"), 0o644), + ).Apply(dir)) + + r.Run(t, []string{"unikraft", "instance", "copy", "./payload.txt", instName + ":" + remote, "--parents"}, integ.WithWorkDir(dir)) + assert.Equal(t, "nested copy\n", remoteContents(t, r, instName, remote)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + // A destination naming an instance but no path keeps the local base name, + // the way "scp file host:" does. + t.Run("destination-without-path", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + local := "sb-copy-" + uniq() + ".txt" + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile(local, []byte("no remote path\n"), 0o644), + ).Apply(dir)) + + out := r.Run(t, []string{"unikraft", "instance", "copy", "./" + local, instName + ":"}, integ.WithWorkDir(dir)) + assert.Regexp(t, `file written`, out) + assert.Contains(t, out, local) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + // A target keeps its "/" qualifier and its "name:" prefix: the + // colon those end with is not the separator. + t.Run("qualified-target", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + dir := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("payload.txt", []byte("qualified\n"), 0o644), + ).Apply(dir)) + + metroRemote := "/sb-metro-" + uniq() + ".txt" + r.Run(t, []string{ + "unikraft", "instance", "copy", + "./payload.txt", r.Config.MetroName + "/" + instName + ":" + metroRemote, + }, integ.WithWorkDir(dir)) + assert.Equal(t, "qualified\n", remoteContents(t, r, instName, metroRemote)) + + nameRemote := "/sb-name-" + uniq() + ".txt" + r.Run(t, []string{ + "unikraft", "instance", "copy", + "./payload.txt", "name:" + instName + ":" + nameRemote, + }, integ.WithWorkDir(dir)) + assert.Equal(t, "qualified\n", remoteContents(t, r, instName, nameRemote)) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + }) + + t.Run("shell", func(t *testing.T) { + t.Run("commands", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + + // The session keeps its own state: the directory persists across + // statements and $? carries the instance's exit status. + assert.Contains(t, shell(t, r, instName, "cd /etc && pwd"), "/etc") + assert.Contains(t, shell(t, r, instName, "false; echo status=$?"), "status=1") + assert.Contains(t, shell(t, r, instName, "true && echo yes || echo no"), "yes") + + // Globs and file tests resolve on the instance, not here. The + // files are made here rather than assumed of the image, which is + // busybox and carries little. + globDir := "/sb-glob-" + uniq() + // Confirmed before it is relied on, so that a setup that did not + // happen reads as such rather than as a broken glob. + require.Contains(t, shell(t, r, instName, + "mkdir -p "+globDir+" && touch "+globDir+"/one.log "+globDir+"/two.log "+globDir+ + "/skip.txt && echo setup-ok"), "setup-ok") + + globbed := shell(t, r, instName, "cd "+globDir+"; echo *.log") + assert.Contains(t, globbed, "one.log") + assert.Contains(t, globbed, "two.log") + assert.NotContains(t, globbed, "skip.txt") + assert.Contains(t, shell(t, r, instName, "[ -d "+globDir+" ] && echo is-a-dir"), "is-a-dir") + + // A redirection writes through to the same filesystem the plugin sees. + remote := "/sb-shell-" + uniq() + ".txt" + shell(t, r, instName, "echo written-by-the-shell > "+remote) + assert.Equal(t, "written-by-the-shell\n", remoteContents(t, r, instName, remote)) + + // "host" runs on this machine, so it can be piped a remote command. + assert.Contains(t, shell(t, r, instName, "echo piped | host tr a-z A-Z"), "PIPED") + + // The environment is the instance's, not this machine's: seeded + // wrong, a bare cd would aim at the local home directory. Checked + // against the instance's own HOME, whatever the image sets. + assert.Contains(t, shell(t, r, instName, `cd /etc; cd; [ "$PWD" = "${HOME:-/}" ] && echo home-ok`), "home-ok") + assert.NotContains(t, shell(t, r, instName, "echo $HOME"), "/home/") + + // Standard input reaches a command that asks for it. + out := shell(t, r, instName, "cat", integ.WithStdin("fed-to-the-shell\n")) + assert.Contains(t, out, "fed-to-the-shell") + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("builtins", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + + // ":help" lists every builtin, which is also what Tab completes. + help := shell(t, r, instName, ":help") + for _, name := range []string{ + ":forget", ":get", ":help", ":jobs", ":kill", ":logs", ":mount", + ":restart", ":start", ":stop", ":suspend", ":unmount", ":volumes", ":wait", + } { + assert.Contains(t, help, name) + } + + // ":get" is the CLI's own get, pointed at this instance. + out := shell(t, r, instName, ":get") + assert.Contains(t, out, instName) + assert.Contains(t, out, "running") + + // ":history" is the engine's own, listed alongside the rest. With + // no prompt there is nothing to list, which is not a failure. + assert.Contains(t, help, ":history") + assert.Contains(t, shell(t, r, instName, ":history; echo status=$?"), "status=0") + + // ":volumes" is the CLI's own list. + assert.Contains(t, shell(t, r, instName, ":volumes"), "NAME") + + // A builtin is wired up like any other command, so its output can + // be piped onwards — to the instance or to this machine — and + // redirected or captured. + assert.Contains(t, shell(t, r, instName, ":help | grep mount"), ":mount") + assert.Contains(t, shell(t, r, instName, ":help | host grep -c ."), "1") + assert.Contains(t, shell(t, r, instName, `echo "[$(:get | head -1)]"`), "[") + + piped := "/sb-builtin-" + uniq() + ".txt" + shell(t, r, instName, ":help > "+piped) + assert.Contains(t, remoteContents(t, r, instName, piped), ":mount") + + // A name that is not a builtin says so rather than reaching the + // instance, and reports a failure through $?. + out = shell(t, r, instName, ":nope; echo status=$?") + assert.Contains(t, out, `unknown builtin "nope"`) + assert.Contains(t, out, "status=1") + + // The shell's own ":" null command is not a builtin. + assert.Contains(t, shell(t, r, instName, ": ; echo status=$?"), "status=0") + + // Arguments are checked before anything reaches the API. + assert.Contains(t, shell(t, r, instName, ":mount only-a-volume"), "mount needs a volume and a path") + assert.Contains(t, shell(t, r, instName, ":unmount"), "unmount needs a volume") + assert.Contains(t, shell(t, r, instName, ":kill"), "kill needs a command id") + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("jobs", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + + // A detached command is one the shell can still find afterwards. + id := strings.TrimSpace(r.Run(t, []string{ + "unikraft", "instance", "exec", instName, "--detach", "--", "sleep", "300", + })) + require.NotEmpty(t, id) + + assert.Contains(t, shell(t, r, instName, ":jobs"), id) + + // Signalling it is what ends it, and the status records the signal. + shell(t, r, instName, ":kill "+id) + assert.Contains(t, shell(t, r, instName, ":wait "+id), "exit -15") + + // Once forgotten the plugin no longer reports it. + shell(t, r, instName, ":forget "+id) + assert.NotContains(t, shell(t, r, instName, ":jobs"), id) + + // A command run in the foreground leaves no record behind, so the + // listing stays about the detached ones. + marker := "left-no-trace-" + uniq() + shell(t, r, instName, "echo "+marker) + assert.NotContains(t, shell(t, r, instName, ":jobs"), marker) + + // The shell backgrounds a statement itself with "&", and waits. + assert.Contains(t, shell(t, r, instName, "sleep 1 & echo started; wait; echo reaped"), "reaped") + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("lifecycle", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + + // Each lifecycle builtin is the CLI command pointed at this + // instance, so the state it leaves behind is what "wait" sees. + shell(t, r, instName, ":stop") + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==stopped", instName}) + + shell(t, r, instName, ":start") + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==running", instName}) + assert.Contains(t, shell(t, r, instName, "echo up-again"), "up-again") + + shell(t, r, instName, ":restart") + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==running", instName}) + + // No scale-to-zero here, so a suspend shows up as stopped. + shell(t, r, instName, ":suspend") + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==stopped", instName}) + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + }) + + t.Run("mount", func(t *testing.T) { + r := runner(t, true, []string{staging}) + instName := newSandboxInstance(t, r) + + volName := "test-" + uniq() + r.Run(t, []string{ + "unikraft", "volume", "create", + "--output", "quiet", + "--set", "name=" + volName, + "--set", "size=10", + "--set", "metro=" + r.Config.MetroName, + }) + + // A volume can only be attached while the instance is down. + shell(t, r, instName, ":stop") + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==stopped", instName}) + + // Both sides of the attachment are checked, the way the volume + // tests do it. Whether the guest surfaces the volume at /data is + // down to the image's rootfs and the metro, not to the builtin. + shell(t, r, instName, ":mount "+volName+" /data") + assert.Contains(t, shell(t, r, instName, ":get"), volName) + assert.Contains(t, r.Run(t, []string{"unikraft", "volume", "inspect", volName}), instName) + + shell(t, r, instName, ":unmount "+volName) + assert.NotContains(t, shell(t, r, instName, ":get"), volName) + assert.NotContains(t, r.Run(t, []string{"unikraft", "volume", "inspect", volName}), instName) + + // The instance still runs commands after all that. + shell(t, r, instName, ":start") + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==running", instName}) + assert.Contains(t, shell(t, r, instName, "echo still-here"), "still-here") + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + r.Run(t, []string{"unikraft", "volume", "delete", volName}) + }) + }) +} diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index 596812e3..8531f4a5 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -34,6 +34,18 @@ Resources: Forward a local port to an unexposed instance. history Show checkpoint history for an instance. + shell, sh + Open an interactive shell on a sandbox instance. + exec + Exec a command on a sandbox instance. + copy, cp + Copy a file to or from a sandbox instance. + write + Write a file to a sandbox instance. + read + Read a file from a sandbox instance. + mkdir + Create a directory on a sandbox instance. Templates: template, templates @@ -61,6 +73,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -132,6 +145,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -211,6 +225,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -292,6 +307,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -365,6 +381,13 @@ Examples: --metro fra \ --template my-template + # Create an instance with a plugin + unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin name=sandbox,rom=plugins/sandbox:latest + Fields: metro name @@ -383,6 +406,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -475,6 +499,9 @@ Create flags: --rom=image=,at= Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom= + Load plugin into the instance. + [examples: name=sandbox, rom=plugins/sandbox:latest] --service= Service group name or key. -p, --publish=:[/] @@ -535,6 +562,13 @@ Examples: --metro fra \ --template my-template + # Create an instance with a plugin + unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin name=sandbox,rom=plugins/sandbox:latest + Fields: metro name @@ -553,6 +587,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -645,6 +680,9 @@ Create flags: --rom=image=,at= Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom= + Load plugin into the instance. + [examples: name=sandbox, rom=plugins/sandbox:latest] --service= Service group name or key. -p, --publish=:[/] @@ -742,6 +780,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -836,6 +875,9 @@ Create flags: --rom=image=,at= Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom= + Load plugin into the instance. + [examples: name=sandbox, rom=plugins/sandbox:latest] --service= Service group name or key. -p, --publish=:[/] @@ -923,6 +965,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -1048,6 +1091,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -2334,3 +2378,128 @@ Global flags: [default: true] --timeout= ($UNIKRAFT_TIMEOUT) Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft instance shell --help + +The shell itself runs on your machine and interprets what you type, so +every command lands in one of three places: + + `:` prefixed a builtin, answered by the CLI — `:help` lists them + `host ` runs on your machine, in the directory you started from + anything else runs on the instance + +Session state — the working directory, variables, functions, `$?` — is +kept here, and so is everything the shell language does: pipelines, +redirections, globs and control flow. Paths, though, resolve against the +instance, so `cd`, `*.log` and `> file` all mean what you would expect. + +While a command runs it is the one reading your keyboard, so prompts like +`Do you want to continue? [Y/n]` can be answered — and anything typed ahead +goes to that command rather than to the next prompt. + +The instance offers no terminal, so programs that need one — `vim`, `top`, +`less` — will not work there yet; reach for `host` for those meanwhile. +A command that fails on the instance sets `$?` but does not fail the shell. + +Usage: + unikraft instances shell [flags] + +Arguments: + + Target instance to open a shell on. + +Examples: + # Open an interactive shell on a sandbox instance + unikraft instance shell my-instance + + # Start the shell in a specific working directory + unikraft instance shell my-instance --dir /var/lib/app + + # Run a single command line and exit + unikraft instance shell my-instance -c 'cd /var/log && ls *.log' + +Flags: + --plugin + Plugin name from the instance to run commands through. + --dir + Directory to start the shell in. + [default: /] + -e, --env== + Environment variables. + [examples: DEBUG=true, PORT=8080] + -c, --command + Run a single command line and exit. + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). + +$ unikraft instance exec --help + +Exec a command on a sandbox instance. + +Usage: + unikraft instances exec ... [flags] + +Arguments: + + Target instances to run the command on. + ... + Command to pass to the instance. + +Examples: + # Run a command on a sandbox instance + unikraft instance exec my-instance --plugin sandbox -- echo hello + + # Run a command in a specific working directory + unikraft instance exec my-instance --plugin sandbox --dir /var/lib/app -- ls - + la + + # Run a command with environment variables set + unikraft instance exec my-instance --plugin sandbox --env DEBUG=true,PORT=8080 -- + ./start.sh + +Flags: + --plugin + Plugin name from the instance to run the command onto. + -d, --detach + Start the command and print its id instead of waiting for it. + --dir + Directory to execute the command from. + -e, --env== + Environment variables. + [examples: DEBUG=true, PORT=8080] + +Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + --timeout= ($UNIKRAFT_TIMEOUT) + Set a deadline for the command (e.g. 30s, 5m, 1h). diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index da4fa741..943a5c7c 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -57,6 +57,7 @@ Fields: volumes, volumes.*, volumes.*.name, volumes.*.uuid, volumes.*.at, volumes.*.readonly, volumes.*.size roms, roms.*, roms.*.name, roms.*.image, roms.*.dir, roms.*.at + plugins, plugins.*, plugins.*.name, plugins.*.rom, plugins.*.config networks, networks.*, networks.*.uuid, networks.*.private-ip, networks.*.mac gpus, gpus.*, gpus.*.uuid, gpus.*.model timestamps, timestamps.created, timestamps.started, timestamps.stopped @@ -151,6 +152,9 @@ Create flags: --rom=image=,at= Attach ROM. [examples: image=myuser/my-rom:latest,at=/rom0,name=my-rom, dir=./mydata,at=/rom] + --plugin=name=,rom= + Load plugin into the instance. + [examples: name=sandbox, rom=plugins/sandbox:latest] --service= Service group name or key. -p, --publish=:[/] diff --git a/go.mod b/go.mod index 2f8af950..5d4ae12f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module unikraft.com/cli -go 1.26.2 +go 1.26.4 tool github.com/caarlos0/svu/v3 @@ -51,6 +51,7 @@ require ( gotest.tools/v3 v3.5.2 mvdan.cc/sh/v3 v3.13.1 sigs.k8s.io/yaml v1.6.0 + unikraft.com/cloud/plugins/sandbox v0.0.0-20260814150108-0d07a5aa09c6 unikraft.com/cloud/sdk v0.3.1-0.20260817110643-696a81b27ce2 unikraft.com/x/colors v0.0.0-20260813113709-544c471e0bc9 unikraft.com/x/filters v0.0.0-20260804153219-d1b47a40e047 @@ -66,6 +67,8 @@ require ( unikraft.com/x/version v0.0.0-20260819081122-82fdef94867a ) +require golang.org/x/term v0.45.0 // indirect + require ( charm.land/fang/v2 v2.0.1 // indirect dario.cat/mergo v1.0.2 // indirect diff --git a/go.sum b/go.sum index 683a2777..85f7319e 100644 --- a/go.sum +++ b/go.sum @@ -108,6 +108,8 @@ github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsx github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -491,6 +493,8 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw= tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4= +unikraft.com/cloud/plugins/sandbox v0.0.0-20260814150108-0d07a5aa09c6 h1:rFWHvhKdyHZFW3loHhH1ighy7qAs/v51EwePSocV77Q= +unikraft.com/cloud/plugins/sandbox v0.0.0-20260814150108-0d07a5aa09c6/go.mod h1:kyKc/Y88zfqPoz87hl+Bd6lSRqADqpXOnfMsZGkTNoA= unikraft.com/cloud/sdk v0.3.1-0.20260817110643-696a81b27ce2 h1:o8grUFAGil4kVkW6TzEO74ZxnyAUDVrhHSh2/F+pkgY= unikraft.com/cloud/sdk v0.3.1-0.20260817110643-696a81b27ce2/go.mod h1:/HDJPy+5ynXjrw+NFm18nAItVzStvGkG/KqYrYH/hgI= unikraft.com/x/colors v0.0.0-20260813113709-544c471e0bc9 h1:nhrTtS3nztz6r83s1tPPWxKapFaGjQ3rMZKR81XXJRE= diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 5ac3351f..7e687692 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -26,6 +26,7 @@ import ( "github.com/distribution/reference" "github.com/go-json-experiment/json/jsontext" "mvdan.cc/sh/v3/shell" + "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" "unikraft.com/cloud/sdk/platform/logs" @@ -65,6 +66,13 @@ type InstancesCmd struct { Restart InstancesRestartCmd `cmd:"" help:"Restart one or more instances."` Tunnel InstancesTunnelCmd `cmd:"" aliases:"port-forward" help:"Forward a local port to an unexposed instance."` History InstanceHistoryCmd `cmd:"" help:"Show checkpoint history for an instance."` + + Shell ShellSandboxInstanceCmd `cmd:"" aliases:"sh" help:"Open an interactive shell on a sandbox instance."` + Exec ExecSandboxInstanceCmd `cmd:"" help:"Exec a command on a sandbox instance."` + Copy CopySandboxInstanceCmd `cmd:"" aliases:"cp" help:"Copy a file to or from a sandbox instance."` + Write WriteSandboxInstanceCmd `cmd:"" help:"Write a file to a sandbox instance."` + Read ReadSandboxInstanceCmd `cmd:"" help:"Read a file from a sandbox instance."` + Mkdir MkdirSandboxInstanceCmd `cmd:"" help:"Create a directory on a sandbox instance."` } // InstanceCreateCmd extends the generic resource create command with shortcut @@ -93,6 +101,8 @@ type InstanceCreateCmd struct { Volume []InstanceVolume `group:"flag-create" shortcut:"volumes" short:"v" sep:"none" help:"Attach volume." placeholder:":[:]" example:"my-vol:/data,cache:/tmp:ro,data:/mnt:size=10GiB"` Rom []InstanceRom `group:"flag-create" shortcut:"roms" sep:"none" help:"Attach ROM." placeholder:"image=,at=" example:"image=myuser/my-rom:latest\\,at=/rom0\\,name=my-rom,dir=./mydata\\,at=/rom"` + Plugin []InstancePlugin `group:"flag-create" shortcut:"plugins" sep:"none" help:"Load plugin into the instance." placeholder:"name=,rom=" example:"name=sandbox,rom=plugins/sandbox:latest"` + Service InstanceService `group:"flag-create" shortcut:"service" help:"Service group name or key." placeholder:"name"` Publish []Service `group:"flag-create" shortcut:"service.services" short:"p" sep:"none" help:"Publish port." placeholder:":[/]" example:"443:8080/http+tls"` Domain []Domain `group:"flag-create" shortcut:"service.domains" sep:"none" help:"Service domain." placeholder:"fqdn" example:"example.com"` @@ -190,6 +200,7 @@ type Instance struct { Service *InstanceService `mirror:"instance.service_group" field:",embed" create:"set"` Volumes []*InstanceVolume `mirror:"instance.volumes" field:",embed" create:"set" edit:"add,del=strings"` Roms []*InstanceRom `mirror:"instance.roms" field:",embed" create:"set" edit:"set,add,del=strings"` + Plugins []*InstancePlugin `mirror:"instance.plugins" field:",embed" create:"set"` Networks []InstanceNetwork `mirror:"instance.network_interfaces" field:",embed"` Gpus []InstanceGpu `mirror:"instance.gpus" field:"gpus,embed"` @@ -440,6 +451,26 @@ func (r *InstanceRom) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, (*romJSON)(r)) } +// InstancePlugin represents a plugin loaded into an instance. +// Parsed via value.Parse as comma-separated key=value pairs: +// +// name=,image=[,=...] +type InstancePlugin struct { + Name string `name:"name" mirror:"name" json:"name" field:",long"` + Rom string `name:"rom" mirror:"rom" json:"rom" field:",long"` + Config string `name:"config" mirror:"config" json:"config,omitempty" field:",long"` +} + +func (p *InstancePlugin) UnmarshalText(data []byte) error { + type alias InstancePlugin + parsed, err := value.Parse[alias]([]string{string(data)}) + if err != nil { + return err + } + *p = InstancePlugin(parsed) + return nil +} + // inlineFilesFromDir walks a local directory and returns its contents as // base64-encoded InlineFile entries suitable for the platform API. func inlineFilesFromDir(dir string) ([]platform.InlineFile, error) { @@ -1143,6 +1174,21 @@ func (Instance) Create(ctx context.Context, fields []resource.Field) ([]resource } req.Roms = append(req.Roms, reqRom) } + case "plugins": + for _, plugin := range field.Create.Set.([]*InstancePlugin) { + reqPlugin := platform.CreateInstanceRequestPlugin{ + Name: plugin.Name, + Rom: platform.ImageReference(plugin.Rom), + } + if plugin.Config != "" { + var config any + if err := json.Unmarshal([]byte(plugin.Config), &config); err != nil { + return nil, fmt.Errorf("parsing plugin config: %w", err) + } + reqPlugin.Config = &config + } + req.Plugins = append(req.Plugins, reqPlugin) + } case "service": svc := field.Create.Set.(*InstanceService) if req.ServiceGroup == nil { @@ -1362,6 +1408,16 @@ func (Instance) Examples() map[cmd.CmdType][]kingkong.Example { --template my-template`, }, }, + { + Description: "Create an instance with a plugin", + Commands: []string{ + `unikraft instance create \ + --name demo-instance \ + --metro fra \ + --image nginx:latest \ + --plugin name=sandbox,rom=plugins/sandbox:latest`, + }, + }, }, cmd.CmdTypeEdit: { { diff --git a/internal/cmd/sandbox.go b/internal/cmd/sandbox.go new file mode 100644 index 00000000..5febea3d --- /dev/null +++ b/internal/cmd/sandbox.go @@ -0,0 +1,864 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2025, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). + +package cmd + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/x/term" + "mvdan.cc/sh/v3/syntax" + + "unikraft.com/cloud/plugins/sandbox" + "unikraft.com/cloud/sdk/platform" + "unikraft.com/cloud/sdk/platform/group" + "unikraft.com/x/kingkong" + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/multimetro" + "unikraft.com/cli/internal/shell" +) + +type ExecOpts struct { + Cmd []string `arg:"" name:"command" help:"Command to pass to the instance." placeholder:"cmd"` + + Plugin string `name:"plugin" help:"Plugin name from the instance to run the command onto"` + Detach bool `name:"detach" short:"d" help:"Start the command and print its id instead of waiting for it."` + Dir string `name:"dir" help:"Directory to execute the command from"` + Env map[string]string `name:"env" short:"e" help:"Environment variables." placeholder:"=" example:"DEBUG=true,PORT=8080" mapsep:","` +} + +type ExecSandboxInstanceCmd struct { + Target string `arg:"" name:"target" completion-predictor:"resource-key-instance" help:"Target instances to run the command on."` + + ExecOpts +} + +func (cmd ExecSandboxInstanceCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Run a command on a sandbox instance", + Commands: []string{ + "unikraft instance exec my-instance --plugin sandbox -- echo hello", + }, + }, + { + Description: "Run a command in a specific working directory", + Commands: []string{ + "unikraft instance exec my-instance --plugin sandbox --dir /var/lib/app -- ls -la", + }, + }, + { + Description: "Run a command with environment variables set", + Commands: []string{ + "unikraft instance exec my-instance --plugin sandbox --env DEBUG=true,PORT=8080 -- ./start.sh", + }, + }, + } +} + +func (c *ExecSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio) error { + target, err := resolveSandboxTarget(ctx, c.Target, c.Plugin) + if err != nil { + return err + } + + in := stdio.Stdin + fd, isFile := in.(interface{ Fd() uintptr }) + if in == nil || (isFile && term.IsTerminal(fd.Fd())) { + in = strings.NewReader("") + } + + // The status is the remote command's, not this command's: it reached the + // instance, so sending it succeeded. + _, err = execSandboxInstance(ctx, shell.Streams{In: in, Out: stdio.Stdout, Err: stdio.Stdout}, target, c.ExecOpts) + return err +} + +type sandboxTarget struct { + client *sandbox.Client + instance platform.Instance + opts []sandbox.Option + + key multimetro.Key + plugin string +} + +// allowStopped lets the shell open on an instance that is not up yet, so that +// its ":start" builtin has something to act on. +const allowStopped = true + +func resolveSandboxTarget(ctx context.Context, target, plugin string, stopped ...bool) (sandboxTarget, error) { + if plugin == "" { + plugin = sandbox.PluginName + } + + key := multimetro.ParseKey(target) + + resources, opErr := Instance{}.Get(ctx, []string{key.String()}) + if len(resources) == 0 { + if opErr != nil { + return sandboxTarget{}, opErr + } + return sandboxTarget{}, fmt.Errorf("instance %q not found", target) + } + + instance, ok := resources[0].(Instance) + if !ok { + return sandboxTarget{}, fmt.Errorf("%q is not an instance", target) + } + + if len(stopped) == 0 || !stopped[0] { + if err := requireRunningInstance(instance); err != nil { + return sandboxTarget{}, err + } + } + + if err := requirePlugin(instance, plugin); err != nil { + return sandboxTarget{}, err + } + + g, err := multimetro.NewClient(ctx) + if err != nil { + return sandboxTarget{}, err + } + + key = instance.Key().(multimetro.Key) + return group.CollectMetro(ctx, g, key.Metro, func(ctx context.Context, c multimetro.MetroClient) (sandboxTarget, error) { + return sandboxTarget{ + client: c.Sandbox, + instance: multimetro.SandboxInstance(key), + opts: c.SandboxOpts(plugin), + key: key, + plugin: plugin, + }, nil + }) +} + +func requireRunningInstance(instance Instance) error { + switch platform.InstanceState(instance.State) { + case platform.InstanceStateRunning, platform.InstanceStateStandby: + return nil + default: + return fmt.Errorf("instance %q is not running (state: %s); start it with \"unikraft instance start %s\"", instance.Name, string(instance.State), instance.Name) + } +} + +func requirePlugin(instance Instance, plugin string) error { + var loaded []string + for _, p := range instance.Plugins { + if p == nil || p.Name == "" { + continue + } + if p.Name == plugin { + return nil + } + loaded = append(loaded, p.Name) + } + + if len(loaded) == 0 { + return fmt.Errorf("instance %q has no plugins loaded, so nothing answers to %q", instance.Name, plugin) + } + return fmt.Errorf("instance %q has no plugin named %q; it has: %s", instance.Name, plugin, strings.Join(loaded, ", ")) +} + +const sandboxLogPollInterval = 100 * time.Millisecond + +func decodeSandboxPayload(s string) []byte { + decoded, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return []byte(s) + } + return decoded +} + +func buildExecCommand(cmdArgs []string) (string, error) { + quoted := make([]string, 0, len(cmdArgs)) + for _, arg := range cmdArgs { + q, err := syntax.Quote(arg, syntax.LangBash) + if err != nil { + return "", fmt.Errorf("cannot quote command argument %q: %w", arg, err) + } + quoted = append(quoted, q) + } + return strings.Join(quoted, " "), nil +} + +// execSandboxInstance runs a command on the instance and reports the status it +// exited with. +func execSandboxInstance(ctx context.Context, streams shell.Streams, target sandboxTarget, opts ExecOpts) (int, error) { + log.G(ctx).Trace().Msg("executing command") + + cmdline, err := buildExecCommand(opts.Cmd) + if err != nil { + return 0, err + } + + req := sandbox.RunCommandRequest{ + Cmd: cmdline, + } + if opts.Dir != "" { + req.Cwd = &opts.Dir + } + if len(opts.Env) > 0 { + req.Env = &opts.Env + } + + execResp, err := target.client.RunCommand(ctx, target.instance, &req, target.opts...) + if err != nil { + return 0, fmt.Errorf("failed to start command: %w", err) + } + if execResp.Data == nil { + return 0, fmt.Errorf("failed to start command: the %q plugin did not report a command UUID", target.plugin) + } + cmdUUID := execResp.Data.Uuid + + // A detached command outlives this call, so neither the interrupt below nor + // the log poll applies to it. + if opts.Detach { + fmt.Fprintln(streams.Out, cmdUUID) + return 0, nil + } + + defer func() { + if ctx.Err() == nil { + return + } + signalCtx, cancelSignal := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancelSignal() + if _, sigErr := target.client.SignalCommand(signalCtx, target.instance, cmdUUID, &sandbox.CommandSignalRequest{ + Signal: int(syscall.SIGINT), + }, target.opts...); sigErr != nil { + log.G(ctx).Debug().Err(sigErr).Str("cmd", cmdUUID).Msg("failed to signal remote command") + } + }() + + if streams.In != nil { + feedCtx, cancelFeed := context.WithCancel(ctx) + defer cancelFeed() + go feedSandboxStdin(feedCtx, target, cmdUUID, streams.In) + } + + logs := &commandLogs{target: target, cmdUUID: cmdUUID, out: streams.Out, errOut: streams.Err} + + log.G(ctx).Trace(). + Str("cmd", cmdUUID). + Str("cmdline", cmdline). + Msg("waiting for command") + + done := make(chan error, 1) + go func() { + _, err := target.client.WaitForCommand(ctx, target.instance, cmdUUID, target.opts...) + done <- err + }() + + for { + select { + case <-ctx.Done(): + log.G(ctx).Trace().Str("cmd", cmdUUID).Msg("context cancelled, exiting wait") + return 0, ctx.Err() + + case waitErr := <-done: + if waitErr != nil { + if ctx.Err() != nil { + return 0, ctx.Err() + } + return 0, fmt.Errorf("failed waiting for command: %w", waitErr) + } + + if err := logs.fetchAndPrint(ctx); err != nil { + return 0, err + } + + inspectCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + inspectResp, err := target.client.GetCommandByUuid(inspectCtx, target.instance, cmdUUID, target.opts...) + cancel() + if err != nil { + return 0, fmt.Errorf("failed to inspect command: %w", err) + } + if inspectResp.Data == nil { + return 0, nil + } + log.G(ctx).Trace(). + Str("cmd", cmdUUID). + Int32("exitcode", inspectResp.Data.Exitcode). + Msg("command finished") + + // The record is spent once its output and status have been read, + // and the plugin keeps every one of them otherwise. Detached + // commands are the exception, and return above. + forgetSandboxCommand(ctx, target, cmdUUID) + return int(inspectResp.Data.Exitcode), nil + + case <-time.After(sandboxLogPollInterval): + if err := logs.fetchAndPrint(ctx); err != nil { + return 0, err + } + } + } +} + +type commandLogs struct { + target sandboxTarget + cmdUUID string + out, errOut io.Writer + + stdoutOffset, stderrOffset uint64 +} + +func (l *commandLogs) fetchAndPrint(ctx context.Context) error { + log.G(ctx).Trace(). + Str("cmd", l.cmdUUID). + Uint64("stdout_offset", l.stdoutOffset). + Uint64("stderr_offset", l.stderrOffset). + Msg("fetching logs") + + req := sandbox.CommandLogsRequest{ + Stdout: sandbox.CommandLogsRange{Offset: l.stdoutOffset}, + Stderr: sandbox.CommandLogsRange{Offset: l.stderrOffset}, + } + fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + resp, err := l.target.client.GetCommandLogs(fetchCtx, l.target.instance, l.cmdUUID, &req, l.target.opts...) + if err != nil { + return fmt.Errorf("failed to fetch logs: %w", err) + } + if resp.Data == nil { + return nil + } + + log.G(ctx).Trace(). + Str("cmd", l.cmdUUID). + Uint64("stdout_offset", l.stdoutOffset). + Uint64("stderr_offset", l.stderrOffset). + Bool("has_stdout", resp.Data.Stdout != ""). + Bool("has_stderr", resp.Data.Stderr != ""). + Uint64("stdout_available", resp.Data.StdoutAvailable). + Uint64("stderr_available", resp.Data.StderrAvailable). + Msg("polled command logs") + + for _, stream := range []struct { + data string + available uint64 + offset *uint64 + out io.Writer + }{ + {resp.Data.Stdout, resp.Data.StdoutAvailable, &l.stdoutOffset, l.out}, + {resp.Data.Stderr, resp.Data.StderrAvailable, &l.stderrOffset, l.errOut}, + } { + if stream.data == "" { + continue + } + decoded := decodeSandboxPayload(stream.data) + fmt.Fprint(stream.out, string(decoded)) + if stream.available > 0 { + *stream.offset = stream.available + } else { + *stream.offset += uint64(len(decoded)) + } + } + return nil +} + +func feedSandboxStdin(ctx context.Context, target sandboxTarget, cmdUUID string, in io.Reader) { + write := func(data string, eof bool) error { + req := sandbox.CommandStdinRequest{Data: data, Eof: &eof} + _, err := target.client.WriteCommandStdin(ctx, target.instance, cmdUUID, &req, target.opts...) + return err + } + + buf := make([]byte, 32*1024) + for { + if ctx.Err() != nil { + return + } + + n, readErr := in.Read(buf) + if n > 0 { + if err := write(base64.StdEncoding.EncodeToString(buf[:n]), false); err != nil { + log.G(ctx).Warn().Err(err).Str("cmd", cmdUUID).Msg("failed to send standard input to the command") + return + } + } + + if readErr != nil { + if ctx.Err() != nil { + return + } + if err := write("", true); err != nil { + log.G(ctx).Warn().Err(err).Str("cmd", cmdUUID).Msg("failed to close the command's standard input") + } + return + } + } +} + +type WriteSandboxInstanceCmd struct { + Target string `arg:"" name:"target" completion-predictor:"resource-key-instance" help:"Target instance to write the file to."` + Local string `arg:"" name:"local" help:"Local file path to read from."` + Remote string `arg:"" name:"remote" help:"Remote destination path on the instance."` + + Plugin string `name:"plugin" help:"Plugin name from the instance to write the file to."` + Append bool `name:"append" help:"Append to the remote file instead of overwriting it."` + Parents bool `name:"parents" help:"Create parent directories on the remote path if they don't already exist."` +} + +func (cmd WriteSandboxInstanceCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Write a local file to a sandbox instance", + Commands: []string{ + "unikraft instance write my-instance ./config.json /etc/app/config.json", + }, + }, + { + Description: "Write a file, creating parent directories as needed", + Commands: []string{ + "unikraft instance write my-instance ./data.bin /var/lib/app/data.bin --parents", + }, + }, + } +} + +func (c *WriteSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio) error { + info, err := os.Stat(c.Local) + if err != nil { + return fmt.Errorf("reading local file %q: %w", c.Local, err) + } + if info.IsDir() { + return fmt.Errorf("%q is a directory: only single files can be written", c.Local) + } + + target, err := resolveSandboxTarget(ctx, c.Target, c.Plugin) + if err != nil { + return err + } + + opts := uploadOpts{ + local: c.Local, + remote: c.Remote, + appendFile: c.Append, + parents: c.Parents, + } + + remote, err := uploadSandboxFile(ctx, target, opts) + if err != nil { + return err + } + + log.G(ctx).Info(). + Str("source", c.Local). + Str("remote", remote). + Msg("file written") + return nil +} + +type uploadOpts struct { + local string + remote string + appendFile bool + parents bool +} + +func uploadSandboxFile(ctx context.Context, target sandboxTarget, opts uploadOpts) (string, error) { + data, err := os.ReadFile(opts.local) + if err != nil { + return "", fmt.Errorf("reading local file %q: %w", opts.local, err) + } + + if opts.parents { + if err := mkdirSandboxInstance(ctx, target, path.Dir(opts.remote), true); err != nil { + return "", fmt.Errorf("creating parent directories: %w", err) + } + } + + remote := opts.remote + if err := writeSandboxFile(ctx, target, remote, data, opts.appendFile); err != nil { + if !strings.Contains(err.Error(), "Is a directory") { + return "", err + } + remote = path.Join(remote, filepath.Base(opts.local)) + if err := writeSandboxFile(ctx, target, remote, data, opts.appendFile); err != nil { + return "", err + } + } + + return remote, nil +} + +func writeSandboxFile(ctx context.Context, target sandboxTarget, remotePath string, data []byte, appendFile bool) error { + log.G(ctx).Trace().Msg("writing file") + + req := sandbox.WriteFileRequest{ + Path: remotePath, + Append: appendFile, + Encoding: sandbox.FileEncodingBase64, + Data: base64.StdEncoding.EncodeToString(data), + } + if _, err := target.client.WriteFile(ctx, target.instance, &req, target.opts...); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + return nil +} + +type ReadSandboxInstanceCmd struct { + Target string `arg:"" name:"target" completion-predictor:"resource-key-instance" help:"Target instance to read the file from."` + Remote string `arg:"" name:"remote" help:"Remote file path to read."` + Local string `arg:"" name:"local" optional:"" help:"Local destination path to write the file to. Defaults to the remote file's base name."` + + Plugin string `name:"plugin" help:"Plugin name from the instance to read the file from."` + Force bool `name:"force" help:"Overwrite the local file if it already exists."` +} + +func (cmd ReadSandboxInstanceCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Read a file from a sandbox instance", + Commands: []string{ + "unikraft instance read my-instance /etc/app/config.json ./config.json", + }, + }, + { + Description: "Read a file into the current directory", + Commands: []string{ + "unikraft instance read my-instance /var/log/app.log", + }, + }, + } +} + +func (c *ReadSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio) error { + target, err := resolveSandboxTarget(ctx, c.Target, c.Plugin) + if err != nil { + return err + } + + opts := downloadOpts{ + remote: c.Remote, + local: c.Local, + force: c.Force, + } + + local, size, err := downloadSandboxFile(ctx, target, opts) + if err != nil { + return err + } + + log.G(ctx).Info(). + Str("remote", c.Remote). + Str("local", local). + Int("size", size). + Msg("file read") + return nil +} + +type downloadOpts struct { + remote string + local string + force bool +} + +func downloadSandboxFile(ctx context.Context, target sandboxTarget, opts downloadOpts) (string, int, error) { + data, err := readSandboxFile(ctx, target, opts.remote) + if err != nil { + return "", 0, err + } + + local := opts.local + if local == "" { + local = path.Base(opts.remote) + } else if info, err := os.Stat(local); err == nil && info.IsDir() { + local = filepath.Join(local, path.Base(opts.remote)) + } + + if !opts.force { + if _, err := os.Stat(local); err == nil { + return "", 0, fmt.Errorf("local file %q already exists (use --force to overwrite)", local) + } + } + + if err := os.WriteFile(local, data, 0o644); err != nil { + return "", 0, fmt.Errorf("writing local file %q: %w", local, err) + } + + return local, len(data), nil +} + +func readSandboxFile(ctx context.Context, target sandboxTarget, remotePath string) ([]byte, error) { + log.G(ctx).Trace().Msg("reading file") + + req := sandbox.ReadFileRequest{ + Path: remotePath, + } + resp, err := target.client.ReadFile(ctx, target.instance, &req, target.opts...) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + if resp.Data == nil { + return nil, fmt.Errorf("failed to read file: the %q plugin returned no contents", target.plugin) + } + + return decodeSandboxPayload(resp.Data.Contents), nil +} + +const copyPathSeparator = ":" + +func parseCopyPath(spec string) (target, filePath string) { + i := strings.Index(spec, copyPathSeparator) + if i < 0 { + return "", spec + } + + head := spec[:i+len(copyPathSeparator)] + for _, prefix := range []string{multimetro.KeyNamePrefix, multimetro.KeyUUIDPrefix} { + if head == prefix || strings.HasSuffix(head, multimetro.MetroKeySeparator+prefix) { + j := strings.Index(spec[i+len(copyPathSeparator):], copyPathSeparator) + if j < 0 { + return "", spec + } + i += len(copyPathSeparator) + j + break + } + } + + target, filePath = spec[:i], spec[i+len(copyPathSeparator):] + + if target == "" || strings.HasPrefix(target, "/") || strings.HasPrefix(target, ".") || strings.HasPrefix(target, "~") { + return "", spec + } + + return target, filePath +} + +type CopySandboxInstanceCmd struct { + Source string `arg:"" name:"source" help:"File to copy from, either a local path or :."` + Destination string `arg:"" name:"destination" help:"Where to copy it to, either a local path or :."` + + Plugin string `name:"plugin" help:"Plugin name from the instance to copy through."` + Force bool `name:"force" help:"Overwrite the local file if it already exists."` + Parents bool `name:"parents" help:"Create parent directories on the remote path if they don't already exist."` +} + +func (cmd CopySandboxInstanceCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Copy a local file to a sandbox instance", + Commands: []string{ + "unikraft instance copy ./config.json my-instance:/etc/app/config.json", + }, + }, + { + Description: "Copy a file off a sandbox instance", + Commands: []string{ + "unikraft instance copy my-instance:/var/log/app.log ./app.log", + }, + }, + { + Description: "Copy a file into a directory, keeping its name", + Commands: []string{ + "unikraft instance copy my-instance:/var/log/app.log ./logs/", + }, + }, + { + Description: "Copy a file to an instance in a specific metro, creating parent directories as needed", + Commands: []string{ + "unikraft instance copy ./data.bin fra0/my-instance:/var/lib/app/data.bin --parents", + }, + }, + } +} + +func (c *CopySandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio) error { + srcTarget, srcPath := parseCopyPath(c.Source) + dstTarget, dstPath := parseCopyPath(c.Destination) + + switch { + case srcTarget != "" && dstTarget != "": + return fmt.Errorf("cannot copy from one instance to another: copy %q to a local path first", c.Source) + + case srcTarget == "" && dstTarget == "": + return fmt.Errorf("neither %q nor %q names an instance: a path on an instance is written as %s", c.Source, c.Destination, copyPathSeparator) + + case dstTarget != "": + if dstPath == "" { + dstPath = filepath.Base(srcPath) + } + + info, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("reading local file %q: %w", srcPath, err) + } + if info.IsDir() { + return fmt.Errorf("%q is a directory: only single files can be copied", srcPath) + } + + target, err := resolveSandboxTarget(ctx, dstTarget, c.Plugin) + if err != nil { + return err + } + + opts := uploadOpts{ + local: srcPath, + remote: dstPath, + parents: c.Parents, + } + + remote, err := uploadSandboxFile(ctx, target, opts) + if err != nil { + return err + } + + log.G(ctx).Info(). + Str("source", srcPath). + Str("remote", remote). + Msg("file written") + return nil + + default: + target, err := resolveSandboxTarget(ctx, srcTarget, c.Plugin) + if err != nil { + return err + } + + opts := downloadOpts{ + remote: srcPath, + local: dstPath, + force: c.Force, + } + + local, size, err := downloadSandboxFile(ctx, target, opts) + if err != nil { + return err + } + + log.G(ctx).Info(). + Str("remote", srcPath). + Str("local", local). + Int("size", size). + Msg("file read") + return nil + } +} + +type MkdirSandboxInstanceCmd struct { + Target string `arg:"" name:"target" completion-predictor:"resource-key-instance" help:"Target instance to create the directory on."` + Path string `arg:"" name:"path" help:"Remote directory path to create."` + + Plugin string `name:"plugin" help:"Plugin name from the instance to create the directory on."` + Parents bool `name:"parents" short:"p" help:"Create parent directories as needed."` +} + +func (cmd MkdirSandboxInstanceCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Create a directory on a sandbox instance", + Commands: []string{ + "unikraft instance mkdir my-instance /var/lib/app", + }, + }, + { + Description: "Create a nested directory, including any missing parents", + Commands: []string{ + "unikraft instance mkdir my-instance /var/lib/app/data --parents", + }, + }, + } +} + +func (c *MkdirSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio) error { + target, err := resolveSandboxTarget(ctx, c.Target, c.Plugin) + if err != nil { + return err + } + if err := mkdirSandboxInstance(ctx, target, c.Path, c.Parents); err != nil { + return err + } + + fmt.Fprintf(stdio.Stdout, "created directory %q\n", c.Path) + return nil +} + +func mkdirSandboxInstance(ctx context.Context, target sandboxTarget, path string, parents bool) error { + log.G(ctx).Trace().Msg("creating directory") + + req := sandbox.MkdirRequest{ + Path: path, + Parents: parents, + } + if _, err := target.client.CreateDirectory(ctx, target.instance, &req, target.opts...); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + return nil +} + +// listSandboxCommands returns the ids of every command the plugin still knows +// about, most recently started last. +func listSandboxCommands(ctx context.Context, target sandboxTarget) ([]string, error) { + resp, err := target.client.ListCommands(ctx, target.instance, target.opts...) + if err != nil { + return nil, fmt.Errorf("failed to list commands: %w", err) + } + if resp.Data == nil { + return nil, nil + } + return resp.Data.Commands, nil +} + +func inspectSandboxCommand(ctx context.Context, target sandboxTarget, uuid string) (*sandbox.GetCommandData, error) { + resp, err := target.client.GetCommandByUuid(ctx, target.instance, uuid, target.opts...) + if err != nil { + return nil, fmt.Errorf("failed to inspect command %s: %w", uuid, err) + } + if resp.Data == nil { + return nil, fmt.Errorf("no such command: %s", uuid) + } + return resp.Data, nil +} + +func signalSandboxCommand(ctx context.Context, target sandboxTarget, uuid string, signal int) error { + req := sandbox.CommandSignalRequest{Signal: signal} + if _, err := target.client.SignalCommand(ctx, target.instance, uuid, &req, target.opts...); err != nil { + return fmt.Errorf("failed to signal command %s: %w", uuid, err) + } + return nil +} + +// printSandboxCommandLogs writes everything a command has produced so far. +func printSandboxCommandLogs(ctx context.Context, target sandboxTarget, uuid string, out, errOut io.Writer) error { + logs := &commandLogs{target: target, cmdUUID: uuid, out: out, errOut: errOut} + return logs.fetchAndPrint(ctx) +} + +func waitForSandboxCommand(ctx context.Context, target sandboxTarget, uuid string) (int, error) { + if _, err := target.client.WaitForCommand(ctx, target.instance, uuid, target.opts...); err != nil { + return 0, fmt.Errorf("failed waiting for command %s: %w", uuid, err) + } + + data, err := inspectSandboxCommand(ctx, target, uuid) + if err != nil { + return 0, err + } + return int(data.Exitcode), nil +} + +// forgetSandboxCommand drops a finished command's record, best effort: it is +// only housekeeping, and the command itself has already been accounted for. +func forgetSandboxCommand(ctx context.Context, target sandboxTarget, uuid string) { + dropCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + + if _, err := target.client.DeleteCommandByUuid(dropCtx, target.instance, uuid, target.opts...); err != nil { + log.G(ctx).Debug().Err(err).Str("cmd", uuid).Msg("could not drop the command record") + } +} diff --git a/internal/cmd/sandbox_test.go b/internal/cmd/sandbox_test.go new file mode 100644 index 00000000..1128229b --- /dev/null +++ b/internal/cmd/sandbox_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestParseCopyPath pins how a copy specification is split into an instance +// target and a path. An empty target means the whole specification is a local +// path. +func TestParseCopyPath(t *testing.T) { + for _, tt := range []struct { + name string + spec string + target string + path string + }{ + // Nothing to split: no separator at all. + {"empty", "", "", ""}, + {"bare-name", "file.txt", "", "file.txt"}, + {"relative-path", "./a.txt", "", "./a.txt"}, + {"absolute-path", "/tmp/x", "", "/tmp/x"}, + + // A plain target and the path after its separator. + {"plain-target", "my-inst:/tmp/x", "my-inst", "/tmp/x"}, + {"relative-remote-path", "my-inst:relative/path", "my-inst", "relative/path"}, + {"single-character-target", "a:/tmp/x", "a", "/tmp/x"}, + {"metro-qualified-target", "fra0/my-inst:/tmp/x", "fra0/my-inst", "/tmp/x"}, + + // The separator is the first colon that is not a prefix's own, so + // later colons stay in the remote path. + {"colon-in-remote-path", "my-inst:/tmp/a:b", "my-inst", "/tmp/a:b"}, + + // A "name:" or "uuid:" prefix owns the colon it ends with. + {"name-prefixed-target", "name:my-inst:/tmp/x", "name:my-inst", "/tmp/x"}, + {"uuid-prefixed-target", "uuid:abc123:/tmp/x", "uuid:abc123", "/tmp/x"}, + {"metro-and-name-prefixed", "fra0/name:my-inst:/tmp/x", "fra0/name:my-inst", "/tmp/x"}, + {"metro-and-uuid-prefixed", "fra0/uuid:abc:/tmp/x", "fra0/uuid:abc", "/tmp/x"}, + {"prefixed-colon-in-remote-path", "uuid:abc:/p:q", "uuid:abc", "/p:q"}, + + // A target with no path keeps the separator, as "scp file host:" does. + {"target-without-path", "my-inst:", "my-inst", ""}, + {"metro-qualified-without-path", "fra0/my-inst:", "fra0/my-inst", ""}, + {"name-prefixed-without-path", "name:my-inst:", "name:my-inst", ""}, + + // A prefix with no second colon carries no path, so the whole + // specification is a local one. + {"name-prefix-without-path", "name:my-inst", "", "name:my-inst"}, + {"uuid-prefix-without-path", "uuid:abc123", "", "uuid:abc123"}, + {"bare-name-prefix", "name:", "", "name:"}, + + // A specification that opens like a filesystem path is a local file + // whose name happens to carry a colon. + {"colon-in-relative-path", "./back:up.tar", "", "./back:up.tar"}, + {"colon-in-parent-path", "../up:x.txt", "", "../up:x.txt"}, + {"colon-in-home-path", "~/back:up.tar", "", "~/back:up.tar"}, + {"colon-in-absolute-path", "/tmp/a:b", "", "/tmp/a:b"}, + {"leading-separator", ":/tmp/x", "", ":/tmp/x"}, + } { + t.Run(tt.name, func(t *testing.T) { + target, path := parseCopyPath(tt.spec) + assert.Equal(t, tt.target, target, "target") + assert.Equal(t, tt.path, path, "path") + }) + } +} diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go new file mode 100644 index 00000000..5a9b1176 --- /dev/null +++ b/internal/cmd/shell.go @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "strconv" + "syscall" + + "github.com/MakeNowJust/heredoc" + + "unikraft.com/x/kingkong" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/resource" + "unikraft.com/cli/internal/resource/cmd" + "unikraft.com/cli/internal/shell" +) + +type ShellSandboxInstanceCmd struct { + Target string `arg:"" name:"target" completion-predictor:"resource-key-instance" help:"Target instance to open a shell on."` + + Plugin string `name:"plugin" help:"Plugin name from the instance to run commands through."` + Dir string `name:"dir" help:"Directory to start the shell in." default:"/"` + Env map[string]string `name:"env" short:"e" help:"Environment variables." placeholder:"=" example:"DEBUG=true,PORT=8080" mapsep:","` + Command string `name:"command" short:"c" help:"Run a single command line and exit."` +} + +func (ShellSandboxInstanceCmd) Help() string { + return heredoc.Docf(` + The shell itself runs on your machine and interprets what you type, so + every command lands in one of three places: + + %[1]s:%[1]s prefixed a builtin, answered by the CLI — %[1]s:help%[1]s lists them + %[1]shost %[1]s runs on your machine, in the directory you started from + anything else runs on the instance + + Session state — the working directory, variables, functions, %[1]s$?%[1]s — is + kept here, and so is everything the shell language does: pipelines, + redirections, globs and control flow. Paths, though, resolve against the + instance, so %[1]scd%[1]s, %[1]s*.log%[1]s and %[1]s> file%[1]s all mean what you would expect. + + While a command runs it is the one reading your keyboard, so prompts like + %[1]sDo you want to continue? [Y/n]%[1]s can be answered — and anything typed ahead + goes to that command rather than to the next prompt. + + The instance offers no terminal, so programs that need one — %[1]svim%[1]s, %[1]stop%[1]s, + %[1]sless%[1]s — will not work there yet; reach for %[1]shost%[1]s for those meanwhile. + A command that fails on the instance sets %[1]s$?%[1]s but does not fail the shell. + `, "`") +} + +func (ShellSandboxInstanceCmd) Examples() []kingkong.Example { + return []kingkong.Example{ + { + Description: "Open an interactive shell on a sandbox instance", + Commands: []string{ + "unikraft instance shell my-instance", + }, + }, + { + Description: "Start the shell in a specific working directory", + Commands: []string{ + "unikraft instance shell my-instance --dir /var/lib/app", + }, + }, + { + Description: "Run a single command line and exit", + Commands: []string{ + `unikraft instance shell my-instance -c 'cd /var/log && ls *.log'`, + }, + }, + } +} + +func (c *ShellSandboxInstanceCmd) Run(ctx context.Context, stdio config.Stdio, sandbox *resource.Sandbox) error { + target, err := resolveSandboxTarget(ctx, c.Target, c.Plugin, allowStopped) + if err != nil { + return err + } + + return shell.Run(ctx, shell.Config{ + Instance: c.Target, + Dir: c.Dir, + Env: c.Env, + Command: c.Command, + Transport: sandboxTransport{target: target}, + Builtins: shellBuiltins{key: target.key.String(), target: target, sandbox: sandbox}, + }, stdio) +} + +// sandboxTransport is the shell's link to the instance. A remote terminal will +// arrive as a Pty method here, which the shell picks up by type assertion. +type sandboxTransport struct { + target sandboxTarget +} + +func (t sandboxTransport) Exec(ctx context.Context, streams shell.Streams, dir string, env map[string]string, args []string) (int, error) { + return execSandboxInstance(ctx, streams, t.target, ExecOpts{ + Cmd: args, + Dir: dir, + Env: env, + }) +} + +func (t sandboxTransport) ReadFile(ctx context.Context, path string) ([]byte, error) { + return readSandboxFile(ctx, t.target, path) +} + +func (t sandboxTransport) WriteFile(ctx context.Context, path string, data []byte, appendFile bool) error { + return writeSandboxFile(ctx, t.target, path, data, appendFile) +} + +// shellBuiltins answers the lines opening with ":". They live here because this +// is the only package that can reach Instance{}, Volume{} and the CLI's own +// commands, whose output they reuse verbatim rather than reformatting. +type shellBuiltins struct { + key string + target sandboxTarget + sandbox *resource.Sandbox +} + +type shellBuiltin struct { + args string + help string + run func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error +} + +// The lifecycle verbs have no home in the resource model — it covers only +// get/list/edit/create/delete — so they go through the commands instead. +var shellBuiltinTable map[string]shellBuiltin + +func init() { + // Assigned here rather than inline: ":help" reads the table it lives in. + shellBuiltinTable = map[string]shellBuiltin{ + "get": {help: "Inspect this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return (&cmd.ResourceGetCmd[Instance]{Targets: []string{b.key}}).Run(ctx, stdio, b.sandbox) + }}, + "start": {help: "Start this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return (&InstancesStartCmd{Targets: []string{b.key}}).Run(ctx, stdio) + }}, + "stop": {help: "Stop this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return (&InstancesStopCmd{Targets: []string{b.key}}).Run(ctx, stdio) + }}, + "restart": {help: "Restart this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return (&InstancesRestartCmd{Targets: []string{b.key}}).Run(ctx, stdio) + }}, + "suspend": {help: "Suspend this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return (&InstancesSuspendCmd{Targets: []string{b.key}}).Run(ctx, stdio) + }}, + "mount": {args: " [ro]", help: "Attach a volume to this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error { + if len(args) < 2 { + return fmt.Errorf("mount needs a volume and a path") + } + return (&VolumeAttachCmd{ + Volume: args[0], + To: b.key, + At: args[1], + Readonly: len(args) > 2 && args[2] == "ro", + }).Run(ctx, stdio, b.sandbox) + }}, + "unmount": {args: "", help: "Detach a volume from this instance.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error { + if len(args) < 1 { + return fmt.Errorf("unmount needs a volume") + } + return (&VolumeDetachCmd{Volume: args[0], From: b.key}).Run(ctx, stdio, b.sandbox) + }}, + "volumes": {help: "List volumes.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return (&cmd.ResourceListCmd[Volume]{}).Run(ctx, stdio, b.sandbox) + }}, + "jobs": {help: "List commands the instance is still holding.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, _ []string) error { + return b.listJobs(ctx, stdio.Stdout) + }}, + "logs": {args: "", help: "Show what a command has printed so far.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error { + if len(args) < 1 { + return fmt.Errorf("logs needs a command id") + } + return printSandboxCommandLogs(ctx, b.target, args[0], stdio.Stdout, stdio.Stderr) + }}, + "kill": {args: " [signal]", help: "Signal a command, SIGTERM by default.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error { + if len(args) < 1 { + return fmt.Errorf("kill needs a command id") + } + signal := int(syscall.SIGTERM) + if len(args) > 1 { + n, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("signal must be a number: %w", err) + } + signal = n + } + return signalSandboxCommand(ctx, b.target, args[0], signal) + }}, + "wait": {args: "", help: "Wait for a command and report its status.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error { + if len(args) < 1 { + return fmt.Errorf("wait needs a command id") + } + code, err := waitForSandboxCommand(ctx, b.target, args[0]) + if err != nil { + return err + } + fmt.Fprintf(stdio.Stdout, "exit %d\n", code) + return nil + }}, + "forget": {args: "", help: "Drop a finished command's record.", run: func(b shellBuiltins, ctx context.Context, stdio config.Stdio, args []string) error { + if len(args) < 1 { + return fmt.Errorf("forget needs a command id") + } + forgetSandboxCommand(ctx, b.target, args[0]) + return nil + }}, + "help": {help: "List these builtins.", run: func(b shellBuiltins, _ context.Context, stdio config.Stdio, _ []string) error { + b.printHelp(stdio.Stdout) + return nil + }}, + } +} + +func (b shellBuiltins) Names() []string { + return slices.Sorted(maps.Keys(shellBuiltinTable)) +} + +func (b shellBuiltins) Run(ctx context.Context, streams shell.Streams, args []string) (int, error) { + builtin, ok := shellBuiltinTable[args[0]] + if !ok { + return 0, fmt.Errorf("unknown builtin %q; try \":help\"", args[0]) + } + + stdio := config.Stdio{Stdin: streams.In, Stdout: streams.Out, Stderr: streams.Err} + if err := builtin.run(b, ctx, stdio, args[1:]); err != nil { + return 1, err + } + return 0, nil +} + +func (b shellBuiltins) printHelp(out io.Writer) { + fmt.Fprintln(out, "Builtins run on this CLI rather than the instance:") + for _, name := range b.Names() { + usage := ":" + name + if args := shellBuiltinTable[name].args; args != "" { + usage += " " + args + } + fmt.Fprintf(out, " %-28s %s\n", usage, shellBuiltinTable[name].help) + } +} + +// listJobs shows what the instance is still holding. The plugin reports no +// running flag, so a command that has not finished simply has no status yet. +func (b shellBuiltins) listJobs(ctx context.Context, out io.Writer) error { + uuids, err := listSandboxCommands(ctx, b.target) + if err != nil { + return err + } + if len(uuids) == 0 { + fmt.Fprintln(out, "no commands") + return nil + } + + fmt.Fprintf(out, "%-38s %-6s %s\n", "ID", "EXIT", "COMMAND") + for _, uuid := range uuids { + data, err := inspectSandboxCommand(ctx, b.target, uuid) + if err != nil { + fmt.Fprintf(out, "%-38s %-6s %s\n", uuid, "?", err) + continue + } + fmt.Fprintf(out, "%-38s %-6d %s\n", data.Uuid, data.Exitcode, data.Cmdline) + } + return nil +} diff --git a/internal/cmd/shell_test.go b/internal/cmd/shell_test.go new file mode 100644 index 00000000..1d5c45fa --- /dev/null +++ b/internal/cmd/shell_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "unikraft.com/cli/internal/shell" +) + +// TestShellBuiltinNames pins what Tab offers and what ":help" lists. +func TestShellBuiltinNames(t *testing.T) { + names := shellBuiltins{}.Names() + + assert.Equal(t, []string{ + "forget", "get", "help", "jobs", "kill", "logs", "mount", "restart", + "start", "stop", "suspend", "unmount", "volumes", "wait", + }, names, "sorted, so completion and help are stable") + + for _, name := range names { + assert.NotEmpty(t, shellBuiltinTable[name].help, "%s needs help text", name) + assert.NotNil(t, shellBuiltinTable[name].run, "%s needs a run", name) + } +} + +// TestShellBuiltinHelp covers ":help", the one builtin that reaches nothing +// outside this package. +func TestShellBuiltinHelp(t *testing.T) { + var out bytes.Buffer + code, err := shellBuiltins{}.Run(t.Context(), shell.Streams{Out: &out}, []string{"help"}) + + require.NoError(t, err) + assert.Zero(t, code) + + printed := out.String() + assert.Contains(t, printed, ":mount [ro]") + assert.Contains(t, printed, ":kill [signal]") + // The engine appends its own builtins and the routing rule. + assert.NotContains(t, printed, "runs on the instance") + assert.Contains(t, printed, "Detach a volume from this instance.") + for _, name := range (shellBuiltins{}).Names() { + assert.Contains(t, printed, ":"+name) + } +} + +// TestShellHelpMentionsBuiltins keeps the command help honest about how to find +// them. +func TestShellHelpMentionsBuiltins(t *testing.T) { + assert.Contains(t, ShellSandboxInstanceCmd{}.Help(), ":help") +} diff --git a/internal/cmd/testdata/TestOutput/instances b/internal/cmd/testdata/TestOutput/instances index 7b05d20a..4ef5d09f 100644 --- a/internal/cmd/testdata/TestOutput/instances +++ b/internal/cmd/testdata/TestOutput/instances @@ -81,6 +81,7 @@ roms: - name: my-rom image: myuser/my-rom:latest at: /rom +plugins: networks: - uuid: net-uuid-1234 private-ip: 192.168.1.10 @@ -603,6 +604,34 @@ fra my-instance running nginx ["arg1", "arg2"] 256MiB 2 example.uni "del": null } }, + { + "name": "plugins", + "elem": { + "name": "", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "rom", + "value": "", + "verbosity": "long" + }, + { + "name": "config", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + }, + "verbosity": "long", + "create": { + "set": null + } + }, { "name": "networks", "subfields": [ diff --git a/internal/integration/env.go b/internal/integration/env.go index 62d21ce9..0635538a 100644 --- a/internal/integration/env.go +++ b/internal/integration/env.go @@ -59,6 +59,7 @@ type CmdOption func(*cmdConfig) type cmdConfig struct { workDir string + stdin string expectFail bool allowFail bool timeout time.Duration @@ -69,6 +70,11 @@ func WithWorkDir(dir string) CmdOption { return func(c *cmdConfig) { c.workDir = dir } } +// WithStdin feeds in to the command's standard input, which is otherwise empty. +func WithStdin(in string) CmdOption { + return func(c *cmdConfig) { c.stdin = in } +} + func ExpectFail() CmdOption { return func(c *cmdConfig) { c.expectFail = true } } @@ -118,6 +124,9 @@ func (env *TestEnv) RunRaw(t *testing.T, args []string, opts ...CmdOption) (stri c.Stdout = &output c.Stderr = &output c.Dir = cfg.workDir + if cfg.stdin != "" { + c.Stdin = strings.NewReader(cfg.stdin) + } c.Env = os.Environ() c.Env = slices.DeleteFunc(c.Env, func(s string) bool { return strings.HasPrefix(s, "UNIKRAFT_") diff --git a/internal/multimetro/client.go b/internal/multimetro/client.go index 98a326d2..bd4dcaaf 100644 --- a/internal/multimetro/client.go +++ b/internal/multimetro/client.go @@ -8,7 +8,9 @@ package multimetro import ( "context" "fmt" + "net/http" + "unikraft.com/cloud/plugins/sandbox" "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" "unikraft.com/x/iata" @@ -21,7 +23,33 @@ import ( type MetroClient struct { platform.Client - Metro config.Metro + Sandbox *sandbox.Client + Metro config.Metro + + // sandboxHTTPClient is the HTTP client sandbox plugin calls on this metro + // are made with. The plugin client is built from the platform client's + // options, which carry no HTTP client of their own, so it is handed to the + // plugin per call instead. + sandboxHTTPClient *http.Client +} + +// SandboxOpts returns the options for a call against the named plugin on this +// metro. An empty plugin name leaves the plugin the client was built with. +func (c MetroClient) SandboxOpts(plugin string) []sandbox.Option { + opts := make([]sandbox.Option, 0, 2) + if c.sandboxHTTPClient != nil { + opts = append(opts, sandbox.WithHTTPClient(c.sandboxHTTPClient)) + } + if plugin != "" { + opts = append(opts, sandbox.WithPluginName(plugin)) + } + return opts +} + +// SandboxInstance addresses the plugin endpoint of the instance a key refers +// to: a plugin client reaches its plugin through the instance's UUID alone. +func SandboxInstance(key Key) platform.Instance { + return platform.Instance{Uuid: key.Ref().UUID} } func NewClient(ctx context.Context) (*group.Group[MetroClient], error) { @@ -42,14 +70,20 @@ func NewClient(ctx context.Context) (*group.Group[MetroClient], error) { } g := group.New[MetroClient]() for _, metro := range metros { - client := platform.NewClient( - platform.WithHTTPClient(httpclient.GetClient(ptr.ZeroIfNil(metro.Insecure))), + httpClient := httpclient.GetClient(ptr.ZeroIfNil(metro.Insecure)) + copts := []platform.ClientOption{ + platform.WithHTTPClient(httpClient), platform.WithToken(profile.Token), platform.WithDefaultMetro(metro.Endpoint), - ) + } g = g.WithClient( metro.Name, - MetroClient{Client: client, Metro: metro}, + MetroClient{ + Client: platform.NewClient(copts...), + Sandbox: sandbox.NewClient(copts...), + Metro: metro, + sandboxHTTPClient: httpClient, + }, ) } diff --git a/internal/resource/value/parse.go b/internal/resource/value/parse.go index 1b4fef54..c10644c0 100644 --- a/internal/resource/value/parse.go +++ b/internal/resource/value/parse.go @@ -18,6 +18,53 @@ import ( xmaps "unikraft.com/cli/internal/x/maps" ) +// splitTopLevel splits s on commas, keeping those inside balanced {} or [] +// pairs and inside JSON string literals, so a value carrying JSON survives. +func splitTopLevel(s string) []string { + var parts []string + var buf strings.Builder + depth := 0 + inStr := false + esc := false + for _, r := range s { + if inStr { + buf.WriteRune(r) + if esc { + esc = false + } else if r == '\\' { + esc = true + } else if r == '"' { + inStr = false + } + continue + } + switch r { + case '"': + inStr = true + buf.WriteRune(r) + case '{', '[': + depth++ + buf.WriteRune(r) + case '}', ']': + depth-- + buf.WriteRune(r) + case ',': + if depth == 0 { + parts = append(parts, buf.String()) + buf.Reset() + } else { + buf.WriteRune(r) + } + default: + buf.WriteRune(r) + } + } + if buf.Len() > 0 { + parts = append(parts, buf.String()) + } + return parts +} + func Parse[T any](input []string) (T, error) { var t T output, err := ParseNew(input, t) @@ -188,12 +235,14 @@ func parseReflect(input []string, value reflect.Value) error { notFound := make(map[string]struct{}) for _, input := range input { process: - for item := range strings.SplitSeq(input, ",") { + for _, item := range splitTopLevel(input) { item = strings.TrimSpace(item) if item == "" { continue } k, v, _ := strings.Cut(item, "=") + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) for i := range s.NumField() { field := s.Type().Field(i) diff --git a/internal/shell/editor.go b/internal/shell/editor.go new file mode 100644 index 00000000..987d3f3c --- /dev/null +++ b/internal/shell/editor.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "fmt" + "io" + "strings" + "unicode" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// Named keys, in a private use area so they cannot collide with a typed rune. +const ( + keyUp rune = 0xe000 + iota + keyDown + keyLeft + keyRight + keyHome + keyEnd + keyDelete + keyWordLeft + keyWordRight +) + +// editor is the line buffer and its drawing. The line is rendered here rather +// than by a library because none of them offer a way to colour what they draw +// without repainting the whole prompt on every keystroke. +type editor struct { + out io.Writer + isBuiltin func(string) bool + + buf []rune + pos int + + prompt string + promptWidth int + width int + + // cursorRow and cursorCol are where the cursor sat in the last frame, the + // row counted down from the one the prompt starts on. prev is the line as + // it was then, so a redraw can start at the first thing that changed. + cursorRow, cursorCol int + prev string + drawn bool +} + +func (e *editor) start(prompt string, width int, line string) { + e.prompt, e.promptWidth = prompt, ansi.StringWidth(prompt) + e.width = max(width, 1) + e.buf = []rune(line) + e.pos = len(e.buf) + e.cursorRow, e.cursorCol, e.prev, e.drawn = 0, 0, "", false +} + +func (e *editor) line() string { return string(e.buf) } + +func (e *editor) setLine(line string) { + e.buf = []rune(line) + e.pos = len(e.buf) +} + +// render redraws in a single write, starting at the first thing that changed +// rather than reprinting the prompt every keystroke. Every position is measured +// on the plain text, so the colour cannot move the cursor. +func (e *editor) render() { + plain := string(e.buf) + spans := highlightSpans(plain, e.isBuiltin) + + var b strings.Builder + from := 0 + if e.drawn { + from = spanStart(spans, commonPrefixLen(e.prev, plain)) + e.moveTo(&b, e.column(plain[:from])) + b.WriteString("\x1b[J") + } else { + b.WriteString("\r\x1b[J") + b.WriteString(e.prompt) + } + + for _, s := range spans { + switch { + case s.end <= from: + case s.start >= from: + b.WriteString(s.text) + default: + // Only an unstyled run can be entered partway, and its text is the + // line itself, so the remainder goes out as-is. + b.WriteString(plain[from:s.end]) + } + } + + end := e.column(plain) + // Content landing exactly on the margin leaves the cursor in a place the + // terminal has not decided about yet; a space forces the wrap. + if end > 0 && end%e.width == 0 { + b.WriteString(" \r") + } + + e.cursorRow, e.cursorCol = end/e.width, end%e.width + e.moveTo(&b, e.column(string(e.buf[:e.pos]))) + e.prev, e.drawn = plain, true + + io.WriteString(e.out, b.String()) //nolint:errcheck // a terminal that will not take a frame cannot be told about it +} + +// column is the display column just past text, counting the prompt. +func (e *editor) column(text string) int { + return e.promptWidth + ansi.StringWidth(text) +} + +// moveTo walks the cursor from where the last frame left it to col, and records +// where it now is. +func (e *editor) moveTo(b *strings.Builder, col int) { + row, at := col/e.width, col%e.width + + switch { + case row < e.cursorRow: + fmt.Fprintf(b, "\x1b[%dA", e.cursorRow-row) + case row > e.cursorRow: + fmt.Fprintf(b, "\x1b[%dB", row-e.cursorRow) + } + b.WriteString("\r") + if at > 0 { + fmt.Fprintf(b, "\x1b[%dC", at) + } + e.cursorRow, e.cursorCol = row, at +} + +// commonPrefixLen is how much of two lines is byte-for-byte the same, rounded +// back to a rune boundary. +func commonPrefixLen(a, b string) int { + n := min(len(a), len(b)) + i := 0 + for i < n && a[i] == b[i] { + i++ + } + for i > 0 && i < len(a) && !utf8.RuneStart(a[i]) { + i-- + } + return i +} + +// finish leaves the cursor on a fresh line below the finished one. +func (e *editor) finish() { + if rows := e.column(string(e.buf))/e.width - e.cursorRow; rows > 0 { + fmt.Fprintf(e.out, "\x1b[%dB", rows) + } + fmt.Fprint(e.out, "\r\n") + e.drawn = false +} + +func (e *editor) insert(r rune) { + e.buf = append(e.buf, 0) + copy(e.buf[e.pos+1:], e.buf[e.pos:]) + e.buf[e.pos] = r + e.pos++ +} + +func (e *editor) deleteBack() { + if e.pos > 0 { + e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...) + e.pos-- + } +} + +func (e *editor) deleteForward() { + if e.pos < len(e.buf) { + e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...) + } +} + +func (e *editor) killToEnd() { e.buf = e.buf[:e.pos] } +func (e *editor) killToStart() { e.buf = e.buf[e.pos:]; e.pos = 0 } + +func (e *editor) killWordBack() { + start := e.wordStart() + e.buf = append(e.buf[:start], e.buf[e.pos:]...) + e.pos = start +} + +// transpose swaps the two characters around the cursor, as Ctrl-T does. +func (e *editor) transpose() { + if len(e.buf) < 2 { + return + } + at := min(e.pos, len(e.buf)-1) + if at == 0 { + at = 1 + } + e.buf[at-1], e.buf[at] = e.buf[at], e.buf[at-1] + e.pos = min(at+1, len(e.buf)) +} + +// wordStart is where the word before the cursor begins, skipping any spaces +// between it and the cursor. +func (e *editor) wordStart() int { + i := e.pos + for i > 0 && unicode.IsSpace(e.buf[i-1]) { + i-- + } + for i > 0 && !unicode.IsSpace(e.buf[i-1]) { + i-- + } + return i +} + +func (e *editor) wordEnd() int { + i := e.pos + for i < len(e.buf) && unicode.IsSpace(e.buf[i]) { + i++ + } + for i < len(e.buf) && !unicode.IsSpace(e.buf[i]) { + i++ + } + return i +} + +func (e *editor) move(to int) { e.pos = min(max(to, 0), len(e.buf)) } diff --git a/internal/shell/editor_test.go b/internal/shell/editor_test.go new file mode 100644 index 00000000..0a4b7bb6 --- /dev/null +++ b/internal/shell/editor_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "bytes" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func knownBuiltin(name string) bool { return name == "jobs" || name == "start" } + +// TestHighlight pins what gets colour. Each expected piece must be immediately +// preceded by an SGR terminator, so a piece that merely appears in the output +// uncoloured does not pass. +func TestHighlight(t *testing.T) { + for _, tt := range []struct { + name string + line string + coloured []string + }{ + {"empty", "", nil}, + {"plain-command", "ls -la", nil}, + {"single-quoted", "echo 'a string'", []string{"'a string'"}}, + {"double-quoted", `echo "a string"`, []string{`"a string"`}}, + {"unterminated-quote", "echo 'half", []string{"'half"}}, + {"pipe-and-redirect", "a | b > c", []string{"|", ">"}}, + {"and-or", "a && b || c", []string{"&", "|"}}, + {"dollar", "echo $HOME", []string{"$"}}, + {"builtin", ":jobs", []string{":jobs"}}, + {"builtin-with-arguments", ":start now", []string{":start"}}, + {"builtin-after-spaces", " :jobs", []string{":jobs"}}, + {"builtin-after-a-tab", "\t:jobs", []string{":jobs"}}, + {"builtin-mid-line", "x :jobs", []string{":jobs"}}, + {"specials-inside-a-quote-stay-plain", "echo '| & ;'", []string{"'| & ;'"}}, + } { + t.Run(tt.name, func(t *testing.T) { + got := highlight(tt.line, knownBuiltin) + + assert.Equal(t, tt.line, ansi.Strip(got), "the text must survive unchanged") + if len(tt.coloured) == 0 { + assert.Equal(t, tt.line, got, "nothing to colour, so nothing added") + return + } + for _, want := range tt.coloured { + at := strings.Index(got, want) + require.GreaterOrEqual(t, at, 0, "%q missing entirely", want) + assert.True(t, at > 0 && got[at-1] == 'm', "%q is not coloured, in %q", want, got) + } + }) + } +} + +// newTestEditor builds an editor writing to a buffer, wide enough not to wrap. +func newTestEditor(width int) (*editor, *bytes.Buffer) { + var out bytes.Buffer + e := &editor{out: &out, isBuiltin: knownBuiltin} + e.start("$ ", width, "") + return e, &out +} + +// TestEditorKeys drives every editing operation the prompt offers. +func TestEditorKeys(t *testing.T) { + type step func(*editor) + typed := func(s string) step { + return func(e *editor) { + for _, r := range s { + e.insert(r) + } + } + } + + for _, tt := range []struct { + name string + steps []step + want string + pos int + }{ + {"insert", []step{typed("hello")}, "hello", 5}, + {"insert-mid-line", []step{typed("hllo"), func(e *editor) { e.move(1) }, typed("e")}, "hello", 2}, + {"backspace", []step{typed("hello"), (*editor).deleteBack}, "hell", 4}, + {"delete-forward", []step{typed("hello"), func(e *editor) { e.move(0) }, (*editor).deleteForward}, "ello", 0}, + {"kill-to-end", []step{typed("hello there"), func(e *editor) { e.move(5) }, (*editor).killToEnd}, "hello", 5}, + {"kill-to-start", []step{typed("hello there"), func(e *editor) { e.move(6) }, (*editor).killToStart}, "there", 0}, + {"kill-word-back", []step{typed("one two three"), (*editor).killWordBack}, "one two ", 8}, + {"kill-word-back-over-spaces", []step{typed("one two "), (*editor).killWordBack}, "one ", 4}, + {"transpose", []step{typed("ab"), (*editor).transpose}, "ba", 2}, + {"word-left", []step{typed("one two"), func(e *editor) { e.move(e.wordStart()) }}, "one two", 4}, + {"word-right", []step{typed("one two"), func(e *editor) { e.move(0) }, func(e *editor) { e.move(e.wordEnd()) }}, "one two", 3}, + } { + t.Run(tt.name, func(t *testing.T) { + e, _ := newTestEditor(80) + for _, s := range tt.steps { + s(e) + } + assert.Equal(t, tt.want, e.line()) + assert.Equal(t, tt.pos, e.pos) + }) + } +} + +// TestEditorRender pins the frame: one write, no cursor query, and the geometry +// taken from the plain text so colour cannot move the cursor. +func TestEditorRender(t *testing.T) { + t.Run("one-write-and-no-query", func(t *testing.T) { + e, out := newTestEditor(80) + e.insert('l') + e.render() + + assert.NotContains(t, out.String(), "\x1b[6n", "must never ask the terminal where the cursor is") + assert.Contains(t, out.String(), "$ ") + }) + + t.Run("cursor-column-follows-the-plain-text", func(t *testing.T) { + e, out := newTestEditor(80) + // A quoted string is coloured, so the frame is far longer than the text. + for _, r := range "echo 'x'" { + e.insert(r) + } + e.move(6) + out.Reset() + e.render() + + // prompt "$ " is 2 wide, cursor sits at index 6 of the line. + assert.Contains(t, out.String(), "\x1b[8C") + }) + + t.Run("wraps-onto-a-second-row", func(t *testing.T) { + e, out := newTestEditor(10) + for _, r := range strings.Repeat("x", 15) { + e.insert(r) + } + out.Reset() + e.render() + + // 2 + 15 = 17 columns over a width of 10: the cursor lands on row 1. + assert.Equal(t, 1, e.cursorRow) + }) + + t.Run("forces-the-wrap-on-an-exact-margin", func(t *testing.T) { + e, out := newTestEditor(10) + for _, r := range strings.Repeat("x", 8) { + e.insert(r) + } + out.Reset() + e.render() + + // 2 + 8 == the width exactly, so the wrap has to be forced. + assert.Contains(t, out.String(), " \r") + assert.Equal(t, 1, e.cursorRow) + }) + + t.Run("does-not-reprint-the-prompt", func(t *testing.T) { + e, out := newTestEditor(80) + for _, r := range "echo hello" { + e.insert(r) + } + e.render() + + out.Reset() + e.insert('!') + e.render() + + // Only the word being typed is repainted, never the prompt. + assert.NotContains(t, out.String(), "$ ") + assert.Less(t, out.Len(), 40, "got %q", out.String()) + }) + + t.Run("repaints-from-the-span-that-changed", func(t *testing.T) { + e, out := newTestEditor(80) + for _, r := range "echo 'a'" { + e.insert(r) + } + e.render() + + out.Reset() + e.move(7) // between the a and the closing quote + e.insert('x') + e.render() + + // The change is inside the quoted run, so the whole run comes back + // with its colour rather than a bare character. + assert.Contains(t, ansi.Strip(out.String()), "'ax'") + assert.Contains(t, out.String(), "\x1b[", "the run keeps its colour") + }) +} diff --git a/internal/shell/exec.go b/internal/shell/exec.go new file mode 100644 index 00000000..66d1c572 --- /dev/null +++ b/internal/shell/exec.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "slices" + "strings" + "time" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + +const ( + // builtinSigil opens a line the CLI answers itself rather than the instance. + builtinSigil = ":" + // hostCommand runs the rest of the line on this machine. + hostCommand = "host" + + // The conventional statuses a shell reports for these three cases. + statusUsage = 2 + statusInterrupted = 130 + statusNotFound = 127 +) + +// isBuiltin matches ":name" but not the shell's own ":" null command, which +// tokenises to a bare sigil. +func isBuiltin(word string) bool { + return len(word) > len(builtinSigil) && strings.HasPrefix(word, builtinSigil) +} + +// route decides where every external command runs. It answers each one itself, +// so the default handler further down the chain is never reached. +func (s *session) route(_ interp.ExecHandlerFunc) interp.ExecHandlerFunc { + return func(ctx context.Context, args []string) error { + switch { + case isBuiltin(args[0]): + return s.runBuiltin(ctx, args) + case args[0] == hostCommand: + return s.runLocal(ctx, args[1:]) + default: + return s.runRemote(ctx, args) + } + } +} + +func (s *session) runRemote(ctx context.Context, args []string) error { + hc := interp.HandlerCtx(ctx) + + // Give the command its own cancellation so the terminal comes back the + // moment it exits, rather than when the whole statement finishes. + ctx, done := context.WithCancel(ctx) + defer done() + + streams := streamsOf(hc) + streams.In = s.commandStdin(ctx, streams.In) + + code, err := s.cfg.Transport.Exec(ctx, streams, hc.Dir, exported(hc.Env), args) + if err != nil { + if ctx.Err() != nil { + return interp.ExitStatus(statusInterrupted) + } + fmt.Fprintln(hc.Stderr, errorStyle.Render(err.Error())) + return interp.ExitStatus(1) + } + return exitStatus(code) +} + +func (s *session) runLocal(ctx context.Context, args []string) error { + hc := interp.HandlerCtx(ctx) + if len(args) == 0 { + fmt.Fprintln(hc.Stderr, errorStyle.Render(hostCommand+": needs a command to run on this machine")) + return interp.ExitStatus(statusUsage) + } + + // The session's directory and environment describe the instance, so a local + // command gets this machine's instead. + bin, err := interp.LookPathDir(s.localDir, expand.ListEnviron(os.Environ()...), args[0]) + if err != nil { + fmt.Fprintln(hc.Stderr, errorStyle.Render(err.Error())) + return interp.ExitStatus(statusNotFound) + } + + // The copier feeding a lent terminal cannot be called off, so bound how + // long Wait will sit on it once the process itself is gone. + ctx, done := context.WithCancel(ctx) + defer done() + + cmd := exec.CommandContext(ctx, bin, args[1:]...) + cmd.Args = args + cmd.Dir = s.localDir + cmd.Env = os.Environ() + cmd.WaitDelay = time.Second + cmd.Stdin, cmd.Stdout, cmd.Stderr = s.commandStdin(ctx, hc.Stdin), hc.Stdout, hc.Stderr + + err = cmd.Run() + var exitErr *exec.ExitError + switch { + case err == nil: + return nil + case errors.As(err, &exitErr): + return exitStatus(exitErr.ExitCode()) + case ctx.Err() != nil: + return interp.ExitStatus(statusInterrupted) + default: + fmt.Fprintln(hc.Stderr, errorStyle.Render(err.Error())) + return interp.ExitStatus(statusNotFound) + } +} + +// sessionBuiltinNames are the ones the engine answers itself, because they are +// about this session rather than about the instance. +var sessionBuiltinNames = []string{"history"} + +func (s *session) runBuiltin(ctx context.Context, args []string) error { + hc := interp.HandlerCtx(ctx) + streams := streamsOf(hc) + + args = append([]string{strings.TrimPrefix(args[0], builtinSigil)}, args[1:]...) + if slices.Contains(sessionBuiltinNames, args[0]) { + s.runSessionBuiltin(streams, args) + return nil + } + + if s.cfg.Builtins == nil { + fmt.Fprintln(hc.Stderr, errorStyle.Render("unknown builtin: "+args[0])) + return interp.ExitStatus(statusNotFound) + } + + code, err := s.cfg.Builtins.Run(ctx, streams, args) + if err != nil { + fmt.Fprintln(hc.Stderr, errorStyle.Render(err.Error())) + if code == 0 { + code = 1 + } + } + if args[0] == "help" && err == nil { + s.printSessionBuiltins(streams.Out) + } + return exitStatus(code) +} + +// runSessionBuiltin answers the engine's own builtins. None of them can fail: +// they only report what the session already knows. +func (s *session) runSessionBuiltin(streams Streams, args []string) { + switch args[0] { + case "history": + if s.editor == nil { + return + } + for i, line := range s.editor.history { + fmt.Fprintf(streams.Out, "%5d %s\n", i+1, line) + } + } +} + +// printSessionBuiltins closes out ":help" with the engine's own builtins and +// the routing rule, both of which belong to the shell rather than to the CLI. +func (s *session) printSessionBuiltins(out io.Writer) { + fmt.Fprintf(out, " %-28s %s\n", builtinSigil+"history", "List what this session has run.") + fmt.Fprintf(out, "\nEverything else runs on the instance; prefix with %q to run it here.\n", hostCommand) +} + +// interceptCd resolves cd itself. The builtin validates its target with a host +// syscall that no handler can replace, so it can never reach a directory that +// only exists on the instance. CallHandler, unlike ExecHandlers, sees builtins. +func (s *session) interceptCd(ctx context.Context, args []string) ([]string, error) { + if args[0] != "cd" || len(args) > 2 { + return args, nil + } + hc := interp.HandlerCtx(ctx) + + target := "" + if len(args) == 2 { + target = args[1] + } + switch target { + case "", "~": + target = hc.Env.Get("HOME").String() + case "-": + target = hc.Env.Get("OLDPWD").String() + } + if target == "" { + target = "/" + } + + dir := s.resolve(target) + if info, err := s.stat(ctx, dir, true); err != nil || !info.IsDir() { + fmt.Fprintln(hc.Stderr, errorStyle.Render("cd: no such directory: "+target)) + // A CallHandler error is fatal to the runner, which a bad cd is not. + return []string{"false"}, nil + } + + old := s.runner.Dir + s.runner.Dir = dir + // eval runs in the current scope, which is the only way to reach the + // runner's variables from out here. + return []string{"eval", "PWD=" + quote(dir) + "; OLDPWD=" + quote(old)}, nil +} + +func quote(s string) string { + q, err := syntax.Quote(s, syntax.LangBash) + if err != nil { + return "''" + } + return q +} + +// resolve makes a path absolute against the session's directory. Paths on the +// instance are slash-separated whatever this machine uses. +func (s *session) resolve(p string) string { + if !path.IsAbs(p) { + p = path.Join(s.runner.Dir, p) + } + return path.Clean(p) +} + +// commandStdin decides what a command reads from. A nil input means it inherits +// the shell's own, which at a terminal is lent out by the pump for exactly as +// long as ctx lasts — that is what lets a remote prompt be answered without the +// reader going on to swallow the next line typed at our own prompt. +func (s *session) commandStdin(ctx context.Context, in io.Reader) io.Reader { + switch { + case in != nil: + return in + case s.pump != nil: + return s.pump.readerFor(ctx) + default: + return nil + } +} + +func streamsOf(hc interp.HandlerContext) Streams { + return Streams{In: hc.Stdin, Out: hc.Stdout, Err: hc.Stderr} +} + +// exported collects the variables a command should inherit. +func exported(env expand.Environ) map[string]string { + vars := map[string]string{} + env.Each(func(name string, vr expand.Variable) bool { + if vr.Exported && vr.IsSet() { + vars[name] = vr.String() + } + return true + }) + return vars +} + +// exitStatus turns a command's status into what a handler must return: nil for +// success, an ExitStatus otherwise. +func exitStatus(code int) error { + switch { + case code == 0: + return nil + case code < 0 || code > 255: + return interp.ExitStatus(1) + default: + return interp.ExitStatus(code) + } +} diff --git a/internal/shell/fs.go b/internal/shell/fs.go new file mode 100644 index 00000000..e762b3e9 --- /dev/null +++ b/internal/shell/fs.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "bytes" + "context" + "io" + "io/fs" + "os" + "path" + "strconv" + "strings" + "time" +) + +// The plugin exposes no stat or readdir, so both are asked of a shell on the +// instance. The path arrives as $1 rather than spliced into the snippet. +const ( + statScript = `p=$1; if [ -d "$p" ]; then echo "d 0"; ` + + `elif [ -f "$p" ]; then echo "f $(wc -c < "$p")"; ` + + `elif [ -e "$p" ]; then echo "o 0"; else exit 1; fi` + + // -p marks directories with a trailing slash, which is all we need to tell + // entries apart without a stat each. + readDirScript = `ls -1Ap -- "$1"` +) + +// stat backs cd, the file tests, and glob expansion. +func (s *session) stat(ctx context.Context, name string, _ bool) (fs.FileInfo, error) { + p := s.resolve(name) + + out, err := s.script(ctx, statScript, p) + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: p, Err: err} + } + + kind, size, _ := strings.Cut(strings.TrimSpace(out), " ") + n, _ := strconv.ParseInt(size, 10, 64) + return remoteFileInfo{name: path.Base(p), size: n, dir: kind == "d"}, nil +} + +// readDir backs glob expansion. +func (s *session) readDir(ctx context.Context, name string) ([]fs.DirEntry, error) { + p := s.resolve(name) + + out, err := s.script(ctx, readDirScript, p) + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: p, Err: err} + } + + var entries []fs.DirEntry + for line := range strings.SplitSeq(out, "\n") { + if line = strings.TrimRight(line, "\r"); line == "" { + continue + } + name, dir := strings.CutSuffix(line, "/") + entries = append(entries, remoteDirEntry{remoteFileInfo{name: name, dir: dir}}) + } + return entries, nil +} + +// open backs redirections. Reads are fetched whole and writes are held until +// close, because the plugin's file API has no streaming form. +func (s *session) open(ctx context.Context, name string, flag int, _ os.FileMode) (io.ReadWriteCloser, error) { + if name == os.DevNull { + return devNull{}, nil + } + p := s.resolve(name) + + if flag&(os.O_WRONLY|os.O_RDWR|os.O_APPEND|os.O_CREATE|os.O_TRUNC) == 0 { + data, err := s.cfg.Transport.ReadFile(ctx, p) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: p, Err: err} + } + return &remoteFile{r: bytes.NewReader(data)}, nil + } + return &remoteFile{ctx: ctx, s: s, path: p, appending: flag&os.O_APPEND != 0}, nil +} + +// script runs a snippet on the instance and returns its standard output. A +// non-zero exit means the path is not there; an error means the instance could +// not be reached at all, which must not be reported as a missing file. +func (s *session) script(ctx context.Context, snippet, arg string) (string, error) { + var out, errOut bytes.Buffer + + code, err := s.cfg.Transport.Exec(ctx, Streams{Out: &out, Err: &errOut}, s.runner.Dir, nil, + []string{"sh", "-c", snippet, "sh", arg}) + switch { + case err != nil: + return "", err + case code != 0: + return "", fs.ErrNotExist + default: + return out.String(), nil + } +} + +// remoteFile is a file on the instance: either the contents already fetched, or +// a buffer flushed back on close. +type remoteFile struct { + r *bytes.Reader + + ctx context.Context + s *session + path string + appending bool + buf bytes.Buffer +} + +func (f *remoteFile) Read(p []byte) (int, error) { + if f.r == nil { + return 0, fs.ErrInvalid + } + return f.r.Read(p) +} + +func (f *remoteFile) Write(p []byte) (int, error) { + if f.r != nil { + return 0, fs.ErrInvalid + } + return f.buf.Write(p) +} + +func (f *remoteFile) Close() error { + if f.r != nil { + return nil + } + return f.s.cfg.Transport.WriteFile(f.ctx, f.path, f.buf.Bytes(), f.appending) +} + +type devNull struct{} + +func (devNull) Read([]byte) (int, error) { return 0, io.EOF } +func (devNull) Write(p []byte) (int, error) { return len(p), nil } +func (devNull) Close() error { return nil } + +type remoteFileInfo struct { + name string + size int64 + dir bool +} + +func (f remoteFileInfo) Name() string { return f.name } +func (f remoteFileInfo) Size() int64 { return f.size } +func (f remoteFileInfo) IsDir() bool { return f.dir } +func (f remoteFileInfo) Sys() any { return nil } + +func (f remoteFileInfo) ModTime() time.Time { return time.Time{} } + +func (f remoteFileInfo) Mode() fs.FileMode { + if f.dir { + return fs.ModeDir | 0o755 + } + return 0o644 +} + +type remoteDirEntry struct{ info remoteFileInfo } + +func (e remoteDirEntry) Name() string { return e.info.Name() } +func (e remoteDirEntry) IsDir() bool { return e.info.IsDir() } +func (e remoteDirEntry) Type() fs.FileMode { return e.info.Mode().Type() } +func (e remoteDirEntry) Info() (fs.FileInfo, error) { return e.info, nil } diff --git a/internal/shell/highlight.go b/internal/shell/highlight.go new file mode 100644 index 00000000..7926f67b --- /dev/null +++ b/internal/shell/highlight.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import "strings" + +// shellSpecial are the characters that mean something to the shell rather than +// to the command, and so are worth telling apart at a glance. +const shellSpecial = "|&;<>()$" + +// span is a run of the line that renders the same way. Spans exist so that a +// redraw can start partway through a line and still emit correct colour: it +// begins at a span boundary, where no style is open. +type span struct { + start, end int + text string + styled bool +} + +// highlightSpans scans a line as it is being typed. It is a scan rather than a +// parse: a half-written line rarely parses, and one that refuses to colour +// until it does would spend most of its time uncoloured. +func highlightSpans(line string, isBuiltin func(string) bool) []span { + var spans []span + add := func(start, end int, text string, styled bool) { + spans = append(spans, span{start: start, end: end, text: text, styled: styled}) + } + + for i := 0; i < len(line); { + switch c := line[i]; { + case c == '\'' || c == '"': + // An unterminated quote runs to the end, which is what it looks + // like mid-word too. + end := strings.IndexByte(line[i+1:], c) + if end < 0 { + end = len(line) + } else { + end = i + 1 + end + 1 + } + add(i, end, highlightStringStyle.Render(line[i:end]), true) + i = end + + case strings.IndexByte(shellSpecial, c) >= 0: + add(i, i+1, highlightSpecialStyle.Render(string(c)), true) + i++ + + case c == ' ' || c == '\t': + // Whitespace is its own run so that every word after it starts a + // span, which is what lets a builtin be recognised mid-line. + end := i + 1 + for end < len(line) && (line[end] == ' ' || line[end] == '\t') { + end++ + } + add(i, end, line[i:end], false) + i = end + + case c == builtinSigil[0]: + end := i + wordLen(line[i:]) + word := line[i:end] + if isBuiltin != nil && isBuiltin(strings.TrimPrefix(word, builtinSigil)) { + add(i, end, highlightBuiltinStyle.Render(word), true) + } else { + add(i, end, word, false) + } + i = end + + default: + end := i + wordLen(line[i:]) + add(i, end, line[i:end], false) + i = end + } + } + return spans +} + +// highlight renders a whole line. +func highlight(line string, isBuiltin func(string) bool) string { + var out strings.Builder + out.Grow(len(line) * 2) + for _, s := range highlightSpans(line, isBuiltin) { + out.WriteString(s.text) + } + return out.String() +} + +// spanStart is the offset a redraw must begin at to repaint the byte at at. A +// styled run has to restart at its boundary so its colour is re-opened; an +// unstyled one can start exactly where it changed. +func spanStart(spans []span, at int) int { + for _, s := range spans { + if at < s.end { + if s.styled { + return s.start + } + return at + } + } + return at +} + +// wordLen measures the word at the start of s. It stops at whitespace as well +// as at the special characters, so that ":jobs arg" is recognised as a builtin +// and not as one long word. It is never zero. +func wordLen(s string) int { + for i := 1; i < len(s); i++ { + c := s[i] + if c == '\'' || c == '"' || c == ' ' || c == '\t' || strings.IndexByte(shellSpecial, c) >= 0 { + return i + } + } + return len(s) +} diff --git a/internal/shell/line.go b/internal/shell/line.go new file mode 100644 index 00000000..131ef30d --- /dev/null +++ b/internal/shell/line.go @@ -0,0 +1,631 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path" + "slices" + "strings" + "syscall" + "time" + "unicode/utf8" + + xterm "github.com/charmbracelet/x/term" +) + +// errInterrupted reports Ctrl-C: the line is abandoned, the shell stays. +var errInterrupted = errors.New("interrupted") + +const ( + ctrlA = 0x01 + ctrlB = 0x02 + ctrlC = 0x03 + ctrlD = 0x04 + ctrlE = 0x05 + ctrlF = 0x06 + ctrlG = 0x07 + ctrlH = 0x08 + ctrlK = 0x0b + ctrlL = 0x0c + ctrlN = 0x0e + ctrlP = 0x10 + ctrlR = 0x12 + ctrlT = 0x14 + ctrlU = 0x15 + ctrlW = 0x17 + esc = 0x1b + backspace = 0x7f +) + +// errSearch reports Ctrl-C's sibling: the line is put aside so that the reverse +// history search can draw over it. +var errSearch = errors.New("search history") + +// lineReader is the prompt's line editor, which brings Tab, history and the +// usual editing keys. The terminal is in raw mode only while a line is being +// typed, so commands run with the line discipline they expect. +type lineReader struct { + tty *os.File + keys *keys + ed *editor + resize chan os.Signal + + complete completeFunc + builtinNames func() []string + history []string + // pending is a line handed back by the search, to be edited before it runs. + pending string + // buf holds keys read but not yet decoded. + buf []byte +} + +// remember keeps a line for Ctrl-R, skipping repeats of the last one. +func (l *lineReader) remember(line string) { + if line = strings.TrimSpace(line); line == "" { + return + } + if n := len(l.history); n > 0 && l.history[n-1] == line { + return + } + l.history = append(l.history, line) +} + +// searchHistory is Ctrl-R. The editor has no hook for it, so the search draws +// its own prompt with the terminal still in raw mode, and hands the chosen line +// back to be run or edited. +func (l *lineReader) searchHistory(out io.Writer) (line string, submit bool, err error) { + state, err := xterm.MakeRaw(l.tty.Fd()) + if err != nil { + return "", false, err + } + defer xterm.Restore(l.tty.Fd(), state) //nolint:errcheck // nothing useful to do if the terminal will not go back + + var query string + match, from := "", len(l.history) + draw := func() { + fmt.Fprint(out, "\r\x1b[K", hintStyle.Render("(reverse-i-search)`"+query+"': "), match) + } + clear := func() { fmt.Fprint(out, "\r\x1b[K") } + draw() + + buf := make([]byte, 64) + for { + n, err := l.keys.Read(buf) + if err != nil { + clear() + return "", false, err + } + + for i, b := range buf[:n] { + done, accept := false, false + switch { + case b == '\r' || b == '\n': + done, accept = true, true + + case b == ctrlC || b == ctrlG: + done = true + match = "" + + case b == ctrlR: + if next, at, ok := l.findBefore(query, from); ok { + match, from = next, at + } + + case b == 0x7f || b == 0x08: + if query != "" { + query = query[:len(query)-1] + match, from, _ = l.findBefore(query, len(l.history)) + } + + case b >= 0x20: + query += string(rune(b)) + if found, at, ok := l.findBefore(query, len(l.history)); ok { + match, from = found, at + } + + default: // Escape and anything else leaves the search to be edited + done = true + } + + if done { + l.keys.unread(buf[i+1 : n]) + clear() + return match, accept, nil + } + } + draw() + } +} + +// findBefore returns the most recent entry containing query, looking only at +// what came before index. +func (l *lineReader) findBefore(query string, before int) (string, int, bool) { + if query == "" { + return "", len(l.history), false + } + for i := min(before, len(l.history)) - 1; i >= 0; i-- { + if strings.Contains(l.history[i], query) { + return l.history[i], i, true + } + } + return "", before, false +} + +func newLineReader(tty *os.File, in io.Reader, out io.Writer, complete completeFunc, builtinNames func() []string) *lineReader { + k := &keys{src: in} + if pump, ok := in.(*stdinPump); ok { + k.wait = func(b []byte) (int, error) { return pump.readWithin(escapeTimeout, b) } + } + + l := &lineReader{ + tty: tty, + keys: k, + ed: &editor{out: out}, + complete: complete, + builtinNames: builtinNames, + resize: make(chan os.Signal, 1), + } + signal.Notify(l.resize, syscall.SIGWINCH) + return l +} + +// prefill seeds the next prompt, which is how the search hands a line back. +func (l *lineReader) prefill(line string) { l.pending = line } + +func (l *lineReader) width() int { + w, _, err := xterm.GetSize(l.tty.Fd()) + if err != nil || w <= 0 { + return 80 + } + return w +} + +func (l *lineReader) close() { signal.Stop(l.resize) } + +// readLine prompts for one line, in raw mode for just as long as it takes. +func (l *lineReader) readLine(prompt string) (string, error) { + state, err := xterm.MakeRaw(l.tty.Fd()) + if err != nil { + return "", err + } + defer xterm.Restore(l.tty.Fd(), state) //nolint:errcheck // nothing useful to do if the terminal will not go back + + l.ed.isBuiltin = l.isBuiltin + l.ed.start(prompt, l.width(), l.pending) + l.pending = "" + l.ed.render() + + histPos, draft := len(l.history), "" + for { + select { + case <-l.resize: + l.ed.width = max(l.width(), 1) + default: + } + + r, err := l.nextKey() + if err != nil { + return "", err + } + + switch r { + case '\r', '\n': + l.ed.finish() + line := l.ed.line() + l.remember(line) + return line, nil + + case ctrlC: + l.ed.finish() + return "", errInterrupted + + case ctrlD: + if len(l.ed.buf) == 0 { + l.ed.finish() + return "", io.EOF + } + l.ed.deleteForward() + + case ctrlR: + l.ed.finish() + return "", errSearch + + case '\t': + l.completeAt() + + case keyLeft, ctrlB: + l.ed.move(l.ed.pos - 1) + case keyRight, ctrlF: + l.ed.move(l.ed.pos + 1) + case keyWordLeft: + l.ed.move(l.ed.wordStart()) + case keyWordRight: + l.ed.move(l.ed.wordEnd()) + case keyHome, ctrlA: + l.ed.move(0) + case keyEnd, ctrlE: + l.ed.move(len(l.ed.buf)) + + case keyUp, ctrlP: + histPos, draft = l.browse(histPos, draft, -1) + case keyDown, ctrlN: + histPos, draft = l.browse(histPos, draft, +1) + + case keyDelete: + l.ed.deleteForward() + case backspace, ctrlH: + l.ed.deleteBack() + case ctrlW: + l.ed.killWordBack() + case ctrlK: + l.ed.killToEnd() + case ctrlU: + l.ed.killToStart() + case ctrlT: + l.ed.transpose() + + case ctrlL: + fmt.Fprint(l.ed.out, "\x1b[H\x1b[2J") + l.ed.drawn = false + + default: + if r >= 0x20 && r != 0x7f && r < 0xe000 { + l.ed.insert(r) + } + } + l.ed.render() + } +} + +// browse steps through history, keeping what was typed so returning to the end +// of the list gets it back. +func (l *lineReader) browse(at int, draft string, by int) (int, string) { + if at == len(l.history) { + draft = l.ed.line() + } + + next := min(max(at+by, 0), len(l.history)) + if next == len(l.history) { + l.ed.setLine(draft) + } else { + l.ed.setLine(l.history[next]) + } + return next, draft +} + +func (l *lineReader) completeAt() { + if l.complete == nil { + return + } + pos := len(string(l.ed.buf[:l.ed.pos])) + if line, at, ok := l.complete(l.ed.line(), pos); ok { + l.ed.setLine(line) + l.ed.move(len([]rune(line[:at]))) + } +} + +func (l *lineReader) isBuiltin(name string) bool { + return l.builtinNames != nil && slices.Contains(l.builtinNames(), name) +} + +// nextKey decodes one key from the normalised stream. +func (l *lineReader) nextKey() (rune, error) { + for { + if r, n := decodeKey(l.buf); n > 0 { + l.buf = l.buf[n:] + return r, nil + } + + var scratch [256]byte + n, err := l.keys.Read(scratch[:]) + if n > 0 { + l.buf = append(l.buf, scratch[:n]...) + continue + } + if err != nil { + return 0, err + } + } +} + +// decodeKey reads the key at the start of b, reporting how many bytes it took. +// The sequences are already normalised, so only the canonical forms appear. +func decodeKey(b []byte) (rune, int) { + if len(b) == 0 { + return 0, 0 + } + if b[0] != esc { + r, n := utf8.DecodeRune(b) + if r == utf8.RuneError && n <= 1 && !utf8.FullRune(b) { + return 0, 0 + } + return r, n + } + + n, ok := sequenceLen(b) + if !ok { + return 0, 0 + } + switch string(b[:n]) { + case "\x1b[A": + return keyUp, n + case "\x1b[B": + return keyDown, n + case "\x1b[C": + return keyRight, n + case "\x1b[D": + return keyLeft, n + case "\x1b[H": + return keyHome, n + case "\x1b[F": + return keyEnd, n + case "\x1b[3~": + return keyDelete, n + case "\x1b[1;3C": + return keyWordRight, n + case "\x1b[1;3D": + return keyWordLeft, n + } + return 0, n // an escape sequence with no meaning here +} + +// The editor decodes CSI arrows, CSI H and F for Home and End, Delete, and the +// emacs control keys. Terminals send more than that, so keys rewrites the rest +// into what it understands before it gets there. +var escapeRewrites = map[string]string{ + "\x1bOA": "\x1b[A", // SS3 arrows, sent in application cursor mode + "\x1bOB": "\x1b[B", + "\x1bOC": "\x1b[C", + "\x1bOD": "\x1b[D", + "\x1bOH": "\x1b[H", + "\x1bOF": "\x1b[F", + "\x1b[1~": "\x1b[H", // VT and rxvt Home and End + "\x1b[7~": "\x1b[H", + "\x1b[4~": "\x1b[F", + "\x1b[8~": "\x1b[F", + "\x1bb": "\x1b[1;3D", // alt-b and alt-f move by word + "\x1bf": "\x1b[1;3C", + "\x1b\x7f": "\x17", // alt-backspace deletes one +} + +// keys normalises the terminal's input for the editor. Ctrl-C is part of that: +// the editor reports it and Ctrl-D with the same io.EOF and leaves its cursor +// state stale on the way out, so it becomes "clear the line, then submit it" +// and the interrupt is reported separately. +// escapeTimeout is how long a lone escape waits for the rest of a sequence +// before it is taken to be the Escape key. +const escapeTimeout = 40 * time.Millisecond + +type keys struct { + src io.Reader + // wait reads only if something arrives promptly. Without it a lone escape + // is taken at face value, which is right for a source that is not a + // terminal delivering one keystroke at a time. + wait func([]byte) (int, error) + pending []byte + partial []byte + err error + scratch [256]byte +} + +// unread returns keys that were read but not acted on, so that whatever comes +// after the one that ended a search is still typed into the prompt. +func (k *keys) unread(b []byte) { + if len(b) > 0 { + k.pending = append(slices.Clone(b), k.pending...) + } +} + +func (k *keys) Read(p []byte) (int, error) { + for len(k.pending) == 0 { + if k.err != nil { + err := k.err + k.err = nil + return 0, err + } + + n, err := k.src.Read(k.scratch[:]) + k.err = err + if n > 0 { + k.pending, k.partial = k.rewrite(append(k.partial, k.scratch[:n]...)) + k.settleEscape() + } + } + + n := copy(p, k.pending) + k.pending = k.pending[n:] + return n, nil +} + +// settleEscape resolves a trailing lone escape: the rest of a sequence, or an +// Alt combination, arrives at once behind it, and nothing else does. +func (k *keys) settleEscape() { + for len(k.partial) == 1 && k.partial[0] == esc { + if k.wait == nil { + break + } + n, err := k.wait(k.scratch[:]) + if n == 0 || err != nil { + break + } + k.pending, k.partial = k.rewrite(append(k.partial, k.scratch[:n]...)) + } + + if len(k.partial) == 1 && k.partial[0] == esc { + k.pending, k.partial = append(k.pending, esc), nil + } +} + +// rewrite splits data into keys the editor understands and a trailing sequence +// that has not arrived in full yet. +func (k *keys) rewrite(data []byte) (out, partial []byte) { + for i := 0; i < len(data); { + switch b := data[i]; b { + case esc: + n, ok := sequenceLen(data[i:]) + if !ok { + return out, bytes.Clone(data[i:]) + } + seq := string(data[i : i+n]) + if to, found := escapeRewrites[seq]; found { + seq = to + } + out = append(out, seq...) + i += n + + default: + out = append(out, b) + i++ + } + } + return out, nil +} + +// sequenceLen measures the escape sequence at the start of b, reporting false +// when it is cut short. +func sequenceLen(b []byte) (int, bool) { + if len(b) < 2 { + return 0, false + } + + switch b[1] { + case '[': // CSI: parameters, then a final byte + for i := 2; i < len(b); i++ { + if b[i] >= 0x40 && b[i] <= 0x7e { + return i + 1, true + } + } + return 0, false + case 'O': // SS3: one more byte + if len(b) < 3 { + return 0, false + } + return 3, true + default: + return 2, true + } +} + +// completeFunc offers a longer version of the word ending at pos. +type completeFunc func(line string, pos int) (string, int, bool) + +// completer completes paths against the instance, and the first word of a line +// against the builtins and what is on the instance's PATH. +func (s *session) completer(ctx context.Context) completeFunc { + return func(line string, pos int) (string, int, bool) { + head := line[:pos] + start := strings.LastIndexAny(head, " \t|;&(<>") + 1 + word := head[start:] + + var matches []string + if strings.TrimSpace(head[:start]) == "" && !strings.Contains(word, "/") { + matches = s.commandMatches(ctx, word) + } else { + matches = s.pathMatches(ctx, word) + } + if len(matches) == 0 { + return "", 0, false + } + + filled := commonPrefix(matches) + if len(matches) == 1 && !strings.HasSuffix(filled, "/") { + filled += " " + } + if filled == word { + return "", 0, false + } + + return line[:start] + filled + line[pos:], start + len(filled), true + } +} + +func (s *session) pathMatches(ctx context.Context, word string) []string { + dir, prefix := path.Split(word) + + entries, err := s.readDir(ctx, cmp(dir, ".")) + if err != nil { + return nil + } + + var matches []string + for _, e := range entries { + if !strings.HasPrefix(e.Name(), prefix) { + continue + } + name := dir + e.Name() + if e.IsDir() { + name += "/" + } + matches = append(matches, name) + } + return matches +} + +func (s *session) commandMatches(ctx context.Context, word string) []string { + var matches []string + for _, name := range s.builtinNames() { + if b := builtinSigil + name; strings.HasPrefix(b, word) { + matches = append(matches, b) + } + } + if strings.HasPrefix(hostCommand, word) { + matches = append(matches, hostCommand) + } + + for _, name := range s.remoteCommands(ctx) { + if strings.HasPrefix(name, word) { + matches = append(matches, name) + } + } + return matches +} + +// remoteCommands lists what is executable on the instance's PATH, once. +func (s *session) remoteCommands(ctx context.Context) []string { + if s.commands != nil { + return s.commands + } + s.commands = []string{} // a failed lookup should not be retried every Tab + + out, err := s.script(ctx, `IFS=:; for d in $PATH; do ls -1 "$d" 2>/dev/null; done`, "") + if err != nil { + return s.commands + } + + seen := map[string]bool{} + for name := range strings.SplitSeq(out, "\n") { + if name = strings.TrimSpace(name); name != "" && !seen[name] { + seen[name] = true + s.commands = append(s.commands, name) + } + } + slices.Sort(s.commands) + return s.commands +} + +func commonPrefix(values []string) string { + prefix := values[0] + for _, v := range values[1:] { + for !strings.HasPrefix(v, prefix) { + prefix = prefix[:len(prefix)-1] + } + } + return prefix +} + +func cmp(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 00000000..7a181ace --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "os/signal" + "slices" + "strings" + "sync" + "syscall" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" + + "unikraft.com/x/log" + + "unikraft.com/cli/internal/config" + xio "unikraft.com/cli/internal/x/io" +) + +// Streams are the three standard streams a single command is wired to. +type Streams struct { + In io.Reader + Out, Err io.Writer +} + +// Transport is how the shell reaches the instance. It is an interface rather +// than a func type so that a PTY session can arrive as an optional second +// capability without disturbing this one. +type Transport interface { + Exec(ctx context.Context, streams Streams, dir string, env map[string]string, args []string) (int, error) + ReadFile(ctx context.Context, path string) ([]byte, error) + WriteFile(ctx context.Context, path string, data []byte, appendFile bool) error +} + +// Builtins handles the lines that open with ":". The engine only routes to it; +// what the builtins do lives in the package that can reach the resources. +type Builtins interface { + Run(ctx context.Context, streams Streams, args []string) (int, error) + Names() []string +} + +type Config struct { + // Instance names the target in the prompt. + Instance string + Transport Transport + Builtins Builtins + + // Dir is the initial working directory on the instance. + Dir string + // Env overlays the environment read off the instance. + Env map[string]string + // Command runs a single line instead of prompting. + Command string +} + +type session struct { + cfg Config + stdio config.Stdio + + runner *interp.Runner + + // localDir anchors "host" commands: the session's directory is a path on + // the instance and means nothing here. + localDir string + interactive bool + + // commands caches what is executable on the instance, for completion. + commands []string + + // tty and pump are set when standard input is a terminal, which is the + // only case where a command and the prompt compete for it. + tty *os.File + pump *stdinPump + + // editor is set once the prompt is up; ":history" is its history. + editor *lineReader +} + +// Run opens a shell against the instance the config's transport reaches. +func Run(ctx context.Context, cfg Config, stdio config.Stdio) error { + if cfg.Transport == nil { + return fmt.Errorf("no transport to the instance") + } + if cfg.Dir == "" { + cfg.Dir = "/" + } + + localDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("could not determine the local working directory: %w", err) + } + + s := &session{cfg: cfg, stdio: stdio, localDir: localDir} + if f, ok := stdio.Stdin.(*os.File); ok && term.IsTerminal(f.Fd()) { + s.tty = f + s.pump = newStdinPump(f) + defer s.pump.close() + } + s.interactive = cfg.Command == "" && s.tty != nil && xio.IsTTY(stdio.Stdout) + + // Commands can run at the same time — the two halves of a pipeline, or a + // background job — and they share these two streams. A pipe between + // commands is its own file, so only the ends reaching the terminal need + // serialising. Wrapped after the checks above, which need the real writer. + var terminal sync.Mutex + s.stdio.Stdout = &lockedWriter{mu: &terminal, w: stdio.Stdout} + s.stdio.Stderr = &lockedWriter{mu: &terminal, w: stdio.Stderr} + + // A terminal is lent out per command by the pump, and a script read off + // stdin belongs to the parser; either way the runner must not hold it. + var cmdStdin io.Reader + if s.tty == nil && cfg.Command != "" { + cmdStdin = stdio.Stdin + } + + runner, err := interp.New( + interp.StdIO(cmdStdin, s.stdio.Stdout, s.stdio.Stderr), + interp.Env(expand.ListEnviron(s.environ(ctx)...)), + interp.Interactive(s.interactive), + interp.ExecHandlers(s.route), + interp.CallHandler(s.interceptCd), + interp.StatHandler(s.stat), + interp.ReadDirHandler2(s.readDir), + interp.OpenHandler(s.open), + ) + if err != nil { + return err + } + + // interp.Dir stats the path on this machine, which a directory that only + // exists on the instance will not survive. Set it before the runner + // snapshots its starting state instead. + runner.Dir = cfg.Dir + runner.Reset() + s.runner = runner + + switch { + case cfg.Command != "": + return s.runSource(ctx, strings.NewReader(cfg.Command)) + case s.interactive: + return s.runInteractive(ctx) + default: + return s.runSource(ctx, stdio.Stdin) + } +} + +func (s *session) runSource(ctx context.Context, src io.Reader) error { + prog, err := syntax.NewParser().Parse(src, "") + if err != nil { + return err + } + return dropExitStatus(s.runner.Run(ctx, prog)) +} + +func (s *session) runInteractive(ctx context.Context) error { + sigint, stop := captureInterrupts() + defer stop() + defer s.plainKeys()() + + editor := newLineReader(s.tty, s.pump, s.stdio.Stdout, s.completer(ctx), s.builtinNames) + defer editor.close() + s.editor = editor + + parser := syntax.NewParser() + var pending strings.Builder + + for { + line, err := editor.readLine(s.prompt(pending.Len() > 0)) + switch { + case errors.Is(err, errSearch): + found, submit, err := editor.searchHistory(s.stdio.Stdout) + if err != nil { + return err + } + if found == "" { + continue + } + if !submit { + // Put it back at the prompt so it can be edited first. + editor.prefill(found) + continue + } + fmt.Fprintln(s.stdio.Stdout, s.prompt(false)+found) + line = found + case errors.Is(err, errInterrupted): + fmt.Fprintln(s.stdio.Stdout, hintStyle.Render("^C")) + pending.Reset() + continue + case errors.Is(err, io.EOF): + fmt.Fprintln(s.stdio.Stdout) + return nil + case err != nil: + return err + } + + pending.WriteString(line) + pending.WriteString("\n") + + prog, err := parser.Parse(strings.NewReader(pending.String()), "") + if err != nil { + if syntax.IsIncomplete(err) { + continue + } + fmt.Fprintln(s.stdio.Stderr, errorStyle.Render(err.Error())) + pending.Reset() + continue + } + pending.Reset() + + for _, stmt := range prog.Stmts { + if err := s.runStmt(ctx, sigint, stmt); err != nil { + fmt.Fprintln(s.stdio.Stderr, errorStyle.Render(err.Error())) + } + if s.runner.Exited() { + return nil + } + } + } +} + +// running command rather than the shell. +func (s *session) runStmt(ctx context.Context, sigint <-chan os.Signal, stmt *syntax.Stmt) error { + // An interrupt typed at the prompt must not carry into the next command. + select { + case <-sigint: + default: + } + + stmtCtx, cancel := context.WithCancel(ctx) + defer cancel() + + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-sigint: + cancel() + case <-done: + } + }() + + return dropExitStatus(s.runner.Run(stmtCtx, stmt)) +} + +// prompt names the instance and where the session is on it. +func (s *session) prompt(continuation bool) string { + if continuation { + return continuationStyle.Render("> ") + } + return promptStyle.Render(s.cfg.Instance) + + promptDirStyle.Render(":"+s.runner.Dir) + + promptStyle.Render("$ ") +} + +// plainKeys asks the terminal to report keys the old way for as long as the +// prompt owns it. Under an enhanced keyboard protocol a bare modifier reports +// an escape sequence, which lands in the line being typed where it is invisible +// and backspace cannot cleanly remove it. +func (s *session) plainKeys() func() { + fmt.Fprint(s.stdio.Stdout, ansi.DisableKittyKeyboard, ansi.ResetModifyOtherKeys) + return func() { + fmt.Fprint(s.stdio.Stdout, ansi.PopKittyKeyboard(1), ansi.ResetModifyOtherKeys) + } +} + +// captureInterrupts takes SIGINT over from the root context, which would +// otherwise tear the whole process down on the Ctrl-C meant for one command. +func captureInterrupts() (<-chan os.Signal, func()) { + signal.Reset(syscall.SIGINT) + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT) + return ch, func() { signal.Stop(ch) } +} + +// dropExitStatus reports a failed command the way `instance exec` does: the +// command reached the instance, so the shell itself succeeded. $? still sees +// the real status inside the session. +func dropExitStatus(err error) error { + if _, ok := errors.AsType[interp.ExitStatus](err); ok { + return nil + } + return err +} + +// environProbe asks the instance for its environment. The explicit lines come +// after env so they win, and they cover what a bare "sh -c" leaves unset: +// without them the interpreter would fall back to this machine's HOME and ids. +const environProbe = `env; printf 'HOME=%s\nPATH=%s\nUID=%s\nEUID=%s\nGID=%s\n' ` + + `"${HOME:-/}" "$PATH" "$(id -u 2>/dev/null || echo 0)" ` + + `"$(id -u 2>/dev/null || echo 0)" "$(id -g 2>/dev/null || echo 0)"` + +// environ seeds the session from the instance's own environment, then overlays +// whatever the caller asked for. +func (s *session) environ(ctx context.Context) []string { + var out bytes.Buffer + if _, err := s.cfg.Transport.Exec(ctx, Streams{Out: &out, Err: io.Discard}, s.cfg.Dir, nil, + []string{"sh", "-c", environProbe}); err != nil { + log.G(ctx).Debug().Err(err).Msg("could not read the instance environment") + } + + vars := map[string]string{} + for line := range strings.SplitSeq(out.String(), "\n") { + if name, value, ok := strings.Cut(line, "="); ok && isEnvName(name) { + vars[name] = value + } + } + maps.Copy(vars, s.cfg.Env) + vars["PWD"] = s.cfg.Dir + + env := make([]string, 0, len(vars)) + for name, value := range vars { + env = append(env, name+"="+value) + } + slices.Sort(env) + return env +} + +func isEnvName(s string) bool { + if s == "" { + return false + } + for i, r := range s { + switch { + case r == '_', r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z': + case i > 0 && r >= '0' && r <= '9': + default: + return false + } + } + return true +} + +// builtinNames is everything ":" answers, the session's own included. It is +// what the prompt highlights and what Tab completes. +func (s *session) builtinNames() []string { + names := slices.Clone(sessionBuiltinNames) + if s.cfg.Builtins != nil { + names = append(names, s.cfg.Builtins.Names()...) + } + slices.Sort(names) + return names +} + +// lockedWriter serialises writes from commands running at the same time. +type lockedWriter struct { + mu *sync.Mutex + w io.Writer +} + +func (l *lockedWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.w.Write(p) +} diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go new file mode 100644 index 00000000..d34b38a6 --- /dev/null +++ b/internal/shell/shell_test.go @@ -0,0 +1,736 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "testing/iotest" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/interp" + + "unikraft.com/cli/internal/config" +) + +// TestIsBuiltin pins which words the CLI answers itself. The shell's own ":" +// null command tokenises to a bare sigil and must still reach the instance's +// shell semantics rather than the builtin table. +func TestIsBuiltin(t *testing.T) { + for _, tt := range []struct { + name string + word string + want bool + }{ + {"named-builtin", ":start", true}, + {"single-letter", ":x", true}, + {"null-command", ":", false}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isBuiltin(tt.word)) + }) + } +} + +// TestExitStatus pins the contract an exec handler has to honour: nil means +// success, anything else carries the status. +func TestExitStatus(t *testing.T) { + require.NoError(t, exitStatus(0)) + + for _, tt := range []struct { + name string + code int + want interp.ExitStatus + }{ + {"failure", 1, 1}, + {"signal", 130, 130}, + {"max", 255, 255}, + } { + t.Run(tt.name, func(t *testing.T) { + var status interp.ExitStatus + require.ErrorAs(t, exitStatus(tt.code), &status) + assert.Equal(t, tt.want, status) + }) + } +} + +// TestIsEnvName pins which lines of the instance's "env" output are taken as +// variables; a value spanning lines leaves fragments that are not. +func TestIsEnvName(t *testing.T) { + for _, tt := range []struct { + name string + input string + want bool + }{ + {"upper", "PATH", true}, + {"underscore-lead", "_x", true}, + {"digits-after-first", "A1", true}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isEnvName(tt.input)) + }) + } +} + +// TestResolve pins how a path is made absolute against the session's directory +// on the instance, which stays slash-separated whatever this machine uses. +func TestResolve(t *testing.T) { + s := &session{runner: &interp.Runner{Dir: "/var/log"}} + + for _, tt := range []struct { + name string + input string + want string + }{ + {"relative", "app.log", "/var/log/app.log"}, + {"absolute", "/etc/hosts", "/etc/hosts"}, + {"dot", ".", "/var/log"}, + {"parent", "../lib", "/var/lib"}, + {"nested", "a/b/../c", "/var/log/a/c"}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, s.resolve(tt.input)) + }) + } +} + +// TestReadDirEntries pins the parse of "ls -1Ap", where a trailing slash is the +// only marker distinguishing a directory. +func TestReadDirEntries(t *testing.T) { + s := &session{ + runner: &interp.Runner{Dir: "/"}, + cfg: Config{Transport: scriptTransport("bin/\netc/\nREADME\n\n")}, + } + + entries, err := s.readDir(t.Context(), "/") + require.NoError(t, err) + require.Len(t, entries, 3) + + assert.Equal(t, "bin", entries[0].Name()) + assert.True(t, entries[0].IsDir()) + assert.Equal(t, "README", entries[2].Name()) + assert.False(t, entries[2].IsDir()) +} + +// scriptTransport answers every command with fixed output. Only the snippets in +// fs.go go through it. +type scriptTransport string + +func (t scriptTransport) Exec(_ context.Context, streams Streams, _ string, _ map[string]string, _ []string) (int, error) { + fmt.Fprint(streams.Out, string(t)) + return 0, nil +} + +func (scriptTransport) ReadFile(context.Context, string) ([]byte, error) { return nil, nil } + +func (scriptTransport) WriteFile(context.Context, string, []byte, bool) error { return nil } + +// localTransport stands in for an instance by running commands here. The shell +// never learns the difference, so the routing, the remote filesystem handlers +// and the session state can all be exercised without a network. +type localTransport struct{} + +func (localTransport) Exec(ctx context.Context, streams Streams, dir string, env map[string]string, args []string) (int, error) { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = dir + cmd.Env = append([]string{"PATH=" + os.Getenv("PATH")}, sortedEnv(env)...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.In, streams.Out, streams.Err + + var exitErr *exec.ExitError + switch err := cmd.Run(); { + case err == nil: + return 0, nil + case errors.As(err, &exitErr): + return exitErr.ExitCode(), nil + default: + return 0, err + } +} + +func (localTransport) ReadFile(_ context.Context, path string) ([]byte, error) { + return os.ReadFile(path) +} + +func (localTransport) WriteFile(_ context.Context, path string, data []byte, appendFile bool) error { + if !appendFile { + return os.WriteFile(path, data, 0o644) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(data) + return err +} + +func sortedEnv(env map[string]string) []string { + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} + +type stubBuiltins struct{} + +func (stubBuiltins) Names() []string { return nil } + +// echoBuiltins answers ":say " by printing it, which is enough to see +// where a builtin's output ends up. +type echoBuiltins struct{} + +func (echoBuiltins) Names() []string { return []string{"say"} } + +func (echoBuiltins) Run(_ context.Context, streams Streams, args []string) (int, error) { + fmt.Fprintln(streams.Out, strings.Join(args[1:], " ")) + return 0, nil +} + +// namedBuiltins advertises names without implementing any of them. +type namedBuiltins []string + +func (b namedBuiltins) Names() []string { return b } + +func (namedBuiltins) Run(_ context.Context, _ Streams, args []string) (int, error) { + return 0, fmt.Errorf("unknown builtin: %s", args[0]) +} + +func (stubBuiltins) Run(_ context.Context, _ Streams, args []string) (int, error) { + return 0, fmt.Errorf("unknown builtin: %s", args[0]) +} + +// captured collects a session's output. A pipeline runs its commands at the +// same time and both write here, so the buffer has to be guarded — a real +// terminal is a file descriptor and does its own serialising. +type captured struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (c *captured) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.buf.Write(p) +} + +func (c *captured) String() string { + c.mu.Lock() + defer c.mu.Unlock() + return ansi.Strip(c.buf.String()) +} + +func (c *captured) Len() int { return len(c.String()) } + +func (c *captured) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.buf.Reset() +} + +// newFixture builds a directory tree that plays the part of the instance. +func newFixture(t *testing.T) string { + t.Helper() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "var", "log"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "var", "log", "app.log"), []byte("a\nb\nerror\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "var", "log", "boot.log"), []byte("boot\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "hostname"), []byte("fakebox\n"), 0o644)) + return root +} + +func runLine(t *testing.T, root, line string) string { + t.Helper() + + var out captured + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: root, + Command: line, + Transport: localTransport{}, + Builtins: stubBuiltins{}, + }, config.Stdio{Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + require.NoError(t, err, "output: %s", out.String()) + + return out.String() +} + +// TestSession drives whole command lines through the engine. $R stands for the +// directory playing the instance's root. +func TestSession(t *testing.T) { + root := newFixture(t) + + for _, tt := range []struct { + name string + line string + want string + }{ + // The shell language is interpreted here, so all of this is local work. + {"echo", `echo hello`, "hello\n"}, + {"variables", `x=5; echo $((x * 2))`, "10\n"}, + {"control-flow", `for i in 1 2 3; do printf %s $i; done; echo`, "123\n"}, + {"command-substitution", `echo "[$(echo inner)]"`, "[inner]\n"}, + + // The status of a command on the instance drives $? and the operators. + {"exit-status", `false; echo $?`, "1\n"}, + {"and-or", `true && echo yes || echo no`, "yes\n"}, + {"short-circuit", `false && echo unreachable; echo after`, "after\n"}, + + // Directory and path handling resolve against the instance. + {"cd-persists", `cd $R/var/log && pwd`, "$R/var/log\n"}, + {"pwd-var", `cd $R/var/log; echo $PWD`, "$R/var/log\n"}, + {"cd-dash", `cd $R/var/log; cd -; pwd`, "$R\n"}, + {"glob", `cd $R/var/log; echo *.log`, "app.log boot.log\n"}, + {"test-file", `[ -f $R/var/log/app.log ] && echo found`, "found\n"}, + {"test-dir", `[ -d $R/var/log ] && echo dir`, "dir\n"}, + + // Routing. + {"pipeline", `cat $R/var/log/app.log | grep -c error`, "1\n"}, + {"mixed-pipeline", `cat $R/hostname | host wc -c`, "8\n"}, + {"null-command", `: ; echo $?`, "0\n"}, + + // Redirection. + {"discard", `echo hidden > /dev/null; echo shown`, "shown\n"}, + {"stderr-passes-through", `echo oops >&2`, "oops\n"}, + {"stderr-is-separable", `sh -c 'echo e >&2' 2>/dev/null; echo done`, "done\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + line := strings.ReplaceAll(tt.line, "$R", root) + want := strings.ReplaceAll(tt.want, "$R", root) + assert.Equal(t, want, runLine(t, root, line)) + }) + } +} + +// TestSessionHistory pins ":history", which the engine answers itself because +// the history is the session's rather than the CLI's. +func TestSessionHistory(t *testing.T) { + s := &session{ + cfg: Config{Builtins: namedBuiltins{"start"}}, + editor: &lineReader{}, + } + for _, line := range []string{"echo one", "echo two"} { + s.editor.remember(line) + } + + var out captured + s.runSessionBuiltin(Streams{Out: &out}, []string{"history"}) + assert.Equal(t, " 1 echo one\n 2 echo two\n", out.String()) + + // It joins the names Tab offers and the prompt highlights. + assert.Equal(t, []string{"history", "start"}, s.builtinNames()) + + // Without a prompt there is no history, and saying so is not a failure. + bare := &session{} + out.Reset() + bare.runSessionBuiltin(Streams{Out: &out}, []string{"history"}) + assert.Empty(t, out.String()) +} + +// chattyTransport writes straight to the streams, as the remote log poller +// does once a command starts producing output. +type chattyTransport struct{} + +func (chattyTransport) Exec(_ context.Context, streams Streams, _ string, _ map[string]string, args []string) (int, error) { + for i := range 50 { + fmt.Fprintf(streams.Out, "%s-out-%d\n", args[0], i) + fmt.Fprintf(streams.Err, "%s-err-%d\n", args[0], i) + } + return 0, nil +} + +func (chattyTransport) ReadFile(context.Context, string) ([]byte, error) { return nil, nil } + +func (chattyTransport) WriteFile(context.Context, string, []byte, bool) error { return nil } + +// TestConcurrentCommandsShareTheTerminal guards the streams the shell hands out. +// Both halves of a pipeline run at once and write to the same terminal, which +// is a colorprofile.Writer with parsing state rather than a bare file +// descriptor. Run this under -race, which is what catches it. +func TestConcurrentCommandsShareTheTerminal(t *testing.T) { + // Deliberately unguarded, standing in for the real stdio. + var out bytes.Buffer + + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: "/", + Command: "alpha | beta", + Transport: chattyTransport{}, + }, config.Stdio{Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + require.NoError(t, err) + + assert.Contains(t, out.String(), "beta-out-49") +} + +// TestBuiltinsCompose pins that a builtin is wired up like any other command: +// the interpreter owns the pipes and redirections, so its output can be fed +// onwards without the builtin knowing anything about it. +func TestBuiltinsCompose(t *testing.T) { + root := newFixture(t) + out := filepath.Join(root, "said.txt") + + run := func(t *testing.T, line string) string { + t.Helper() + + var buf captured + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: root, + Command: line, + Transport: localTransport{}, + Builtins: echoBuiltins{}, + }, config.Stdio{Stdin: strings.NewReader(""), Stdout: &buf, Stderr: &buf}) + require.NoError(t, err) + + return buf.String() + } + + assert.Equal(t, "HELLO\n", run(t, ":say hello | tr a-z A-Z"), "piped into a command on the instance") + assert.Equal(t, "HELLO\n", run(t, ":say hello | host tr a-z A-Z"), "piped into a command here") + assert.Equal(t, "[x]\n", run(t, `echo "[$(:say x)]"`), "captured by a substitution") + assert.Empty(t, run(t, ":say quiet > "+out), "redirected away") + + written, err := os.ReadFile(out) + require.NoError(t, err) + assert.Equal(t, "quiet\n", string(written)) +} + +// TestRedirectionWritesToTheInstance covers the write path, which buffers until +// the redirection closes because the plugin's file API cannot stream. +func TestRedirectionWritesToTheInstance(t *testing.T) { + root := newFixture(t) + out := filepath.Join(root, "out.txt") + + runLine(t, root, "echo written > "+out) + data, err := os.ReadFile(out) + require.NoError(t, err) + assert.Equal(t, "written\n", string(data)) + + runLine(t, root, "echo more >> "+out) + data, err = os.ReadFile(out) + require.NoError(t, err) + assert.Equal(t, "written\nmore\n", string(data)) + + assert.Equal(t, "written\nmore\n", runLine(t, root, "cat < "+out)) + + // An empty redirection still truncates. + runLine(t, root, "printf '' > "+out) + data, err = os.ReadFile(out) + require.NoError(t, err) + assert.Empty(t, data) +} + +// TestEnvironment covers seeding the session from the instance's own +// environment and overlaying the caller's. +func TestEnvironment(t *testing.T) { + root := newFixture(t) + + var out captured + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: root, + Env: map[string]string{"GREETING": "hi"}, + Command: `echo "$GREETING $PWD"; env | grep -c '^PATH='`, + Transport: localTransport{}, + }, config.Stdio{Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + require.NoError(t, err) + + assert.Equal(t, "hi "+root+"\n1\n", out.String()) +} + +// TestEnvironIsTheInstances pins that the session takes HOME and the ids off +// the instance. Left unset, the interpreter fills them from this machine, and a +// bare "cd" then aims at the local home directory. +func TestEnvironIsTheInstances(t *testing.T) { + s := &session{cfg: Config{ + Transport: scriptTransport("HOME=/instance\nPATH=/bin\nUID=7\nEUID=7\nGID=7\n"), + Dir: "/srv", + Env: map[string]string{"EXTRA": "1"}, + }} + + env := s.environ(t.Context()) + + assert.Equal(t, []string{ + "EUID=7", "EXTRA=1", "GID=7", "HOME=/instance", "PATH=/bin", "PWD=/srv", "UID=7", + }, env) +} + +// TestScriptFromStdin covers the non-interactive path, where the script is read +// off standard input instead of a prompt. +func TestScriptFromStdin(t *testing.T) { + root := newFixture(t) + + var out captured + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: root, + Transport: localTransport{}, + }, config.Stdio{ + Stdin: strings.NewReader("cd var/log\nls\n"), + Stdout: &out, + Stderr: &out, + }) + require.NoError(t, err) + + assert.Equal(t, "app.log\nboot.log\n", out.String()) +} + +// TestFailedCommandIsNotAShellFailure pins the same call as `instance exec`: +// the command reached the instance, so delivering it succeeded. +func TestFailedCommandIsNotAShellFailure(t *testing.T) { + root := newFixture(t) + + var out captured + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: root, + Command: "false", + Transport: localTransport{}, + }, config.Stdio{Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + + assert.NoError(t, err) +} + +// TestCompletion covers Tab: paths come off the instance, the first word also +// matches the builtins and host. +func TestCompletion(t *testing.T) { + root := newFixture(t) + s := &session{ + runner: &interp.Runner{Dir: root}, + cfg: Config{Transport: localTransport{}, Builtins: namedBuiltins{"start", "stop"}}, + } + complete := s.completer(t.Context()) + + for _, tt := range []struct { + name string + line string + want string + ok bool + }{ + {"unique-path", "ls $R/host", "ls $R/hostname ", true}, + {"directory-gets-a-slash", "ls $R/va", "ls $R/var/", true}, + {"builtin-unique", ":sta", ":start ", true}, + {"host", "ho", "host ", true}, + } { + t.Run(tt.name, func(t *testing.T) { + line := strings.ReplaceAll(tt.line, "$R", root) + want := strings.ReplaceAll(tt.want, "$R", root) + + got, pos, ok := complete(line, len(line)) + assert.Equal(t, tt.ok, ok, "completed") + if tt.ok { + assert.Equal(t, want, got) + assert.Equal(t, len(want), pos) + } + }) + } +} + +// TestKeys pins the rewrites the decoder relies on: terminals send SS3 arrows +// in application cursor mode and several forms of Home and End, and all of them +// have to arrive as the one canonical form. +func TestKeys(t *testing.T) { + for _, tt := range []struct { + name string + input string + want string + }{ + {"plain-text", "ls -la\r", "ls -la\r"}, + {"control-keys-pass-through", "\x03\x12\x15", "\x03\x12\x15"}, + {"csi-arrows-pass-through", "\x1b[A\x1b[D", "\x1b[A\x1b[D"}, + {"ss3-arrows-become-csi", "\x1bOA\x1bOD", "\x1b[A\x1b[D"}, + {"ss3-home-end", "\x1bOH\x1bOF", "\x1b[H\x1b[F"}, + {"vt-home-end", "\x1b[1~\x1b[4~", "\x1b[H\x1b[F"}, + {"rxvt-home-end", "\x1b[7~\x1b[8~", "\x1b[H\x1b[F"}, + {"delete-passes-through", "\x1b[3~", "\x1b[3~"}, + {"alt-word-movement", "\x1bb\x1bf", "\x1b[1;3D\x1b[1;3C"}, + {"alt-backspace", "\x1b\x7f", "\x17"}, + } { + t.Run(tt.name, func(t *testing.T) { + k := &keys{src: strings.NewReader(tt.input)} + + got, err := io.ReadAll(k) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } + + // A sequence trickling in one byte at a time is still reassembled, because + // the escape waits to see what follows it. + t.Run("split-across-reads", func(t *testing.T) { + p := newStdinPump(iotest.OneByteReader(strings.NewReader("\x1bOAx"))) + defer p.close() + k := &keys{src: p, wait: func(b []byte) (int, error) { return p.readWithin(escapeTimeout, b) }} + + got, err := io.ReadAll(k) + require.NoError(t, err) + assert.Equal(t, "\x1b[Ax", string(got)) + }) +} + +// TestDecodeKey pins the decoder, which only ever sees the canonical forms. +func TestDecodeKey(t *testing.T) { + for _, tt := range []struct { + name string + input string + want rune + size int + }{ + {"rune", "a", 'a', 1}, + {"utf8-rune", "é", 'é', 2}, + {"enter", "\r", '\r', 1}, + {"ctrl-c", "\x03", ctrlC, 1}, + {"up", "\x1b[A", keyUp, 3}, + {"left", "\x1b[D", keyLeft, 3}, + {"home", "\x1b[H", keyHome, 3}, + {"end", "\x1b[F", keyEnd, 3}, + {"delete", "\x1b[3~", keyDelete, 4}, + {"alt-left", "\x1b[1;3D", keyWordLeft, 6}, + {"alt-right", "\x1b[1;3C", keyWordRight, 6}, + {"unknown-sequence-is-skipped", "\x1b[9~", 0, 4}, + } { + t.Run(tt.name, func(t *testing.T) { + r, n := decodeKey([]byte(tt.input)) + assert.Equal(t, tt.size, n, "bytes consumed") + assert.Equal(t, tt.want, r) + }) + } +} + +// exitTransport reports a fixed outcome for every command. +type exitTransport struct { + code int + err error +} + +func (t exitTransport) Exec(context.Context, Streams, string, map[string]string, []string) (int, error) { + return t.code, t.err +} + +func (exitTransport) ReadFile(context.Context, string) ([]byte, error) { return nil, nil } + +func (exitTransport) WriteFile(context.Context, string, []byte, bool) error { return nil } + +// TestScriptErrors pins how a filesystem handler reports trouble: a command +// that exits non-zero means the path is not there, but the instance being +// unreachable has to say so rather than claim a missing file. +func TestScriptErrors(t *testing.T) { + t.Run("non-zero-exit-is-a-missing-path", func(t *testing.T) { + s := &session{runner: &interp.Runner{Dir: "/"}, cfg: Config{Transport: exitTransport{code: 1}}} + + _, err := s.stat(t.Context(), "/nope", true) + assert.ErrorIs(t, err, fs.ErrNotExist) + }) + + t.Run("an-unreachable-instance-says-so", func(t *testing.T) { + boom := errors.New("504 Gateway Time-out") + s := &session{runner: &interp.Runner{Dir: "/"}, cfg: Config{Transport: exitTransport{err: boom}}} + + _, err := s.readDir(t.Context(), "/") + require.ErrorIs(t, err, boom) + assert.NotErrorIs(t, err, fs.ErrNotExist) + }) +} + +func TestSequenceLen(t *testing.T) { + for _, tt := range []struct { + name string + input string + want int + ok bool + }{ + {"csi", "\x1b[A", 3, true}, + {"csi-with-params", "\x1b[1;3C", 6, true}, + {"csi-tilde", "\x1b[3~", 4, true}, + {"ss3", "\x1bOA", 3, true}, + {"two-byte", "\x1bb", 2, true}, + } { + t.Run(tt.name, func(t *testing.T) { + n, ok := sequenceLen([]byte(tt.input)) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, n) + }) + } +} + +func TestCommonPrefix(t *testing.T) { + assert.Equal(t, "ho", commonPrefix([]string{"host", "hound"})) + assert.Equal(t, "only", commonPrefix([]string{"only"})) + assert.Empty(t, commonPrefix([]string{"a", "b"})) +} + +// TestStdinReachesTheCommand guards that a pipe or a file is handed to the +// command untouched; only a terminal is lent out per command by the pump. +func TestStdinReachesTheCommand(t *testing.T) { + root := newFixture(t) + + var out captured + err := Run(t.Context(), Config{ + Instance: "fake", + Dir: root, + Command: "cat", + Transport: localTransport{}, + }, config.Stdio{Stdin: strings.NewReader("payload\n"), Stdout: &out, Stderr: &out}) + require.NoError(t, err) + + assert.Equal(t, "payload\n", out.String()) +} + +// TestStdinPump pins the handover: a chunk read while one consumer is leaving +// goes to the next rather than being lost, which is what keeps a line typed as +// a command exits from disappearing. +func TestStdinPump(t *testing.T) { + t.Run("serves-a-consumer", func(t *testing.T) { + p := newStdinPump(strings.NewReader("hello")) + defer p.close() + + got, err := io.ReadAll(p.readerFor(t.Context())) + require.NoError(t, err) + assert.Equal(t, "hello", string(got)) + }) + + t.Run("hands-the-remainder-on", func(t *testing.T) { + p := newStdinPump(strings.NewReader("abcdef")) + defer p.close() + + // A consumer that takes only part of a chunk before leaving. + short := make([]byte, 2) + n, err := p.readerFor(t.Context()).Read(short) + require.NoError(t, err) + assert.Equal(t, "ab", string(short[:n])) + + rest, err := io.ReadAll(p.readerFor(t.Context())) + require.NoError(t, err) + assert.Equal(t, "cdef", string(rest)) + }) + + t.Run("a-cancelled-consumer-loses-nothing", func(t *testing.T) { + p := newStdinPump(strings.NewReader("kept")) + defer p.close() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := p.readerFor(ctx).Read(make([]byte, 4)) + require.ErrorIs(t, err, context.Canceled) + + got, err := io.ReadAll(p.readerFor(t.Context())) + require.NoError(t, err) + assert.Equal(t, "kept", string(got)) + }) +} diff --git a/internal/shell/stdin.go b/internal/shell/stdin.go new file mode 100644 index 00000000..8b9b275c --- /dev/null +++ b/internal/shell/stdin.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "context" + "errors" + "io" + "sync" + "time" +) + +// stdinPump owns the terminal's input for the whole session and lends it out a +// consumer at a time: the line editor between commands, the running command for +// as long as it lasts. Reading it directly from two places loses keystrokes, +// because a reader blocked on the terminal cannot be called off — here a chunk +// that arrives as one consumer leaves is handed to the next instead. +type stdinPump struct { + chunks chan []byte + done chan struct{} + stop sync.Once + + mu sync.Mutex + held []byte + err error +} + +func newStdinPump(src io.Reader) *stdinPump { + p := &stdinPump{chunks: make(chan []byte), done: make(chan struct{})} + go p.run(src) + return p +} + +func (p *stdinPump) run(src io.Reader) { + defer close(p.chunks) + + for { + buf := make([]byte, 4096) + n, err := src.Read(buf) + if n > 0 { + select { + case p.chunks <- buf[:n]: + case <-p.done: + return + } + } + if err != nil { + p.mu.Lock() + p.err = err + p.mu.Unlock() + return + } + } +} + +func (p *stdinPump) close() { p.stop.Do(func() { close(p.done) }) } + +// Read serves the line editor, which reads until it has a line. +func (p *stdinPump) Read(b []byte) (int, error) { return p.read(context.Background(), b) } + +// readWithin reads only if something arrives before d elapses, which is how a +// lone Escape is told apart from the start of a sequence. +func (p *stdinPump) readWithin(d time.Duration, b []byte) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), d) + defer cancel() + + n, err := p.read(ctx, b) + if errors.Is(err, context.DeadlineExceeded) { + return 0, nil + } + return n, err +} + +// readerFor lends the terminal to a command. When ctx ends the reader stops, +// and whatever it had not passed on waits for the next consumer. +func (p *stdinPump) readerFor(ctx context.Context) io.Reader { + return readerFunc(func(b []byte) (int, error) { return p.read(ctx, b) }) +} + +func (p *stdinPump) read(ctx context.Context, b []byte) (int, error) { + // Checked before anything else: a select would pick at random between a + // waiting chunk and a finished context, and a consumer that is leaving has + // nowhere to put what it is handed. Whatever is waiting keeps for the next. + if err := ctx.Err(); err != nil { + return 0, err + } + + p.mu.Lock() + if len(p.held) > 0 { + n := copy(b, p.held) + p.held = p.held[n:] + p.mu.Unlock() + return n, nil + } + p.mu.Unlock() + + select { + case chunk, ok := <-p.chunks: + if !ok { + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return 0, p.err + } + return 0, io.EOF + } + + n := copy(b, chunk) + if n < len(chunk) { + p.mu.Lock() + p.held = append(p.held, chunk[n:]...) + p.mu.Unlock() + } + return n, nil + + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +type readerFunc func([]byte) (int, error) + +func (f readerFunc) Read(b []byte) (int, error) { return f(b) } diff --git a/internal/shell/styles.go b/internal/shell/styles.go new file mode 100644 index 00000000..4af848bf --- /dev/null +++ b/internal/shell/styles.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package shell + +import ( + "charm.land/lipgloss/v2" + "unikraft.com/x/colors" +) + +// Plain tokens rather than compat.AdaptiveColor: resolving an adaptive colour +// queries the terminal, and the reply would land in the input the prompt is +// reading. +var ( + promptStyle = lipgloss.NewStyle().Foreground(colors.Primary).Bold(true) + promptDirStyle = lipgloss.NewStyle().Foreground(colors.Slate500) + continuationStyle = lipgloss.NewStyle().Foreground(colors.Slate500) + errorStyle = lipgloss.NewStyle().Foreground(colors.Error) + hintStyle = lipgloss.NewStyle().Foreground(colors.Slate500) + + highlightStringStyle = lipgloss.NewStyle().Foreground(colors.Emerald400) + highlightBuiltinStyle = lipgloss.NewStyle().Foreground(colors.Primary).Bold(true) + highlightSpecialStyle = lipgloss.NewStyle().Foreground(colors.Orange400) +)