From 3dc994af41dcfa3a1c9b2c0727a101cf08cdb0d4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Feb 2025 16:17:47 +0000 Subject: [PATCH 1/4] feat(cli): add capability for SSH command to connect to a running container --- agent/agent.go | 2 ++ agent/agentssh/agentssh.go | 60 ++++++++++++++++++++++++------- cli/ssh.go | 45 ++++++++++++++++++++++++ cli/ssh_test.go | 72 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 12 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 504fff2386826..ea9e2b5d2ce62 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -307,6 +307,8 @@ func (a *agent) init() { return a.reportConnection(id, connectionType, ip) }, + + ExperimentalContainersEnabled: a.experimentalDevcontainersEnabled, }) if err != nil { panic(err) diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 4a5d3215db911..81a8d7eee0bbe 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -29,6 +29,7 @@ import ( "cdr.dev/slog" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentrsa" "github.com/coder/coder/v2/agent/usershell" @@ -104,6 +105,9 @@ type Config struct { BlockFileTransfer bool // ReportConnection. ReportConnection reportConnectionFunc + // Experimental: allow connecting to running containers if + // CODER_AGENT_DEVCONTAINERS_ENABLE=true. + ExperimentalContainersEnabled bool } type Server struct { @@ -324,6 +328,22 @@ func (s *sessionCloseTracker) Close() error { return s.Session.Close() } +func extractContainerInfo(env []string) (container, containerUser string, filteredEnv []string) { + for _, kv := range env { + if strings.HasPrefix(kv, "CODER_CONTAINER=") { + container = strings.TrimPrefix(kv, "CODER_CONTAINER=") + } + + if strings.HasPrefix(kv, "CODER_CONTAINER_USER=") { + containerUser = strings.TrimPrefix(kv, "CODER_CONTAINER_USER=") + } + } + + return container, containerUser, slices.DeleteFunc(env, func(kv string) bool { + return strings.HasPrefix(kv, "CODER_CONTAINER=") || strings.HasPrefix(kv, "CODER_CONTAINER_USER=") + }) +} + func (s *Server) sessionHandler(session ssh.Session) { ctx := session.Context() id := uuid.New() @@ -353,6 +373,7 @@ func (s *Server) sessionHandler(session ssh.Session) { defer s.trackSession(session, false) reportSession := true + switch magicType { case MagicSessionTypeVSCode: s.connCountVSCode.Add(1) @@ -395,9 +416,19 @@ func (s *Server) sessionHandler(session ssh.Session) { return } + container, containerUser, env := extractContainerInfo(env) + s.logger.Debug(ctx, "container info", + slog.F("container", container), + slog.F("container_user", containerUser), + ) + switch ss := session.Subsystem(); ss { case "": case "sftp": + if s.config.ExperimentalContainersEnabled && container != "" { + closeCause("sftp not yet supported with containers") + return + } err := s.sftpHandler(logger, session) if err != nil { closeCause(err.Error()) @@ -422,7 +453,7 @@ func (s *Server) sessionHandler(session ssh.Session) { env = append(env, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber)) } - err := s.sessionStart(logger, session, env, magicType) + err := s.sessionStart(logger, session, env, magicType, container, containerUser) var exitError *exec.ExitError if xerrors.As(err, &exitError) { code := exitError.ExitCode() @@ -495,18 +526,28 @@ func (s *Server) fileTransferBlocked(session ssh.Session) bool { return false } -func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType) (retErr error) { +func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType, container, containerUser string) (retErr error) { ctx := session.Context() magicTypeLabel := magicTypeMetricLabel(magicType) sshPty, windowSize, isPty := session.Pty() + ptyLabel := "no" + if isPty { + ptyLabel = "yes" + } - cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, nil) - if err != nil { - ptyLabel := "no" - if isPty { - ptyLabel = "yes" + // plumb in envinfoer here to modify command for container exec? + var ei usershell.EnvInfoer + var err error + if s.config.ExperimentalContainersEnabled && container != "" { + ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser) + if err != nil { + s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1) + return err } + } + cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, ei) + if err != nil { s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "create_command").Add(1) return err } @@ -514,11 +555,6 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str if ssh.AgentRequested(session) { l, err := ssh.NewAgentListener() if err != nil { - ptyLabel := "no" - if isPty { - ptyLabel = "yes" - } - s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "listener").Add(1) return xerrors.Errorf("new agent listener: %w", err) } diff --git a/cli/ssh.go b/cli/ssh.go index 884c5500d703c..91d4216dca928 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -76,6 +76,9 @@ func (r *RootCmd) ssh() *serpent.Command { appearanceConfig codersdk.AppearanceConfig networkInfoDir string networkInfoInterval time.Duration + + container string + containerUser string ) client := new(codersdk.Client) cmd := &serpent.Command{ @@ -282,6 +285,23 @@ func (r *RootCmd) ssh() *serpent.Command { } conn.AwaitReachable(ctx) + if container != "" { + cts, err := client.WorkspaceAgentListContainers(ctx, workspaceAgent.ID, nil) + if err != nil { + return xerrors.Errorf("list containers: %w", err) + } + var found bool + for _, c := range cts.Containers { + if c.FriendlyName == container || c.ID == container { + found = true + break + } + } + if !found { + return xerrors.Errorf("container not found: %q", container) + } + } + stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace) defer stopPolling() @@ -454,6 +474,17 @@ func (r *RootCmd) ssh() *serpent.Command { } } + if container != "" { + for k, v := range map[string]string{ + "CODER_CONTAINER": container, + "CODER_CONTAINER_USER": containerUser, + } { + if err := sshSession.Setenv(k, v); err != nil { + return xerrors.Errorf("setenv: %w", err) + } + } + } + err = sshSession.RequestPty("xterm-256color", 128, 128, gossh.TerminalModes{}) if err != nil { return xerrors.Errorf("request pty: %w", err) @@ -594,6 +625,20 @@ func (r *RootCmd) ssh() *serpent.Command { Default: "5s", Value: serpent.DurationOf(&networkInfoInterval), }, + { + Flag: "container", + FlagShorthand: "c", + Description: "Specifies a container inside the workspace to connect to.", + Value: serpent.StringOf(&container), + Hidden: true, // Hidden until this features is at least in beta. + }, + { + Flag: "container-user", + FlagShorthand: "u", + Description: "When connecting to a container, specifies the user to connect as.", + Value: serpent.StringOf(&containerUser), + Hidden: true, // Hidden until this features is at least in beta. + }, sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)), } return cmd diff --git a/cli/ssh_test.go b/cli/ssh_test.go index d20278bbf7ced..dccce2fc122c5 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -24,6 +24,8 @@ import ( "time" "github.com/google/uuid" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1924,6 +1926,76 @@ Expire-Date: 0 <-cmdDone } +func TestSSH_Container(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" { + t.Skip("Skipping test on non-Linux platform") + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client, workspace, agentToken := setupWorkspaceForAgent(t) + ctx := testutil.Context(t, testutil.WaitLong) + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infnity"}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start container") + // Wait for container to start + require.Eventually(t, func() bool { + ct, ok := pool.ContainerByName(ct.Container.Name) + return ok && ct.Container.State.Running + }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") + t.Cleanup(func() { + err := pool.Purge(ct) + require.NoError(t, err, "Could not stop container") + }) + + _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { + o.ExperimentalContainersEnabled = true + }) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + + inv, root := clitest.New(t, "ssh", workspace.Name, "-c", ct.Container.ID) + clitest.SetupConfig(t, client, root) + ptty := ptytest.New(t).Attach(inv) + + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }) + + ptty.ExpectMatch(" #") + ptty.WriteLine("hostname") + ptty.ExpectMatch(ct.Container.Config.Hostname) + ptty.WriteLine("exit") + <-cmdDone + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + client, workspace, agentToken := setupWorkspaceForAgent(t) + _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { + o.ExperimentalContainersEnabled = true + }) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + + inv, root := clitest.New(t, "ssh", workspace.Name, "-c", uuid.NewString()) + clitest.SetupConfig(t, client, root) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "container not found:") + }) +} + // tGoContext runs fn in a goroutine passing a context that will be // canceled on test completion and wait until fn has finished executing. // Done and cancel are returned for optionally waiting until completion From 56538a975f8bff2dec0cc306087ce693441cbb5b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 27 Feb 2025 15:21:48 +0000 Subject: [PATCH 2/4] fixup! feat(cli): add capability for SSH command to connect to a running container --- agent/agentssh/agentssh.go | 19 +++++++++++++------ cli/ssh.go | 5 +++-- cli/ssh_test.go | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 81a8d7eee0bbe..79bf3b3a01559 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -61,6 +61,14 @@ const ( // MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection. // This is stripped from any commands being executed, and is counted towards connection stats. MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE" + // ContainerEnvironmentVariable is used to specify the target container for an SSH connection. + // This is stripped from any commands being executed. + // Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true. + ContainerEnvironmentVariable = "CODER_CONTAINER" + // ContainerUserEnvironmentVariable is used to specify the container user for + // an SSH connection. + // Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true. + ContainerUserEnvironmentVariable = "CODER_CONTAINER_USER" ) // MagicSessionType enums. @@ -330,17 +338,17 @@ func (s *sessionCloseTracker) Close() error { func extractContainerInfo(env []string) (container, containerUser string, filteredEnv []string) { for _, kv := range env { - if strings.HasPrefix(kv, "CODER_CONTAINER=") { - container = strings.TrimPrefix(kv, "CODER_CONTAINER=") + if strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") { + container = strings.TrimPrefix(kv, ContainerEnvironmentVariable+"=") } - if strings.HasPrefix(kv, "CODER_CONTAINER_USER=") { - containerUser = strings.TrimPrefix(kv, "CODER_CONTAINER_USER=") + if strings.HasPrefix(kv, ContainerUserEnvironmentVariable+"=") { + containerUser = strings.TrimPrefix(kv, ContainerUserEnvironmentVariable+"=") } } return container, containerUser, slices.DeleteFunc(env, func(kv string) bool { - return strings.HasPrefix(kv, "CODER_CONTAINER=") || strings.HasPrefix(kv, "CODER_CONTAINER_USER=") + return strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") || strings.HasPrefix(kv, ContainerUserEnvironmentVariable+"=") }) } @@ -536,7 +544,6 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str ptyLabel = "yes" } - // plumb in envinfoer here to modify command for container exec? var ei usershell.EnvInfoer var err error if s.config.ExperimentalContainersEnabled && container != "" { diff --git a/cli/ssh.go b/cli/ssh.go index 91d4216dca928..05267453998cf 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -34,6 +34,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/agent/agentssh" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/coderd/autobuild/notify" @@ -476,8 +477,8 @@ func (r *RootCmd) ssh() *serpent.Command { if container != "" { for k, v := range map[string]string{ - "CODER_CONTAINER": container, - "CODER_CONTAINER_USER": containerUser, + agentssh.ContainerEnvironmentVariable: container, + agentssh.ContainerUserEnvironmentVariable: containerUser, } { if err := sshSession.Setenv(k, v); err != nil { return xerrors.Errorf("setenv: %w", err) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index dccce2fc122c5..ddb3fa14feb73 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -1994,6 +1994,20 @@ func TestSSH_Container(t *testing.T) { err := inv.WithContext(ctx).Run() require.ErrorContains(t, err, "container not found:") }) + + t.Run("NotEnabled", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + client, workspace, agentToken := setupWorkspaceForAgent(t) + _ = agenttest.New(t, client.URL, agentToken) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + + inv, root := clitest.New(t, "ssh", workspace.Name, "-c", uuid.NewString()) + clitest.SetupConfig(t, client, root) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "container not found:") + }) } // tGoContext runs fn in a goroutine passing a context that will be From 7437e49b7c30cb946922271ffd1be66da7c2b282 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 27 Feb 2025 18:50:17 +0000 Subject: [PATCH 3/4] address PR comments --- agent/agent.go | 12 ++++----- agent/agent_test.go | 2 +- agent/agentssh/agentssh.go | 17 +++++++------ agent/reconnectingpty/server.go | 4 +-- cli/agent.go | 44 ++++++++++++++++----------------- cli/exp_rpty_test.go | 2 +- cli/ssh.go | 34 ++++++++++++++++--------- cli/ssh_test.go | 30 +++++++++++++++++----- 8 files changed, 88 insertions(+), 57 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index ea9e2b5d2ce62..614ae0fdd0e65 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -91,8 +91,8 @@ type Options struct { Execer agentexec.Execer ContainerLister agentcontainers.Lister - ExperimentalContainersEnabled bool - ExperimentalConnectionReports bool + ExperimentalConnectionReports bool + ExperimentalDevcontainersEnabled bool } type Client interface { @@ -156,7 +156,7 @@ func New(options Options) Agent { options.Execer = agentexec.DefaultExecer } if options.ContainerLister == nil { - options.ContainerLister = agentcontainers.NewDocker(options.Execer) + options.ContainerLister = agentcontainers.NoopLister{} } hardCtx, hardCancel := context.WithCancel(context.Background()) @@ -195,7 +195,7 @@ func New(options Options) Agent { execer: options.Execer, lister: options.ContainerLister, - experimentalDevcontainersEnabled: options.ExperimentalContainersEnabled, + experimentalDevcontainersEnabled: options.ExperimentalDevcontainersEnabled, experimentalConnectionReports: options.ExperimentalConnectionReports, } // Initially, we have a closed channel, reflecting the fact that we are not initially connected. @@ -308,7 +308,7 @@ func (a *agent) init() { return a.reportConnection(id, connectionType, ip) }, - ExperimentalContainersEnabled: a.experimentalDevcontainersEnabled, + ExperimentalDevContainersEnabled: a.experimentalDevcontainersEnabled, }) if err != nil { panic(err) @@ -337,7 +337,7 @@ func (a *agent) init() { a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors, a.reconnectingPTYTimeout, func(s *reconnectingpty.Server) { - s.ExperimentalContainersEnabled = a.experimentalDevcontainersEnabled + s.ExperimentalDevcontainersEnabled = a.experimentalDevcontainersEnabled }, ) go a.runLoop() diff --git a/agent/agent_test.go b/agent/agent_test.go index 7ccce20ae776e..6e27f525f8cb4 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1841,7 +1841,7 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { // nolint: dogsled conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalContainersEnabled = true + o.ExperimentalDevcontainersEnabled = true }) ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) { arp.Container = ct.Container.ID diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 79bf3b3a01559..b1a1f32baf032 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -115,7 +115,7 @@ type Config struct { ReportConnection reportConnectionFunc // Experimental: allow connecting to running containers if // CODER_AGENT_DEVCONTAINERS_ENABLE=true. - ExperimentalContainersEnabled bool + ExperimentalDevContainersEnabled bool } type Server struct { @@ -425,16 +425,19 @@ func (s *Server) sessionHandler(session ssh.Session) { } container, containerUser, env := extractContainerInfo(env) - s.logger.Debug(ctx, "container info", - slog.F("container", container), - slog.F("container_user", containerUser), - ) + if container != "" { + s.logger.Debug(ctx, "container info", + slog.F("container", container), + slog.F("container_user", containerUser), + ) + } switch ss := session.Subsystem(); ss { case "": case "sftp": - if s.config.ExperimentalContainersEnabled && container != "" { + if s.config.ExperimentalDevContainersEnabled && container != "" { closeCause("sftp not yet supported with containers") + _ = session.Exit(1) return } err := s.sftpHandler(logger, session) @@ -546,7 +549,7 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str var ei usershell.EnvInfoer var err error - if s.config.ExperimentalContainersEnabled && container != "" { + if s.config.ExperimentalDevContainersEnabled && container != "" { ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser) if err != nil { s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1) diff --git a/agent/reconnectingpty/server.go b/agent/reconnectingpty/server.go index 7ad7db976c8b0..33ed76a73c60e 100644 --- a/agent/reconnectingpty/server.go +++ b/agent/reconnectingpty/server.go @@ -32,7 +32,7 @@ type Server struct { reconnectingPTYs sync.Map timeout time.Duration - ExperimentalContainersEnabled bool + ExperimentalDevcontainersEnabled bool } // NewServer returns a new ReconnectingPTY server @@ -187,7 +187,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co }() var ei usershell.EnvInfoer - if s.ExperimentalContainersEnabled && msg.Container != "" { + if s.ExperimentalDevcontainersEnabled && msg.Container != "" { dei, err := agentcontainers.EnvInfo(ctx, s.commandCreator.Execer, msg.Container, msg.ContainerUser) if err != nil { return xerrors.Errorf("get container env info: %w", err) diff --git a/cli/agent.go b/cli/agent.go index 638f7083805ab..5466ba9a5bc67 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -38,24 +38,24 @@ import ( func (r *RootCmd) workspaceAgent() *serpent.Command { var ( - auth string - logDir string - scriptDataDir string - pprofAddress string - noReap bool - sshMaxTimeout time.Duration - tailnetListenPort int64 - prometheusAddress string - debugAddress string - slogHumanPath string - slogJSONPath string - slogStackdriverPath string - blockFileTransfer bool - agentHeaderCommand string - agentHeader []string - devcontainersEnabled bool - - experimentalConnectionReports bool + auth string + logDir string + scriptDataDir string + pprofAddress string + noReap bool + sshMaxTimeout time.Duration + tailnetListenPort int64 + prometheusAddress string + debugAddress string + slogHumanPath string + slogJSONPath string + slogStackdriverPath string + blockFileTransfer bool + agentHeaderCommand string + agentHeader []string + + experimentalConnectionReports bool + experimentalDevcontainersEnabled bool ) cmd := &serpent.Command{ Use: "agent", @@ -319,7 +319,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { } var containerLister agentcontainers.Lister - if !devcontainersEnabled { + if !experimentalDevcontainersEnabled { logger.Info(ctx, "agent devcontainer detection not enabled") containerLister = &agentcontainers.NoopLister{} } else { @@ -358,8 +358,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { Execer: execer, ContainerLister: containerLister, - ExperimentalContainersEnabled: devcontainersEnabled, - ExperimentalConnectionReports: experimentalConnectionReports, + ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled, + ExperimentalConnectionReports: experimentalConnectionReports, }) promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger) @@ -487,7 +487,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { Default: "false", Env: "CODER_AGENT_DEVCONTAINERS_ENABLE", Description: "Allow the agent to automatically detect running devcontainers.", - Value: serpent.BoolOf(&devcontainersEnabled), + Value: serpent.BoolOf(&experimentalDevcontainersEnabled), }, { Flag: "experimental-connection-reports-enable", diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 782a7b5c08d48..01bfdf39cc739 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -88,7 +88,7 @@ func TestExpRpty(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalContainersEnabled = true + o.ExperimentalDevcontainersEnabled = true }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/ssh.go b/cli/ssh.go index 05267453998cf..da84a7886b048 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -78,7 +78,7 @@ func (r *RootCmd) ssh() *serpent.Command { networkInfoDir string networkInfoInterval time.Duration - container string + containerName string containerUser string ) client := new(codersdk.Client) @@ -286,20 +286,31 @@ func (r *RootCmd) ssh() *serpent.Command { } conn.AwaitReachable(ctx) - if container != "" { + if containerName != "" { cts, err := client.WorkspaceAgentListContainers(ctx, workspaceAgent.ID, nil) if err != nil { return xerrors.Errorf("list containers: %w", err) } + if len(cts.Containers) == 0 { + cliui.Info(inv.Stderr, "No containers found!") + cliui.Info(inv.Stderr, "Tip: Agent container integration is experimental and not enabled by default.") + cliui.Info(inv.Stderr, " To enable it, set CODER_AGENT_DEVCONTAINERS_ENABLE=true in your template.") + return nil + } var found bool for _, c := range cts.Containers { - if c.FriendlyName == container || c.ID == container { + if c.FriendlyName == containerName || c.ID == containerName { found = true break } } if !found { - return xerrors.Errorf("container not found: %q", container) + availableContainers := make([]string, len(cts.Containers)) + for i, c := range cts.Containers { + availableContainers[i] = c.FriendlyName + } + cliui.Errorf(inv.Stderr, "Container not found: %q\nAvailable containers: %v", containerName, availableContainers) + return nil } } @@ -475,9 +486,9 @@ func (r *RootCmd) ssh() *serpent.Command { } } - if container != "" { + if containerName != "" { for k, v := range map[string]string{ - agentssh.ContainerEnvironmentVariable: container, + agentssh.ContainerEnvironmentVariable: containerName, agentssh.ContainerUserEnvironmentVariable: containerUser, } { if err := sshSession.Setenv(k, v); err != nil { @@ -630,15 +641,14 @@ func (r *RootCmd) ssh() *serpent.Command { Flag: "container", FlagShorthand: "c", Description: "Specifies a container inside the workspace to connect to.", - Value: serpent.StringOf(&container), + Value: serpent.StringOf(&containerName), Hidden: true, // Hidden until this features is at least in beta. }, { - Flag: "container-user", - FlagShorthand: "u", - Description: "When connecting to a container, specifies the user to connect as.", - Value: serpent.StringOf(&containerUser), - Hidden: true, // Hidden until this features is at least in beta. + Flag: "container-user", + Description: "When connecting to a container, specifies the user to connect as.", + Value: serpent.StringOf(&containerUser), + Hidden: true, // Hidden until this features is at least in beta. }, sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)), } diff --git a/cli/ssh_test.go b/cli/ssh_test.go index ddb3fa14feb73..8a8d2d6ef3f6f 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -35,6 +35,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentssh" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" @@ -1959,7 +1960,8 @@ func TestSSH_Container(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalContainersEnabled = true + o.ExperimentalDevcontainersEnabled = true + o.ContainerLister = agentcontainers.NewDocker(o.Execer) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() @@ -1985,14 +1987,22 @@ func TestSSH_Container(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) client, workspace, agentToken := setupWorkspaceForAgent(t) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalContainersEnabled = true + o.ExperimentalDevcontainersEnabled = true + o.ContainerLister = agentcontainers.NewDocker(o.Execer) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() inv, root := clitest.New(t, "ssh", workspace.Name, "-c", uuid.NewString()) clitest.SetupConfig(t, client, root) - err := inv.WithContext(ctx).Run() - require.ErrorContains(t, err, "container not found:") + ptty := ptytest.New(t).Attach(inv) + + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }) + + ptty.ExpectMatch("Container not found:") + <-cmdDone }) t.Run("NotEnabled", func(t *testing.T) { @@ -2005,8 +2015,16 @@ func TestSSH_Container(t *testing.T) { inv, root := clitest.New(t, "ssh", workspace.Name, "-c", uuid.NewString()) clitest.SetupConfig(t, client, root) - err := inv.WithContext(ctx).Run() - require.ErrorContains(t, err, "container not found:") + ptty := ptytest.New(t).Attach(inv) + + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }) + + ptty.ExpectMatch("No containers found!") + ptty.ExpectMatch("Tip: Agent container integration is experimental and not enabled by default.") + <-cmdDone }) } From a1d0d00a28b960625f429aeee934010d45f8f79a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 27 Feb 2025 20:27:26 +0000 Subject: [PATCH 4/4] fixup! address PR comments --- cli/exp_rpty_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 01bfdf39cc739..bfede8213d4c9 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -9,6 +9,7 @@ import ( "github.com/ory/dockertest/v3/docker" "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -89,6 +90,7 @@ func TestExpRpty(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true + o.ContainerLister = agentcontainers.NewDocker(o.Execer) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()