Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit7d4b3c8

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

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
iferr!=nil {
10801080
returnxerrors.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+
ifa.experimentalDevcontainersEnabled {
1123+
vardcScripts []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
iferr!=nil {
11201131
returnxerrors.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
iferr!=nil {
11301149
a.logger.Warn(ctx,"startup script(s) failed",slog.Error(err))
@@ -1851,30 +1870,29 @@ func userHomeDir() (string, error) {
18511870
returnu.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-
funcexpandDirectory(dirstring) (string,error) {
1858-
ifdir=="" {
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+
funcexpandPathToAbs(pathstring) (string,error) {
1876+
ifpath=="" {
18591877
return"",nil
18601878
}
1861-
ifdir[0]=='~' {
1879+
ifpath[0]=='~' {
18621880
home,err:=userHomeDir()
18631881
iferr!=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
iferr!=nil {
18731891
return"",err
18741892
}
1875-
dir=filepath.Join(home,dir)
1893+
path=filepath.Join(home,path)
18761894
}
1877-
returndir,nil
1895+
returnpath,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+
funcTestAgent_DevcontainerAutostart(t*testing.T) {
1948+
t.Parallel()
1949+
ifos.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+
varcontainer docker.APIContainers
2003+
require.Eventually(t,func()bool {
2004+
containers,err:=pool.Client.ListContainers(docker.ListContainersOptions{All:true})
2005+
iferr!=nil {
2006+
t.Logf("Error listing containers: %v",err)
2007+
returnfalse
2008+
}
2009+
2010+
for_,c:=rangecontainers {
2011+
t.Logf("Found container: %s with labels: %v",c.ID[:12],c.Labels)
2012+
iflabelValue,ok:=c.Labels["devcontainer.local_folder"];ok {
2013+
iflabelValue==tempWorkspaceFolder {
2014+
t.Logf("Found matching container: %s",c.ID[:12])
2015+
container=c
2016+
returntrue
2017+
}
2018+
}
2019+
}
2020+
2021+
returnfalse
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+
deferac.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(linestring)bool {
2050+
returnstrings.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
funcTestAgent_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+
constdevcontainerUpScriptTemplate=`
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+
funcExtractAndInitializeDevcontainerScripts(
31+
logger slog.Logger,
32+
expandPathfunc(string) (string,error),
33+
devcontainers []codersdk.WorkspaceAgentDevcontainer,
34+
scripts []codersdk.WorkspaceAgentScript,
35+
) (filteredScripts []codersdk.WorkspaceAgentScript,devcontainerScripts []codersdk.WorkspaceAgentScript) {
36+
ScriptLoop:
37+
for_,script:=rangescripts {
38+
for_,dc:=rangedevcontainers {
39+
// The devcontainer scripts match the devcontainer ID for
40+
// identification.
41+
ifscript.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+
returnfilteredScripts,devcontainerScripts
52+
}
53+
54+
funcdevcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer,script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript {
55+
varargs []string
56+
args=append(args,fmt.Sprintf("--workspace-folder %q",dc.WorkspaceFolder))
57+
ifdc.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+
returnscript
66+
}
67+
68+
funcexpandDevcontainerPaths(logger slog.Logger,expandPathfunc(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+
ifwf,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+
ifdc.ConfigPath!="" {
77+
// Let expandPath handle home directory, otherwise assume relative to
78+
// workspace folder or absolute.
79+
ifdc.ConfigPath[0]=='~' {
80+
ifcp,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+
returndc
90+
}
91+
92+
funcrelativePathToAbs(workdir,pathstring)string {
93+
path=os.ExpandEnv(path)
94+
if!filepath.IsAbs(path) {
95+
path=filepath.Join(workdir,path)
96+
}
97+
returnpath
98+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp