Skip to content

Commit 7d4b3c8

Browse files
authored
feat(agent): add devcontainer autostart support (#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 #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

Lines changed: 34 additions & 16 deletions
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

Lines changed: 128 additions & 0 deletions
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

Lines changed: 98 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy