Merge branch 'main' into bobby/plat-550-expose-skipped-canceled-outcomes

This commit is contained in:
Bobby Ho
2026-10-15 13:53:40 -07:00
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.
//
// API v2.15:
// - Added SKIPPED and CANCELED to Timing.Status on the Agent API. A v2.15