Files
coder/agent/agentscripts/script_order.go
T
Bobby Ho 4d1f95e73e 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
2026-10-15 20:33:48 +00:00

356 lines
11 KiB
Go

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
}
}