Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cli/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func newWorkspaceCmd(g *GlobalOpts) *cobra.Command {
Use: "workspace",
Short: "Workspace-level lifecycle (teardown)",
}
cmd.AddCommand(newWorkspaceDestroyCmd(g))
cmd.AddCommand(newWorkspaceDestroyCmd(g), newWorkspaceListCmd(g))
return cmd
}

Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func NewRootCmd(opts Options) *cobra.Command {
root.AddCommand(
newUpCmd(g),
newDownCmd(g),
newShellCmd(g),
newStatusCmd(g),
newDnsCmd(g),
newTrustCmd(g),
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/self.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command {
var (
check bool
pin string
force bool
)
cmd := &cobra.Command{
Use: "update",
Expand All @@ -65,7 +66,7 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command {
if check {
return newSelfCheckCmd(g).RunE(cmd, nil)
}
res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin})
res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin, Force: force})
if err != nil {
return err
}
Expand Down Expand Up @@ -96,5 +97,6 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command {
}
cmd.Flags().BoolVar(&check, "check", false, "only check for a newer version; do not install")
cmd.Flags().StringVar(&pin, "version", "", "install a specific release tag (e.g. v0.2.0)")
cmd.Flags().BoolVar(&force, "force", false, "re-install even when already up to date (still refuses package-managed installs)")
return cmd
}
200 changes: 200 additions & 0 deletions internal/cli/shell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package cli

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/config"
"github.com/open-source-cloud/devstack/internal/docker"
"github.com/open-source-cloud/devstack/internal/generate"
)

