Skip to content
147 changes: 147 additions & 0 deletions cmd/container_info_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package cmd

import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"testing"

"al.essio.dev/pkg/shellescape"
"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()
root, _ := BuildRoot()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
args := []string{
"container",
"info",
"--home",
t.TempDir(),
"--context",
"not-configured",
"--provider",
"not-configured",
"--result-format",
format,
}
root.SetArgs(args)
err := root.Execute()
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.Empty(t, raw)
}

func TestContainerInfoFailureRendering(t *testing.T) {
fixture := containerInfoFixture(t, infoSetupFixture)
require.NoError(t, os.Remove(fixture))
_, err := executeContainerInfo(t, "json")
require.Error(t, err)
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), "read selected container setup result")
require.Contains(t, string(raw), "No such file or directory")
}
}
16 changes: 16 additions & 0 deletions cmd/internal/container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cmdinternal

import (
"github.com/devsy-org/devsy/cmd/flags"
"github.com/spf13/cobra"
)

func NewContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "container",
Short: "Inspect the current container",
PersistentPreRunE: agentPreRunE(globalFlags),
}
cmd.AddCommand(newContainerInfoCmd(globalFlags))
return cmd
}
104 changes: 104 additions & 0 deletions cmd/internal/container_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmdinternal

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 newContainerInfoCmd(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
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags)
update.NewUpdateCmd(),
wsCmdPkg.NewWorkspaceCmd(globalFlags),
cmdinternal.NewInternalCmd(globalFlags),
cmdinternal.NewContainerCmd(globalFlags),
)
if proEnabled() {
rootCmd.AddCommand(pro.NewProCmd(globalFlags))
Expand Down
1 change: 1 addition & 0 deletions e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
_ "github.com/devsy-org/devsy/e2e/tests/ci"
_ "github.com/devsy-org/devsy/e2e/tests/configapply"
_ "github.com/devsy-org/devsy/e2e/tests/configread"
_ "github.com/devsy-org/devsy/e2e/tests/containerinfo"
_ "github.com/devsy-org/devsy/e2e/tests/context"
_ "github.com/devsy-org/devsy/e2e/tests/delivery"
_ "github.com/devsy-org/devsy/e2e/tests/dockerbuildkit"
Expand Down
107 changes: 107 additions & 0 deletions e2e/tests/containerinfo/container_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package containerinfo

import (
"context"
"encoding/json"
"os"
"path/filepath"

"al.essio.dev/pkg/shellescape"
"github.com/devsy-org/devsy/e2e/framework"
"github.com/devsy-org/devsy/pkg/config"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

var _ = ginkgo.Describe("container connection information", ginkgo.Label("container-info"), func() {
ginkgo.It(
"should show the container's SSH connection details",
ginkgo.SpecTimeout(framework.TimeoutShort()),
ginkgo.Label("integration"),
func(ctx context.Context) {
t := ginkgo.GinkgoT()
dir := t.TempDir()
fixture := filepath.Join(dir, "setup-result.json")
framework.ExpectNoError(
os.WriteFile(
fixture,
[]byte(
`{
"MergedConfig":{"remoteUser":"configured-user","remoteEnv":{"TOKEN":"do-not-output"}},
"ContainerDetails":{"Config":{"User":"image-user"}}
}`,
),
0o600,
),
)
script, err := os.CreateTemp(dir, "setup-reader-*")
framework.ExpectNoError(err)
_, err = script.WriteString(
"#!/bin/sh\nexec /bin/cat " + shellescape.Quote(fixture) + "\n",
)
framework.ExpectNoError(err)
framework.ExpectNoError(script.Chmod(0o700))
framework.ExpectNoError(script.Close())
framework.ExpectNoError(os.Symlink(script.Name(), filepath.Join(dir, "sh")))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(config.EnvWorkspaceID, "test-workspace")
t.Setenv("USER", "wrong-os-user")
t.Setenv("LOGNAME", "wrong-os-user")
t.Setenv("DEVSY_UI", "")
cwd, err := os.Getwd()
framework.ExpectNoError(err)
f := framework.NewDefaultFramework(filepath.Join(cwd, "bin"))
args := []string{
"container",
"info",
"--home",
t.TempDir(),
"--context",
"not-configured",
"--provider",
"not-configured",
"--result-format",
"json",
}
stdout, stderr, err := f.ExecCommandCapture(ctx, args)
framework.ExpectNoError(err)
gomega.Expect(stderr).To(gomega.BeEmpty())
var got map[string]any
framework.ExpectNoError(json.Unmarshal([]byte(stdout), &got))
gomega.Expect(got).To(gomega.Equal(map[string]any{
"sshCommand": "ssh configured-user@test-workspace.devsy",
"sshHost": "test-workspace.devsy", "remoteUser": "configured-user",
}))
args[len(args)-1] = "plain"
stdout, stderr, err = f.ExecCommandCapture(ctx, args)
framework.ExpectNoError(err)
gomega.Expect(stderr).To(gomega.BeEmpty())
for _, value := range []string{
"Field", "Value", "SSH command", "SSH host", "Remote user",
"ssh configured-user@test-workspace.devsy",
} {
gomega.Expect(stdout).To(gomega.ContainSubstring(value))
}
for _, omitted := range []string{
"do-not-output", "wrong-os-user",
} {
gomega.Expect(stdout).NotTo(gomega.ContainSubstring(omitted))
}
t.Setenv(config.EnvWorkspaceID, "")
stdout, stderr, err = f.ExecCommandCapture(
ctx,
[]string{
"container",
"info",
"--home",
t.TempDir(),
"--result-format",
"json",
},
)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(stdout).To(gomega.BeEmpty())
gomega.Expect(stderr).To(gomega.ContainSubstring(config.EnvWorkspaceID))
},
)
})
Loading
Loading