Skip to content

feat(agent): add devcontainer autostart support #17076

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Mar 27, 2025
Merged
Next Next commit
wip: feat(agent): add devcontainer autostart support
  • Loading branch information
mafredri committed Mar 25, 2025
commit 63a2ec149b72145a10d466909e221e34a1f09e92
8 changes: 7 additions & 1 deletion agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,12 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
}
}

err = a.scriptRunner.Init(manifest.Scripts, aAPI.ScriptCompleted)
var postStartScripts []codersdk.WorkspaceAgentScript
for _, dc := range manifest.Devcontainers {
postStartScripts = append(postStartScripts, agentcontainers.DevcontainerStartupScript(dc))
}

err = a.scriptRunner.Init(manifest.Scripts, aAPI.ScriptCompleted, agentscripts.WithPostStartScripts(postStartScripts...))
if err != nil {
return xerrors.Errorf("init script runner: %w", err)
}
Expand All @@ -1124,6 +1129,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
// here we use the graceful context because the script runner is not directly tied
// to the agent API.
err := a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecuteStartScripts)
err = errors.Join(err, a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecutePostStartScripts))
// Measure the time immediately after the script has finished
dur := time.Since(start).Seconds()
if err != nil {
Expand Down
25 changes: 25 additions & 0 deletions agent/agentcontainers/devcontainer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package agentcontainers

import (
"fmt"

"github.com/google/uuid"

"github.com/coder/coder/v2/codersdk"
)

func DevcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentScript {
script := fmt.Sprintf("devcontainer up --workspace-folder %q", dc.WorkspaceFolder)
if dc.ConfigPath != "" {
script = fmt.Sprintf("%s --config %q", script, dc.ConfigPath)
}
return codersdk.WorkspaceAgentScript{
ID: uuid.New(),
LogSourceID: uuid.Nil, // TODO(mafredri): Add a devcontainer log source?
LogPath: "",
Script: script,
Cron: "",
Timeout: 0,
DisplayName: fmt.Sprintf("Dev Container (%s)", dc.WorkspaceFolder),
}
}
48 changes: 42 additions & 6 deletions agent/agentscripts/agentscripts.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ func New(opts Options) *Runner {

type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error)

type runnerScript struct {
runOnPostStart bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: this is fine for now, but I could see this being an option on WorkspaceAgentScript in future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a good call-out 👍. I hope we figure out a better way to define dependencies or ordering for start scripts, though.

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

Expand All @@ -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

Expand Down Expand Up @@ -119,30 +134,49 @@ 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)
if err != nil {
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))
}
Expand Down Expand Up @@ -186,6 +220,7 @@ type ExecuteOption int
const (
ExecuteAllScripts ExecuteOption = iota
ExecuteStartScripts
ExecutePostStartScripts
ExecuteStopScripts
ExecuteCronScripts
)
Expand All @@ -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

Expand All @@ -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)
}
Expand Down