// newShellCmd wires `devstack shell <service> [--project P] [-- cmd...]` (spec 26):
// an interactive exec into a running service container of the current (or
// --project) project stack. It shells `docker compose exec` with all three std
// streams inherited (a real -it TTY) via the InteractiveRunner and propagates the
// container's exit code verbatim. With no `-- cmd` it opens a login shell
// (bash→sh). A non-TTY invocation errors clearly rather than hanging.
func newShellCmd(g *GlobalOpts) *cobra.Command {
var project string
cmd := &cobra.Command{
Use: "shell [service] [-- cmd...]",
Short: "Open an interactive shell (or run a command) in a service container",
Long: "shell execs into a running service container of this workspace. With one\n" +
"service it defaults to it; with many, name the service (completion helps).\n" +
"With no `-- cmd` it opens a login shell (bash, falling back to sh); pass\n" +
"`shell <service> -- <cmd...>` to run a one-off command instead. Requires an\n" +
"interactive terminal.",
// Args are the service name and, after `--`, the command. Cobra puts args
// after `--` in ArgsLenAtDash; we split them ourselves below.
ValidArgsFunction: shellServiceCompletion(&project),
RunE: func(cmd *cobra.Command, args []string) error {
// Split the positional service arg from the post-`--` command.
service, command := splitShellArgs(cmd, args)

cwd, err := os.Getwd()
if err != nil {
return err
}
model, err := config.Load(cwd)
if err != nil {
return err
}
proj, svc, err := resolveShellTarget(model, project, service)
if err != nil {
return err
}

// An interactive `compose exec -it` needs a real terminal on stdin; a
// pipe/CI would otherwise hang or fail opaquely. Fail clearly first.
if !isTerminal(os.Stdin) {
return fmt.Errorf("shell needs an interactive terminal (stdin is not a TTY); " +
"run it from a real terminal, or use a lifecycle hook / `docker compose exec -T` for scripted commands")
}

outDir := filepath.Join(model.ProjectDir(proj), generate.GenDir)
composeFile := filepath.Join(outDir, generate.ComposeFile)
if _, err := os.Stat(composeFile); err != nil {
composeFile = "" // fall back to label-driven `compose -p <proj> exec`
}
run := command
if len(run) == 0 {
run = defaultShellCmd()
}
cp := docker.Compose{
Project: "devstack-" + proj,
File: composeFile,
Dir: outDir,
Runner: docker.InteractiveRunner{},
}
err = cp.Exec(cmd.Context(), svc, true, run...)
if err != nil {
// Propagate the container's exit code verbatim (spec 26): a non-zero
// shell exit is the shell's outcome, not a devstack failure.
var ee *exec.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
return err
}
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "project whose stack to exec into (default: the only project, else required)")
return cmd
}

// splitShellArgs separates the positional service name (before `--`) from the
// command to run (after `--`). Cobra records the `--` position in ArgsLenAtDash.
func splitShellArgs(cmd *cobra.Command, args []string) (service string, command []string) {
dash := cmd.ArgsLenAtDash()
if dash < 0 {
// No `--`: the first arg (if any) is the service; anything else is ignored.
if len(args) > 0 {
return args[0], nil
}
return "", nil
}
if dash > 0 {
service = args[0]
}
command = args[dash:]
return service, command
}

// resolveShellTarget resolves the project + service to exec into. project defaults
// to the workspace's only project (else it is required); service defaults to the
// project's only service (else it is required). Errors list the choices.
func resolveShellTarget(m *config.Model, project, service string) (proj, svc string, err error) {
proj = project
if proj == "" {
names := sortedProjectNames(m)
switch len(names) {
case 0:
return "", "", fmt.Errorf("this workspace has no projects")
case 1:
proj = names[0]
default:
return "", "", fmt.Errorf("workspace has multiple projects (%v); pass --project", names)
}
}
p, ok := m.Projects[proj]
if !ok {
return "", "", fmt.Errorf("project %q is not in this workspace", proj)
}
svc = service
if svc == "" {
names := sortedServiceNames(p.Services)
switch len(names) {
case 0:
return "", "", fmt.Errorf("project %q has no services", proj)
case 1:
svc = names[0]
default:
return "", "", fmt.Errorf("project %q has multiple services (%v); name one", proj, names)
}
return proj, svc, nil
}
if _, ok := p.Services[svc]; !ok {
return "", "", fmt.Errorf("service %q is not in project %q (have %v)", svc, proj, sortedServiceNames(p.Services))
}
return proj, svc, nil
}

// defaultShellCmd is the login-shell command when no `-- cmd` is given: prefer
// bash, fall back to sh, probed inside the container in a single exec
// (Q-SHELL-DEFAULT-CMD).
func defaultShellCmd() []string {
return []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
}

// shellServiceCompletion completes the service argument with the workspace's
// service names (of --project, or all projects), per spec 07's ValidArgsFunction.
func shellServiceCompletion(project *string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cwd, err := os.Getwd()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
m, err := config.Load(cwd)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
set := map[string]bool{}
for pname, p := range m.Projects {
if *project != "" && pname != *project {
continue
}
for sname := range p.Services {
set[sname] = true
}
}
out := make([]string, 0, len(set))
for s := range set {
out = append(out, s)
}
sort.Strings(out)
return out, cobra.ShellCompDirectiveNoFileComp
}
}

// isTerminal reports whether f is a character device (a TTY), using only stdlib.
func isTerminal(f *os.File) bool {
if f == nil {
return false
}
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
151 changes: 151 additions & 0 deletions internal/cli/shell_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package cli

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/open-source-cloud/devstack/internal/config"
)

