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

Skip to content

Commit 3692436

Browse files
committed
feat(cli): add capability for SSH command to connect to a running container
1 parent 7c035a4 commit 3692436

File tree

4 files changed

+135
-11
lines changed

4 files changed

+135
-11
lines changed

agent/agent.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ func (a *agent) init() {
279279
UpdateEnv: a.updateCommandEnv,
280280
WorkingDirectory: func() string { return a.manifest.Load().Directory },
281281
BlockFileTransfer: a.blockFileTransfer,
282+
283+
ExperimentalContainersEnabled: a.experimentalDevcontainersEnabled,
282284
})
283285
if err != nil {
284286
panic(err)

agent/agentssh/agentssh.go

Lines changed: 53 additions & 11 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"
@@ -100,6 +101,8 @@ type Config struct {
100101
X11DisplayOffset *int
101102
// BlockFileTransfer restricts use of file transfer applications.
102103
BlockFileTransfer bool
104+
105+
ExperimentalContainersEnabled bool
103106
}
104107

105108
type Server struct {
@@ -288,6 +291,22 @@ func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType
288291
})
289292
}
290293

294+
func extractContainerInfo(env []string) (container, containerUser string, filteredEnv []string) {
295+
for _, kv := range env {
296+
if strings.HasPrefix(kv, "CODER_CONTAINER=") {
297+
container = strings.TrimPrefix(kv, "CODER_CONTAINER=")
298+
}
299+
300+
if strings.HasPrefix(kv, "CODER_CONTAINER_USER=") {
301+
containerUser = strings.TrimPrefix(kv, "CODER_CONTAINER_USER=")
302+
}
303+
}
304+
305+
return container, containerUser, slices.DeleteFunc(env, func(kv string) bool {
306+
return strings.HasPrefix(kv, "CODER_CONTAINER=") || strings.HasPrefix(kv, "CODER_CONTAINER_USER=")
307+
})
308+
}
309+
291310
func (s *Server) sessionHandler(session ssh.Session) {
292311
ctx := session.Context()
293312
id := uuid.New()
@@ -310,6 +329,11 @@ func (s *Server) sessionHandler(session ssh.Session) {
310329

311330
env := session.Environ()
312331
magicType, magicTypeRaw, env := extractMagicSessionType(env)
332+
container, containerUser, env := extractContainerInfo(env)
333+
s.logger.Debug(ctx, "container info",
334+
slog.F("container", container),
335+
slog.F("container_user", containerUser),
336+
)
313337

314338
switch magicType {
315339
case MagicSessionTypeVSCode:
@@ -340,6 +364,11 @@ func (s *Server) sessionHandler(session ssh.Session) {
340364
switch ss := session.Subsystem(); ss {
341365
case "":
342366
case "sftp":
367+
if s.config.ExperimentalContainersEnabled && container != "" {
368+
logger.Warn(ctx, "sftp not yet supported with containers")
369+
_ = session.Exit(1)
370+
return
371+
}
343372
s.sftpHandler(logger, session)
344373
return
345374
default:
@@ -359,7 +388,7 @@ func (s *Server) sessionHandler(session ssh.Session) {
359388
env = append(env, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
360389
}
361390

362-
err := s.sessionStart(logger, session, env, magicType)
391+
err := s.sessionStart(logger, session, env, magicType, container, containerUser)
363392
var exitError *exec.ExitError
364393
if xerrors.As(err, &exitError) {
365394
code := exitError.ExitCode()
@@ -429,30 +458,43 @@ func (s *Server) fileTransferBlocked(session ssh.Session) bool {
429458
return false
430459
}
431460

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

435464
magicTypeLabel := magicTypeMetricLabel(magicType)
436465
sshPty, windowSize, isPty := session.Pty()
466+
ptyLabel := "no"
467+
if isPty {
468+
ptyLabel = "yes"
469+
}
437470

471+
// plumb in envinfoer here to modify command for container exec?
438472
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, nil)
439473
if err != nil {
440-
ptyLabel := "no"
441-
if isPty {
442-
ptyLabel = "yes"
443-
}
444474
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "create_command").Add(1)
445475
return err
446476
}
447477

478+
if s.config.ExperimentalContainersEnabled && container != "" {
479+
ei, err := agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser)
480+
if err != nil {
481+
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1)
482+
return err
483+
}
484+
modifiedCmd, modifiedArgs := ei.ModifyCommand(cmd.Path, cmd.Args...)
485+
if modifiedCmd != cmd.Path || slices.Compare(modifiedArgs, cmd.Args) != 0 {
486+
logger.Debug(ctx, "modified command for container",
487+
slog.F("before", append([]string{cmd.Path}, cmd.Args...)),
488+
slog.F("after", append([]string{modifiedCmd}, modifiedArgs...)),
489+
)
490+
cmd.Path = modifiedCmd
491+
cmd.Args = modifiedArgs
492+
}
493+
}
494+
448495
if ssh.AgentRequested(session) {
449496
l, err := ssh.NewAgentListener()
450497
if err != nil {
451-
ptyLabel := "no"
452-
if isPty {
453-
ptyLabel = "yes"
454-
}
455-
456498
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "listener").Add(1)
457499
return xerrors.Errorf("new agent listener: %w", err)
458500
}

cli/ssh.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ func (r *RootCmd) ssh() *serpent.Command {
7676
appearanceConfig codersdk.AppearanceConfig
7777
networkInfoDir string
7878
networkInfoInterval time.Duration
79+
80+
container string
81+
containerUser string
7982
)
8083
client := new(codersdk.Client)
8184
cmd := &serpent.Command{
@@ -454,6 +457,17 @@ func (r *RootCmd) ssh() *serpent.Command {
454457
}
455458
}
456459

460+
if container != "" {
461+
for k, v := range map[string]string{
462+
"CODER_CONTAINER": container,
463+
"CODER_CONTAINER_USER": containerUser,
464+
} {
465+
if err := sshSession.Setenv(k, v); err != nil {
466+
return xerrors.Errorf("setenv: %w", err)
467+
}
468+
}
469+
}
470+
457471
err = sshSession.RequestPty("xterm-256color", 128, 128, gossh.TerminalModes{})
458472
if err != nil {
459473
return xerrors.Errorf("request pty: %w", err)
@@ -594,6 +608,20 @@ func (r *RootCmd) ssh() *serpent.Command {
594608
Default: "5s",
595609
Value: serpent.DurationOf(&networkInfoInterval),
596610
},
611+
{
612+
Flag: "container",
613+
FlagShorthand: "c",
614+
Description: "Specifies a container inside the workspace to connect to.",
615+
Value: serpent.StringOf(&container),
616+
Hidden: true, // Hidden until this features is at least in beta.
617+
},
618+
{
619+
Flag: "container-user",
620+
FlagShorthand: "u",
621+
Description: "When connecting to a container, specifies the user to connect as.",
622+
Value: serpent.StringOf(&containerUser),
623+
Hidden: true, // Hidden until this features is at least in beta.
624+
},
597625
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
598626
}
599627
return cmd

cli/ssh_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import (
2424
"time"
2525

2626
"github.com/google/uuid"
27+
"github.com/ory/dockertest/v3"
28+
"github.com/ory/dockertest/v3/docker"
2729
"github.com/spf13/afero"
2830
"github.com/stretchr/testify/assert"
2931
"github.com/stretchr/testify/require"
@@ -1924,6 +1926,56 @@ Expire-Date: 0
19241926
<-cmdDone
19251927
}
19261928

1929+
func TestSSH_Container(t *testing.T) {
1930+
t.Parallel()
1931+
// if runtime.GOOS != "linux" {
1932+
// t.Skip("Skipping test on non-Linux platform")
1933+
// }
1934+
1935+
client, workspace, agentToken := setupWorkspaceForAgent(t)
1936+
ctx := testutil.Context(t, testutil.WaitLong)
1937+
pool, err := dockertest.NewPool("")
1938+
require.NoError(t, err, "Could not connect to docker")
1939+
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
1940+
Repository: "busybox",
1941+
Tag: "latest",
1942+
Cmd: []string{"sleep", "infnity"},
1943+
}, func(config *docker.HostConfig) {
1944+
config.AutoRemove = true
1945+
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
1946+
})
1947+
require.NoError(t, err, "Could not start container")
1948+
// Wait for container to start
1949+
require.Eventually(t, func() bool {
1950+
ct, ok := pool.ContainerByName(ct.Container.Name)
1951+
return ok && ct.Container.State.Running
1952+
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
1953+
t.Cleanup(func() {
1954+
err := pool.Purge(ct)
1955+
require.NoError(t, err, "Could not stop container")
1956+
})
1957+
1958+
inv, root := clitest.New(t, "ssh", workspace.Name, "-c", ct.Container.ID)
1959+
clitest.SetupConfig(t, client, root)
1960+
ptty := ptytest.New(t).Attach(inv)
1961+
1962+
cmdDone := tGo(t, func() {
1963+
err := inv.WithContext(ctx).Run()
1964+
assert.NoError(t, err)
1965+
})
1966+
1967+
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
1968+
o.ExperimentalContainersEnabled = true
1969+
})
1970+
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
1971+
1972+
ptty.ExpectMatch(" #")
1973+
ptty.WriteLine("hostname")
1974+
ptty.ExpectMatch(ct.Container.Config.Hostname)
1975+
ptty.WriteLine("exit")
1976+
<-cmdDone
1977+
}
1978+
19271979
// tGoContext runs fn in a goroutine passing a context that will be
19281980
// canceled on test completion and wait until fn has finished executing.
19291981
// Done and cancel are returned for optionally waiting until completion

0 commit comments

Comments
 (0)