Skip to content

Commit 7d4b3c8

Browse files
authored
feat(agent): add devcontainer autostart support (coder#17076)
This change adds support for devcontainer autostart in workspaces. The preconditions for utilizing this feature are: 1. The `coder_devcontainer` resource must be defined in Terraform 2. By the time the startup scripts have completed, - The `@devcontainers/cli` tool must be installed - The given workspace folder must contain a devcontainer configuration Example Terraform: ```tf resource "coder_devcontainer" "coder" { agent_id = coder_agent.main.id workspace_folder = "/home/coder/coder" config_path = ".devcontainer/devcontainer.json" # (optional) } ``` Closes coder#16423
1 parent 2ba3d77 commit 7d4b3c8

File tree

7 files changed

+779
-50
lines changed

7 files changed

+779
-50
lines changed

agent/agent.go

+34-16
Original file line numberDiff line numberDiff line change
@@ -1075,7 +1075,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
10751075
//
10761076
// An example is VS Code Remote, which must know the directory
10771077
// before initializing a connection.
1078-
manifest.Directory, err = expandDirectory(manifest.Directory)
1078+
manifest.Directory, err = expandPathToAbs(manifest.Directory)
10791079
if err != nil {
10801080
return xerrors.Errorf("expand directory: %w", err)
10811081
}
@@ -1115,16 +1115,35 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
11151115
}
11161116
}
11171117

