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
23 changes: 23 additions & 0 deletions commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ApplyCommand struct {
failFast bool
listTasks bool
startAtTask string
detailedExitCode bool
arguments map[string]*Argument

// tasksData caches the recipe bytes read while building the FlagSet (to
Expand Down Expand Up @@ -98,6 +99,7 @@ func (c *ApplyCommand) FlagSet() *flag.FlagSet {
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.")
f.BoolVar(&c.detailedExitCode, "detailed-exitcode", false, "exit 0 when nothing changed, 2 when at least one task changed, 1 on error. Without this flag apply exits 0 whether or not anything changed.")

data, format, source := preloadRecipeForFlags(os.Args, true)
if data == nil {
Expand Down Expand Up @@ -133,10 +135,27 @@ func (c *ApplyCommand) AutocompleteFlags() complete.Flags {
"--fail-fast": complete.PredictNothing,
"--list-tasks": complete.PredictNothing,
"--start-at-task": complete.PredictAnything,
"--detailed-exitcode": complete.PredictNothing,
},
)
}

// Run executes every task in the parsed recipe against the live server,
// printing a one-line summary per task plus a final summary line.
//
// Exit codes (default):
//
// 0 - the run completed without errors, whether or not anything changed
// 1 - read error, parse error, or at least one task errored
//
// Exit codes (--detailed-exitcode):
//
// 0 - the run completed cleanly; nothing changed
// 1 - read error, parse error, or at least one task errored (errors win)
// 2 - the run completed; at least one task changed server state
//
// --list-tasks returns before any task runs, so it is unaffected by
// --detailed-exitcode and still exits 0 or 1.
func (c *ApplyCommand) Run(args []string) int {
flags := c.FlagSet()
flags.Usage = func() { c.Ui.Output(c.Help()) }
Expand Down Expand Up @@ -334,9 +353,13 @@ playLoop:
}

emitter.ApplySummary(counts, time.Since(start))

if hasError {
return 1
}
if c.detailedExitCode && counts.Changed > 0 {
return 2
}
return 0
}

Expand Down
115 changes: 115 additions & 0 deletions commands/apply_exitcode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package commands

import (
"errors"
"testing"
)

// These tests pin the `docket apply --detailed-exitcode` contract that
// docs/ansible-dokku.md tells wrapper authors to rely on: a wrapper that
// runs one docket invocation per Ansible task derives `changed` from the
// exit code alone, without parsing the --json event stream. They mirror
// the plan-side guard in play_executor_test.go
// (TestPlanProbeErrorRendersMarkerAndExits).

// TestApplyDetailedExitCodeChanged: a task that changes state exits 2
// with the flag and 0 without it.
func TestApplyDetailedExitCodeChanged(t *testing.T) {
defer stubReset()
stubSet("a", StubFixture{Changed: true})

path := writeTasksFile(t, `---
- tasks:
- name: changes
dokku_stub: { key: a }
`)

if _, _, exit := runApply(t, path, "--detailed-exitcode"); exit != 2 {
t.Errorf("detailed-exitcode exit = %d, want 2 (task changed)", exit)
}
if _, _, exit := runApply(t, path); exit != 0 {
t.Errorf("plain exit = %d, want 0 (apply ignores change without the flag)", exit)
}
}

// TestApplyDetailedExitCodeUnchanged: an in-sync task exits 0 either way.
func TestApplyDetailedExitCodeUnchanged(t *testing.T) {
defer stubReset()
stubSet("a", StubFixture{Changed: false})

path := writeTasksFile(t, `---
- tasks:
- name: in sync
dokku_stub: { key: a }
`)

if _, _, exit := runApply(t, path, "--detailed-exitcode"); exit != 0 {
t.Errorf("detailed-exitcode exit = %d, want 0 (nothing changed)", exit)
}
if _, _, exit := runApply(t, path); exit != 0 {
t.Errorf("plain exit = %d, want 0", exit)
}
}

// TestApplyDetailedExitCodeErrorsWinOverChanges: when a task changed and
// a later task errored, the exit code is 1, not 2. A wrapper must never
// read a failure as "changed".
func TestApplyDetailedExitCodeErrorsWinOverChanges(t *testing.T) {
defer stubReset()
stubSet("a", StubFixture{Changed: true})
stubSet("b", StubFixture{ExecuteError: errors.New("boom")})

path := writeTasksFile(t, `---
- tasks:
- name: changes
dokku_stub: { key: a }
- name: errors
dokku_stub: { key: b }
`)

if _, _, exit := runApply(t, path, "--detailed-exitcode"); exit != 1 {
t.Errorf("detailed-exitcode exit = %d, want 1 (errors win over changes)", exit)
}
if _, _, exit := runApply(t, path); exit != 1 {
t.Errorf("plain exit = %d, want 1", exit)
}
}

