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:
@@ -80,6 +80,21 @@ func New(opts Options) *Runner {
|
||||
|
||||
type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error)
|
||||
|
||||
type runnerScript struct {
|
||||
runOnPostStart bool
|
||||
codersdk.WorkspaceAgentScript
|
||||
}
|
||||
|
||||
func toRunnerScript(scripts ...codersdk.WorkspaceAgentScript) []runnerScript {
|
||||
var rs []runnerScript
|
||||
for _, s := range scripts {
|
||||
rs = append(rs, runnerScript{
|
||||
WorkspaceAgentScript: s,
|
||||
})
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
Options
|
||||
|
||||
@@ -90,7 +105,7 @@ type Runner struct {
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
scripts []runnerScript
|
||||
dataDir string
|
||||
scriptCompleted ScriptCompletedFunc
|
||||
|
||||
@@ -119,16 +134,35 @@ func (r *Runner) RegisterMetrics(reg prometheus.Registerer) {
|
||||
reg.MustRegister(r.scriptsExecuted)
|
||||
}
|
||||
|
||||
// InitOption describes an option for the runner initialization.
|
||||
type InitOption func(*Runner)
|
||||
|
||||
// WithPostStartScripts adds scripts that should be run after the workspace
|
||||
// start scripts but before the workspace is marked as started.
|
||||
func WithPostStartScripts(scripts ...codersdk.WorkspaceAgentScript) InitOption {
|
||||
return func(r *Runner) {
|
||||
for _, s := range scripts {
|
||||
r.scripts = append(r.scripts, runnerScript{
|
||||
runOnPostStart: true,
|
||||
WorkspaceAgentScript: s,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the runner with the provided scripts.
|
||||
// It also schedules any scripts that have a schedule.
|
||||
// This function must be called before Execute.
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc) error {
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc, opts ...InitOption) error {
|
||||
if r.initialized.Load() {
|
||||
return xerrors.New("init: already initialized")
|
||||
}
|
||||
r.initialized.Store(true)
|
||||
r.scripts = scripts
|
||||
r.scripts = toRunnerScript(scripts...)
|
||||
r.scriptCompleted = scriptCompleted
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir))
|
||||
|
||||
err := r.Filesystem.MkdirAll(r.ScriptBinDir(), 0o700)
|
||||
@@ -136,13 +170,13 @@ func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted S
|
||||
return xerrors.Errorf("create script bin dir: %w", err)
|
||||
}
|
||||
|
||||
for _, script := range scripts {
|
||||
for _, script := range r.scripts {
|
||||
if script.Cron == "" {
|
||||
continue
|
||||
}
|
||||
script := script
|
||||
_, err := r.cron.AddFunc(script.Cron, func() {
|
||||
err := r.trackRun(r.cronCtx, script, ExecuteCronScripts)
|
||||
err := r.trackRun(r.cronCtx, script.WorkspaceAgentScript, ExecuteCronScripts)
|
||||
if err != nil {
|
||||
r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err))
|
||||
}
|
||||
@@ -186,6 +220,7 @@ type ExecuteOption int
|
||||
const (
|
||||
ExecuteAllScripts ExecuteOption = iota
|
||||
ExecuteStartScripts
|
||||
ExecutePostStartScripts
|
||||
ExecuteStopScripts
|
||||
ExecuteCronScripts
|
||||
)
|
||||
@@ -196,6 +231,7 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
|
||||
for _, script := range r.scripts {
|
||||
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
|
||||
(option == ExecuteStopScripts && script.RunOnStop) ||
|
||||
(option == ExecutePostStartScripts && script.runOnPostStart) ||
|
||||
(option == ExecuteCronScripts && script.Cron != "") ||
|
||||
option == ExecuteAllScripts
|
||||
|
||||
@@ -205,7 +241,7 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
|
||||
|
||||
script := script
|
||||
eg.Go(func() error {
|
||||
err := r.trackRun(ctx, script, option)
|
||||
err := r.trackRun(ctx, script.WorkspaceAgentScript, option)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -151,11 +153,160 @@ func TestCronClose(t *testing.T) {
|
||||
require.NoError(t, runner.Close(), "close runner")
|
||||
}
|
||||
|
||||
func TestExecuteOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo start",
|
||||
RunOnStart: true,
|
||||
}
|
||||
stopScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo stop",
|
||||
RunOnStop: true,
|
||||
}
|
||||
postStartScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo poststart",
|
||||
}
|
||||
regularScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo regular",
|
||||
}
|
||||
|
||||
scripts := []codersdk.WorkspaceAgentScript{
|
||||
startScript,
|
||||
stopScript,
|
||||
regularScript,
|
||||
}
|
||||
allScripts := append(slices.Clone(scripts), postStartScript)
|
||||
|
||||
scriptByID := func(t *testing.T, id uuid.UUID) codersdk.WorkspaceAgentScript {
|
||||
for _, script := range allScripts {
|
||||
if script.ID == id {
|
||||
return script
|
||||
}
|
||||
}
|
||||
t.Fatal("script not found")
|
||||
return codersdk.WorkspaceAgentScript{}
|
||||
}
|
||||
|
||||
wantOutput := map[uuid.UUID]string{
|
||||
startScript.ID: "start",
|
||||
stopScript.ID: "stop",
|
||||
postStartScript.ID: "poststart",
|
||||
regularScript.ID: "regular",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
option agentscripts.ExecuteOption
|
||||
wantRun []uuid.UUID
|
||||
}{
|
||||
{
|
||||
name: "ExecuteAllScripts",
|
||||
option: agentscripts.ExecuteAllScripts,
|
||||
wantRun: []uuid.UUID{startScript.ID, stopScript.ID, regularScript.ID, postStartScript.ID},
|
||||
},
|
||||
{
|
||||
name: "ExecuteStartScripts",
|
||||
option: agentscripts.ExecuteStartScripts,
|
||||
wantRun: []uuid.UUID{startScript.ID},
|
||||
},
|
||||
{
|
||||
name: "ExecutePostStartScripts",
|
||||
option: agentscripts.ExecutePostStartScripts,
|
||||
wantRun: []uuid.UUID{postStartScript.ID},
|
||||
},
|
||||
{
|
||||
name: "ExecuteStopScripts",
|
||||
option: agentscripts.ExecuteStopScripts,
|
||||
wantRun: []uuid.UUID{stopScript.ID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
executedScripts := make(map[uuid.UUID]bool)
|
||||
fLogger := &executeOptionTestLogger{
|
||||
tb: t,
|
||||
executedScripts: executedScripts,
|
||||
wantOutput: wantOutput,
|
||||
}
|
||||
|
||||
runner := setup(t, func(uuid.UUID) agentscripts.ScriptLogger {
|
||||
return fLogger
|
||||
})
|
||||
defer runner.Close()
|
||||
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
|
||||
err := runner.Init(
|
||||
scripts,
|
||||
aAPI.ScriptCompleted,
|
||||
agentscripts.WithPostStartScripts(postStartScript),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = runner.Execute(ctx, tc.option)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotRun := map[uuid.UUID]bool{}
|
||||
for _, id := range tc.wantRun {
|
||||
gotRun[id] = true
|
||||
require.True(t, executedScripts[id],
|
||||
"script %s should have run when using filter %s", scriptByID(t, id).Script, tc.name)
|
||||
}
|
||||
|
||||
for _, script := range allScripts {
|
||||
if _, ok := gotRun[script.ID]; ok {
|
||||
continue
|
||||
}
|
||||
require.False(t, executedScripts[script.ID],
|
||||
"script %s should not have run when using filter %s", script.Script, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type executeOptionTestLogger struct {
|
||||
tb testing.TB
|
||||
executedScripts map[uuid.UUID]bool
|
||||
wantOutput map[uuid.UUID]string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (l *executeOptionTestLogger) Send(_ context.Context, logs ...agentsdk.Log) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for _, log := range logs {
|
||||
l.tb.Log(log.Output)
|
||||
for id, output := range l.wantOutput {
|
||||
if log.Output == output {
|
||||
l.executedScripts[id] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*executeOptionTestLogger) Flush(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setup(t *testing.T, getScriptLogger func(logSourceID uuid.UUID) agentscripts.ScriptLogger) *agentscripts.Runner {
|
||||
t.Helper()
|
||||
if getScriptLogger == nil {
|
||||
// noop
|
||||
getScriptLogger = func(uuid uuid.UUID) agentscripts.ScriptLogger {
|
||||
getScriptLogger = func(uuid.UUID) agentscripts.ScriptLogger {
|
||||
return noopScriptLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user