Skip to content

plan

import "github.com/alehatsman/mooncake/internal/plan"

Package plan provides plan generation and persistence for mooncake configurations.

Index

Variables

ErrInputFileMissing is returned by HashInputFiles when one of the recorded input files no longer exists at apply time. Surfaces as part of the stale-plan policy.

var ErrInputFileMissing = errors.New("input file missing")

func HashInputFiles

func HashInputFiles(paths []string) (string, error)

HashInputFiles computes a deterministic hash over the contents of the given files. Used at both plan time (to record what the plan was built from) and apply time (to detect that the source files have changed since).

The hash mixes the file path AND content so renames are detected (different path → different hash even with identical content).

Returns ErrInputFileMissing if any path is unreadable; callers should treat that as a stale-plan condition.

func IsStaleError

func IsStaleError(err error) bool

IsStaleError reports whether err is a stale-plan rejection.

func SavePlanToFile

func SavePlanToFile(p *Plan, filePath string) (err error)

SavePlanToFile saves a plan to a file in JSON or YAML format.

Before writing, the marshalled bytes go through redactSecretMarkers which rewrites any in-memory `\x00__MOONCAKE_SECRET__:env:FOO` sentinel back to a human-readable `!secret env:FOO` form. This is the spec-23 §3 plan-output redaction: the real secret value never makes it to disk (the marker carries only the *ref*, never the resolved value), but the marker itself looks like a control character so we rewrite it before serialization for readability.

func ValidateForApply

func ValidateForApply(p *Plan, opts ValidateOptions) error

ValidateForApply is the convenience shim around ValidateForApplyWithReasons that drops the per-check reason list. Existing callers that only care about pass/fail keep working.

type ExpansionContext

ExpansionContext holds the context during plan expansion

type ExpansionContext struct {
    Variables  map[string]interface{}
    CurrentDir string
    Tags       []string
    // SkipTags excludes steps whose tags intersect this list
    // (MT-58 `--skip-tags`). Composes with Tags via AND.
    SkipTags []string
    // Names is the spec-50 step-name filter. When non-empty, a step is
    // only kept (step.Skipped=false) when its name matches one of the
    // entries. Untagged steps still run on a tag filter; unnamed steps
    // are dropped on a name filter (see utils.MatchesNames).
    Names []string
}

type HostFacts

HostFacts captures the minimum set of facts needed to detect a stale plan being applied on the wrong host. Spec 16's stale-plan policy compares these at apply time and refuses on mismatch unless --allow-stale is set.

Deliberately small. Hostname, kernel version, package versions etc. are too strict and would make plans annoyingly unportable across similar dev machines.

type HostFacts struct {
    OsFamily     string `json:"os_family,omitempty" yaml:"os_family,omitempty"`
    Arch         string `json:"arch,omitempty" yaml:"arch,omitempty"`
    DistroFamily string `json:"distro_family,omitempty" yaml:"distro_family,omitempty"`
}

type IncludeFrame

IncludeFrame tracks a frame in the include stack for cycle detection and origin tracking

type IncludeFrame struct {
    FilePath string
    Line     int
    Column   int
}

type Plan

Plan represents a fully expanded, deterministic execution plan.

Spec 16 adds: - Inspections: per-step state predictions - GeneratedOn: host facts subset for stale-plan detection - InputFiles + InputFilesHash: source-file integrity check so `apply --from-plan` refuses to run plans that no longer match the YAML they were built from.

type Plan struct {
    Version        string                 `json:"version" yaml:"version"`
    GeneratedAt    time.Time              `json:"generated_at" yaml:"generated_at"`
    GeneratedOn    HostFacts              `json:"generated_on,omitempty" yaml:"generated_on,omitempty"`
    RootFile       string                 `json:"root_file" yaml:"root_file"`
    InputFiles     []string               `json:"input_files,omitempty" yaml:"input_files,omitempty"`
    InputFilesHash string                 `json:"input_files_hash,omitempty" yaml:"input_files_hash,omitempty"`
    Steps          []config.Step          `json:"steps" yaml:"steps"`
    Inspections    []StepInspection       `json:"inspections,omitempty" yaml:"inspections,omitempty"`
    InitialVars    map[string]interface{} `json:"initial_vars,omitempty" yaml:"initial_vars,omitempty"`
    Tags           []string               `json:"tags,omitempty" yaml:"tags,omitempty"`
    // Modules carries the playbook's `modules:` alias map (spec-67) so the
    // executor can resolve `use: <alias>` references at apply time.
    Modules map[string]string `json:"modules,omitempty" yaml:"modules,omitempty"`

    // UnresolvedTemplates lists `{{ root }}` references whose root
    // identifier is not present in initial_vars, not produced by a
    // prior step's `as:` register, and not a recognized loop/env name.
    // Populated by CheckPlanStrict after expansion; surfaced by the
    // `validate` and `plan` commands.
    UnresolvedTemplates []UnresolvedRef `json:"unresolved_templates,omitempty" yaml:"unresolved_templates,omitempty"`
}

func LoadPlanFromFile

func LoadPlanFromFile(filePath string) (*Plan, error)

LoadPlanFromFile loads a plan from a JSON or YAML file

type Planner

Planner builds deterministic execution plans from config files

type Planner struct {
    // contains filtered or unexported fields
}

func NewPlanner

func NewPlanner() (*Planner, error)

NewPlanner creates a new Planner instance. Returns an error if template renderer initialization fails.

func (*Planner) BuildPlan

func (p *Planner) BuildPlan(cfg PlannerConfig) (*Plan, error)

BuildPlan generates a deterministic execution plan from a config file

func (*Planner) ExpandStepsWithContext

func (p *Planner) ExpandStepsWithContext(steps []config.Step, variables map[string]interface{}, currentDir string) ([]config.Step, error)

ExpandStepsWithContext expands a list of steps with the given context. This is useful for expanding preset steps which may contain includes, loops, etc. Returns the expanded steps ready for execution.

type PlannerConfig

PlannerConfig holds configuration for building a plan

type PlannerConfig struct {
    ConfigPath string
    Variables  map[string]interface{}
    Tags       []string
    // SkipTags excludes steps whose tags intersect this list (MT-58).
    SkipTags []string
    // Names is the spec-50 step-name filter; propagated into
    // ExpansionContext so per-step skip evaluation can consult it.
    Names []string

    // TaskName, when non-empty, selects a named task from the config's
    // `tasks:` block instead of the top-level `steps:` list. The
    // planner replaces RunConfig.Steps with the task's Steps and
    // layers the task's Vars between file-level vars (lowest) and the
    // caller-supplied Variables (highest). An unknown task name is an
    // error from BuildPlan.
    TaskName string
}

type StaleError

StaleError describes a stale-plan rejection. Callers compare Reason to StaleReason constants; the human Message is suitable for direct display.

type StaleError struct {
    Reason  StaleReason
    Message string
}

func (*StaleError) Error

func (e *StaleError) Error() string

type StaleReason

StaleReason identifies why a plan was rejected as stale at apply time. Returned via StaleError so callers can present specific messages or honor a typed --allow-stale override.

type StaleReason string
const (
    StaleReasonHostMismatch StaleReason = "host_mismatch"
    StaleReasonHashMismatch StaleReason = "input_files_changed"
    StaleReasonFileMissing  StaleReason = "input_file_missing"
    StaleReasonAgeExceeded  StaleReason = "max_age_exceeded"
)

func ValidateForApplyWithReasons

func ValidateForApplyWithReasons(p *Plan, opts ValidateOptions) ([]StaleReason, error)

ValidateForApplyWithReasons checks that a plan loaded from disk is safe to apply against the current host and returns BOTH:

- reasons: every stale-check that would have rejected the plan,
  populated regardless of AllowStale. Callers running with
  `--allow-stale` use this to surface "we allowed apply despite
  X / Y" so the operator sees what was overridden.
- err: the first *StaleError that fired (when AllowStale is
  false), wrapped via standard `errors.Is/As`. nil when no
  check failed OR when AllowStale demoted them all.

Checks (in order):

  1. The host facts subset (os_family, arch, distro_family) matches the values captured at plan time. 2. The on-disk contents of every input file (root config + all includes) hash to the value captured at plan time. Detects unrelated edits to the YAML between plan and apply. 3. If opts.MaxAge is set, the plan must be younger than that.

Hash I/O errors that aren't "file missing" (perm-denied, EIO, …) short-circuit immediately and return as the raw wrap — they aren't stale-plan conditions, they're system errors.

type StepInspection

StepInspection is the result of running a handler in ModePlan against a single step. One inspection per Plan.Steps entry (matched by StepID).

type StepInspection struct {
    StepID      string `json:"step_id" yaml:"step_id"`
    ActionType  string `json:"action_type,omitempty" yaml:"action_type,omitempty"`
    WouldChange bool   `json:"would_change" yaml:"would_change"`
    Checkable   bool   `json:"checkable" yaml:"checkable"`
    Reason      string `json:"reason,omitempty" yaml:"reason,omitempty"`
    // Skipped reflects when/tag filtering decisions made at plan time.
    // Skipped steps have WouldChange=false and Reason explains why.
    Skipped bool `json:"skipped,omitempty" yaml:"skipped,omitempty"`
    // Detail carries action-specific plan data (e.g. effects.ContentDiff for file writes).
    Detail any `json:"detail,omitempty" yaml:"detail,omitempty"`

    // Diff is the spec-22 structural per-step delta when the handler
    // implements actions.Differ. nil for handlers that haven't opted
    // in (assert, shell, cmd, etc.) — we deliberately don't synthesize
    // a default-Differ Diff here because the coarse "Operation=update,
    // Resource.Kind=other" fallback would appear on every non-Differ
    // step and add noise to JSON output without information.
    //
    // Consumers wanting structured information about steps that don't
    // implement Differ should look at Reason + Detail instead.
    Diff *actions.Diff `json:"diff,omitempty" yaml:"diff,omitempty"`

    // Cost is the spec-22 phase 6 informational cost estimate when
    // the handler implements actions.Coster. nil for handlers that
    // haven't opted in. Surfaced in `mooncake plan --format json`
    // per step so cost-aware tooling (transaction risk gates, agent
    // safety prompts) can read it without re-running.
    Cost *actions.CostEstimate `json:"cost,omitempty" yaml:"cost,omitempty"`
}

type UnresolvedRef

UnresolvedRef describes a single `{{ root }}` reference whose root identifier is not in scope at the step's plan-time position.

type UnresolvedRef struct {
    StepID   string         `json:"step_id,omitempty" yaml:"step_id,omitempty"`
    StepName string         `json:"step_name,omitempty" yaml:"step_name,omitempty"`
    Field    string         `json:"field,omitempty" yaml:"field,omitempty"`
    Root     string         `json:"root" yaml:"root"`
    Origin   *config.Origin `json:"origin,omitempty" yaml:"origin,omitempty"`
}

func CheckPlanStrict

func CheckPlanStrict(p *Plan) []UnresolvedRef

CheckPlanStrict scans the expanded plan for unresolved root identifiers. Returns a deterministic list (steps in order, refs per step in field declaration order, deduplicated by root).

type ValidateOptions

ValidateOptions controls which checks ValidateForApply runs and what overrides the caller has explicitly enabled.

type ValidateOptions struct {
    // MaxAge, when non-zero, rejects plans older than this duration.
    MaxAge time.Duration
    // AllowStale, when true, demotes all stale-plan rejections to a
    // best-effort warning (returned as nil error). The caller is
    // responsible for logging the reasons separately if desired.
    AllowStale bool
}

Generated by gomarkdoc