// writeWorkspace writes a workspace.yaml + one or more project devstack.yaml files
// under a fresh temp dir and returns the root. projects maps project name → body.
func writeWS(t *testing.T, workspaceYAML string, projects map[string]string) string {
t.Helper()
root := t.TempDir()
write := func(rel, body string) {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("workspace.yaml", workspaceYAML)
for name, body := range projects {
write(filepath.Join(name, "devstack.yaml"), body)
}
return root
}

func TestShellRegistered(t *testing.T) {
if !findCmd(t, "shell") {
t.Fatal("shell must be a real RunE command (graduated from stubs)")
}
// And it must NOT appear as a stub anymore.
root := NewRootCmd(Options{})
for _, c := range root.Commands() {
if c.Name() == "shell" && c.RunE == nil {
t.Fatal("shell is still a stub group")
}
}
}

func TestResolveShellTarget(t *testing.T) {
oneSvc := "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n"
twoSvc := "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n api: { template: node.vite }\n"

t.Run("single project single service defaults", func(t *testing.T) {
root := writeWS(t,
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
map[string]string{"app": oneSvc})
m := mustLoad(t, root)
proj, svc, err := resolveShellTarget(m, "", "")
if err != nil || proj != "app" || svc != "web" {
t.Fatalf("resolve = (%q,%q,%v), want app/web", proj, svc, err)
}
})

t.Run("multi service requires explicit name", func(t *testing.T) {
root := writeWS(t,
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
map[string]string{"app": twoSvc})
m := mustLoad(t, root)
if _, _, err := resolveShellTarget(m, "", ""); err == nil {
t.Fatal("multi-service project must require an explicit service")
}
proj, svc, err := resolveShellTarget(m, "", "api")
if err != nil || proj != "app" || svc != "api" {
t.Fatalf("resolve with name = (%q,%q,%v)", proj, svc, err)
}
})

t.Run("unknown service errors", func(t *testing.T) {
root := writeWS(t,
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
map[string]string{"app": oneSvc})
m := mustLoad(t, root)
if _, _, err := resolveShellTarget(m, "", "nope"); err == nil {
t.Fatal("unknown service must error")
}
})

t.Run("multi project requires --project", func(t *testing.T) {
root := writeWS(t,
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n - { name: svc, path: svc }\n",
map[string]string{
"app": oneSvc,
"svc": "apiVersion: devstack/v1\nkind: Project\nname: svc\nservices:\n worker: { template: node.vite }\n",
})
m := mustLoad(t, root)
if _, _, err := resolveShellTarget(m, "", ""); err == nil {
t.Fatal("multi-project workspace must require --project")
}
proj, svc, err := resolveShellTarget(m, "svc", "")
if err != nil || proj != "svc" || svc != "worker" {
t.Fatalf("resolve with --project = (%q,%q,%v)", proj, svc, err)
}
})
}

func TestDefaultShellCmd(t *testing.T) {
cmd := defaultShellCmd()
if len(cmd) < 3 || cmd[0] != "sh" || cmd[1] != "-c" {
t.Fatalf("defaultShellCmd = %v, want an sh -c probe", cmd)
}
if !strings.Contains(cmd[2], "bash") || !strings.Contains(cmd[2], "sh") {
t.Errorf("default shell probe should prefer bash, fall back to sh: %q", cmd[2])
}
}

func TestShellNonTTYErrorsClearly(t *testing.T) {
root := writeWS(t,
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
map[string]string{"app": "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n"})
t.Chdir(root)

// Force a non-TTY stdin (a pipe is not a character device) so the check is
// deterministic regardless of how the tests are launched.
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer func() { _ = w.Close(); _ = r.Close() }()
old := os.Stdin
os.Stdin = r
defer func() { os.Stdin = old }()

rootCmd := NewRootCmd(Options{})
var out strings.Builder
rootCmd.SetArgs([]string{"shell", "web"})
rootCmd.SetOut(&out)
rootCmd.SetErr(&out)
err = rootCmd.Execute()
if err == nil {
t.Fatal("shell in a non-TTY must error, not hang")
}
if !strings.Contains(err.Error(), "TTY") && !strings.Contains(err.Error(), "terminal") {
t.Errorf("non-TTY error should mention the terminal requirement: %v", err)
}
}

func mustLoad(t *testing.T, root string) *config.Model {
t.Helper()
m, err := config.LoadAt(root)
if err != nil {
t.Fatalf("load %s: %v", root, err)
}
return m
}
Loading
Loading