mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const devcontainerUpScriptTemplate = `
|
||||
if ! which devcontainer > /dev/null 2>&1; then
|
||||
echo "ERROR: Unable to start devcontainer, @devcontainers/cli is not installed."
|
||||
exit 1
|
||||
fi
|
||||
devcontainer up %s
|
||||
`
|
||||
|
||||
// ExtractAndInitializeDevcontainerScripts extracts devcontainer scripts from
|
||||
// the given scripts and devcontainers. The devcontainer scripts are removed
|
||||
// from the returned scripts so that they can be run separately.
|
||||
//
|
||||
// Dev Containers have an inherent dependency on start scripts, since they
|
||||
// initialize the workspace (e.g. git clone, npm install, etc). This is
|
||||
// important if e.g. a Coder module to install @devcontainer/cli is used.
|
||||
func ExtractAndInitializeDevcontainerScripts(
|
||||
logger slog.Logger,
|
||||
expandPath func(string) (string, error),
|
||||
devcontainers []codersdk.WorkspaceAgentDevcontainer,
|
||||
scripts []codersdk.WorkspaceAgentScript,
|
||||
) (filteredScripts []codersdk.WorkspaceAgentScript, devcontainerScripts []codersdk.WorkspaceAgentScript) {
|
||||
ScriptLoop:
|
||||
for _, script := range scripts {
|
||||
for _, dc := range devcontainers {
|
||||
// The devcontainer scripts match the devcontainer ID for
|
||||
// identification.
|
||||
if script.ID == dc.ID {
|
||||
dc = expandDevcontainerPaths(logger, expandPath, dc)
|
||||
devcontainerScripts = append(devcontainerScripts, devcontainerStartupScript(dc, script))
|
||||
continue ScriptLoop
|
||||
}
|
||||
}
|
||||
|
||||
filteredScripts = append(filteredScripts, script)
|
||||
}
|
||||
|
||||
return filteredScripts, devcontainerScripts
|
||||
}
|
||||
|
||||
func devcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer, script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript {
|
||||
var args []string
|
||||
args = append(args, fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder))
|
||||
if dc.ConfigPath != "" {
|
||||
args = append(args, fmt.Sprintf("--config %q", dc.ConfigPath))
|
||||
}
|
||||
cmd := fmt.Sprintf(devcontainerUpScriptTemplate, strings.Join(args, " "))
|
||||
script.Script = cmd
|
||||
// Disable RunOnStart, scripts have this set so that when devcontainers
|
||||
// have not been enabled, a warning will be surfaced in the agent logs.
|
||||
script.RunOnStart = false
|
||||
return script
|
||||
}
|
||||
|
||||
func expandDevcontainerPaths(logger slog.Logger, expandPath func(string) (string, error), dc codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
logger = logger.With(slog.F("devcontainer", dc.Name), slog.F("workspace_folder", dc.WorkspaceFolder), slog.F("config_path", dc.ConfigPath))
|
||||
|
||||
if wf, err := expandPath(dc.WorkspaceFolder); err != nil {
|
||||
logger.Warn(context.Background(), "expand devcontainer workspace folder failed", slog.Error(err))
|
||||
} else {
|
||||
dc.WorkspaceFolder = wf
|
||||
}
|
||||
if dc.ConfigPath != "" {
|
||||
// Let expandPath handle home directory, otherwise assume relative to
|
||||
// workspace folder or absolute.
|
||||
if dc.ConfigPath[0] == '~' {
|
||||
if cp, err := expandPath(dc.ConfigPath); err != nil {
|
||||
logger.Warn(context.Background(), "expand devcontainer config path failed", slog.Error(err))
|
||||
} else {
|
||||
dc.ConfigPath = cp
|
||||
}
|
||||
} else {
|
||||
dc.ConfigPath = relativePathToAbs(dc.WorkspaceFolder, dc.ConfigPath)
|
||||
}
|
||||
}
|
||||
return dc
|
||||
}
|
||||
|
||||
func relativePathToAbs(workdir, path string) string {
|
||||
path = os.ExpandEnv(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(workdir, path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package agentcontainers_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestExtractAndInitializeDevcontainerScripts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptIDs := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
devcontainerIDs := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
|
||||
type args struct {
|
||||
expandPath func(string) (string, error)
|
||||
devcontainers []codersdk.WorkspaceAgentDevcontainer
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantFilteredScripts []codersdk.WorkspaceAgentScript
|
||||
wantDevcontainerScripts []codersdk.WorkspaceAgentScript
|
||||
|
||||
skipOnWindowsDueToPathSeparator bool
|
||||
}{
|
||||
{
|
||||
name: "no scripts",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: nil,
|
||||
scripts: nil,
|
||||
},
|
||||
wantFilteredScripts: nil,
|
||||
wantDevcontainerScripts: nil,
|
||||
},
|
||||
{
|
||||
name: "no devcontainers",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: nil,
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
wantDevcontainerScripts: nil,
|
||||
},
|
||||
{
|
||||
name: "no scripts match devcontainers",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{ID: devcontainerIDs[0]},
|
||||
{ID: devcontainerIDs[1]},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
wantDevcontainerScripts: nil,
|
||||
},
|
||||
{
|
||||
name: "scripts match devcontainers and sets RunOnStart=false",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{ID: devcontainerIDs[0], WorkspaceFolder: "workspace1"},
|
||||
{ID: devcontainerIDs[1], WorkspaceFolder: "workspace2"},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0], RunOnStart: true},
|
||||
{ID: scriptIDs[1], RunOnStart: true},
|
||||
{ID: devcontainerIDs[0], RunOnStart: true},
|
||||
{ID: devcontainerIDs[1], RunOnStart: true},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0], RunOnStart: true},
|
||||
{ID: scriptIDs[1], RunOnStart: true},
|
||||
},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"workspace1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"workspace2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scripts match devcontainers with config path",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
WorkspaceFolder: "workspace1",
|
||||
ConfigPath: "config1",
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
WorkspaceFolder: "workspace2",
|
||||
ConfigPath: "config2",
|
||||
},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: devcontainerIDs[0]},
|
||||
{ID: devcontainerIDs[1]},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"workspace1\" --config \"workspace1/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"workspace2\" --config \"workspace2/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
skipOnWindowsDueToPathSeparator: true,
|
||||
},
|
||||
{
|
||||
name: "scripts match devcontainers with expand path",
|
||||
args: args{
|
||||
expandPath: func(s string) (string, error) {
|
||||
return "/home/" + s, nil
|
||||
},
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
WorkspaceFolder: "workspace1",
|
||||
ConfigPath: "config1",
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
WorkspaceFolder: "workspace2",
|
||||
ConfigPath: "config2",
|
||||
},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: devcontainerIDs[0], RunOnStart: true},
|
||||
{ID: devcontainerIDs[1], RunOnStart: true},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace1\" --config \"/home/workspace1/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace2\" --config \"/home/workspace2/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
skipOnWindowsDueToPathSeparator: true,
|
||||
},
|
||||
{
|
||||
name: "expand config path when ~",
|
||||
args: args{
|
||||
expandPath: func(s string) (string, error) {
|
||||
s = strings.Replace(s, "~/", "", 1)
|
||||
if filepath.IsAbs(s) {
|
||||
return s, nil
|
||||
}
|
||||
return "/home/" + s, nil
|
||||
},
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
WorkspaceFolder: "workspace1",
|
||||
ConfigPath: "~/config1",
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
WorkspaceFolder: "workspace2",
|
||||
ConfigPath: "/config2",
|
||||
},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: devcontainerIDs[0], RunOnStart: true},
|
||||
{ID: devcontainerIDs[1], RunOnStart: true},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace1\" --config \"/home/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace2\" --config \"/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
skipOnWindowsDueToPathSeparator: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if tt.skipOnWindowsDueToPathSeparator && filepath.Separator == '\\' {
|
||||
t.Skip("Skipping test on Windows due to path separator difference.")
|
||||
}
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
if tt.args.expandPath == nil {
|
||||
tt.args.expandPath = func(s string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
gotFilteredScripts, gotDevcontainerScripts := agentcontainers.ExtractAndInitializeDevcontainerScripts(
|
||||
logger,
|
||||
tt.args.expandPath,
|
||||
tt.args.devcontainers,
|
||||
tt.args.scripts,
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(tt.wantFilteredScripts, gotFilteredScripts, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("ExtractAndInitializeDevcontainerScripts() gotFilteredScripts mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Preprocess the devcontainer scripts to remove scripting part.
|
||||
for i := range gotDevcontainerScripts {
|
||||
gotDevcontainerScripts[i].Script = textGrep("devcontainer up", gotDevcontainerScripts[i].Script)
|
||||
require.NotEmpty(t, gotDevcontainerScripts[i].Script, "devcontainer up script not found")
|
||||
}
|
||||
if diff := cmp.Diff(tt.wantDevcontainerScripts, gotDevcontainerScripts); diff != "" {
|
||||
t.Errorf("ExtractAndInitializeDevcontainerScripts() gotDevcontainerScripts mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// textGrep returns matching lines from multiline string.
|
||||
func textGrep(want, got string) (filtered string) {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
if strings.Contains(line, want) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
Reference in New Issue
Block a user