Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ec44f06

Browse files
authored
feat(cli): allow SSH command to connect to running container (#16726)
Fixes #16709 and #16420 Adds the capability to`coder ssh` into a running container if `CODER_AGENT_DEVCONTAINERS_ENABLE=true`. Notes: * SFTP is currently not supported * Haven't tested X11 container forwarding * Haven't tested agent forwarding
1 parent 64fec8b commit ec44f06

File tree

8 files changed

+253
-43
lines changed

8 files changed

+253
-43
lines changed

agent/agent.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ type Options struct {
9191
Execer agentexec.Execer
9292
ContainerLister agentcontainers.Lister
9393

94-
ExperimentalContainersEnabled bool
95-
ExperimentalConnectionReports bool
94+
ExperimentalConnectionReports bool
95+
ExperimentalDevcontainersEnabled bool
9696
}
9797

9898
type Client interface {
@@ -156,7 +156,7 @@ func New(options Options) Agent {
156156
options.Execer = agentexec.DefaultExecer
157157
}
158158
if options.ContainerLister == nil {
159-
options.ContainerLister = agentcontainers.NewDocker(options.Execer)
159+
options.ContainerLister = agentcontainers.NoopLister{}
160160
}
161161

162162
hardCtx, hardCancel := context.WithCancel(context.Background())
@@ -195,7 +195,7 @@ func New(options Options) Agent {
195195
execer: options.Execer,
196196
lister: options.ContainerLister,
197197

198-
experimentalDevcontainersEnabled: options.ExperimentalContainersEnabled,
198+
experimentalDevcontainersEnabled: options.ExperimentalDevcontainersEnabled,
199199
experimentalConnectionReports: options.ExperimentalConnectionReports,
200200
}
201201
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
@@ -307,6 +307,8 @@ func (a *agent) init() {
307307

308308
return a.reportConnection(id, connectionType, ip)
309309
},
310+
311+
ExperimentalDevContainersEnabled: a.experimentalDevcontainersEnabled,
310312
})
311313
if err != nil {
312314
panic(err)
@@ -335,7 +337,7 @@ func (a *agent) init() {
335337
a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors,
336338
a.reconnectingPTYTimeout,
337339
func(s *reconnectingpty.Server) {
338-
s.ExperimentalContainersEnabled = a.experimentalDevcontainersEnabled
340+
s.ExperimentalDevcontainersEnabled = a.experimentalDevcontainersEnabled
339341
},
340342
)
341343
go a.runLoop()

agent/agent_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1841,7 +1841,7 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) {
18411841

18421842
// nolint: dogsled
18431843
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
1844-
o.ExperimentalContainersEnabled = true
1844+
o.ExperimentalDevcontainersEnabled = true
18451845
})
18461846
ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) {
18471847
arp.Container = ct.Container.ID

agent/agentssh/agentssh.go

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929

3030
"cdr.dev/slog"
3131

32+
"github.com/coder/coder/v2/agent/agentcontainers"
3233
"github.com/coder/coder/v2/agent/agentexec"
3334
"github.com/coder/coder/v2/agent/agentrsa"
3435
"github.com/coder/coder/v2/agent/usershell"
@@ -60,6 +61,14 @@ const (
6061
// MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection.
6162
// This is stripped from any commands being executed, and is counted towards connection stats.
6263
MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE"
64+
// ContainerEnvironmentVariable is used to specify the target container for an SSH connection.
65+
// This is stripped from any commands being executed.
66+
// Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true.
67+
ContainerEnvironmentVariable = "CODER_CONTAINER"
68+
// ContainerUserEnvironmentVariable is used to specify the container user for
69+
// an SSH connection.
70+
// Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true.
71+
ContainerUserEnvironmentVariable = "CODER_CONTAINER_USER"
6372
)
6473

6574
// MagicSessionType enums.
@@ -104,6 +113,9 @@ type Config struct {
104113
BlockFileTransfer bool
105114
// ReportConnection.
106115
ReportConnection reportConnectionFunc
116+
// Experimental: allow connecting to running containers if
117+
// CODER_AGENT_DEVCONTAINERS_ENABLE=true.
118+
ExperimentalDevContainersEnabled bool
107119
}
108120

109121
type Server struct {
@@ -324,6 +336,22 @@ func (s *sessionCloseTracker) Close() error {
324336
return s.Session.Close()
325337
}
326338

339+
func extractContainerInfo(env []string) (container, containerUser string, filteredEnv []string) {
340+
for _, kv := range env {
341+
if strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") {
342+
container = strings.TrimPrefix(kv, ContainerEnvironmentVariable+"=")
343+
}
344+
345+
if strings.HasPrefix(kv, ContainerUserEnvironmentVariable+"=") {
346+
containerUser = strings.TrimPrefix(kv, ContainerUserEnvironmentVariable+"=")
347+
}
348+
}
349+
350+
return container, containerUser, slices.DeleteFunc(env, func(kv string) bool {
351+
return strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") || strings.HasPrefix(kv, ContainerUserEnvironmentVariable+"=")
352+
})
353+
}
354+
327355
func (s *Server) sessionHandler(session ssh.Session) {
328356
ctx := session.Context()
329357
id := uuid.New()
@@ -353,6 +381,7 @@ func (s *Server) sessionHandler(session ssh.Session) {
353381
defer s.trackSession(session, false)
354382

355383
reportSession := true
384+
356385
switch magicType {
357386
case MagicSessionTypeVSCode:
358387
s.connCountVSCode.Add(1)
@@ -395,9 +424,22 @@ func (s *Server) sessionHandler(session ssh.Session) {
395424
return
396425
}
397426

427+
container, containerUser, env := extractContainerInfo(env)
428+
if container != "" {
429+
s.logger.Debug(ctx, "container info",
430+
slog.F("container", container),
431+
slog.F("container_user", containerUser),
432+
)
433+
}
434+
398435
switch ss := session.Subsystem(); ss {
399436
case "":
400437
case "sftp":
438+
if s.config.ExperimentalDevContainersEnabled && container != "" {
439+
closeCause("sftp not yet supported with containers")
440+
_ = session.Exit(1)
441+
return
442+
}
401443
err := s.sftpHandler(logger, session)
402444
if err != nil {
403445
closeCause(err.Error())
@@ -422,7 +464,7 @@ func (s *Server) sessionHandler(session ssh.Session) {
422464
env = append(env, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
423465
}
424466

425-
err := s.sessionStart(logger, session, env, magicType)
467+
err := s.sessionStart(logger, session, env, magicType, container, containerUser)
426468
var exitError *exec.ExitError
427469
if xerrors.As(err, &exitError) {
428470
code := exitError.ExitCode()
@@ -495,30 +537,34 @@ func (s *Server) fileTransferBlocked(session ssh.Session) bool {
495537
return false
496538
}
497539

498-
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType) (retErr error) {
540+
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType, container, containerUser string) (retErr error) {
499541
ctx := session.Context()
500542

501543
magicTypeLabel := magicTypeMetricLabel(magicType)
502544
sshPty, windowSize, isPty := session.Pty()
545+
ptyLabel := "no"
546+
if isPty {
547+
ptyLabel = "yes"
548+
}
503549

504-
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, nil)
505-
if err != nil {
506-
ptyLabel := "no"
507-
if isPty {
508-
ptyLabel = "yes"
550+
var ei usershell.EnvInfoer
551+
var err error
552+
if s.config.ExperimentalDevContainersEnabled && container != "" {
553+
ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser)
554+
if err != nil {
555+
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1)
556+
return err
509557
}
558+
}
559+
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, ei)
560+
if err != nil {
510561
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "create_command").Add(1)
511562
return err
512563
}
513564

514565
if ssh.AgentRequested(session) {
515566
l, err := ssh.NewAgentListener()
516567
if err != nil {
517-
ptyLabel := "no"
518-
if isPty {
519-
ptyLabel = "yes"
520-
}
521-
522568
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "listener").Add(1)
523569
return xerrors.Errorf("new agent listener: %w", err)
524570
}

agent/reconnectingpty/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type Server struct {
3232
reconnectingPTYs sync.Map
3333
timeout time.Duration
3434

35-
ExperimentalContainersEnabled bool
35+
ExperimentalDevcontainersEnabled bool
3636
}
3737

3838
// NewServer returns a new ReconnectingPTY server
@@ -187,7 +187,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co
187187
}()
188188

189189
var ei usershell.EnvInfoer
190-
if s.ExperimentalContainersEnabled && msg.Container != "" {
190+
if s.ExperimentalDevcontainersEnabled && msg.Container != "" {
191191
dei, err := agentcontainers.EnvInfo(ctx, s.commandCreator.Execer, msg.Container, msg.ContainerUser)
192192
if err != nil {
193193
return xerrors.Errorf("get container env info: %w", err)

cli/agent.go

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,24 @@ import (
3838

3939
func (r *RootCmd) workspaceAgent() *serpent.Command {
4040
var (
41-
auth string
42-
logDir string
43-
scriptDataDir string
44-
pprofAddress string
45-
noReap bool
46-
sshMaxTimeout time.Duration
47-
tailnetListenPort int64
48-
prometheusAddress string
49-
debugAddress string
50-
slogHumanPath string
51-
slogJSONPath string
52-
slogStackdriverPath string
53-
blockFileTransfer bool
54-
agentHeaderCommand string
55-
agentHeader []string
56-
devcontainersEnabled bool
57-
58-
experimentalConnectionReports bool
41+
auth string
42+
logDir string
43+
scriptDataDir string
44+
pprofAddress string
45+
noReap bool
46+
sshMaxTimeout time.Duration
47+
tailnetListenPort int64
48+
prometheusAddress string
49+
debugAddress string
50+
slogHumanPath string
51+
slogJSONPath string
52+
slogStackdriverPath string
53+
blockFileTransfer bool
54+
agentHeaderCommand string
55+
agentHeader []string
56+
57+
experimentalConnectionReports bool
58+
experimentalDevcontainersEnabled bool
5959
)
6060
cmd := &serpent.Command{
6161
Use: "agent",
@@ -319,7 +319,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
319319
}
320320

321321
var containerLister agentcontainers.Lister
322-
if !devcontainersEnabled {
322+
if !experimentalDevcontainersEnabled {
323323
logger.Info(ctx, "agent devcontainer detection not enabled")
324324
containerLister = &agentcontainers.NoopLister{}
325325
} else {
@@ -358,8 +358,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
358358
Execer: execer,
359359
ContainerLister: containerLister,
360360

361-
ExperimentalContainersEnabled: devcontainersEnabled,
362-
ExperimentalConnectionReports: experimentalConnectionReports,
361+
ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled,
362+
ExperimentalConnectionReports: experimentalConnectionReports,
363363
})
364364

365365
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
@@ -487,7 +487,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
487487
Default: "false",
488488
Env: "CODER_AGENT_DEVCONTAINERS_ENABLE",
489489
Description: "Allow the agent to automatically detect running devcontainers.",
490-
Value: serpent.BoolOf(&devcontainersEnabled),
490+
Value: serpent.BoolOf(&experimentalDevcontainersEnabled),
491491
},
492492
{
493493
Flag: "experimental-connection-reports-enable",

cli/exp_rpty_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/ory/dockertest/v3/docker"
1010

1111
"github.com/coder/coder/v2/agent"
12+
"github.com/coder/coder/v2/agent/agentcontainers"
1213
"github.com/coder/coder/v2/agent/agenttest"
1314
"github.com/coder/coder/v2/cli/clitest"
1415
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -88,7 +89,8 @@ func TestExpRpty(t *testing.T) {
8889
})
8990

9091
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
91-
o.ExperimentalContainersEnabled = true
92+
o.ExperimentalDevcontainersEnabled = true
93+
o.ContainerLister = agentcontainers.NewDocker(o.Execer)
9294
})
9395
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
9496

cli/ssh.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434

3535
"cdr.dev/slog"
3636
"cdr.dev/slog/sloggers/sloghuman"
37+
"github.com/coder/coder/v2/agent/agentssh"
3738
"github.com/coder/coder/v2/cli/cliui"
3839
"github.com/coder/coder/v2/cli/cliutil"
3940
"github.com/coder/coder/v2/coderd/autobuild/notify"
@@ -76,6 +77,9 @@ func (r *RootCmd) ssh() *serpent.Command {
7677
appearanceConfig codersdk.AppearanceConfig
7778
networkInfoDir string
7879
networkInfoInterval time.Duration
80+
81+
containerName string
82+
containerUser string
7983
)
8084
client := new(codersdk.Client)
8185
cmd := &serpent.Command{
@@ -282,6 +286,34 @@ func (r *RootCmd) ssh() *serpent.Command {
282286
}
283287
conn.AwaitReachable(ctx)
284288

289+
if containerName != "" {
290+
cts, err := client.WorkspaceAgentListContainers(ctx, workspaceAgent.ID, nil)
291+
if err != nil {
292+
return xerrors.Errorf("list containers: %w", err)
293+
}
294+
if len(cts.Containers) == 0 {
295+
cliui.Info(inv.Stderr, "No containers found!")
296+
cliui.Info(inv.Stderr, "Tip: Agent container integration is experimental and not enabled by default.")
297+
cliui.Info(inv.Stderr, " To enable it, set CODER_AGENT_DEVCONTAINERS_ENABLE=true in your template.")
298+
return nil
299+
}
300+
var found bool
301+
for _, c := range cts.Containers {
302+
if c.FriendlyName == containerName || c.ID == containerName {
303+
found = true
304+
break
305+
}
306+
}
307+
if !found {
308+
availableContainers := make([]string, len(cts.Containers))
309+
for i, c := range cts.Containers {
310+
availableContainers[i] = c.FriendlyName
311+
}
312+
cliui.Errorf(inv.Stderr, "Container not found: %q\nAvailable containers: %v", containerName, availableContainers)
313+
return nil
314+
}
315+
}
316+
285317
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
286318
defer stopPolling()
287319

@@ -454,6 +486,17 @@ func (r *RootCmd) ssh() *serpent.Command {
454486
}
455487
}
456488

489+
if containerName != "" {
490+
for k, v := range map[string]string{
491+
agentssh.ContainerEnvironmentVariable: containerName,
492+
agentssh.ContainerUserEnvironmentVariable: containerUser,
493+
} {
494+
if err := sshSession.Setenv(k, v); err != nil {
495+
return xerrors.Errorf("setenv: %w", err)
496+
}
497+
}
498+
}
499+
457500
err = sshSession.RequestPty("xterm-256color", 128, 128, gossh.TerminalModes{})
458501
if err != nil {
459502
return xerrors.Errorf("request pty: %w", err)
@@ -594,6 +637,19 @@ func (r *RootCmd) ssh() *serpent.Command {
594637
Default: "5s",
595638
Value: serpent.DurationOf(&networkInfoInterval),
596639
},
640+
{
641+
Flag: "container",
642+
FlagShorthand: "c",
643+
Description: "Specifies a container inside the workspace to connect to.",
644+
Value: serpent.StringOf(&containerName),
645+
Hidden: true, // Hidden until this features is at least in beta.
646+
},
647+
{
648+
Flag: "container-user",
649+
Description: "When connecting to a container, specifies the user to connect as.",
650+
Value: serpent.StringOf(&containerUser),
651+
Hidden: true, // Hidden until this features is at least in beta.
652+
},
597653
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
598654
}
599655
return cmd

0 commit comments

Comments
 (0)