feat: order lifecycle scripts by their dependencies (#30765)

feat(agent): enforce lifecycle script ordering

Honor `coder_script_order` dependencies for startup and shutdown scripts
using the existing outcome-aware unit manager. Cron, legacy scripts,
and scripts without dependencies retain their existing behavior.

- Wait for all prerequisites before running. `success` requires a
  successful prerequisite; `completion` also accepts failure, timeout,
  or skip. Skip dependents as soon as a required success becomes
  impossible, preserving the original failure as the lifecycle error.
- Reject invalid dependency graphs before any scripts start and write
  the reason to every selected script's log.
- Log unmet dependencies every 30 seconds and explain skips. Populate
  display names in the manifest so skip messages use readable names,
  falling back to Terraform addresses.
- Exclude dependency waits from script timeouts, record an outcome on
  every exit, and use fresh state per execution. Cancellation stops
  waiters without starting dependents or emitting skip messages.

Add regression coverage for ordering, skip propagation, timeouts,
cancellation, invalid graphs, and unchanged unordered execution.

Refs https://linear.app/codercom/issue/PLAT-549/phase-3-enforce-lifecycle-script-ordering
This commit is contained in:
Bobby Ho
2026-10-15 20:33:48 +00:00
committed by GitHub
parent fa926fcc77
commit 4d1f95e73e
8 changed files with 1458 additions and 9 deletions
+1
View File
@@ -422,6 +422,7 @@ func (a *agent) init() {
LogDir: a.logDir,
DataDirBase: a.scriptDataDir,
Logger: a.logger,
Clock: a.clock,
SSHServer: sshSrv,
Filesystem: a.filesystem,
GetScriptLogger: func(logSourceID uuid.UUID) agentscripts.ScriptLogger {
+43 -6
View File
@@ -26,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/quartz"
)
var (
@@ -56,10 +57,17 @@ type Options struct {
SSHServer *agentssh.Server
Filesystem afero.Fs
GetScriptLogger func(logSourceID uuid.UUID) ScriptLogger
// Clock drives the periodic "Waiting for dependencies" line for
// ordered scripts, so a test can advance it. Script timeouts keep
// using the context deadline.
Clock quartz.Clock
}
// New creates a runner for the provided scripts.
func New(opts Options) *Runner {
if opts.Clock == nil {
opts.Clock = quartz.NewReal()
}
cronCtx, cronCtxCancel := context.WithCancel(context.Background())
return &Runner{
Options: opts,
@@ -211,7 +219,7 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
return initErr
}
var eg errgroup.Group
var scripts []codersdk.WorkspaceAgentScript
for _, script := range r.scripts {
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
(option == ExecuteStopScripts && script.RunOnStop) ||
@@ -221,18 +229,47 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
if !runScript {
continue
}
scripts = append(scripts, script)
}
if (option == ExecuteStartScripts || option == ExecuteStopScripts) && anyOrdered(scripts) {
executor := lifecycleExecutor{
clock: r.Clock,
logger: r.Logger,
getScriptLogger: r.GetScriptLogger,
run: r.runTracked,
}
return executor.execute(ctx, scripts, option)
}
var eg errgroup.Group
for _, script := range scripts {
eg.Go(func() error {
err := r.trackRun(ctx, script, option)
if err != nil {
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
}
return nil
return r.runTracked(ctx, script, option)
})
}
return eg.Wait()
}
// runTracked runs one script and wraps its error the same way for both the
// ordered and the unordered path.
func (r *Runner) runTracked(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
err := r.trackRun(ctx, script, option)
if err != nil {
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
}
return nil
}
func anyOrdered(scripts []codersdk.WorkspaceAgentScript) bool {
for _, script := range scripts {
if len(script.Dependencies) > 0 {
return true
}
}
return false
}
// trackRun wraps "run" with metrics.
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
err := r.run(ctx, script, option)
+120
View File
@@ -341,6 +341,126 @@ func (*executeOptionTestLogger) Flush(context.Context) error {
return nil
}
func TestExecuteFastPath(t *testing.T) {
t.Parallel()
t.Run("AddressesWithoutDependencies", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logs := newRecordingScriptLogs()
runner := setup(t, logs.logger)
defer runner.Close()
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
scripts := []codersdk.WorkspaceAgentScript{
{ID: uuid.New(), LogSourceID: uuid.New(), ResourceAddress: "coder_script.a", Script: "echo a", RunOnStart: true},
{ID: uuid.New(), LogSourceID: uuid.New(), ResourceAddress: "coder_script.b", Script: "echo b", RunOnStart: true},
}
require.NoError(t, runner.Init(scripts, aAPI.ScriptCompleted))
require.NoError(t, runner.Execute(ctx, agentscripts.ExecuteStartScripts))
require.Equal(t, []string{"a"}, logs.outputs(scripts[0].LogSourceID))
require.Equal(t, []string{"b"}, logs.outputs(scripts[1].LogSourceID))
})
t.Run("DependencyOnlyAtStop", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logs := newRecordingScriptLogs()
runner := setup(t, logs.logger)
defer runner.Close()
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
// "exit 1" alone runs the same under sh and PowerShell.
save := codersdk.WorkspaceAgentScript{
ID: uuid.New(), LogSourceID: uuid.New(), DisplayName: "Save state",
ResourceAddress: "coder_script.save_state", Script: "exit 1",
RunOnStart: true, RunOnStop: true,
}
stopDB := codersdk.WorkspaceAgentScript{
ID: uuid.New(), LogSourceID: uuid.New(), DisplayName: "Stop database",
ResourceAddress: "coder_script.stop_db", Script: "echo stopping", RunOnStop: true,
Dependencies: []codersdk.WorkspaceAgentScriptDependency{{
PrerequisiteResourceAddress: "coder_script.save_state",
Requirement: codersdk.WorkspaceAgentScriptDependencyRequirementSuccess,
}},
}
require.NoError(t, runner.Init([]codersdk.WorkspaceAgentScript{save, stopDB}, aAPI.ScriptCompleted))
require.Error(t, runner.Execute(ctx, agentscripts.ExecuteStartScripts))
require.Empty(t, logs.outputs(stopDB.LogSourceID))
require.Error(t, runner.Execute(ctx, agentscripts.ExecuteStopScripts))
require.Equal(t, []string{`Skipping script: dependency "Save state" failed.`}, logs.outputs(stopDB.LogSourceID))
})
}
func TestExecuteOrderedTimeoutStartsAfterWaiting(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("uses sleep")
}
ctx := testutil.Context(t, testutil.WaitShort)
logs := newRecordingScriptLogs()
runner := setup(t, logs.logger)
defer runner.Close()
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
clone := codersdk.WorkspaceAgentScript{
ID: uuid.New(), LogSourceID: uuid.New(), ResourceAddress: "coder_script.clone",
Script: "sleep 2", RunOnStart: true,
}
install := codersdk.WorkspaceAgentScript{
ID: uuid.New(), LogSourceID: uuid.New(), ResourceAddress: "coder_script.install",
Script: "echo installed", RunOnStart: true, Timeout: time.Second,
Dependencies: []codersdk.WorkspaceAgentScriptDependency{{
PrerequisiteResourceAddress: "coder_script.clone",
Requirement: codersdk.WorkspaceAgentScriptDependencyRequirementSuccess,
}},
}
require.NoError(t, runner.Init([]codersdk.WorkspaceAgentScript{clone, install}, aAPI.ScriptCompleted))
require.NoError(t, runner.Execute(ctx, agentscripts.ExecuteStartScripts))
require.Equal(t, []string{"installed"}, logs.outputs(install.LogSourceID))
}
// recordingScriptLogs keeps every line per log source so a test can read a
// script's output and its dependency lines together.
type recordingScriptLogs struct {
mu sync.Mutex
byID map[uuid.UUID][]string
}
func newRecordingScriptLogs() *recordingScriptLogs {
return &recordingScriptLogs{byID: make(map[uuid.UUID][]string)}
}
func (l *recordingScriptLogs) logger(logSourceID uuid.UUID) agentscripts.ScriptLogger {
return recordingScriptLogger{logs: l, logSourceID: logSourceID}
}
func (l *recordingScriptLogs) outputs(logSourceID uuid.UUID) []string {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.byID[logSourceID]...)
}
type recordingScriptLogger struct {
logs *recordingScriptLogs
logSourceID uuid.UUID
}
func (l recordingScriptLogger) Send(_ context.Context, logs ...agentsdk.Log) error {
l.logs.mu.Lock()
defer l.logs.mu.Unlock()
for _, log := range logs {
l.logs.byID[l.logSourceID] = append(l.logs.byID[l.logSourceID], log.Output)
}
return nil
}
func (recordingScriptLogger) 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 {
+355
View File
@@ -0,0 +1,355 @@
package agentscripts
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/quartz"
)
// Often enough to show the wait is alive, rare enough not to flood the log.
const dependencyLogInterval = 30 * time.Second
// lifecycleExecutor runs one start or stop execution whose scripts carry
// dependencies.
type lifecycleExecutor struct {
clock quartz.Clock
logger slog.Logger
getScriptLogger func(uuid.UUID) ScriptLogger
// run is Runner.runTracked; tests replace it with a fake.
run func(context.Context, codersdk.WorkspaceAgentScript, ExecuteOption) error
}
func (e lifecycleExecutor) execute(ctx context.Context, scripts []codersdk.WorkspaceAgentScript, option ExecuteOption) error {
manager, scriptsByAddress, err := lifecycleManager(scripts)
if err != nil {
// The agent log is not visible in the dashboard.
for _, script := range scripts {
e.sendScriptLog(ctx, script, "Lifecycle scripts did not run: "+err.Error())
}
return err
}
return e.executeWith(ctx, manager, scriptsByAddress, scripts, option)
}
// executeWith is separate from execute so a test can keep the manager and
// read every unit's outcome after the execution ends.
func (e lifecycleExecutor) executeWith(
ctx context.Context,
manager *unit.Manager,
scriptsByAddress map[string]codersdk.WorkspaceAgentScript,
scripts []codersdk.WorkspaceAgentScript,
option ExecuteOption,
) error {
var group errgroup.Group
for _, script := range scripts {
group.Go(func() error {
// A script without an address is not a unit and nothing can
// wait for it, so it starts at once.
if script.ResourceAddress == "" {
return e.run(ctx, script, option)
}
return e.executeScript(ctx, manager, scriptsByAddress, script, option)
})
}
return group.Wait()
}
// lifecycleManager builds the manager for one execution and checks the
// whole graph before any script starts, so an inconsistent dependency list
// fails with nothing half done.
func lifecycleManager(scripts []codersdk.WorkspaceAgentScript) (*unit.Manager, map[string]codersdk.WorkspaceAgentScript, error) {
manager := unit.NewManager()
scriptsByAddress := make(map[string]codersdk.WorkspaceAgentScript, len(scripts))
// Both ends of an edge must be registered before the edge is added, and
// a prerequisite can come after its dependent in the list.
for _, script := range scripts {
if script.ResourceAddress == "" {
if len(script.Dependencies) > 0 {
return nil, nil, xerrors.Errorf("script %q has dependencies but no Terraform resource address", script.DisplayName)
}
continue
}
if _, exists := scriptsByAddress[script.ResourceAddress]; exists {
return nil, nil, xerrors.Errorf("duplicate script resource address %q", script.ResourceAddress)
}
scriptsByAddress[script.ResourceAddress] = script
if err := manager.Register(unit.ID(script.ResourceAddress)); err != nil {
return nil, nil, xerrors.Errorf("register script %q: %w", script.ResourceAddress, err)
}
}
for _, script := range scripts {
for _, dependency := range script.Dependencies {
if _, exists := scriptsByAddress[dependency.PrerequisiteResourceAddress]; !exists {
return nil, nil, xerrors.Errorf(
"script %q depends on %q, which is not part of this lifecycle execution",
script.ResourceAddress, dependency.PrerequisiteResourceAddress,
)
}
requirement, err := lifecycleRequirement(dependency.Requirement)
if err != nil {
return nil, nil, xerrors.Errorf(
"script %q dependency on %q: %w",
script.ResourceAddress, dependency.PrerequisiteResourceAddress, err,
)
}
if err := manager.AddConditionalDependency(
unit.ID(script.ResourceAddress),
unit.ID(dependency.PrerequisiteResourceAddress),
requirement,
); err != nil {
return nil, nil, xerrors.Errorf(
"add script dependency %q after %q: %w",
script.ResourceAddress, dependency.PrerequisiteResourceAddress, err,
)
}
}
}
return manager, scriptsByAddress, nil
}
// lifecycleRequirement uses a switch rather than a cast so the codersdk and
// unit requirement strings can change independently.
func lifecycleRequirement(requirement codersdk.WorkspaceAgentScriptDependencyRequirement) (unit.Requirement, error) {
switch requirement {
case codersdk.WorkspaceAgentScriptDependencyRequirementSuccess:
return unit.RequirementSuccess, nil
case codersdk.WorkspaceAgentScriptDependencyRequirementCompletion:
return unit.RequirementCompletion, nil
default:
return "", xerrors.Errorf("unsupported requirement %q", requirement)
}
}
// executeScript waits for the manager's decision, then skips or runs the
// script. Every exit records one terminal outcome, because dependents only
// wake on one and the execution would otherwise stay open until the agent
// shuts down. Only this goroutine writes its unit's outcome.
func (e lifecycleExecutor) executeScript(
ctx context.Context,
manager *unit.Manager,
scriptsByAddress map[string]codersdk.WorkspaceAgentScript,
script codersdk.WorkspaceAgentScript,
option ExecuteOption,
) (err error) {
id := unit.ID(script.ResourceAddress)
terminal := false
record := func(outcome unit.Outcome) error {
if err := recordOutcome(manager, id, outcome); err != nil {
return err
}
if outcome.Terminal() {
terminal = true
}
return nil
}
defer func() {
if !terminal {
err = errors.Join(err, recordOutcome(manager, id, unit.OutcomeFailed))
}
}()
evaluation, err := e.waitForDecision(ctx, manager, script)
if err == nil {
// Nothing starts after cancellation, even if the decision arrived
// in the same instant.
err = ctx.Err()
}
if err != nil {
if ctx.Err() != nil {
return errors.Join(err, record(unit.OutcomeCanceled))
}
return err
}
switch evaluation.Decision {
case unit.DecisionSkip:
e.logSkipped(ctx, script, scriptsByAddress, evaluation)
if err := record(unit.OutcomeSkipped); err != nil {
return err
}
// The prerequisite's own error already sets the lifecycle state.
return nil
case unit.DecisionRunnable:
if err := record(unit.OutcomeRunning); err != nil {
return err
}
default:
return xerrors.Errorf("script %q returned unexpected dependency decision %q", script.ResourceAddress, evaluation.Decision)
}
runErr := e.run(ctx, script, option)
if err := record(scriptOutcome(runErr, ctx.Err())); err != nil {
return errors.Join(runErr, err)
}
return runErr
}
func recordOutcome(manager *unit.Manager, id unit.ID, outcome unit.Outcome) error {
if err := manager.UpdateOutcome(id, outcome); err != nil {
return xerrors.Errorf("record script %q outcome %q: %w", id, outcome, err)
}
return nil
}
func (e lifecycleExecutor) waitForDecision(
ctx context.Context,
manager *unit.Manager,
script codersdk.WorkspaceAgentScript,
) (unit.Evaluation, error) {
id := unit.ID(script.ResourceAddress)
evaluation, err := manager.Evaluate(id)
if err != nil || evaluation.Decision != unit.DecisionWaiting {
return evaluation, err
}
ticker := e.clock.NewTicker(dependencyLogInterval, "agent_scripts", "dependency_wait")
defer ticker.Stop("agent_scripts", "dependency_wait")
type result struct {
evaluation unit.Evaluation
err error
}
// Buffered so the goroutine can deliver after this function has
// already returned on ctx.Done.
decision := make(chan result, 1)
go func() {
evaluation, err := manager.WaitForDecision(ctx, id)
decision <- result{evaluation: evaluation, err: err}
}()
var waited time.Duration
for {
select {
case <-ctx.Done():
return unit.Evaluation{}, ctx.Err()
case result := <-decision:
return result.evaluation, result.err
case <-ticker.C:
waited += dependencyLogInterval
// Re-evaluate so prerequisites that finished since the wait
// began drop out of the line.
evaluation, err := manager.Evaluate(id)
if err != nil {
return unit.Evaluation{}, err
}
if evaluation.Decision == unit.DecisionWaiting {
e.logWaiting(ctx, script, evaluation.UnmetDependencies, waited)
}
}
}
}
func (e lifecycleExecutor) logWaiting(
ctx context.Context,
script codersdk.WorkspaceAgentScript,
dependencies []unit.ConditionalDependency,
waited time.Duration,
) {
// The manager already sorts the unmet list by prerequisite address.
quoted := make([]string, 0, len(dependencies))
for _, dependency := range dependencies {
quoted = append(quoted, strconv.Quote(string(dependency.DependsOn)))
}
e.sendScriptLog(ctx, script, fmt.Sprintf(
"Waiting for dependencies: %s (%ds)",
strings.Join(quoted, ", "), int(waited.Seconds()),
))
}
// logSkipped names every direct success prerequisite that can no longer
// succeed at the moment of the decision.
func (e lifecycleExecutor) logSkipped(
ctx context.Context,
script codersdk.WorkspaceAgentScript,
scriptsByAddress map[string]codersdk.WorkspaceAgentScript,
evaluation unit.Evaluation,
) {
var causes []string
for _, dependency := range evaluation.UnmetDependencies {
if dependency.Requirement != unit.RequirementSuccess {
continue
}
how, impossible := skipCause(dependency.CurrentOutcome)
if !impossible {
continue
}
prerequisite := scriptsByAddress[string(dependency.DependsOn)]
name := prerequisite.DisplayName
if name == "" {
name = prerequisite.ResourceAddress
}
causes = append(causes, fmt.Sprintf("dependency %q %s", name, how))
}
if len(causes) == 0 {
// A skip decision always has at least one impossible edge, so this
// means the manager's rules and skipCause disagree.
e.logger.Warn(ctx, "skip decision with no impossible dependency", slog.F("resource_address", script.ResourceAddress))
return
}
e.sendScriptLog(ctx, script, "Skipping script: "+strings.Join(causes, ", ")+".")
}
// skipCause mirrors unit.Requirement.impossible, which is not exported.
func skipCause(outcome unit.Outcome) (string, bool) {
switch outcome {
case unit.OutcomeFailed:
return "failed", true
case unit.OutcomeTimedOut:
return "timed out", true
case unit.OutcomeSkipped:
return "was skipped", true
default:
return "", false
}
}
// sendScriptLog flushes after sending so the line is visible while the
// script is still waiting. A failed send never changes a decision.
func (e lifecycleExecutor) sendScriptLog(ctx context.Context, script codersdk.WorkspaceAgentScript, output string) {
if e.getScriptLogger == nil {
return
}
logger := e.getScriptLogger(script.LogSourceID)
if err := logger.Send(ctx, agentsdk.Log{
CreatedAt: e.clock.Now("agent_scripts", "dependency_log").UTC(),
Output: output,
Level: codersdk.LogLevelInfo,
}); err != nil {
e.logger.Warn(ctx, "send script dependency log", slog.F("resource_address", script.ResourceAddress), slog.Error(err))
return
}
if err := logger.Flush(ctx); err != nil {
e.logger.Warn(ctx, "flush script dependency log", slog.F("resource_address", script.ResourceAddress), slog.Error(err))
}
}
// scriptOutcome keeps success and the script's own timeout even when the
// context is canceled, because cancellation did not cause them.
func scriptOutcome(runErr error, ctxErr error) unit.Outcome {
switch {
case runErr == nil:
return unit.OutcomeSucceeded
case errors.Is(runErr, ErrTimeout):
return unit.OutcomeTimedOut
case ctxErr != nil:
return unit.OutcomeCanceled
default:
return unit.OutcomeFailed
}
}
@@ -0,0 +1,933 @@
package agentscripts
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// goleak runs from TestMain in agentscripts_test.go, which shares this
// test binary.
const (
requireSuccess = codersdk.WorkspaceAgentScriptDependencyRequirementSuccess
requireCompletion = codersdk.WorkspaceAgentScriptDependencyRequirementCompletion
)
func TestLifecycleExecutorOrdersScripts(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.hold("coder_script.clone")
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
done := runExecute(ctx, executor, clone, install)
runs.waitStarted(ctx, t, "coder_script.clone")
runs.requireNotStarted(t, "coder_script.install")
runs.release("coder_script.clone", nil)
runs.waitStarted(ctx, t, "coder_script.install")
require.NoError(t, testutil.RequireReceive(ctx, t, done))
}
func TestLifecycleExecutorWaitsForEveryPrerequisite(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.hold("coder_script.a")
runs.hold("coder_script.b")
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
a := scriptOrderTestScript("coder_script.a")
b := scriptOrderTestScript("coder_script.b")
c := scriptOrderTestScript("coder_script.c",
scriptOrderTestDependency("coder_script.a", requireSuccess),
scriptOrderTestDependency("coder_script.b", requireCompletion),
)
done := runExecute(ctx, executor, a, b, c)
runs.waitStarted(ctx, t, "coder_script.a")
runs.waitStarted(ctx, t, "coder_script.b")
runs.release("coder_script.a", nil)
runs.requireNotStarted(t, "coder_script.c")
runs.release("coder_script.b", nil)
runs.waitStarted(ctx, t, "coder_script.c")
require.NoError(t, testutil.RequireReceive(ctx, t, done))
}
// The executor hands run a context with no deadline of its own, so the
// timeout context created inside run cannot have started during the wait.
// TestExecuteOrderedTimeoutStartsAfterWaiting covers the same with a real
// process.
func TestLifecycleExecutorTimeoutStartsAfterWaiting(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
executeCtx, cancel := context.WithCancel(context.Background())
defer cancel()
runs := newScriptOrderRuns()
runs.hold("coder_script.clone")
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
deadlines := make(chan bool, 2)
executor.run = func(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
_, hasDeadline := ctx.Deadline()
deadlines <- hasDeadline
return runs.run(ctx, script, option)
}
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
install.Timeout = 10 * time.Millisecond
done := runExecute(executeCtx, executor, clone, install)
runs.waitStarted(ctx, t, "coder_script.clone")
require.False(t, testutil.RequireReceive(ctx, t, deadlines))
runs.release("coder_script.clone", nil)
runs.waitStarted(ctx, t, "coder_script.install")
require.False(t, testutil.RequireReceive(ctx, t, deadlines))
require.NoError(t, testutil.RequireReceive(ctx, t, done))
}
func TestScriptOutcome(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
runErr error
ctxErr error
want unit.Outcome
}{
{name: "Succeeded", want: unit.OutcomeSucceeded},
{name: "TimedOut", runErr: ErrTimeout, want: unit.OutcomeTimedOut},
{name: "TimedOutDuringCancel", runErr: ErrTimeout, ctxErr: context.Canceled, want: unit.OutcomeTimedOut},
{name: "PipesLeftOpen", runErr: ErrOutputPipesOpen, want: unit.OutcomeFailed},
{name: "ExitCode", runErr: xerrors.New("exit status 1"), want: unit.OutcomeFailed},
{name: "Canceled", runErr: context.Canceled, ctxErr: context.Canceled, want: unit.OutcomeCanceled},
{name: "OtherErrorDuringCancel", runErr: xerrors.New("signal: killed"), ctxErr: context.Canceled, want: unit.OutcomeCanceled},
{name: "WrappedTimeout", runErr: xerrors.Errorf("run agent script: %w", ErrTimeout), want: unit.OutcomeTimedOut},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, test.want, scriptOutcome(test.runErr, test.ctxErr))
})
}
}
func TestLifecycleExecutorRequirements(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
requirement codersdk.WorkspaceAgentScriptDependencyRequirement
cloneErr error
wantRun bool
wantLogs []string
}{
{name: "SuccessAfterFailure", requirement: requireSuccess, cloneErr: xerrors.New("exit status 128"), wantLogs: []string{`Skipping script: dependency "Clone" failed.`}},
{name: "SuccessAfterTimeout", requirement: requireSuccess, cloneErr: ErrTimeout, wantLogs: []string{`Skipping script: dependency "Clone" timed out.`}},
{name: "SuccessAfterSuccess", requirement: requireSuccess, wantRun: true},
{name: "CompletionAfterFailure", requirement: requireCompletion, cloneErr: xerrors.New("exit status 128"), wantRun: true},
{name: "CompletionAfterTimeout", requirement: requireCompletion, cloneErr: ErrTimeout, wantRun: true},
{name: "CompletionAfterSuccess", requirement: requireCompletion, wantRun: true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.result("coder_script.clone", test.cloneErr)
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
clone := scriptOrderTestScript("coder_script.clone")
clone.DisplayName = "Clone"
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", test.requirement))
err := executor.execute(ctx, []codersdk.WorkspaceAgentScript{clone, install}, ExecuteStartScripts)
if test.cloneErr != nil {
require.ErrorIs(t, err, test.cloneErr)
} else {
require.NoError(t, err)
}
require.Equal(t, test.wantRun, runs.started("coder_script.install"))
require.Equal(t, test.wantLogs, logs.outputs(install.LogSourceID))
})
}
}
func TestLifecycleExecutorPropagatesSkippedOutcomes(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.result("coder_script.a", xerrors.New("exit status 1"))
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
a := scriptOrderTestScript("coder_script.a")
b := scriptOrderTestScript("coder_script.b", scriptOrderTestDependency("coder_script.a", requireSuccess))
c := scriptOrderTestScript("coder_script.c", scriptOrderTestDependency("coder_script.b", requireCompletion))
d := scriptOrderTestScript("coder_script.d", scriptOrderTestDependency("coder_script.b", requireSuccess))
err := executor.execute(ctx, []codersdk.WorkspaceAgentScript{a, b, c, d}, ExecuteStartScripts)
require.Error(t, err)
require.ElementsMatch(t, []string{"coder_script.a", "coder_script.c"}, runs.allStarted())
require.Equal(t, []string{`Skipping script: dependency "coder_script.a" failed.`}, logs.outputs(b.LogSourceID))
require.Empty(t, logs.outputs(c.LogSourceID))
require.Equal(t, []string{`Skipping script: dependency "coder_script.b" was skipped.`}, logs.outputs(d.LogSourceID))
}
func TestLifecycleExecutorMixedRequirements(t *testing.T) {
t.Parallel()
scripts := func() (clone, warm, build codersdk.WorkspaceAgentScript) {
clone = scriptOrderTestScript("coder_script.clone_repo")
clone.DisplayName = "Clone repo"
warm = scriptOrderTestScript("coder_script.warm_cache")
warm.DisplayName = "Warm cache"
build = scriptOrderTestScript("coder_script.build",
scriptOrderTestDependency("coder_script.clone_repo", requireSuccess),
scriptOrderTestDependency("coder_script.warm_cache", requireCompletion),
)
return clone, warm, build
}
t.Run("CompletionPrerequisiteFails", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.result("coder_script.warm_cache", xerrors.New("exit status 1"))
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
clone, warm, build := scripts()
err := executor.execute(ctx, []codersdk.WorkspaceAgentScript{clone, warm, build}, ExecuteStartScripts)
require.Error(t, err)
require.True(t, runs.started("coder_script.build"))
require.Empty(t, logs.outputs(build.LogSourceID))
})
t.Run("SuccessPrerequisiteFails", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.result("coder_script.clone_repo", xerrors.New("exit status 128"))
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
clone, warm, build := scripts()
err := executor.execute(ctx, []codersdk.WorkspaceAgentScript{clone, warm, build}, ExecuteStartScripts)
require.Error(t, err)
require.False(t, runs.started("coder_script.build"))
require.Equal(t, []string{`Skipping script: dependency "Clone repo" failed.`}, logs.outputs(build.LogSourceID))
})
}
func TestLifecycleExecutorSkipsOnFirstImpossibleEdge(t *testing.T) {
t.Parallel()
scripts := func() (clone, secrets, build codersdk.WorkspaceAgentScript) {
clone = scriptOrderTestScript("coder_script.clone_repo")
clone.DisplayName = "Clone repo"
secrets = scriptOrderTestScript("coder_script.fetch_secrets")
secrets.DisplayName = "Fetch secrets"
build = scriptOrderTestScript("coder_script.build",
scriptOrderTestDependency("coder_script.clone_repo", requireSuccess),
scriptOrderTestDependency("coder_script.fetch_secrets", requireSuccess),
)
return clone, secrets, build
}
t.Run("OnePrerequisiteStillRunning", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.hold("coder_script.clone_repo")
runs.result("coder_script.fetch_secrets", xerrors.New("exit status 1"))
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
clone, secrets, build := scripts()
done := runExecute(ctx, executor, clone, secrets, build)
runs.waitStarted(ctx, t, "coder_script.clone_repo")
// The skip line arrives while clone_repo is still held.
require.Equal(t, `Skipping script: dependency "Fetch secrets" failed.`, logs.waitOutput(ctx, t, build.LogSourceID))
runs.release("coder_script.clone_repo", nil)
require.Error(t, testutil.RequireReceive(ctx, t, done))
require.False(t, runs.started("coder_script.build"))
})
t.Run("BothPrerequisitesFinishedBadly", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.result("coder_script.clone_repo", xerrors.New("exit status 128"))
runs.result("coder_script.fetch_secrets", ErrTimeout)
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
clone, secrets, build := scripts()
// Both prerequisites finish before build is evaluated, so the
// manager lists both as impossible.
manager, byAddress, err := lifecycleManager([]codersdk.WorkspaceAgentScript{clone, secrets, build})
require.NoError(t, err)
require.Error(t, executor.executeWith(ctx, manager, byAddress, []codersdk.WorkspaceAgentScript{clone, secrets}, ExecuteStartScripts))
require.NoError(t, executor.executeWith(ctx, manager, byAddress, []codersdk.WorkspaceAgentScript{build}, ExecuteStartScripts))
require.False(t, runs.started("coder_script.build"))
require.Equal(t, []string{
`Skipping script: dependency "Clone repo" failed, dependency "Fetch secrets" timed out.`,
}, logs.outputs(build.LogSourceID))
})
}
func TestLifecycleExecutorWaitingLogsAreCombinedAndSorted(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
tickerTrap := clock.Trap().NewTicker("agent_scripts", "dependency_wait")
defer tickerTrap.Close()
runs := newScriptOrderRuns()
runs.hold("coder_script.z_clone")
runs.hold("coder_script.a_auth")
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
executor.clock = clock
zClone := scriptOrderTestScript("coder_script.z_clone")
aAuth := scriptOrderTestScript("coder_script.a_auth")
install := scriptOrderTestScript("coder_script.install",
scriptOrderTestDependency("coder_script.z_clone", requireSuccess),
scriptOrderTestDependency("coder_script.a_auth", requireCompletion),
)
scripts := []codersdk.WorkspaceAgentScript{zClone, aAuth, install}
manager, byAddress, err := lifecycleManager(scripts)
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- executor.executeWith(ctx, manager, byAddress, scripts, ExecuteStartScripts) }()
tickerTrap.MustWait(ctx).MustRelease(ctx)
clock.Advance(dependencyLogInterval).MustWait(ctx)
require.Equal(t, `Waiting for dependencies: "coder_script.a_auth", "coder_script.z_clone" (30s)`, logs.waitOutput(ctx, t, install.LogSourceID))
clock.Advance(dependencyLogInterval).MustWait(ctx)
require.Equal(t, `Waiting for dependencies: "coder_script.a_auth", "coder_script.z_clone" (60s)`, logs.waitOutput(ctx, t, install.LogSourceID))
runs.release("coder_script.a_auth", nil)
// The outcome is recorded after run returns, so wait for the record
// itself rather than for run.
require.Eventually(t, func() bool {
u, err := manager.Unit("coder_script.a_auth")
return err == nil && u.Outcome() == unit.OutcomeSucceeded
}, testutil.WaitShort, testutil.IntervalFast)
clock.Advance(dependencyLogInterval).MustWait(ctx)
require.Equal(t, `Waiting for dependencies: "coder_script.z_clone" (90s)`, logs.waitOutput(ctx, t, install.LogSourceID))
runs.release("coder_script.z_clone", nil)
runs.waitStarted(ctx, t, "coder_script.install")
require.NoError(t, testutil.RequireReceive(ctx, t, done))
require.Len(t, logs.outputs(install.LogSourceID), 3)
}
func TestLifecycleExecutorNoWaitingLineWhenDecidedEarly(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
tickerTrap := clock.Trap().NewTicker("agent_scripts", "dependency_wait")
defer tickerTrap.Close()
runs := newScriptOrderRuns()
runs.hold("coder_script.clone")
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
executor.clock = clock
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
done := runExecute(ctx, executor, clone, install)
// Only install waits, so exactly one ticker is created.
tickerTrap.MustWait(ctx).MustRelease(ctx)
runs.release("coder_script.clone", nil)
runs.waitStarted(ctx, t, "coder_script.install")
require.NoError(t, testutil.RequireReceive(ctx, t, done))
require.Empty(t, logs.outputs(clone.LogSourceID))
require.Empty(t, logs.outputs(install.LogSourceID))
}
func TestLifecycleExecutorCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))
defer cancel()
runs := newScriptOrderRuns()
runs.hold("coder_script.a")
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
a := scriptOrderTestScript("coder_script.a")
b := scriptOrderTestScript("coder_script.b", scriptOrderTestDependency("coder_script.a", requireSuccess))
c := scriptOrderTestScript("coder_script.c", scriptOrderTestDependency("coder_script.a", requireCompletion))
d := scriptOrderTestScript("coder_script.d")
scripts := []codersdk.WorkspaceAgentScript{a, b, c, d}
manager, byAddress, err := lifecycleManager(scripts)
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- executor.executeWith(ctx, manager, byAddress, scripts, ExecuteStartScripts) }()
runs.waitStarted(ctx, t, "coder_script.a")
runs.waitFinished(ctx, t, "coder_script.d")
cancel()
waitCtx := testutil.Context(t, testutil.WaitShort)
require.ErrorIs(t, testutil.RequireReceive(waitCtx, t, done), context.Canceled)
require.ElementsMatch(t, []string{"coder_script.a", "coder_script.d"}, runs.allStarted())
require.Empty(t, logs.outputs(b.LogSourceID))
require.Empty(t, logs.outputs(c.LogSourceID))
for address, want := range map[string]unit.Outcome{
"coder_script.a": unit.OutcomeCanceled,
"coder_script.b": unit.OutcomeCanceled,
"coder_script.c": unit.OutcomeCanceled,
"coder_script.d": unit.OutcomeSucceeded,
} {
u, err := manager.Unit(unit.ID(address))
require.NoError(t, err)
require.Equal(t, want, u.Outcome(), address)
}
}
func TestLifecycleExecutorStopScripts(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
saveErr := xerrors.New("exit status 1")
runs := newScriptOrderRuns()
runs.result("coder_script.save_state", saveErr)
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
save := scriptOrderTestScript("coder_script.save_state")
save.DisplayName = "Save state"
stopDB := scriptOrderTestScript("coder_script.stop_db", scriptOrderTestDependency("coder_script.save_state", requireSuccess))
report := scriptOrderTestScript("coder_script.report_stop", scriptOrderTestDependency("coder_script.save_state", requireCompletion))
err := executor.execute(ctx, []codersdk.WorkspaceAgentScript{save, stopDB, report}, ExecuteStopScripts)
require.ErrorIs(t, err, saveErr)
require.ElementsMatch(t, []string{"coder_script.save_state", "coder_script.report_stop"}, runs.allStarted())
require.Equal(t, []string{`Skipping script: dependency "Save state" failed.`}, logs.outputs(stopDB.LogSourceID))
require.Equal(t, ExecuteStopScripts, runs.option("coder_script.report_stop"))
}
func TestLifecycleExecutorFreshManagerPerExecution(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
scripts := []codersdk.WorkspaceAgentScript{clone, install}
first := newScriptOrderRuns()
first.result("coder_script.clone", xerrors.New("exit status 128"))
executor := newTestExecutor(t, first, newScriptOrderTestLogs())
require.Error(t, executor.execute(ctx, scripts, ExecuteStartScripts))
require.False(t, first.started("coder_script.install"))
second := newScriptOrderRuns()
executor.run = second.run
require.NoError(t, executor.execute(ctx, scripts, ExecuteStartScripts))
require.True(t, second.started("coder_script.install"))
}
func TestLifecycleExecutorRecordsFailureOnUnrecordedExit(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
scripts := []codersdk.WorkspaceAgentScript{clone, install}
manager, byAddress, err := lifecycleManager(scripts)
require.NoError(t, err)
// Pre-setting the outcome makes the executor's own update fail.
require.NoError(t, manager.UpdateOutcome("coder_script.clone", unit.OutcomeRunning))
err = executor.executeWith(ctx, manager, byAddress, scripts, ExecuteStartScripts)
require.ErrorIs(t, err, unit.ErrSameOutcomeAlreadySet)
require.NoError(t, ctx.Err())
require.Empty(t, runs.allStarted())
u, err := manager.Unit("coder_script.clone")
require.NoError(t, err)
require.Equal(t, unit.OutcomeFailed, u.Outcome())
require.Equal(t, []string{`Skipping script: dependency "coder_script.clone" failed.`}, logs.outputs(install.LogSourceID))
}
func TestLifecycleExecutorAddresslessScriptsRunAtOnce(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.hold("coder_script.clone")
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
legacy := scriptOrderTestScript("")
devcontainer := scriptOrderTestScript("")
done := runExecute(ctx, executor, clone, install, legacy, devcontainer)
runs.waitStarted(ctx, t, "coder_script.clone")
runs.waitFinished(ctx, t, legacy.LogSourceID.String())
runs.waitFinished(ctx, t, devcontainer.LogSourceID.String())
runs.requireNotStarted(t, "coder_script.install")
runs.release("coder_script.clone", nil)
require.NoError(t, testutil.RequireReceive(ctx, t, done))
}
func TestLifecycleExecutorSkipIsNotAnError(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
cloneErr error
}{
{name: "Failure", cloneErr: xerrors.New("exit status 128")},
{name: "Timeout", cloneErr: ErrTimeout},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.result("coder_script.clone", test.cloneErr)
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
clone := scriptOrderTestScript("coder_script.clone")
install := scriptOrderTestScript("coder_script.install", scriptOrderTestDependency("coder_script.clone", requireSuccess))
post := scriptOrderTestScript("coder_script.post", scriptOrderTestDependency("coder_script.clone", requireCompletion))
err := executor.execute(ctx, []codersdk.WorkspaceAgentScript{clone, install, post}, ExecuteStartScripts)
require.ErrorIs(t, err, test.cloneErr)
// errgroup keeps the first error only, so a skip that returned
// an error could only show up as the whole result.
require.Equal(t, test.cloneErr.Error(), err.Error())
require.ElementsMatch(t, []string{"coder_script.clone", "coder_script.post"}, runs.allStarted())
})
}
}
func TestLifecycleExecutorRejectsInvalidDependenciesBeforeRunning(t *testing.T) {
t.Parallel()
noAddress := scriptOrderTestScript("", scriptOrderTestDependency("coder_script.clone", requireSuccess))
noAddress.DisplayName = "Install tools"
for _, test := range []struct {
name string
scripts []codersdk.WorkspaceAgentScript
wantErr string
}{
{
name: "DependenciesWithoutAddress",
scripts: []codersdk.WorkspaceAgentScript{
scriptOrderTestScript("coder_script.clone"),
noAddress,
},
wantErr: `script "Install tools" has dependencies but no Terraform resource address`,
},
{
name: "DuplicateAddress",
scripts: []codersdk.WorkspaceAgentScript{
scriptOrderTestScript("coder_script.setup"),
scriptOrderTestScript("coder_script.setup", scriptOrderTestDependency("coder_script.setup", requireSuccess)),
},
wantErr: `duplicate script resource address "coder_script.setup"`,
},
{
name: "MissingPrerequisite",
scripts: []codersdk.WorkspaceAgentScript{
scriptOrderTestScript("coder_script.install_tools", scriptOrderTestDependency("coder_script.clone", requireSuccess)),
},
wantErr: `script "coder_script.install_tools" depends on "coder_script.clone", which is not part of this lifecycle execution`,
},
{
name: "UnsupportedRequirement",
scripts: []codersdk.WorkspaceAgentScript{
scriptOrderTestScript("coder_script.clone_repo"),
scriptOrderTestScript("coder_script.install_tools", scriptOrderTestDependency("coder_script.clone_repo", "started")),
},
wantErr: `script "coder_script.install_tools" dependency on "coder_script.clone_repo": unsupported requirement "started"`,
},
{
name: "Cycle",
scripts: []codersdk.WorkspaceAgentScript{
scriptOrderTestScript("coder_script.a", scriptOrderTestDependency("coder_script.b", requireSuccess)),
scriptOrderTestScript("coder_script.b", scriptOrderTestDependency("coder_script.a", requireSuccess)),
},
wantErr: `add script dependency "coder_script.b" after "coder_script.a"`,
},
{
name: "ConflictingRequirement",
scripts: []codersdk.WorkspaceAgentScript{
scriptOrderTestScript("coder_script.clone_repo"),
scriptOrderTestScript("coder_script.build",
scriptOrderTestDependency("coder_script.clone_repo", requireSuccess),
scriptOrderTestDependency("coder_script.clone_repo", requireCompletion),
),
},
wantErr: `add script dependency "coder_script.build" after "coder_script.clone_repo"`,
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
logs := newScriptOrderTestLogs()
executor := newTestExecutor(t, runs, logs)
err := executor.execute(ctx, test.scripts, ExecuteStartScripts)
require.ErrorContains(t, err, test.wantErr)
switch test.name {
case "Cycle":
require.ErrorIs(t, err, unit.ErrCycleDetected)
case "ConflictingRequirement":
require.ErrorIs(t, err, unit.ErrConflictingRequirement)
}
require.Empty(t, runs.allStarted())
for _, script := range test.scripts {
require.Equal(t, []string{"Lifecycle scripts did not run: " + err.Error()}, logs.outputs(script.LogSourceID))
}
})
}
}
func TestLifecycleExecutorManyWaiters(t *testing.T) {
t.Parallel()
const waiters = 50
scripts := func() []codersdk.WorkspaceAgentScript {
all := []codersdk.WorkspaceAgentScript{scriptOrderTestScript("coder_script.clone")}
for i := range waiters {
all = append(all, scriptOrderTestScript(
fmt.Sprintf("coder_script.dependent_%02d", i),
scriptOrderTestDependency("coder_script.clone", requireSuccess),
))
}
return all
}
t.Run("AllRun", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
runs := newScriptOrderRuns()
runs.hold("coder_script.clone")
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
done := runExecute(ctx, executor, scripts()...)
runs.waitStarted(ctx, t, "coder_script.clone")
require.Len(t, runs.allStarted(), 1)
runs.release("coder_script.clone", nil)
require.NoError(t, testutil.RequireReceive(ctx, t, done))
require.Len(t, runs.allStarted(), waiters+1)
})
t.Run("AllCanceled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))
defer cancel()
runs := newScriptOrderRuns()
runs.hold("coder_script.clone")
executor := newTestExecutor(t, runs, newScriptOrderTestLogs())
all := scripts()
manager, byAddress, err := lifecycleManager(all)
require.NoError(t, err)
done := make(chan error, 1)
go func() { done <- executor.executeWith(ctx, manager, byAddress, all, ExecuteStartScripts) }()
runs.waitStarted(ctx, t, "coder_script.clone")
cancel()
waitCtx := testutil.Context(t, testutil.WaitShort)
require.ErrorIs(t, testutil.RequireReceive(waitCtx, t, done), context.Canceled)
require.Len(t, runs.allStarted(), 1)
for _, script := range all {
u, err := manager.Unit(unit.ID(script.ResourceAddress))
require.NoError(t, err)
require.Equal(t, unit.OutcomeCanceled, u.Outcome(), script.ResourceAddress)
}
})
}
func newTestExecutor(t *testing.T, runs *scriptOrderRuns, logs *scriptOrderTestLogs) lifecycleExecutor {
t.Helper()
return lifecycleExecutor{
clock: quartz.NewReal(),
logger: testutil.Logger(t),
getScriptLogger: logs.logger,
run: runs.run,
}
}
func runExecute(ctx context.Context, executor lifecycleExecutor, scripts ...codersdk.WorkspaceAgentScript) <-chan error {
done := make(chan error, 1)
go func() { done <- executor.execute(ctx, scripts, ExecuteStartScripts) }()
return done
}
func scriptOrderTestScript(address string, dependencies ...codersdk.WorkspaceAgentScriptDependency) codersdk.WorkspaceAgentScript {
return codersdk.WorkspaceAgentScript{
ID: uuid.New(),
LogSourceID: uuid.New(),
ResourceAddress: address,
RunOnStart: true,
Dependencies: dependencies,
}
}
func scriptOrderTestDependency(address string, requirement codersdk.WorkspaceAgentScriptDependencyRequirement) codersdk.WorkspaceAgentScriptDependency {
return codersdk.WorkspaceAgentScriptDependency{
PrerequisiteResourceAddress: address,
Requirement: requirement,
}
}
// scriptOrderRuns is a fake run. A script's key is its resource address,
// or its log source ID when it has no address. A held script blocks until
// released; any other script returns its configured result at once.
type scriptOrderRuns struct {
mu sync.Mutex
held map[string]chan error
results map[string]error
startedCh map[string]chan struct{}
finishedCh map[string]chan struct{}
options map[string]ExecuteOption
order []string
}
func newScriptOrderRuns() *scriptOrderRuns {
return &scriptOrderRuns{
held: make(map[string]chan error),
results: make(map[string]error),
startedCh: make(map[string]chan struct{}),
finishedCh: make(map[string]chan struct{}),
options: make(map[string]ExecuteOption),
}
}
func scriptOrderKey(script codersdk.WorkspaceAgentScript) string {
if script.ResourceAddress != "" {
return script.ResourceAddress
}
return script.LogSourceID.String()
}
func (r *scriptOrderRuns) hold(key string) {
r.mu.Lock()
defer r.mu.Unlock()
r.held[key] = make(chan error, 1)
}
func (r *scriptOrderRuns) result(key string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.results[key] = err
}
func (r *scriptOrderRuns) release(key string, err error) {
r.mu.Lock()
release := r.held[key]
r.mu.Unlock()
release <- err
}
func signal(m map[string]chan struct{}, key string) chan struct{} {
if _, ok := m[key]; !ok {
m[key] = make(chan struct{})
}
return m[key]
}
func (r *scriptOrderRuns) run(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
key := scriptOrderKey(script)
r.mu.Lock()
r.order = append(r.order, key)
r.options[key] = option
close(signal(r.startedCh, key))
release, held := r.held[key]
result := r.results[key]
finished := signal(r.finishedCh, key)
r.mu.Unlock()
defer close(finished)
if !held {
return result
}
select {
case err := <-release:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (r *scriptOrderRuns) waitStarted(ctx context.Context, t *testing.T, key string) {
t.Helper()
r.mu.Lock()
started := signal(r.startedCh, key)
r.mu.Unlock()
select {
case <-started:
case <-ctx.Done():
t.Fatalf("script %q did not start", key)
}
}
func (r *scriptOrderRuns) waitFinished(ctx context.Context, t *testing.T, key string) {
t.Helper()
r.mu.Lock()
finished := signal(r.finishedCh, key)
r.mu.Unlock()
select {
case <-finished:
case <-ctx.Done():
t.Fatalf("script %q did not finish", key)
}
}
func (r *scriptOrderRuns) requireNotStarted(t *testing.T, key string) {
t.Helper()
require.False(t, r.started(key), "script %q started too early", key)
}
func (r *scriptOrderRuns) started(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
started, ok := r.startedCh[key]
if !ok {
return false
}
select {
case <-started:
return true
default:
return false
}
}
func (r *scriptOrderRuns) allStarted() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.order...)
}
func (r *scriptOrderRuns) option(key string) ExecuteOption {
r.mu.Lock()
defer r.mu.Unlock()
return r.options[key]
}
// scriptOrderTestLogs records every line sent to each script's log source.
type scriptOrderTestLogs struct {
mu sync.Mutex
byID map[uuid.UUID][]string
sent chan scriptOrderLog
}
type scriptOrderLog struct {
logSourceID uuid.UUID
output string
}
func newScriptOrderTestLogs() *scriptOrderTestLogs {
return &scriptOrderTestLogs{
byID: make(map[uuid.UUID][]string),
sent: make(chan scriptOrderLog, 256),
}
}
func (l *scriptOrderTestLogs) logger(logSourceID uuid.UUID) ScriptLogger {
return scriptOrderTestLogger{logs: l, logSourceID: logSourceID}
}
func (l *scriptOrderTestLogs) outputs(logSourceID uuid.UUID) []string {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.byID[logSourceID]...)
}
// waitOutput blocks until the next line for the given log source arrives.
func (l *scriptOrderTestLogs) waitOutput(ctx context.Context, t *testing.T, logSourceID uuid.UUID) string {
t.Helper()
for {
log := testutil.RequireReceive(ctx, t, l.sent)
if log.logSourceID == logSourceID {
return log.output
}
}
}
type scriptOrderTestLogger struct {
logs *scriptOrderTestLogs
logSourceID uuid.UUID
}
func (l scriptOrderTestLogger) Send(_ context.Context, logs ...agentsdk.Log) error {
l.logs.mu.Lock()
defer l.logs.mu.Unlock()
for _, log := range logs {
l.logs.byID[l.logSourceID] = append(l.logs.byID[l.logSourceID], log.Output)
l.logs.sent <- scriptOrderLog{logSourceID: l.logSourceID, output: log.Output}
}
return nil
}
func (scriptOrderTestLogger) Flush(context.Context) error {
return nil
}
+1
View File
@@ -247,6 +247,7 @@ func dbAgentScriptToProto(script database.GetWorkspaceAgentScriptsByAgentIDsRow)
RunOnStop: script.RunOnStop,
StartBlocksLogin: script.StartBlocksLogin,
Timeout: durationpb.New(time.Duration(script.TimeoutSeconds) * time.Second),
DisplayName: script.DisplayName,
ResourceAddress: sdkScript.ResourceAddress,
Dependencies: dependencies,
}, nil
+4
View File
@@ -126,6 +126,7 @@ func TestGetManifest(t *testing.T) {
WorkspaceAgentID: agent.ID,
LogSourceID: uuid.New(),
LogPath: "/cool/log/path/1",
DisplayName: "Install tools",
Script: "cool script 1",
Cron: "30 2 * * *",
StartBlocksLogin: true,
@@ -140,6 +141,7 @@ func TestGetManifest(t *testing.T) {
WorkspaceAgentID: agent.ID,
LogSourceID: uuid.New(),
LogPath: "/cool/log/path/2",
DisplayName: "Clone repo",
Script: "cool script 2",
Cron: "",
StartBlocksLogin: false,
@@ -212,6 +214,7 @@ func TestGetManifest(t *testing.T) {
RunOnStop: scripts[0].RunOnStop,
StartBlocksLogin: scripts[0].StartBlocksLogin,
Timeout: durationpb.New(time.Duration(scripts[0].TimeoutSeconds) * time.Second),
DisplayName: scripts[0].DisplayName,
ResourceAddress: "coder_script.install_tools",
Dependencies: []*agentproto.WorkspaceAgentScriptDependency{{
PrerequisiteResourceAddress: "coder_script.clone_repo",
@@ -228,6 +231,7 @@ func TestGetManifest(t *testing.T) {
RunOnStop: scripts[1].RunOnStop,
StartBlocksLogin: scripts[1].StartBlocksLogin,
Timeout: durationpb.New(time.Duration(scripts[1].TimeoutSeconds) * time.Second),
DisplayName: scripts[1].DisplayName,
ResourceAddress: "coder_script.clone_repo",
// An unordered script sends no entries, not placeholders.
Dependencies: nil,
+1 -3
View File
@@ -101,9 +101,7 @@ import (
// WorkspaceAgentScriptDependency message. They carry each script's
// Terraform address and its resolved coder_script_order prerequisites
// in the manifest. Both are empty for unordered scripts.
// - A v2.14 agent decodes these fields but does not yet enforce the
// ordering, so a version check alone does not tell coderd that an agent
// orders its scripts.
// - A v2.14 agent runs start and stop scripts in dependency order.
const (
CurrentMajor = 2
CurrentMinor = 14