Ctrl K
ComunidadGuías…

agent/agentscripts/script_order.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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/proto"
	"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

const (
	canceledBeforeStartLine = "Canceled before starting."
	canceledRunningLine     = "Canceled while running."
)

// 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
	// reportUnstarted is Runner.reportUnstarted; tests replace it with a
	// fake or leave it nil.
	reportUnstarted func(context.Context, codersdk.WorkspaceAgentScript, ExecuteOption, proto.Timing_Status)
}

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 {
			e.logCanceled(ctx, script, canceledBeforeStartLine)
			e.report(ctx, script, option, proto.Timing_CANCELED)
			return errors.Join(err, record(unit.OutcomeCanceled))
		}
		return err
	}

	switch evaluation.Decision {
	case unit.DecisionSkip:
		e.logSkipped(ctx, script, scriptsByAddress, evaluation)
		e.report(ctx, script, option, proto.Timing_SKIPPED)
		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)
	outcome := scriptOutcome(runErr, ctx.Err())
	if outcome == unit.OutcomeCanceled {
		// run already sent the timing with the real interval.
		e.logCanceled(ctx, script, canceledRunningLine)
	}
	if err := record(outcome); err != nil {
		return errors.Join(runErr, err)
	}
	return runErr
}

func (e lifecycleExecutor) report(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption, status proto.Timing_Status) {
	if e.reportUnstarted != nil {
		e.reportUnstarted(ctx, script, option, status)
	}
}

// logCanceled uses a context that outlives the canceled execution, because
// sending through the execution's context would fail at exactly this moment.
func (e lifecycleExecutor) logCanceled(ctx context.Context, script codersdk.WorkspaceAgentScript, line string) {
	logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), reportTimeout)
	defer cancel()
	e.sendScriptLog(logCtx, script, line)
}

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