diff --git a/cmd/container/container.go b/cmd/container/container.go new file mode 100644 index 000000000..8462be768 --- /dev/null +++ b/cmd/container/container.go @@ -0,0 +1,19 @@ +package container + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/envfile" + "github.com/spf13/cobra" +) + +func NewContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "container", + Short: "Inspect the current container", + PersistentPreRun: func(_ *cobra.Command, _ []string) { + envfile.Apply() + }, + } + cmd.AddCommand(newInfoCmd(globalFlags)) + return cmd +} diff --git a/cmd/container/info.go b/cmd/container/info.go new file mode 100644 index 000000000..be662947c --- /dev/null +++ b/cmd/container/info.go @@ -0,0 +1,104 @@ +package container + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "al.essio.dev/pkg/shellescape" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/clierr" + "github.com/devsy-org/devsy/pkg/config" + devconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/table" + "github.com/spf13/cobra" +) + +type containerInfo struct { + SSHCommand string `json:"sshCommand"` + SSHHost string `json:"sshHost"` + RemoteUser string `json:"remoteUser"` +} + +func newInfoCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + return &cobra.Command{ + Use: "info", + Short: "Show this container's SSH connection information", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mode, err := output.ResolveMode(globalFlags.ResultFormat) + if err != nil { + return err + } + info, err := readContainerInfo(cmd.Context()) + if err != nil { + classified := clierr.Classify(err) + if classified.Code == clierr.CodeUnknown { + classified.Code = "container_info_failed" + } + return classified + } + return writeContainerInfo(cmd.OutOrStdout(), mode, info) + }, + } +} + +func readContainerInfo(ctx context.Context) (*containerInfo, error) { + workspaceID := os.Getenv(config.EnvWorkspaceID) + if workspaceID == "" { + return nil, fmt.Errorf("%s is missing; run inside a Devsy container", config.EnvWorkspaceID) + } + result, err := readInfoSetupResult(ctx) + if err != nil { + return nil, err + } + info := &containerInfo{ + SSHHost: workspaceID + config.SSHHostSuffix, + RemoteUser: devconfig.GetRemoteUser(result), + } + info.SSHCommand = "ssh " + shellescape.Quote(info.RemoteUser+"@"+info.SSHHost) + return info, nil +} + +func readInfoSetupResult(ctx context.Context) (*devconfig.Result, error) { + command := exec.CommandContext(ctx, "sh") + command.Args = append(command.Args, "-c", config.ReadDevContainerResultCommand()) + raw, err := command.Output() + if err != nil { + if exit, ok := errors.AsType[*exec.ExitError](err); ok { + return nil, fmt.Errorf( + "read selected container setup result: %w: %s", + err, + strings.TrimSpace(string(exit.Stderr)), + ) + } + return nil, fmt.Errorf("read selected container setup result: %w", err) + } + result := &devconfig.Result{} + if err := json.Unmarshal(raw, result); err != nil { + return nil, fmt.Errorf("parse container setup result: %w", err) + } + if result.MergedConfig == nil || result.ContainerDetails == nil || result.Error != "" { + return nil, fmt.Errorf("container setup result is incomplete or failed") + } + return result, nil +} + +func writeContainerInfo(w io.Writer, mode string, info *containerInfo) error { + if mode == output.ModeJSON { + return json.NewEncoder(w).Encode(info) + } + rows := [][]string{ + {"SSH command", info.SSHCommand}, + {"SSH host", info.SSHHost}, + {"Remote user", info.RemoteUser}, + } + _, err := fmt.Fprintln(w, table.Render([]string{"Field", "Value"}, rows)) + return err +} diff --git a/cmd/container/info_test.go b/cmd/container/info_test.go new file mode 100644 index 000000000..96cbd928d --- /dev/null +++ b/cmd/container/info_test.go @@ -0,0 +1,116 @@ +package container + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "al.essio.dev/pkg/shellescape" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/clierr" + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/require" +) + +const ( + infoWorkspaceID = "test-workspace" + infoIncompleteResult = "incomplete or failed" + infoSetupFixture = `{ + "MergedConfig":{"remoteUser":"configured-user","containerUser":"other-user","remoteEnv":{"TOKEN":"do-not-output"}}, + "ContainerDetails":{"Config":{"User":"image-user"}} + }` +) + +func containerInfoFixture(t *testing.T, result string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("container commands require sh") + } + dir := t.TempDir() + fixture := filepath.Join(dir, "setup-result.json") + require.NoError(t, os.WriteFile(fixture, []byte(result), 0o600)) + script := "#!/bin/sh\nexec /bin/cat " + shellescape.Quote(fixture) + "\n" + file, err := os.CreateTemp(dir, "sh-*") + require.NoError(t, err) + _, err = file.WriteString(script) + require.NoError(t, err) + require.NoError(t, file.Chmod(0o700)) + require.NoError(t, file.Close()) + require.NoError(t, os.Symlink(file.Name(), filepath.Join(dir, "sh"))) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(config.EnvWorkspaceID, infoWorkspaceID) + t.Setenv("DEVSY_UI", "") + t.Setenv("LC_ALL", "C") + t.Setenv("USER", "wrong-os-user") + t.Setenv("LOGNAME", "wrong-os-user") + return fixture +} + +func executeContainerInfo(t *testing.T, format string) (string, error) { + t.Helper() + command := newInfoCmd(&flags.GlobalFlags{ResultFormat: format}) + command.SetContext(t.Context()) + var out bytes.Buffer + command.SetOut(&out) + command.SetErr(&out) + err := command.RunE(command, nil) + return out.String(), err +} + +func TestContainerInfo(t *testing.T) { + containerInfoFixture(t, infoSetupFixture) + raw, err := executeContainerInfo(t, "json") + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &got)) + require.Equal(t, map[string]any{ + "sshCommand": "ssh configured-user@test-workspace.devsy", + "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", + }, got) + text, err := executeContainerInfo(t, "plain") + require.NoError(t, err) + for _, value := range []string{ + "Field", "Value", "SSH command", "SSH host", "Remote user", + "ssh configured-user@test-workspace.devsy", + } { + require.Contains(t, text, value) + } + for _, omitted := range []string{"do-not-output", "wrong-os-user"} { + require.NotContains(t, text, omitted) + } +} + +func TestContainerInfoSetupFailures(t *testing.T) { + for _, tt := range []struct{ name, result, workspaceID, want string }{ + {"missing workspace", infoSetupFixture, "", config.EnvWorkspaceID}, + {"invalid JSON", "{", infoWorkspaceID, "parse container setup result"}, + {"null result", "null", infoWorkspaceID, infoIncompleteResult}, + {"missing config", `{"ContainerDetails":{}}`, infoWorkspaceID, infoIncompleteResult}, + {"missing container", `{"MergedConfig":{}}`, infoWorkspaceID, infoIncompleteResult}, + { + "failed setup", `{"MergedConfig":{},"ContainerDetails":{},"Error":"private-error"}`, + infoWorkspaceID, infoIncompleteResult, + }, + } { + t.Run(tt.name, func(t *testing.T) { + containerInfoFixture(t, tt.result) + t.Setenv(config.EnvWorkspaceID, tt.workspaceID) + raw, err := executeContainerInfo(t, "json") + require.ErrorContains(t, err, tt.want) + require.Empty(t, raw) + }) + } +} + +func TestContainerInfoReadFailure(t *testing.T) { + fixture := containerInfoFixture(t, infoSetupFixture) + require.NoError(t, os.Remove(fixture)) + raw, err := executeContainerInfo(t, "json") + require.ErrorContains(t, err, "read selected container setup result") + require.ErrorContains(t, err, "No such file or directory") + require.Equal(t, clierr.Code("container_info_failed"), clierr.Classify(err).Code) + require.Empty(t, raw) +} diff --git a/cmd/container_test.go b/cmd/container_test.go new file mode 100644 index 000000000..cf56335f8 --- /dev/null +++ b/cmd/container_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestContainerInfoFailureRendering(t *testing.T) { + t.Setenv(config.EnvWorkspaceID, "") + root, _ := BuildRoot() + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{ + "container", "info", "--home", t.TempDir(), + "--context", "not-configured", "--provider", "not-configured", "--result-format", "json", + }) + err := root.Execute() + require.ErrorContains(t, err, config.EnvWorkspaceID) + require.Empty(t, out.String()) + for _, machine := range []bool{false, true} { + reader, writer, pipeErr := os.Pipe() + require.NoError(t, pipeErr) + original := os.Stderr + os.Stderr = writer + code := exitCodeForError(err, machine) + os.Stderr = original + require.NoError(t, writer.Close()) + raw, readErr := io.ReadAll(reader) + require.NoError(t, readErr) + require.NoError(t, reader.Close()) + require.Equal(t, 1, code) + require.Contains(t, string(raw), config.EnvWorkspaceID) + require.Contains(t, string(raw), "container_info_failed") + } +} diff --git a/cmd/root.go b/cmd/root.go index f51c8927d..2d5f10247 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/devsy-org/devsy/cmd/ci" "github.com/devsy-org/devsy/cmd/completion" cliconfig "github.com/devsy-org/devsy/cmd/config" + "github.com/devsy-org/devsy/cmd/container" "github.com/devsy-org/devsy/cmd/context" envcmd "github.com/devsy-org/devsy/cmd/env" "github.com/devsy-org/devsy/cmd/feature" @@ -421,6 +422,7 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) rootCmd.AddCommand( ci.NewCICmd(globalFlags), cliconfig.NewConfigCmd(globalFlags), + container.NewContainerCmd(globalFlags), context.NewContextCmd(globalFlags), envcmd.NewEnvCmd(globalFlags), feature.NewFeatureCmd(globalFlags), diff --git a/e2e/tests/up/up.go b/e2e/tests/up/up.go index 157e944db..1632c2223 100644 --- a/e2e/tests/up/up.go +++ b/e2e/tests/up/up.go @@ -146,7 +146,10 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-workspaces"), fun }, ginkgo.SpecTimeout(framework.TimeoutShort())) ginkgo.It("create workspace without devcontainer.json", func(ctx context.Context) { - const providerName = "test-docker" + const ( + providerName = "test-docker" + remoteUser = "vscode" + ) f := framework.NewDefaultFramework(initialDir + "/bin") tempDir, err := framework.CopyToTempDir("tests/up/testdata/no-devcontainer") @@ -169,6 +172,23 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-workspaces"), fun workspace, err := f.FindWorkspace(ctx, tempDir) framework.ExpectNoError(err) + infoOutput, err := f.DevsySSH( + ctx, + workspace.ID, + "devsy container info --result-format json", + ) + framework.ExpectNoError(err) + sshHost := workspace.ID + pkgconfig.SSHHostSuffix + var info struct { + SSHCommand string `json:"sshCommand"` + SSHHost string `json:"sshHost"` + RemoteUser string `json:"remoteUser"` + } + framework.ExpectNoError(json.Unmarshal([]byte(infoOutput), &info)) + gomega.Expect(info.SSHHost).To(gomega.Equal(sshHost)) + gomega.Expect(info.RemoteUser).To(gomega.Equal(remoteUser)) + gomega.Expect(info.SSHCommand).To(gomega.Equal("ssh " + remoteUser + "@" + sshHost)) + projectName := workspace.ID eventsOutput, err := f.ExecCommandOutput(ctx, []string{ cmdWorkspace, "events", "--result-format", "json", projectName, diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx index afe4241e8..72acb0f05 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx @@ -59,6 +59,12 @@ devsy workspace ssh my-workspace devsy workspace ssh my-workspace --command "echo Hello World" ``` +If you are already inside a Devsy container and need the host and remote user for a manually configured IDE connection, run this from the container terminal: + +```sh +devsy container info +``` + ## IDE options Each IDE has options such as the version. List and change them with: