mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(cli): allow SSH command to connect to running container (#16726)
Fixes https://github.com/coder/coder/issues/16709 and https://github.com/coder/coder/issues/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
This commit is contained in:
+21
-21
@@ -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
|
||||
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
|
||||
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",
|
||||
|
||||
@@ -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"
|
||||
@@ -88,7 +89,8 @@ func TestExpRpty(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()
|
||||
|
||||
|
||||
+56
@@ -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"
|
||||
@@ -76,6 +77,9 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
networkInfoDir string
|
||||
networkInfoInterval time.Duration
|
||||
|
||||
containerName string
|
||||
containerUser string
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -282,6 +286,34 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
conn.AwaitReachable(ctx)
|
||||
|
||||
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 == containerName || c.ID == containerName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
@@ -454,6 +486,17 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if containerName != "" {
|
||||
for k, v := range map[string]string{
|
||||
agentssh.ContainerEnvironmentVariable: containerName,
|
||||
agentssh.ContainerUserEnvironmentVariable: 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 +637,19 @@ 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(&containerName),
|
||||
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)),
|
||||
}
|
||||
return cmd
|
||||
|
||||
+104
@@ -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"
|
||||
@@ -33,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"
|
||||
@@ -1924,6 +1927,107 @@ 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.ExperimentalDevcontainersEnabled = true
|
||||
o.ContainerLister = agentcontainers.NewDocker(o.Execer)
|
||||
})
|
||||
_ = 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.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)
|
||||
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) {
|
||||
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)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user