// TestApplyDetailedExitCodeIgnoredErrorStillCountsChanges: an error
// swallowed by ignore_errors is not an error for exit-code purposes, so
// a change elsewhere in the run still surfaces as 2.
func TestApplyDetailedExitCodeIgnoredErrorStillCountsChanges(t *testing.T) {
defer stubReset()
stubSet("a", StubFixture{Changed: true})
stubSet("b", StubFixture{ExecuteError: errors.New("boom")})

path := writeTasksFile(t, `---
- tasks:
- name: changes
dokku_stub: { key: a }
- name: errors but ignored
ignore_errors: true
dokku_stub: { key: b }
`)

if _, _, exit := runApply(t, path, "--detailed-exitcode"); exit != 2 {
t.Errorf("detailed-exitcode exit = %d, want 2 (ignored error is not an error)", exit)
}
}

// TestApplyDetailedExitCodeListTasksUnaffected: --list-tasks returns
// before any task runs, so it cannot report a change.
func TestApplyDetailedExitCodeListTasksUnaffected(t *testing.T) {
defer stubReset()
stubSet("a", StubFixture{Changed: true})

path := writeTasksFile(t, `---
- tasks:
- name: changes
dokku_stub: { key: a }
`)

if _, _, exit := runApply(t, path, "--detailed-exitcode", "--list-tasks"); exit != 0 {
t.Errorf("--list-tasks exit = %d, want 0", exit)
}
}
70 changes: 70 additions & 0 deletions commands/list_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,76 @@ func TestPlanListTasksWorks(t *testing.T) {
}
}

// TestListTasksJSONMatchesSchema drives every branch of the
// --list-tasks --json emitter through one recipe and holds each line to
// docs/schemas/list-tasks-v1.schema.json. That stream is deliberately
// not the same shape as the apply/plan run stream - its play_skipped
// carries `play` where the run stream's carries `name`, and it has no
// `ts` - which is why it has its own schema file.
func TestListTasksJSONMatchesSchema(t *testing.T) {
defer stubReset()
path := writeTasksFile(t, `---
- name: skipped play
when: 'false'
tasks:
- name: never listed
dokku_stub: { key: a }

- name: listed play
tasks:
- name: tagged
tags: [core]
dokku_stub: { key: a }
- name: looped
loop: [one, two]
dokku_stub: { key: a }
- name: when false
when: 'false'
dokku_stub: { key: a }
- name: when registered
when: 'registered.nothing.Changed'
dokku_stub: { key: a }
- name: deprecated
dokku_storage_ensure:
app: api
- name: group
block:
- name: in block
dokku_stub: { key: a }
rescue:
- name: in rescue
dokku_stub: { key: a }
always:
- name: in always
dokku_stub: { key: a }
`)

stdout, stderr, exit := runApply(t, path, "--list-tasks", "--json")
if exit != 0 {
t.Fatalf("exit = %d, want 0; stdout=%s stderr=%s", exit, stdout, stderr)
}
assertLinesMatchSchema(t, listTasksSchemaPath, stdout)

// Guard against the recipe silently losing a branch: if any of these
// stop appearing, the schema is no longer being exercised end to end.
for _, want := range []string{
`"type":"play_skipped"`,
`"tags":`,
`"loop_index":`,
`"skipped":true`,
`"unknown":true`,
`"deprecated":true`,
`"group":true`,
`"phase":"block"`,
`"phase":"rescue"`,
`"phase":"always"`,
} {
if !strings.Contains(stdout, want) {
t.Errorf("expected %s somewhere in the stream; got:\n%s", want, stdout)
}
}
}

// TestApplyStartAtTaskUnknownErrors pins the up-front guard:
// --start-at-task pointing at a name that does not exist returns 1
// with the available-names hint.
Expand Down
14 changes: 8 additions & 6 deletions commands/output_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,31 @@ func emitterTestUI() (*JSONEmitter, *cli.MockUi) {
}

// decodeOnly parses the captured stdout as a single JSON-lines event and
// returns the resulting map. Fails the test on any parse error.
// returns the resulting map. Fails the test on any parse error, and on
// any drift from docs/schemas/events-v1.schema.json - every test in this
// file doubles as a conformance check on the published schema.
func decodeOnly(t *testing.T, out string) map[string]interface{} {
t.Helper()
out = strings.TrimRight(out, "\n")
var ev map[string]interface{}
if err := json.Unmarshal([]byte(out), &ev); err != nil {
t.Fatalf("invalid JSON: %v\nraw: %q", err, out)
}
assertMatchesSchema(t, eventsSchemaPath, out)
return ev
}

// decodeLines parses every newline-delimited JSON event from out.
// decodeLines parses every newline-delimited JSON event from out, and
// validates each against the published event schema.
func decodeLines(t *testing.T, out string) []map[string]interface{} {
t.Helper()
var events []map[string]interface{}
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
if line == "" {
continue
}
for _, line := range jsonLines(out) {
var ev map[string]interface{}
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("invalid JSON line %q: %v", line, err)
}
assertMatchesSchema(t, eventsSchemaPath, line)
events = append(events, ev)
}
return events
Expand Down
Loading