1118-
err = a.scriptRunner.Init(manifest.Scripts, aAPI.ScriptCompleted)
1118+
var (
1119+
scripts = manifest.Scripts
1120+
scriptRunnerOpts []agentscripts.InitOption
1121+
)
1122+
if a.experimentalDevcontainersEnabled {
1123+
var dcScripts []codersdk.WorkspaceAgentScript
1124+
scripts, dcScripts = agentcontainers.ExtractAndInitializeDevcontainerScripts(a.logger, expandPathToAbs, manifest.Devcontainers, scripts)
1125+
// See ExtractAndInitializeDevcontainerScripts for motivation
1126+
// behind running dcScripts as post start scripts.
1127+
scriptRunnerOpts = append(scriptRunnerOpts, agentscripts.WithPostStartScripts(dcScripts...))
1128+
}
1129+
err = a.scriptRunner.Init(scripts, aAPI.ScriptCompleted, scriptRunnerOpts...)
11191130
if err != nil {
11201131
return xerrors.Errorf("init script runner: %w", err)
11211132
}
11221133
err = a.trackGoroutine(func() {
11231134
start := time.Now()
1124-
// here we use the graceful context because the script runner is not directly tied
1125-
// to the agent API.
1135+
// Here we use the graceful context because the script runner is
1136+
// not directly tied to the agent API.
1137+
//
1138+
// First we run the start scripts to ensure the workspace has
1139+
// been initialized and then the post start scripts which may
1140+
// depend on the workspace start scripts.
1141+
//
1142+
// Measure the time immediately after the start scripts have
1143+
// finished (both start and post start). For instance, an
1144+
// autostarted devcontainer will be included in this time.
11261145
err := a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecuteStartScripts)
1127-
// Measure the time immediately after the script has finished
1146+
err = errors.Join(err, a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecutePostStartScripts))
11281147
dur := time.Since(start).Seconds()
11291148
if err != nil {
11301149
a.logger.Warn(ctx, "startup script(s) failed", slog.Error(err))
@@ -1851,30 +1870,29 @@ func userHomeDir() (string, error) {
18511870
return u.HomeDir, nil
18521871
}
18531872

1854-
// expandDirectory converts a directory path to an absolute path.
1855-
// It primarily resolves the home directory and any environment
1856-
// variables that may be set
1857-
func expandDirectory(dir string) (string, error) {
1858-
if dir == "" {
1873+
// expandPathToAbs converts a path to an absolute path. It primarily resolves
1874+
// the home directory and any environment variables that may be set.
1875+
func expandPathToAbs(path string) (string, error) {
1876+
if path == "" {
18591877
return "", nil
18601878
}
1861-
if dir[0] == '~' {
1879+
if path[0] == '~' {
18621880
home, err := userHomeDir()
18631881
if err != nil {
18641882
return "", err
18651883
}
1866-
dir = filepath.Join(home, dir[1:])
1884+
path = filepath.Join(home, path[1:])
18671885
}
1868-
dir = os.ExpandEnv(dir)
1886+
path = os.ExpandEnv(path)
18691887

1870-
if !filepath.IsAbs(dir) {
1888+
if !filepath.IsAbs(path) {
18711889
home, err := userHomeDir()
18721890
if err != nil {
18731891
return "", err
18741892
}
1875-
dir = filepath.Join(home, dir)
1893+
path = filepath.Join(home, path)
18761894
}
1877-
return dir, nil
1895+
return path, nil
18781896
}
18791897

18801898
// EnvAgentSubsystem is the environment variable used to denote the

agent/agent_test.go

+128
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,134 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) {
19371937
require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF)
19381938
}
19391939

1940+
// This tests end-to-end functionality of auto-starting a devcontainer.
1941+
// It runs "devcontainer up" which creates a real Docker container. As
1942+
// such, it does not run by default in CI.
1943+
//
1944+
// You can run it manually as follows:
1945+
//
1946+
// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_DevcontainerAutostart
1947+
func TestAgent_DevcontainerAutostart(t *testing.T) {
1948+
t.Parallel()
1949+
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
1950+
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
1951+
}
1952+
1953+
ctx := testutil.Context(t, testutil.WaitLong)
1954+
1955+
// Connect to Docker
1956+
pool, err := dockertest.NewPool("")
1957+
require.NoError(t, err, "Could not connect to docker")
1958+
1959+
// Prepare temporary devcontainer for test (mywork).
1960+
devcontainerID := uuid.New()
1961+
tempWorkspaceFolder := t.TempDir()
1962+
tempWorkspaceFolder = filepath.Join(tempWorkspaceFolder, "mywork")
1963+
t.Logf("Workspace folder: %s", tempWorkspaceFolder)
1964+
devcontainerPath := filepath.Join(tempWorkspaceFolder, ".devcontainer")
1965+
err = os.MkdirAll(devcontainerPath, 0o755)
1966+
require.NoError(t, err, "create devcontainer directory")
1967+
devcontainerFile := filepath.Join(devcontainerPath, "devcontainer.json")
1968+
err = os.WriteFile(devcontainerFile, []byte(`{
1969+
"name": "mywork",
1970+
"image": "busybox:latest",
1971+
"cmd": ["sleep", "infinity"]
1972+
}`), 0o600)
1973+
require.NoError(t, err, "write devcontainer.json")
1974+
1975+
manifest := agentsdk.Manifest{
1976+
// Set up pre-conditions for auto-starting a devcontainer, the script
1977+
// is expected to be prepared by the provisioner normally.
1978+
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
1979+
{
1980+
ID: devcontainerID,
1981+
Name: "test",
1982+
WorkspaceFolder: tempWorkspaceFolder,
1983+
},
1984+
},
1985+
Scripts: []codersdk.WorkspaceAgentScript{
1986+
{
1987+
ID: devcontainerID,
1988+
LogSourceID: agentsdk.ExternalLogSourceID,
1989+
RunOnStart: true,
1990+
Script: "echo this-will-be-replaced",
1991+
DisplayName: "Dev Container (test)",
1992+
},
1993+
},
1994+
}
1995+
// nolint: dogsled
1996+
conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) {
1997+
o.ExperimentalDevcontainersEnabled = true
1998+
})
1999+
2000+
t.Logf("Waiting for container with label: devcontainer.local_folder=%s", tempWorkspaceFolder)
2001+
2002+
var container docker.APIContainers
2003+
require.Eventually(t, func() bool {
2004+
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{All: true})
2005+
if err != nil {
2006+
t.Logf("Error listing containers: %v", err)
2007+
return false
2008+
}
2009+
2010+
for _, c := range containers {
2011+
t.Logf("Found container: %s with labels: %v", c.ID[:12], c.Labels)
2012+
if labelValue, ok := c.Labels["devcontainer.local_folder"]; ok {
2013+
if labelValue == tempWorkspaceFolder {
2014+
t.Logf("Found matching container: %s", c.ID[:12])
2015+
container = c
2016+
return true
2017+
}
2018+
}
2019+
}
2020+
2021+
return false
2022+
}, testutil.WaitSuperLong, testutil.IntervalMedium, "no container with workspace folder label found")
2023+
2024+
t.Cleanup(func() {
2025+
// We can't rely on pool here because the container is not
2026+
// managed by it (it is managed by @devcontainer/cli).
2027+
err := pool.Client.RemoveContainer(docker.RemoveContainerOptions{
2028+
ID: container.ID,
2029+
RemoveVolumes: true,
2030+
Force: true,
2031+
})
2032+
assert.NoError(t, err, "remove container")
2033+
})
2034+
2035+
containerInfo, err := pool.Client.InspectContainer(container.ID)
2036+
require.NoError(t, err, "inspect container")
2037+
t.Logf("Container state: status: %v", containerInfo.State.Status)
2038+
require.True(t, containerInfo.State.Running, "container should be running")
2039+
2040+
ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "", func(opts *workspacesdk.AgentReconnectingPTYInit) {
2041+
opts.Container = container.ID
2042+
})
2043+
require.NoError(t, err, "failed to create ReconnectingPTY")
2044+
defer ac.Close()
2045+
2046+
// Use terminal reader so we can see output in case somethin goes wrong.
2047+
tr := testutil.NewTerminalReader(t, ac)
2048+
2049+
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
2050+
return strings.Contains(line, "#") || strings.Contains(line, "$")
2051+
}), "find prompt")
2052+
2053+
wantFileName := "file-from-devcontainer"
2054+
wantFile := filepath.Join(tempWorkspaceFolder, wantFileName)
2055+
2056+
require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{
2057+
// NOTE(mafredri): We must use absolute path here for some reason.
2058+
Data: fmt.Sprintf("touch /workspaces/mywork/%s; exit\r", wantFileName),
2059+
}), "create file inside devcontainer")
2060+
2061+
// Wait for the connection to close to ensure the touch was executed.
2062+
require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF)
2063+
2064+
_, err = os.Stat(wantFile)
2065+
require.NoError(t, err, "file should exist outside devcontainer")
2066+
}
2067+
19402068
func TestAgent_Dial(t *testing.T) {
19412069
t.Parallel()
19422070

agent/agentcontainers/devcontainer.go

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package agentcontainers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"cdr.dev/slog"
11+
12+
"github.com/coder/coder/v2/codersdk"
13+
)
14+
15+
const devcontainerUpScriptTemplate = `
16+
if ! which devcontainer > /dev/null 2>&1; then
17+
echo "ERROR: Unable to start devcontainer, @devcontainers/cli is not installed."
18+
exit 1
19+
fi
20+
devcontainer up %s
21+
`
22+
23+
// ExtractAndInitializeDevcontainerScripts extracts devcontainer scripts from
24+
// the given scripts and devcontainers. The devcontainer scripts are removed
25+
// from the returned scripts so that they can be run separately.
26+
//
27+
// Dev Containers have an inherent dependency on start scripts, since they
28+
// initialize the workspace (e.g. git clone, npm install, etc). This is
29+
// important if e.g. a Coder module to install @devcontainer/cli is used.
30+
func ExtractAndInitializeDevcontainerScripts(
31+
logger slog.Logger,
32+
expandPath func(string) (string, error),
33+
devcontainers []codersdk.WorkspaceAgentDevcontainer,
34+
scripts []codersdk.WorkspaceAgentScript,
35+
) (filteredScripts []codersdk.WorkspaceAgentScript, devcontainerScripts []codersdk.WorkspaceAgentScript) {
36+
ScriptLoop:
37+
for _, script := range scripts {
38+
for _, dc := range devcontainers {
39+
// The devcontainer scripts match the devcontainer ID for
40+
// identification.
41+
if script.ID == dc.ID {
42+
dc = expandDevcontainerPaths(logger, expandPath, dc)
43+
devcontainerScripts = append(devcontainerScripts, devcontainerStartupScript(dc, script))
44+
continue ScriptLoop
45+
}
46+
}
47+
48+
filteredScripts = append(filteredScripts, script)
49+
}
50+
51+
return filteredScripts, devcontainerScripts
52+
}
53+
54+
func devcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer, script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript {
55+
var args []string
56+
args = append(args, fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder))
57+
if dc.ConfigPath != "" {
58+
args = append(args, fmt.Sprintf("--config %q", dc.ConfigPath))
59+
}
60+
cmd := fmt.Sprintf(devcontainerUpScriptTemplate, strings.Join(args, " "))
61+
script.Script = cmd
62+
// Disable RunOnStart, scripts have this set so that when devcontainers
63+
// have not been enabled, a warning will be surfaced in the agent logs.
64+
script.RunOnStart = false
65+
return script
66+
}
67+
68+
func expandDevcontainerPaths(logger slog.Logger, expandPath func(string) (string, error), dc codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
69+
logger = logger.With(slog.F("devcontainer", dc.Name), slog.F("workspace_folder", dc.WorkspaceFolder), slog.F("config_path", dc.ConfigPath))
70+
71+
if wf, err := expandPath(dc.WorkspaceFolder); err != nil {
72+
logger.Warn(context.Background(), "expand devcontainer workspace folder failed", slog.Error(err))
73+
} else {
74+
dc.WorkspaceFolder = wf
75+
}
76+
if dc.ConfigPath != "" {
77+
// Let expandPath handle home directory, otherwise assume relative to
78+
// workspace folder or absolute.
79+
if dc.ConfigPath[0] == '~' {
80+
if cp, err := expandPath(dc.ConfigPath); err != nil {
81+
logger.Warn(context.Background(), "expand devcontainer config path failed", slog.Error(err))
82+
} else {
83+
dc.ConfigPath = cp
84+
}
85+
} else {
86+
dc.ConfigPath = relativePathToAbs(dc.WorkspaceFolder, dc.ConfigPath)
87+
}
88+
}
89+
return dc
90+
}
91+
92+
func relativePathToAbs(workdir, path string) string {
93+
path = os.ExpandEnv(path)
94+
if !filepath.IsAbs(path) {
95+
path = filepath.Join(workdir, path)
96+
}
97+
return path
98+
}

0 commit comments

Comments
 (0)