diff --git a/cmd/container/container.go b/cmd/container/container.go new file mode 100644 index 0000000000..d6d679a283 --- /dev/null +++ b/cmd/container/container.go @@ -0,0 +1,29 @@ +package container + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/envfile" + "github.com/spf13/cobra" +) + +// NewContainerCmd returns the container command. Cobra runs only the nearest +// persistent pre-run hook, so the root hook is chained explicitly to keep +// log-level validation and context-deletion recovery in effect. +func NewContainerCmd( + globalFlags *flags.GlobalFlags, + rootPreRunE func(*cobra.Command, []string) error, +) *cobra.Command { + cmd := &cobra.Command{ + Use: "container", + Short: "Inspect the current container", + PersistentPreRunE: func(command *cobra.Command, args []string) error { + envfile.Apply() + if rootPreRunE != nil { + return rootPreRunE(command, args) + } + return nil + }, + } + cmd.AddCommand(newInfoCmd(globalFlags)) + return cmd +} diff --git a/cmd/container/info.go b/cmd/container/info.go new file mode 100644 index 0000000000..50353f186b --- /dev/null +++ b/cmd/container/info.go @@ -0,0 +1,108 @@ +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; Devsy workspace identity is unavailable here. "+ + "recreate Docker Compose containers created before it was injected", + 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 0000000000..96cbd928db --- /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 0000000000..00a2ad604d --- /dev/null +++ b/cmd/container_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/require" +) + +const ( + containerCommandName = "container" + infoCommandName = "info" +) + +func TestContainerInfoFailureRendering(t *testing.T) { + t.Setenv(config.EnvWorkspaceID, "") + // --home sets process-wide EnvHome; register a restore before it leaks. + t.Setenv(config.EnvHome, "") + root, _ := BuildRoot() + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{ + containerCommandName, infoCommandName, "--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") + } +} + +func TestContainerInfoRunsRootValidation(t *testing.T) { + t.Setenv(config.EnvWorkspaceID, "") + root, _ := BuildRoot() + root.SetArgs([]string{ + containerCommandName, infoCommandName, "--log-level", "definitely-invalid", + }) + err := root.Execute() + require.ErrorContains(t, err, "invalid log level") +} diff --git a/cmd/root.go b/cmd/root.go index f51c8927d8..7e4da50ad9 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, rootCmd.PersistentPreRunE), context.NewContextCmd(globalFlags), envcmd.NewEnvCmd(globalFlags), feature.NewFeatureCmd(globalFlags), diff --git a/cmd/root_recovery_test.go b/cmd/root_recovery_test.go index 8e6dad7871..a6c6b542f3 100644 --- a/cmd/root_recovery_test.go +++ b/cmd/root_recovery_test.go @@ -56,6 +56,11 @@ func TestStartupFencesInterruptedContextDeletion(t *testing.T) { blob, err := os.ReadFile(blobPath) require.NoError(t, err) require.Equal(t, "not an encrypted store", string(blob)) + // container info must not bypass the recovery gate via its own pre-run hook. + t.Setenv(config.EnvWorkspaceID, "") + root, _ = BuildRoot() + root.SetArgs([]string{containerCommandName, infoCommandName}) + require.ErrorIs(t, root.Execute(), config.ErrContextDeletionPending) } func TestStartupRejectsMalformedDeletionIntentWithoutLeakingContent(t *testing.T) { diff --git a/e2e/tests/up-docker-compose/config.go b/e2e/tests/up-docker-compose/config.go index b4033589c2..58f4228ac8 100644 --- a/e2e/tests/up-docker-compose/config.go +++ b/e2e/tests/up-docker-compose/config.go @@ -284,6 +284,34 @@ var _ = ginkgo.Describe( []string{cmdWorkspace, cmdSSH, flagCommand, "echo $FOO", workspace.ID}, ) framework.ExpectNoError(err) + + identity, err := tc.f.DevsySSH( + ctx, + workspace.ID, + "echo $"+pkgconfig.EnvWorkspaceID+" $"+pkgconfig.EnvWorkspaceUID, + ) + framework.ExpectNoError(err) + gomega.Expect(strings.Fields(identity)).To( + gomega.Equal([]string{workspace.ID, workspace.UID}), + ) + + infoOutput, err := tc.f.DevsySSH( + ctx, + workspace.ID, + "devsy container info --result-format json", + ) + framework.ExpectNoError(err) + 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(workspace.ID + pkgconfig.SSHHostSuffix)) + gomega.Expect(info.RemoteUser).NotTo(gomega.BeEmpty()) + gomega.Expect(info.SSHCommand).To( + gomega.Equal("ssh " + info.RemoteUser + "@" + info.SSHHost), + ) }, ginkgo.SpecTimeout(framework.TimeoutLong())) ginkgo.It("user", func(ctx context.Context) { diff --git a/e2e/tests/up/up.go b/e2e/tests/up/up.go index 157e944db2..1632c22232 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/pkg/devcontainer/compose_up.go b/pkg/devcontainer/compose_up.go index 6de8812ca6..f1079f4fc4 100644 --- a/pkg/devcontainer/compose_up.go +++ b/pkg/devcontainer/compose_up.go @@ -1,6 +1,7 @@ package devcontainer import ( + "maps" "reflect" "strings" @@ -62,10 +63,15 @@ func (r *runner) generateDockerComposeUpProject(params *composeUpParams) *compos params.imageDetails, ) + // Copy so the workspace identity does not leak into the shared merged config. + containerEnv := make(map[string]string, len(mergedConfig.ContainerEnv)+2) + maps.Copy(containerEnv, mergedConfig.ContainerEnv) + r.addWorkspaceEnvVars(containerEnv) + overrideService := &composetypes.ServiceConfig{ Name: composeService.Name, Entrypoint: buildOverrideEntrypoint(mergedConfig, userEntrypoint), - Environment: mappingFromMap(mergedConfig.ContainerEnv), + Environment: mappingFromMap(containerEnv), Init: mergedConfig.Init, CapAdd: mergedConfig.CapAdd, SecurityOpt: mergedConfig.SecurityOpt, diff --git a/pkg/devcontainer/compose_up_test.go b/pkg/devcontainer/compose_up_test.go new file mode 100644 index 0000000000..11de0a77ca --- /dev/null +++ b/pkg/devcontainer/compose_up_test.go @@ -0,0 +1,101 @@ +package devcontainer + +import ( + "testing" + + composetypes "github.com/compose-spec/compose-go/v2/types" + pkgconfig "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/require" +) + +const ( + composeUpTestWorkspaceID = "workspace-name" + composeUpTestWorkspaceUID = "workspace-uid" + composeUpTestUserKey = "FOO" + composeUpTestUserValue = "bar" +) + +func generateComposeTestEnvironment( + t *testing.T, + workspaceConfig *provider.AgentWorkspaceInfo, + containerEnv map[string]string, +) composetypes.MappingWithEquals { + t.Helper() + workspaceConfig.CLIOptions.GPUAvailability = stringFalse + r := &runner{workspaceConfig: workspaceConfig} + mergedConfig := &config.MergedDevContainerConfig{} + mergedConfig.ContainerEnv = containerEnv + project := r.generateDockerComposeUpProject(&composeUpParams{ + parsedConfig: &config.SubstitutedConfig{Config: &config.DevContainerConfig{}}, + composeService: &composetypes.ServiceConfig{ + Name: composeSecretTestServiceName, + }, + imageDetails: &config.ImageDetails{}, + mergedConfig: mergedConfig, + }) + return project.Services[composeSecretTestServiceName].Environment +} + +func testWorkspaceInfo() *provider.AgentWorkspaceInfo { + return &provider.AgentWorkspaceInfo{ + Workspace: &provider.Workspace{ + ID: composeUpTestWorkspaceID, + UID: composeUpTestWorkspaceUID, + }, + } +} + +func userContainerEnv() map[string]string { + return map[string]string{composeUpTestUserKey: composeUpTestUserValue} +} + +func requireEnvValue(t *testing.T, env composetypes.MappingWithEquals, key, want string) { + t.Helper() + got, ok := env[key] + require.True(t, ok, "%s missing from environment", key) + require.NotNil(t, got) + require.Equal(t, want, *got) +} + +func TestGenerateDockerComposeUpProjectInjectsWorkspaceIdentity(t *testing.T) { + env := generateComposeTestEnvironment(t, testWorkspaceInfo(), nil) + requireEnvValue(t, env, pkgconfig.EnvWorkspaceID, composeUpTestWorkspaceID) + requireEnvValue(t, env, pkgconfig.EnvWorkspaceUID, composeUpTestWorkspaceUID) +} + +func TestGenerateDockerComposeUpProjectPreservesUserContainerEnv(t *testing.T) { + env := generateComposeTestEnvironment(t, testWorkspaceInfo(), userContainerEnv()) + requireEnvValue(t, env, composeUpTestUserKey, composeUpTestUserValue) + requireEnvValue(t, env, pkgconfig.EnvWorkspaceID, composeUpTestWorkspaceID) +} + +func TestGenerateDockerComposeUpProjectWorkspaceIdentityWinsOverUserEnv(t *testing.T) { + env := generateComposeTestEnvironment(t, testWorkspaceInfo(), map[string]string{ + pkgconfig.EnvWorkspaceID: "user-id", + pkgconfig.EnvWorkspaceUID: "user-uid", + }) + requireEnvValue(t, env, pkgconfig.EnvWorkspaceID, composeUpTestWorkspaceID) + requireEnvValue(t, env, pkgconfig.EnvWorkspaceUID, composeUpTestWorkspaceUID) +} + +func TestGenerateDockerComposeUpProjectDoesNotMutateMergedContainerEnv(t *testing.T) { + containerEnv := userContainerEnv() + generateComposeTestEnvironment(t, testWorkspaceInfo(), containerEnv) + require.Equal(t, userContainerEnv(), containerEnv) +} + +func TestGenerateDockerComposeUpProjectWithoutWorkspaceConfig(t *testing.T) { + for name, workspaceConfig := range map[string]*provider.AgentWorkspaceInfo{ + "no workspace": {}, + "empty details": {Workspace: &provider.Workspace{}}, + } { + t.Run(name, func(t *testing.T) { + env := generateComposeTestEnvironment(t, workspaceConfig, userContainerEnv()) + requireEnvValue(t, env, composeUpTestUserKey, composeUpTestUserValue) + require.NotContains(t, env, pkgconfig.EnvWorkspaceID) + require.NotContains(t, env, pkgconfig.EnvWorkspaceUID) + }) + } +} 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 afe4241e81..c118739074 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 @@ -46,7 +46,7 @@ Fleet works only through a manually added SSH connection to `WORKSPACE_NAME.devs ### SSH -Devsy adds a `WORKSPACE_NAME.devsy` entry to `~/.ssh/config` when it creates a workspace. +When SSH configuration is enabled, Devsy adds a `WORKSPACE_NAME.devsy` entry to `~/.ssh/config` on the machine running Devsy when it creates a workspace. ```sh ssh WORKSPACE_NAME.devsy @@ -59,6 +59,19 @@ 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 +devsy container info --result-format json +``` + +Run `devsy container info` inside the workspace container, not on your local machine. The printed `ssh` command works only from a client that has the matching Devsy SSH configuration on its host and a reachable transport. The `.devsy` suffix is an SSH alias that Devsy writes to the SSH config, not a network name that resolves on its own. + +When Devsy runs on a remote VM, a separate laptop or IDE client needs its own working SSH transport or host configuration. `devsy container info` does not configure it. + +Docker Compose workspaces created before Devsy injected `DEVSY_WORKSPACE_ID` into the service lack that variable, so `devsy container info` fails in them. Recreate the workspace to fix it. + ## IDE options Each IDE has options such as the version. List and change them with: