Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ The flags are:
| `--vars-file <path>` | Load input values from a YAML or JSON file. Repeatable; later files override earlier files for the same key. CLI `--name=value` flags always win. See "Layered input variables with `--vars-file`" below. |
| `--play <name>` | Run only the play with this name. Matches the play's `name:` field; auto-named plays use `play #N`. Composes with `--tags`. |
| `--fail-fast` | Abort the entire run on the first task error. Without this flag, an error aborts only the current play and the next play still runs. |
| `--list-tasks` | Print the resolved task plan and exit without running. Honors `--play` / `--tags` / `--skip-tags` and shows expanded loop iterations and `[skipped]` markers for `when:`-skipped tasks. See "Inspecting and resuming" below. |
| `--start-at-task <name>` | Skip every task before the matched name (rendered as `[skipped] ... (before --start-at-task)`); the matched task and successors run normally. Filter order: `--start-at-task` -> `--tags`/`--skip-tags` -> per-task `when:` at execution. The name search walks every play in source order, narrowed by `--play`. |

For example, a multi-command task renders one continuation per invocation:

Expand All @@ -440,6 +442,40 @@ For example, a multi-command task renders one continuation per invocation:

Color output respects [`NO_COLOR`](https://no-color.org/): set `NO_COLOR=1` to disable ANSI escapes, or pipe to a non-TTY (output is plain in that case automatically).

#### Inspecting and resuming with `--list-tasks` / `--start-at-task`

Two flags help when a recipe grows long: `--list-tasks` previews the resolved task plan without running, and `--start-at-task <name>` resumes a partially-applied recipe from a specific task.

`--list-tasks` walks the resolved plan (post `--play` / `--tags` filtering, post `loop:` expansion, post `when:` evaluation against inputs) and prints one line per envelope:

```text
$ docket apply --list-tasks
==> Play: api
[0] dokku apps:create api [tags=core]
[1] dokku git:sync api [tags=deploy]
[2] dokku config:set api [tags=core,deploy]
[3] dokku ports:add api [tags=deploy]
```

`when:` predicates that evaluate false against the inputs render as `[skipped]`. Predicates that reference `.registered.<name>` cannot be decided without running prior tasks, so they render as `[unknown]` rather than misreporting a skip. `block:` groups print the group line followed by indented `[block]` / `[rescue]` / `[always]` children.

`--start-at-task <name>` takes the exact envelope name (matching `name:` in the recipe). Earlier tasks render as `[skipped]` with a `(before --start-at-task)` reason and do not run; the matched task and every task after it run normally:

```text
$ docket apply --start-at-task "dokku config:set api"
==> Play: api
[skipped] dokku apps:create api (before --start-at-task)
[skipped] dokku git:sync api (before --start-at-task)
[ok] dokku config:set api
[changed] dokku ports:add api

Summary: 4 tasks * 1 changed * 1 ok * 2 skipped * 0 errors (took 1.1s)
```

Resolution order is: `--start-at-task` selects first, then `--tags` / `--skip-tags` filter, then per-task `when:` at execution time. A task can be selected by `--start-at-task` and still be filtered out by `--tags`. Inside a `block:`, matching a child does not unwind the group: the executor enters the block, skips earlier children, runs from the matched child onward, and continues with `rescue` / `always` per normal block semantics. For multi-play files the search walks every play in source order; `--play` narrows it.

If `--start-at-task` does not match any task name, the run exits 1 with the available names listed so the typo can be fixed quickly.

### Remote execution over SSH

Set `DOKKU_HOST=[user@]host[:port]` (or pass `--host`) to route every dokku invocation through an `ssh` subprocess so docket can manage a remote dokku server from a developer laptop or CI runner without installing the binary on the server. All invocations in one run share a single TCP+SSH connection via OpenSSH ControlMaster multiplexing.
Expand Down Expand Up @@ -512,6 +548,7 @@ The flags are:
| `--detailed-exitcode` | Exit `0` when no drift is detected, `2` when at least one task reports drift, `1` on read or probe error. Errors win over drift. Without this flag, plan exits `0` regardless of drift. Mirrors the `git diff --exit-code` / `terraform plan -detailed-exitcode` convention. |
| `--vars-file <path>` | Load input values from a YAML or JSON file. Repeatable; later files override earlier files for the same key. CLI `--name=value` flags always win. See "Layered input variables with `--vars-file`" below. |
| `--play <name>` | Plan only the play with this name. Matches the play's `name:` field; auto-named plays use `play #N`. Composes with `--tags`. |
| `--list-tasks` | Print the resolved task plan and exit without contacting the server. Honors `--play` / `--tags` / `--skip-tags` and shows expanded loop iterations and `[skipped]` markers for `when:`-skipped tasks. See "Inspecting and resuming" under `apply` for the full output shape. |

```shell
# CI gate: fail the job if any task would change the server.
Expand Down Expand Up @@ -551,11 +588,13 @@ The shipping checks cover: YAML parses, recipe shape (top-level list of plays wi
docket validate --tasks path/to/tasks.yml
```

Exit codes are `0` when no problems are found and `1` otherwise. Three flags are available:
Exit codes are `0` when no problems are found and `1` otherwise. Five flags are available:

- `--json` emits one JSON-lines event per problem with a stable `version: 1` schema (`{"type":"validate_problem","code":"unknown_task_type", ...}`), suitable for piping into a CI annotator.
- `--strict` additionally flags any input declared `required: true` that has no `default` and no value supplied via a CLI flag or `--vars-file` - useful in CI to ensure the recipe can be applied without runtime overrides.
- `--strict` additionally flags any input declared `required: true` that has no `default` and no value supplied via a CLI flag or `--vars-file`, and verifies that any `--play` / `--start-at-task` references passed alongside resolve to real names in the file (problem codes `unknown_play_reference` / `unknown_start_at_task`) - useful in CI to ensure the recipe can be applied without runtime overrides or stale CLI invocations.
- `--vars-file <path>` loads input values from a YAML or JSON file (repeatable; later files override earlier; CLI `--name=value` flags always win). Values fed in through `--vars-file` count as overrides for `--strict`. See "Layered input variables with `--vars-file`" below.
- `--play <name>` (strict-only) verifies the named play exists in the recipe. Pair with `--strict` so a CI lint job catches stale `docket apply --play <name>` invocations.
- `--start-at-task <name>` (strict-only) verifies a task with this name exists in the recipe; narrowed by `--play` when both are set. Pair with `--strict` so a CI lint job catches typos in resume invocations before they reach `apply`.

A task file can also be specified via flag, and may be a file retrieved via http:

Expand Down
131 changes: 131 additions & 0 deletions commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/dokku/docket/subprocess"
Expand All @@ -28,6 +29,8 @@ type ApplyCommand struct {
varsFiles []string
play string
failFast bool
listTasks bool
startAtTask string
arguments map[string]*Argument
}

Expand Down Expand Up @@ -78,6 +81,8 @@ func (c *ApplyCommand) FlagSet() *flag.FlagSet {
f.StringArrayVar(&c.varsFiles, "vars-file", nil, "load input values from a YAML or JSON file (repeatable; later files override earlier; CLI --name=value flags always win). A .json extension parses as JSON; otherwise YAML.")
f.StringVar(&c.play, "play", "", "run only the play with this name (matches the play's `name:` field; auto-named plays use `play #N`)")
f.BoolVar(&c.failFast, "fail-fast", false, "abort the entire run on the first task error. By default, an error aborts only the current play and the next play still runs.")
f.BoolVar(&c.listTasks, "list-tasks", false, "print the resolved task plan and exit without running. Honors --play / --tags / --skip-tags and shows expanded loop iterations and [skipped] markers for when:-skipped tasks.")
f.StringVar(&c.startAtTask, "start-at-task", "", "skip every task before the matched name; the matched task and successors run normally. Filter order: --start-at-task -> --tags/--skip-tags -> per-task when: at execution. The name search walks every play in source order, narrowed by --play.")

taskFile := getTaskYamlFilename(os.Args)
data, err := os.ReadFile(taskFile)
Expand Down Expand Up @@ -109,6 +114,8 @@ func (c *ApplyCommand) AutocompleteFlags() complete.Flags {
"--vars-file": complete.PredictFiles("*"),
"--play": complete.PredictAnything,
"--fail-fast": complete.PredictNothing,
"--list-tasks": complete.PredictNothing,
"--start-at-task": complete.PredictAnything,
},
)
}
Expand Down Expand Up @@ -170,6 +177,28 @@ func (c *ApplyCommand) Run(args []string) int {
return 1
}

if c.startAtTask != "" {
if !startAtTaskMatches(plays, c.startAtTask) {
c.Ui.Error(fmt.Sprintf(
"--start-at-task %q: no task matched name; available names: %s",
c.startAtTask, formatStartAtTaskNames(plays),
))
return 1
}
}

if c.listTasks {
return renderListTasks(c.Ui, listTasksOptions{
plays: plays,
includes: c.tags,
skips: c.skipTags,
fileLevelKeys: fileLevelKeys,
userSet: userSet,
context: context,
jsonOut: c.json,
})
}

sensitiveValues = append(sensitiveValues, tasks.CollectPlaySensitiveValues(plays)...)
subprocess.SetGlobalSensitive(sensitiveValues)
defer subprocess.SetGlobalSensitive(nil)
Expand All @@ -183,6 +212,11 @@ func (c *ApplyCommand) Run(args []string) int {
counts := ApplyCounts{}
playWhenExprCtx := buildEnvelopeExprContext(buildPlayWhenContext(context, fileLevelKeys, userSet))
hasError := false
// startedAtTask gates --start-at-task: false until an envelope name
// matches c.startAtTask, at which point every subsequent task runs
// normally. Shared by reference across plays so the search spans the
// whole filtered play list.
startedAtTask := c.startAtTask == ""
// registered is the run-wide map predicates reach via
// `.registered.<name>`. loopAccum buffers per-iteration states for
// loop+register expansions so the running aggregate is exposed to
Expand Down Expand Up @@ -230,6 +264,8 @@ playLoop:
emitter: emitter,
counts: &counts,
failFast: c.failFast,
startAtTask: c.startAtTask,
started: &startedAtTask,
}

failed := false
Expand Down Expand Up @@ -268,6 +304,13 @@ type applyContext struct {
emitter EventEmitter
counts *ApplyCounts
failFast bool
// startAtTask is the --start-at-task target, or "" when the flag
// was not set. started is a shared pointer flipped to true the
// first time an envelope name matches the target; until it flips,
// executeTask emits a [skipped] event with the "before
// --start-at-task" reason and skips dispatch.
startAtTask string
started *bool
}

// applyTaskOutcome is the per-task verdict the apply loop reads back
Expand All @@ -288,7 +331,38 @@ type applyTaskOutcome struct {
// "always"); top-level callers pass "". failedTask is non-nil only
// when called from a rescue walker so the rescue child's predicates
// can reference `.failed_task`.
//
// --start-at-task gating sits at the top: until ac.started flips to
// true, an envelope whose name does not match ac.startAtTask (and
// whose group descendants also do not) is reported as `[skipped]`
// with reason "before --start-at-task" and dispatch is skipped. A
// group whose own name does not match but a descendant does is
// entered so the recursive executeTask call lands on the matched
// child.
func (c *ApplyCommand) executeTask(env *tasks.TaskEnvelope, name string, ac *applyContext, failedTask interface{}, phase string) applyTaskOutcome {
if !*ac.started && ac.startAtTask != "" {
switch {
case name == ac.startAtTask:
*ac.started = true
case env.IsGroup() && tasks.EnvelopeContainsName(env, ac.startAtTask):
// Don't skip; descend into the group so the matching
// child runs. The synthesized group state will reflect
// only the executed children, not the skipped ones.
default:
ac.counts.Tasks++
ac.counts.Skipped++
ac.emitter.ApplyTask(ApplyTaskEvent{
Play: ac.play.Name,
Name: name,
Phase: phase,
Group: env.IsGroup(),
Skipped: true,
SkipReason: "before --start-at-task",
Timestamp: time.Now().UTC(),
})
return applyTaskOutcome{skipped: true}
}
}
if env.IsGroup() {
return c.executeGroup(env, name, ac, failedTask, phase)
}
Expand Down Expand Up @@ -621,6 +695,63 @@ func (c *ApplyCommand) executeGroup(env *tasks.TaskEnvelope, name string, ac *ap
}
}

// startAtTaskMatches reports whether target matches some envelope name
// across plays, walking each play's task envelopes plus any block /
// rescue / always children of group entries. Used to validate the
// --start-at-task flag before the executor begins so a typo errors out
// up-front instead of silently skipping the entire run.
func startAtTaskMatches(plays []*tasks.Play, target string) bool {
for _, play := range plays {
if play == nil {
continue
}
for _, name := range play.Tasks.Keys() {
env := play.Tasks.GetEnvelope(name)
if name == target {
return true
}
if tasks.EnvelopeContainsName(env, target) {
return true
}
}
}
return false
}

// formatStartAtTaskNames builds the "available names: ..." hint for an
// unmatched --start-at-task error. Names are deduplicated and rendered
// in source order, quoted so the user can copy a name verbatim back
// onto the CLI.
func formatStartAtTaskNames(plays []*tasks.Play) string {
seen := map[string]bool{}
var quoted []string
for _, play := range plays {
if play == nil {
continue
}
for _, name := range play.Tasks.Keys() {
if !seen[name] {
seen[name] = true
quoted = append(quoted, fmt.Sprintf("%q", name))
}
env := play.Tasks.GetEnvelope(name)
for _, descendant := range tasks.CollectEnvelopeNames([]*tasks.TaskEnvelope{env}) {
if descendant == "" || descendant == name {
continue
}
if !seen[descendant] {
seen[descendant] = true
quoted = append(quoted, fmt.Sprintf("%q", descendant))
}
}
}
}
if len(quoted) == 0 {
return "(none)"
}
return strings.Join(quoted, ", ")
}

// filterPlaysByName narrows plays to the single play whose Name matches
// target. An empty target returns plays unchanged. An unmatched target
// returns an error so the user sees a clear "no such play" diagnostic
Expand Down
Loading