diff --git a/commands/apply.go b/commands/apply.go index 38102ec..905cbef 100644 --- a/commands/apply.go +++ b/commands/apply.go @@ -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 @@ -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 { @@ -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()) } @@ -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 } diff --git a/commands/apply_exitcode_test.go b/commands/apply_exitcode_test.go new file mode 100644 index 0000000..82949ca --- /dev/null +++ b/commands/apply_exitcode_test.go @@ -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) + } +} diff --git a/commands/list_tasks_test.go b/commands/list_tasks_test.go index 84028cc..6292ece 100644 --- a/commands/list_tasks_test.go +++ b/commands/list_tasks_test.go @@ -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. diff --git a/commands/output_json_test.go b/commands/output_json_test.go index a16fb11..64d1563 100644 --- a/commands/output_json_test.go +++ b/commands/output_json_test.go @@ -19,7 +19,9 @@ 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") @@ -27,21 +29,21 @@ func decodeOnly(t *testing.T, out string) 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 diff --git a/commands/schema_test.go b/commands/schema_test.go new file mode 100644 index 0000000..65de745 --- /dev/null +++ b/commands/schema_test.go @@ -0,0 +1,217 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// The JSON-lines streams are hand-written schemas under docs/schemas/ +// rather than reflected types, because every event is built as a +// map[string]interface{} in output_json.go / list_tasks.go / +// validate.go. These helpers close that gap: every test that decodes an +// emitted event validates it against the published schema first, so a +// new or renamed field cannot ship without the schema (and therefore +// docs/json-output.md and docs/ansible-dokku.md) being updated to match. +// +// Every schema sets "additionalProperties": false, so an undocumented +// field fails loudly instead of being silently tolerated. +const ( + eventsSchemaPath = "../docs/schemas/events-v1.schema.json" + listTasksSchemaPath = "../docs/schemas/list-tasks-v1.schema.json" + validateSchemaPath = "../docs/schemas/validate-v1.schema.json" +) + +var ( + schemaMu sync.Mutex + schemaCache = map[string]*jsonschema.Schema{} +) + +// loadSchema compiles the schema at path once per process. +func loadSchema(t *testing.T, path string) *jsonschema.Schema { + t.Helper() + schemaMu.Lock() + defer schemaMu.Unlock() + if s, ok := schemaCache[path]; ok { + return s + } + + abs, err := filepath.Abs(path) + if err != nil { + t.Fatalf("resolve %s: %v", path, err) + } + f, err := os.Open(abs) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + + doc, err := jsonschema.UnmarshalJSON(f) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource(abs, doc); err != nil { + t.Fatalf("add %s: %v", path, err) + } + s, err := c.Compile(abs) + if err != nil { + t.Fatalf("compile %s: %v", path, err) + } + schemaCache[path] = s + return s +} + +// assertMatchesSchema validates one raw JSON-lines event against the +// schema at path. +func assertMatchesSchema(t *testing.T, path, line string) { + t.Helper() + inst, err := jsonschema.UnmarshalJSON(strings.NewReader(line)) + if err != nil { + t.Fatalf("invalid JSON: %v\nraw: %q", err, line) + } + if err := loadSchema(t, path).Validate(inst); err != nil { + t.Errorf("event does not match %s: %v\nraw: %s", filepath.Base(path), err, line) + } +} + +// jsonLines splits a captured JSON-lines stream into its non-empty +// lines. Shared by every helper that walks an emitted stream so the +// "trim trailing newline, skip blanks" rule lives in one place. +func jsonLines(out string) []string { + var lines []string + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + lines = append(lines, line) + } + return lines +} + +// assertLinesMatchSchema validates every non-empty line of out. +func assertLinesMatchSchema(t *testing.T, path, out string) { + t.Helper() + for _, line := range jsonLines(out) { + assertMatchesSchema(t, path, line) + } +} + +// TestEventsSchemaRejectsUnknownField is a self-check on the guard: if +// the schema ever stopped forbidding extra properties, every other +// conformance assertion in this package would quietly become a no-op. +func TestEventsSchemaRejectsUnknownField(t *testing.T) { + e, ui := emitterTestUI() + e.PlayStart("tasks", "") + + var ev map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimRight(ui.OutputWriter.String(), "\n")), &ev); err != nil { + t.Fatalf("decode play_start: %v", err) + } + ev["not_a_real_field"] = "x" + raw, err := json.Marshal(ev) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + inst, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw))) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := loadSchema(t, eventsSchemaPath).Validate(inst); err == nil { + t.Error("expected an unknown field to fail validation; schema must set additionalProperties: false") + } +} + +// problemCodeLiteral matches the `Code: "..."` field of a tasks.Problem +// composite literal. The leading \b keeps it off `ExitCode:` - there is +// no word boundary between "t" and "C". +var problemCodeLiteral = regexp.MustCompile(`\bCode:\s*"([a-z0-9_]+)"`) + +// problemCodeCall matches the problem("code", ...) / addProblem("code", +// ...) closures tasks/parse.go records structural findings through. +var problemCodeCall = regexp.MustCompile(`\b(?:add)?[Pp]roblem\("([a-z0-9_]+)"`) + +// TestValidateSchemaCodeEnumCoversEmittedCodes keeps the `code` enum in +// docs/schemas/validate-v1.schema.json in sync with the codes the source +// actually emits. additionalProperties: false only guards field *names*; +// `code` is a value, so nothing else catches a new problem category that +// never reaches the published schema (and therefore fails validation in +// a wrapper that trusts it). The scan reads string literals, so a code +// routed through a variable would be missed - keep them literal. +func TestValidateSchemaCodeEnumCoversEmittedCodes(t *testing.T) { + emitted := map[string]bool{} + for _, dir := range []string{".", "../tasks"} { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s/%s: %v", dir, name, err) + } + for _, re := range []*regexp.Regexp{problemCodeLiteral, problemCodeCall} { + for _, m := range re.FindAllStringSubmatch(string(raw), -1) { + emitted[m[1]] = true + } + } + } + } + if len(emitted) == 0 { + t.Fatal("found no problem codes in the source; the scan patterns have drifted") + } + + raw, err := os.ReadFile(validateSchemaPath) + if err != nil { + t.Fatalf("read %s: %v", validateSchemaPath, err) + } + var schema struct { + Properties struct { + Code struct { + Enum []string `json:"enum"` + } `json:"code"` + } `json:"properties"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("parse %s: %v", validateSchemaPath, err) + } + listed := map[string]bool{} + for _, code := range schema.Properties.Code.Enum { + listed[code] = true + } + + var missing, stale []string + for code := range emitted { + if !listed[code] { + missing = append(missing, code) + } + } + for code := range listed { + if !emitted[code] { + stale = append(stale, code) + } + } + sort.Strings(missing) + sort.Strings(stale) + + if len(missing) > 0 { + t.Errorf("%s omits %d emitted code(s): %s\nAdd each to the code enum and to the table in docs/json-output.md.", + validateSchemaPath, len(missing), strings.Join(missing, ", ")) + } + if len(stale) > 0 { + t.Errorf("%s lists %d code(s) nothing emits: %s\nDrop them, or fix the typo.", + validateSchemaPath, len(stale), strings.Join(stale, ", ")) + } +} diff --git a/commands/validate_test.go b/commands/validate_test.go index 6908b31..79a1da9 100644 --- a/commands/validate_test.go +++ b/commands/validate_test.go @@ -2,6 +2,7 @@ package commands import ( "encoding/json" + "slices" "strings" "testing" @@ -91,36 +92,115 @@ func TestFormatProblemHumanOutput(t *testing.T) { } } -// TestValidateJSONEventShape constructs a Problem and round-trips it through -// the JSON encoder used by --json output to confirm that consumers can rely -// on the documented fields. +// TestValidateJSONEventShape runs `validate --json` over recipes that +// trip a spread of problem categories and holds every emitted line to +// docs/schemas/validate-v1.schema.json. That schema is what an +// ansible-dokku-style wrapper parses to turn a bad module argument into +// an Ansible failure, so a renamed field or an unlisted `code` must fail +// here rather than in the wrapper. func TestValidateJSONEventShape(t *testing.T) { - event := map[string]interface{}{ - "version": 1, - "type": "validate_problem", - "code": "unknown_task_type", - "message": "unknown task type \"dokku_appp\"", - "play": "play #1", - "task": "task #2", - "line": 8, - "column": 7, - "hint": "did you mean \"dokku_app\"?", - } - b, err := json.Marshal(event) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - var decoded map[string]interface{} - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("json.Unmarshal: %v", err) + recipes := map[string]struct { + recipe string + codes []string + }{ + "unknown task type": { + recipe: `--- +- tasks: + - name: typo + dokku_appp: + app: api +`, + codes: []string{"unknown_task_type"}, + }, + "missing required field": { + recipe: `--- +- tasks: + - name: no app + dokku_config: + restart: true +`, + codes: []string{"missing_required_field"}, + }, + "conditional input rule": { + recipe: `--- +- tasks: + - name: cert without material + dokku_certs: + app: api +`, + codes: []string{"invalid_task_input"}, + }, + "no task-type key": { + recipe: `--- +- tasks: + - name: nothing here +`, + codes: []string{"task_entry_shape"}, + }, + "empty task body": { + recipe: `--- +- tasks: + - name: null body + dokku_app: +`, + codes: []string{"empty_task_body"}, + }, + "reserved input name": { + recipe: `--- +- inputs: + - name: tasks + tasks: + - name: create app + dokku_app: + app: api +`, + codes: []string{"reserved_input_name"}, + }, } - if v, ok := decoded["version"].(float64); !ok || int(v) != 1 { - t.Errorf("expected version=1, got %v", decoded["version"]) + + for name, tt := range recipes { + t.Run(name, func(t *testing.T) { + exit, out := runValidateOverStdin(t, tt.recipe, []string{"-", "--json"}) + if exit != 1 { + t.Fatalf("exit = %d, want 1; output:\n%s", exit, out) + } + assertLinesMatchSchema(t, validateSchemaPath, out) + + var codes []string + 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) + } + // Not a type assertion with the comma-ok dropped: + // assertLinesMatchSchema above reports a missing or + // non-string `code` with t.Errorf, so execution + // reaches here and a bare assertion would panic the + // whole test binary instead of failing this case. + code, ok := ev["code"].(string) + if !ok { + t.Fatalf("line %q has no string \"code\" field", line) + } + codes = append(codes, code) + } + for _, want := range tt.codes { + if !slices.Contains(codes, want) { + t.Errorf("expected a %q problem, got codes %v\noutput:\n%s", want, codes, out) + } + } + }) } - if decoded["type"] != "validate_problem" { - t.Errorf("expected type=validate_problem, got %v", decoded["type"]) +} + +// TestValidateJSONEmitsNothingOnSuccess pins the other half of the +// contract: a clean recipe produces an empty stdout and exit 0, so a +// wrapper can treat "any output at all" as failure. +func TestValidateJSONEmitsNothingOnSuccess(t *testing.T) { + exit, out := runValidateOverStdin(t, stdinYAMLRecipe, []string{"-", "--json"}) + if exit != 0 { + t.Fatalf("exit = %d, want 0; output:\n%s", exit, out) } - if decoded["code"] != "unknown_task_type" { - t.Errorf("expected code=unknown_task_type, got %v", decoded["code"]) + if strings.TrimSpace(out) != "" { + t.Errorf("expected no output on a clean recipe, got:\n%s", out) } } diff --git a/docs/README.md b/docs/README.md index 99458a2..377adbb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,5 +19,6 @@ Complete documentation for docket, a declarative way to pre-package and ship app - [Remote execution](remote-execution.md) -- drive a remote Dokku server over SSH - [Migration](migration.md) -- move a Dokku setup to a new server - [JSON output](json-output.md) -- the `--json` event schema for `apply` and `plan` +- [Wrapping docket from ansible-dokku](ansible-dokku.md) -- the contract for driving docket from another tool - [Writing tasks](writing-tasks.md) -- contribute a new task type - [Roadmap](roadmap.md) -- ideas for where docket could go next diff --git a/docs/ansible-dokku.md b/docs/ansible-dokku.md new file mode 100644 index 0000000..1702139 --- /dev/null +++ b/docs/ansible-dokku.md @@ -0,0 +1,348 @@ +# Wrapping docket from ansible-dokku + +[ansible-dokku](https://github.com/dokku/ansible-dokku) is a collection of hand-written Ansible +modules that each shell out to `dokku` directly. docket does the same work, so the two carry two +implementations of "read the current state, decide, mutate". This page is the contract for collapsing +that into one: what a module generates, how it hands it to docket, and how it turns what comes back +into Ansible's `changed` and `failed`. + +It is written for whoever is doing that migration. If you are writing recipes by hand, you want +[recipes](recipes.md) instead. + +## The shape of the integration + +An Ansible module runs once per task and reports one result, so the natural unit is **one docket +invocation per module invocation**, carrying a recipe with a single play and a single task. The +module builds that recipe as JSON, pipes it to docket on stdin, and reads the result off the exit +code and the `--json` event stream. + +Nothing stops a wrapper from batching a whole play into one recipe, and docket is happy to run it - +but Ansible has nowhere to put a multi-task result, so the per-task shape is what the rest of this +page assumes. + +## Invoking docket + +`apply`, `plan`, and `validate` all read a recipe from stdin when the path is `-`. The three calls a +wrapper needs: + +| Purpose | Command | +|---------|---------| +| Normal run | `docket apply --tasks-format json5 --json --detailed-exitcode -` | +| `check_mode` | `docket plan --tasks-format json5 --json --detailed-exitcode -` | +| Argument checking, offline | `docket validate --tasks-format json5 --json -` | + +Three notes on the flags: + +- `--tasks-format` states the format outright. Without it docket sniffs the first non-whitespace byte + of stdin, which would get a JSON payload right today, but a generator should not depend on a + heuristic it does not control. `json5` is the canonical spelling and every JSON document is valid + JSON5; `json` is accepted as a synonym. +- `--json` swaps the human formatter for the JSON-lines event stream described in + [JSON output](json-output.md). Without it there is nothing machine-readable to parse. +- `--detailed-exitcode` makes `apply` exit `2` when something changed. Without it `apply` exits `0` + whether or not it changed anything, and `changed` has to come out of the event stream. + +For a remote server, pass `--host user@host:port` and optionally `--sudo`; see +[remote execution](remote-execution.md). docket reuses one SSH connection for the whole invocation, +which for the per-task shape means one connection per Ansible task. + +Reading the recipe from stdin consumes it, so a `dokku` command that would otherwise inherit the +caller's stdin sees end-of-file. No task depends on this - every task that streams data to `dokku` +supplies it explicitly. + +## The payload + +A recipe is a list of plays; each play has a `tasks` list; each task entry has a `name` plus exactly +one task-type key whose value is the task's fields. For the per-task shape that is one play with one +task: + +```json +[ + { + "name": "dokku_config", + "tasks": [ + { + "name": "set config on api", + "dokku_config": { + "app": "api", + "restart": true, + "config": { "LOG_LEVEL": "info" }, + "state": "present" + } + } + ] + } +] +``` + +The play `name` and the task `name` are echoed back on every event as `play` and `name`. Set the task +`name` to the Ansible task's name so a wrapper can correlate events without tracking order. + +Each task's fields are documented on its own page under [tasks](tasks/README.md). Omit a field to get +its default; do not send an explicit `null`. + +### Generate a fully resolved recipe + +Two rules keep a generated payload out of trouble. + +**Do not emit an `inputs:` block.** Inputs exist so a human can parameterize a recipe from the command +line; they become real `--` flags and add a second layer of substitution the wrapper would have +to reason about. An Ansible module already has concrete values - write them straight into the +payload. + +**Escape literal `{{` in every value.** docket renders the whole recipe as text through +[sigil](https://github.com/gliderlabs/sigil) *before* parsing it, so a `{{` anywhere in the file is a +template action regardless of which field it sits in. An app config value of `{{ .Values.name }}` +would render to ``, and a value like `hello {{ world` fails the render outright: + +```text +! line 1: template render error: template: tasks.yml:1: bad character U+0022 '"' +``` + +`docket validate` reports that as a `template_render` problem, which is the cheapest place to catch +it. + +The fix is to replace every literal `{{` with ``{{ `{{` }}`` - a template action that emits two +opening braces. Everything else in the value passes through untouched: + +| Intended value | Emit in the payload | +|----------------|---------------------| +| `{{ .Values.name }}` | ``{{ `{{` }} .Values.name }}`` | +| `say {{hi}} and }} alone` | ``say {{ `{{` }}hi}} and }} alone`` | + +Use the backtick form, not `{{ "{{" }}`. Both work in a YAML recipe, but JSON has to backslash-escape +an inner double quote, and sigil sees the raw file bytes - `{{ \"{{\" }}` is not a valid action. +Backticks need no JSON escaping. + +Related but distinct: `unsafe_input_value` and the `dq` filter in +[inputs](inputs.md#special-characters-in-values) cover the other direction, where an input's *value* +breaks the scalar it is substituted into. A wrapper that follows the two rules above never hits it. + +### Envelope keys worth passing through + +Several Ansible task keywords have a direct docket equivalent, so a wrapper can forward them instead +of reimplementing them. They sit next to the task-type key, not inside it. See +[task envelope](task-envelope.md) for the full set. + +| Ansible | docket | Notes | +|---------|--------|-------| +| `name` | `name` | Echoed on every event. | +| `when` | `when` | Different expression language: docket uses [expr](https://expr-lang.org/), not Jinja2. | +| `changed_when` | `changed_when` | Overrides the task's own verdict. | +| `failed_when` | `failed_when` | Overrides the task's own verdict. | +| `ignore_errors` | `ignore_errors` | Emits `"ignored": true` and drops the task from the error count. | +| `loop` | `loop` | Expands to one task event per item. | +| `register` | `register` | Only useful when batching several tasks into one recipe. | +| `tags` | `tags` | Filtered with `--tags` / `--skip-tags`. | + +A wrapper that keeps the one-task-per-invocation shape will usually let Ansible handle `when`, `loop`, +and `register` itself and forward only `changed_when` / `failed_when` / `ignore_errors`, which need +docket's view of the result to evaluate. + +## Reading the result + +### Exit codes + +| Command | `0` | `1` | `2` | +|---------|-----|-----|-----| +| `apply` | Completed; may or may not have changed anything | Read error, parse error, or a task errored | - | +| `apply --detailed-exitcode` | Completed; nothing changed | Same as above (errors win) | Completed; at least one task changed | +| `plan` | Completed, regardless of drift | Read error, parse error, or a probe errored | - | +| `plan --detailed-exitcode` | Completed; no drift | Same as above (errors win) | Completed; at least one task would change | +| `validate` | No problems | At least one problem, or the recipe could not be read | - | + +`--list-tasks` returns before any task runs, so it is unaffected by `--detailed-exitcode` and exits +`0` or `1`. + +### Deriving `changed` + +There are three signals, and they agree. Pick whichever fits the wrapper: + +| Source | Read it as | +|--------|------------| +| Exit code `2` from `apply --detailed-exitcode` | `changed: true` | +| `summary.changed > 0` | `changed: true` for the run | +| `task.changed` on the per-task event | `changed: true` for that task | + +The exit code is the cheapest, and for the one-task-per-invocation shape it is enough on its own. Note +that `apply` on its own exits `0` either way - there is no exit code that means "changed" without the +flag. + +### Deriving `failed` + +Exit `1` is failure; exit `0` and `2` are not. Within the stream, a failed task carries +`"status": "error"` and an `error` message. On an `apply` task it also carries `stdout`, `stderr`, +and `exit_code` when the failure came from a `dokku` subprocess; those map onto Ansible's `msg`, +`stdout`, `stderr`, and `rc`. A `plan` task carries only `error`, so a `check_mode` failure has no +`rc` to forward. + +A task whose error was swallowed by `ignore_errors` carries `"ignored": true`, does not count toward +`summary.errors`, and does not affect the exit code - the same semantics Ansible gives the keyword. + +One case needs handling separately. A load-time failure - an unreadable recipe, a parse error, an +unknown task type - happens before the emitter starts, so it produces **no JSON on stdout** and a +human-readable message on stderr. A wrapper must treat "non-zero exit with empty stdout" as a failure +whose detail is on stderr, and should strip ANSI escapes from it. The cleaner path is to run +`docket validate --json` first: it reports the same class of problem as structured +[`validate_problem` events](json-output.md#validate-problems) with a stable `code` field, which is +exactly what turning a bad module argument into an Ansible failure needs. A clean recipe validates +silently and exits `0`, so any output at all is a failure. + +### `check_mode` + +Ansible's `check_mode` maps onto `plan`, which reads the server and reports what `apply` would do +without mutating anything. Per task, `would_change` feeds `changed` and `mutations` - an itemized list +of the operations `apply` would perform - feeds `diff`. + +A few tasks cannot read their state without running the underlying command (notably `dokku_git_auth`, +`dokku_registry_auth`, and `dokku_storage_ensure`); they always report drift with a `(... not probed)` +reason. When the probe itself fails - no `dokku` binary, unreachable host - the task reports +`"status": "error"` and `plan` exits `1` rather than optimistically predicting a create. + +### Secrets + +Values from inputs declared `sensitive: true` and from task fields tagged `sensitive:"true"` are +masked as `***` everywhere in the `apply` / `plan` / `validate` output, including `commands` and +`name`. A wrapper cannot read a secret back out of those streams, which is the point - but it also +means a wrapper must not diff a returned value against the one it sent. + +The one exception is `--list-tasks`, which renders the resolved plan before any sensitive value is +registered and does no masking at all: an interpolated secret comes back verbatim in `name`, +`when`, and `loop_item`. Never surface that stream to an Ansible caller. + +## Module mapping + +Every ansible-dokku module and the docket task it maps to. The plugin column lists the third-party +dokku plugin the row needs; blank means dokku core. + +| ansible-dokku module | docket task | Plugin | Notes | +|----------------------|-------------|--------|-------| +| `dokku_acl_app` | `dokku_acl_app` | dokku-acl | Direct. | +| `dokku_acl_service` | `dokku_acl_service` | dokku-acl | Direct. | +| `dokku_app` | `dokku_app` | | Direct. | +| `dokku_builder` | `dokku_builder_property` | | Direct; both wrap `builder:set`. | +| `dokku_buildpacks` | `dokku_buildpacks` | | Direct. | +| `dokku_certs` | `dokku_certs` | | docket adds `cert_content` / `key_content` for inline PEM. | +| `dokku_checks` | `dokku_checks_toggle` | | `checks:enable` / `checks:disable`. | +| `dokku_clone` | `dokku_app` and `dokku_git_sync` | | The module runs core `git:sync`, and creates the app first. Emit both tasks. `version` becomes `git_ref`. | +| `dokku_config` | `dokku_config` | | docket also supports `state: absent` (`config:unset`). | +| `dokku_docker_options` | `dokku_docker_options` | | docket adds `process_type`. | +| `dokku_domains` | `dokku_domains` and `dokku_domains_toggle` | | `state: enable` / `disable` become `dokku_domains_toggle`; the rest map onto `dokku_domains`. | +| `dokku_git_sync` | none | dokku-git-sync | See [what cannot be delegated](#what-cannot-be-delegated-yet). | +| `dokku_global_cert` | `dokku_certs` with `global: true` | dokku-global-cert | docket folds the global certificate into one task. | +| `dokku_http_auth` | `dokku_http_auth` | dokku-http-auth | Direct. | +| `dokku_image` | `dokku_git_from_image` | | `user_name` / `user_email` become `git_username` / `git_email`. | +| `dokku_letsencrypt` | `dokku_letsencrypt` | dokku-letsencrypt | Direct. | +| `dokku_network` | `dokku_network` | | Direct. | +| `dokku_network_property` | `dokku_network_property` | | docket adds `state`. | +| `dokku_ports` | `dokku_ports` | | `mappings` strings become structured `port_mappings`; `state: clear` has no equivalent. | +| `dokku_proxy` | `dokku_proxy_toggle` | | `proxy:enable` / `proxy:disable`. | +| `dokku_ps_scale` | `dokku_ps_scale` | | Direct. | +| `dokku_registry` | `dokku_registry_auth` and `dokku_registry_property` | | Credentials go to `dokku_registry_auth` (`registry:login`); `image` and `server` go to `dokku_registry_property` as `image-repo` and `server`. The module still declares the old third-party `dokku-registry` plugin; docket treats `registry` as core. | +| `dokku_resource_limit` | `dokku_resource_limit` | | Direct. | +| `dokku_resource_reserve` | `dokku_resource_reserve` | | Direct. | +| `dokku_service_create` | `dokku_service_create` | datastore plugin | docket adds `state: absent`. | +| `dokku_service_link` | `dokku_service_link` | datastore plugin | Direct. | +| `dokku_storage` | `dokku_storage_mount`, `dokku_storage_entry`, `dokku_storage_ensure` | | One `dokku_storage_mount` per entry in `mounts`. Host-directory creation is only partly covered. | + +The mapping is not always command-for-command. `dokku_registry` drives `registry:set username` +while `dokku_registry_auth` drives `registry:login`, and `dokku_proxy` reads its current state from +`config:get DOKKU_DISABLE_PROXY` while docket reads the proxy plugin's report. In both cases +the intent matches even though the wire calls differ, which is the point of delegating - docket's +side is the one that stays current with dokku. + +## Required and optional fields disagree + +The two field sets were defined independently, so a module that treats a field as optional cannot +assume the task does, and vice versa. Every row below is a real divergence. + +One wrinkle first: in nine modules the `DOCUMENTATION` block disagrees with the `argument_spec` in the +same file, and it is the spec that Ansible enforces. Those modules are `dokku_builder`, `dokku_certs`, +`dokku_docker_options`, `dokku_domains`, `dokku_global_cert`, `dokku_network_property`, `dokku_ports`, +`dokku_registry`, and `dokku_storage`. The table is built from the spec. + +| Field | ansible-dokku | docket | What a wrapper has to do | +|-------|---------------|--------|--------------------------| +| `users` | Required on `dokku_acl_app` and `dokku_acl_service` | Optional | Nothing; a stricter caller is always safe. | +| `buildpacks` | Required | Optional | Nothing. | +| `config` | Required | Optional | Nothing. | +| `domains` | Required | Optional | Nothing. | +| `app` on `dokku_certs` | Required | Optional, but exactly one of `app` or `global` must be set | Nothing for the app case; the global case goes through the same task. | +| `cert` / `key` | Optional in the spec | Optional, but `state: present` requires `cert` + `key` or `cert_content` + `key_content` | A `state: present` call with no material passes Ansible's arg spec and fails `docket validate`. Validate before applying. | +| `phase` on `dokku_docker_options` | Required in the spec, optional in the docs | Required | Nothing; docket agrees with the spec. | +| `mappings` / `port_mappings` | Optional list of `"http:80:5000"` strings | **Required** list of `{scheme, host, container}` objects | Parse each string and restructure it. An empty list is rejected too (`no port mappings provided`), so a module call that relied on omitting `mappings` has nothing to send. | +| `username` / `password` on `dokku_registry` | Both required, even for `state: absent` | Only `server` is required; credentials are required when `state: present` | A `state: absent` call carries credentials docket does not need. Drop them. | +| `app` on `dokku_builder`, `dokku_network_property` | Required in the docs, optional in the spec | Optional, paired with `global` | Nothing. | +| `app` on `dokku_storage` | Required | `dokku_storage_mount` requires `app` and `container_dir` | Split `mounts` into one task per entry, and split each `host:container` string. | +| `build` on `dokku_clone` | Defaults to `true` | `dokku_git_sync.build` has no default, so it is off | Send `build: true` explicitly to preserve module behavior. | +| `state` on `dokku_image`, `dokku_service_create`, `dokku_network_property` | No `state` option at all | Present on all three | Nothing; each docket default matches the module's only behavior. Note that `dokku_git_from_image.state` defaults to `deployed`, not `present`, so do not send `present`. | + +Two module-side defaults are worth knowing because they are not in the argument spec at all: +`dokku_config.restart` and `dokku_ps_scale.skip_deploy` have no declared default and are driven by +identity checks in the module body, making the effective defaults `true` and `false`. docket declares +the same two defaults outright, so the behavior matches. + +## What cannot be delegated yet + +Four things. Each is tracked, so a wrapper can keep the module implementation for now and drop it when +the task lands. + +- **The `dokku_git_sync` module** + ([#414](https://github.com/dokku/docket/issues/414)). It targets the commercial `dokku-git-sync` + plugin (`git-sync:set remote`), which docket has no task for. Mind the name collision: + docket's `dokku_git_sync` is core `git:sync`, which is what the `dokku_clone` module does. The two + are unrelated. +- **`dokku_ports` with `state: clear`** + ([#415](https://github.com/dokku/docket/issues/415)). `dokku_ports` handles `present` and `absent` + only, and rejects an empty `port_mappings` list, so clearing every mapping has to stay in the module + for now. +- **Host-directory management in `dokku_storage`** + ([#416](https://github.com/dokku/docket/issues/416)). With `create_host_dir`, the module does + host-side `os.makedirs`, `chmod 0777`, and `chown` using the `user` / `group` options; + `destroy_host_dir` does an `os.rmdir` before unmounting. `dokku_storage_entry` creates the entry's + host directory and takes a `chown` preset or numeric uid, which covers the common case, but there is + no chmod and no host-directory removal. The module gets away with raw filesystem calls because + Ansible is already running on the dokku host; docket may be driving it over SSH, so anything it does + here has to go through a `dokku` subcommand. +- **Custom service images** + ([#417](https://github.com/dokku/docket/issues/417)). `dokku_service_create` in the module reads + image overrides from Ansible's `environment:` (`POSTGRES_IMAGE` and friends) rather than from a + module argument. docket does not forward those. + +## docket tasks with no ansible-dokku module + +44 of docket's 73 task types have no ansible-dokku counterpart. They are not blockers for the +migration, but they are what a wrapper gains access to for free once it delegates. + +Per-plugin property tasks, each wrapping a `:set`: + +`dokku_app_json_property`, `dokku_apps_property`, `dokku_builder_dockerfile_property`, +`dokku_builder_herokuish_property`, `dokku_builder_lambda_property`, `dokku_builder_nixpacks_property`, +`dokku_builder_pack_property`, `dokku_builder_railpack_property`, `dokku_buildpacks_property`, +`dokku_builds_property`, `dokku_caddy_property`, `dokku_checks_property`, `dokku_cron_property`, +`dokku_git_property`, `dokku_haproxy_property`, `dokku_letsencrypt_property`, `dokku_logs_property`, +`dokku_nginx_property`, `dokku_openresty_property`, `dokku_proxy_property`, `dokku_ps_property`, +`dokku_scheduler_property`, `dokku_traefik_property`. + +The scheduler-k3s set: `dokku_scheduler_k3s_annotations`, `dokku_scheduler_k3s_autoscaling_auth`, +`dokku_scheduler_k3s_chart`, `dokku_scheduler_k3s_labels`, `dokku_scheduler_k3s_profile`, +`dokku_scheduler_k3s_property`, plus `dokku_scheduler_docker_local_property`. + +Finer-grained HTTP auth than the module's on/off switch: `dokku_http_auth_allowed_ip`, +`dokku_http_auth_domain`, `dokku_http_auth_user`. + +Deploy sources and app lifecycle: `dokku_app_clone` - which runs `apps:clone` to copy one app to +another, not to be confused with the similarly-named module that syncs a git repository - +`dokku_app_lock`, `dokku_git_auth`, and `dokku_git_from_archive`. + +Everything else: `dokku_maintenance`, `dokku_maintenance_custom_page`, `dokku_plugin`, +`dokku_service_backup`, `dokku_service_expose`, `dokku_service_property`, `dokku_ssh_key`. + +## See also + +- [Command reference](command-reference.md) - every flag named on this page +- [JSON output](json-output.md) - the event schemas and the JSON Schema files +- [Recipes](recipes.md) - the recipe format and how docket finds one +- [Task envelope](task-envelope.md) - `when`, `loop`, `register`, and error handling per task +- [Tasks](tasks/README.md) - the fields of every task type +- [Remote execution](remote-execution.md) - driving a remote server over SSH diff --git a/docs/command-reference.md b/docs/command-reference.md index 0b1181c..c49c1ed 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -235,6 +235,13 @@ On error, the message prints on a `!`-prefixed line and the run aborts with exit [`--fail-fast`](recipes.md#error-handling-across-plays) is off and only the play aborts). The summary still prints with partial counts. +By default `apply` exits `0` whether or not anything changed, because "the server now matches the +recipe" is the same outcome either way. Pass `--detailed-exitcode` when the caller needs to know: +`0` means nothing changed, `2` means at least one task changed, and `1` still means an error. Errors +win over changes, matching `plan`. An error swallowed by +[`ignore_errors`](task-envelope.md#ignore_errors-continue-past-a-failure) is not an error for this +purpose. `--list-tasks` returns before any task runs, so it is unaffected. + | Flag | Effect | |------|--------| | `--tasks ` | Use a specific recipe. Accepts a local path, an `http(s)://` URL (fetched over HTTP), or `-` for stdin. | @@ -242,6 +249,7 @@ summary still prints with partial counts. | `--tasks-format ` | Parse the recipe as `yaml` or `json5` instead of detecting it. | | `--verbose` | After each task, echo every resolved Dokku command it ran, one per `→` line. Masked against sensitive values. Ignored with `--json` (which already includes commands). | | `--json` | Emit JSON-lines events instead of the human formatter. See [JSON output](json-output.md). | +| `--detailed-exitcode` | Exit `0` when nothing changed, `2` when something did, `1` on error. Errors win over changes. | | `--vars-file ` | Load input values from a file (repeatable). See [inputs](inputs.md#layered-values-with---vars-file). | | `--play ` | Run only the named play. Composes with `--tags`. | | `--tags ` | Run only tasks whose tags intersect the list. See [task envelope](task-envelope.md#tags). | diff --git a/docs/docs.yaml b/docs/docs.yaml index 1f02795..10dc551 100644 --- a/docs/docs.yaml +++ b/docs/docs.yaml @@ -7,6 +7,7 @@ docs: - task-envelope.md - remote-execution.md - json-output.md + - ansible-dokku.md - writing-tasks.md - roadmap.md - tasks/README.md diff --git a/docs/json-output.md b/docs/json-output.md index 2a602e2..651a655 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -2,14 +2,17 @@ `docket apply --json` and `docket plan --json` replace the human-readable output with one JSON object per line (JSON-lines). This is what you reach for when a CI pipeline or dashboard needs to -consume the result programmatically instead of scraping text. +consume the result programmatically instead of scraping text. `docket validate --json` does the same +for offline problems, and every stream has a [JSON Schema](#schemas). Every event carries a `version` integer, pinned at `1`. Consumers should branch on `version` so a future schema change does not silently break them. Values marked sensitive - inputs declared `sensitive: true`, or task fields tagged `sensitive:"true"` - are masked as `***`. Masking covers every string field a secret can reach, including `name` and `play` (a loop over a sensitive value expands the task name) and the `when` / `reason` fields on `play_skipped` (a play predicate can -interpolate a sensitive input). +interpolate a sensitive input). Masking applies to the `apply` / `plan` run stream and to +`validate --json`; the `--list-tasks --json` stream is **not** masked, so do not route it anywhere a +secret must not land. ## Events @@ -21,11 +24,23 @@ slightly between `apply` and `plan`: | `play_start` | `version`, `type`, `name`, `ts` | `host` | | `play_skipped` | `version`, `type`, `name`, `ts` | `when`, `reason` | | `warning` | `version`, `type`, `play`, `name`, `reason`, `message`, `ts` | - | -| `task` (apply) | `version`, `type`, `play`, `name`, `status` (`ok`/`changed`/`skipped`/`error`), `changed`, `state`, `desired_state`, `duration_ms`, `ts` | `error`, `commands` | -| `task` (plan) | `version`, `type`, `play`, `name`, `status` (`ok`/`+`/`~`/`-`/`skipped`/`error`), `would_change`, `state`, `desired_state`, `duration_ms`, `ts` | `reason`, `mutations`, `commands`, `error` | +| `task` (apply) | `version`, `type`, `play`, `name`, `status` (`ok`/`changed`/`skipped`/`error`), `changed`, `state`, `desired_state`, `duration_ms`, `ts` | `error`, `skip_reason`, `stdout`, `stderr`, `exit_code`, `ignored`, `commands`, `phase`, `group` | +| `task` (plan) | `version`, `type`, `play`, `name`, `status` (`ok`/`+`/`~`/`-`/`skipped`/`error`), `would_change`, `state`, `desired_state`, `duration_ms`, `ts` | `reason`, `mutations`, `commands`, `error`, `phase`, `group` | | `summary` (apply) | `version`, `type`, `tasks`, `changed`, `ok`, `skipped`, `errors`, `plays_skipped`, `duration_ms` | - | | `summary` (plan) | `version`, `type`, `tasks`, `would_change`, `in_sync`, `skipped`, `errors`, `plays_skipped`, `duration_ms` | - | +A few fields need a word of explanation: + +- `skip_reason` accompanies a `skipped` apply task when a reason was recorded. +- `stdout`, `stderr`, and `exit_code` are the failing command's output and are present only on an + errored apply task. `ignored` is `true` when [`ignore_errors`](task-envelope.md#ignore_errors-continue-past-a-failure) + swallowed the error, in which case the task counts toward neither `errors` nor the exit code. +- `phase` is `block`, `rescue`, or `always` on a child of a + [group](task-envelope.md#block--rescue--always-structured-error-handling); `group` is `true` on the + group envelope itself. +- On a plan task, `state` mirrors `desired_state`. Plan never mutates, so it has no post-mutation + state to report; the field exists so `task` events have the same key set on both commands. + A `warning` event precedes the `task` event it is associated with so consumers can correlate by ordering. The `reason` is a stable machine key so consumers can branch on the category: @@ -54,6 +69,76 @@ A `plan --json` line for a config task with two new keys: {"version":1,"type":"task","play":"tasks","name":"configure","status":"~","would_change":true,"state":"present","desired_state":"present","reason":"2 key(s) to set","mutations":["set KEY (new)","set SECRET (new)"],"commands":["dokku --quiet config:set --encoded api KEY=*** SECRET=***"],"duration_ms":58,"ts":"2026-04-26T11:30:00Z"} ``` +## Validate problems + +`docket validate --json` writes a different stream: one `validate_problem` object per problem, and +nothing at all when the recipe is clean. A clean run exits `0` with empty stdout, so a consumer can +treat any output as failure without parsing it. Every problem carries `version`, `type`, `code`, and +`message`; `play`, `task`, `line`, `column`, and `hint` appear when they are known. `message` and +`hint` are masked. + +```jsonl +{"code":"unknown_task_type","column":7,"hint":"did you mean \"dokku_app\"?","line":4,"message":"unknown task type \"dokku_appp\"","play":"play #1","task":"task #1 \"typo\"","type":"validate_problem","version":1} +{"code":"missing_required_field","column":9,"line":8,"message":"missing required field \"app\" on dokku_config","play":"play #1","task":"task #2 \"configure\"","type":"validate_problem","version":1} +``` + +`code` is a stable machine key. Branch on it rather than on `message`, which is prose and may be +reworded: + +| `code` | Reported when | +|--------|---------------| +| `yaml_parse` | The recipe is not parseable YAML. | +| `json5_parse` | The recipe is not parseable JSON5. | +| `duplicate_key` | The same key appears twice in one mapping. | +| `recipe_shape` | The recipe is not a list of plays, or a play is not a mapping. | +| `task_entry_shape` | A task entry does not carry exactly one task-type key. | +| `empty_task_body` | A task-type key has a null body (`dokku_app:` with nothing after it). | +| `unknown_task_type` | The task-type key is not registered. `hint` carries a did-you-mean. | +| `unknown_key` | An unrecognized envelope key sits alongside the task-type key. | +| `envelope_key_type` | An envelope key has the wrong type (for example `tags:` as a string). | +| `duplicate_task_name` | Two tasks in one play share a `name`. | +| `block_shape` | A `block` / `rescue` / `always` clause is not a list of task entries. | +| `block_empty` | A `block:` clause contains no child tasks. | +| `block_orphan_clause` | A task entry declares `rescue` or `always` with no `block`. | +| `block_with_task_type` | A group entry also carries a task-type key. | +| `envelope_key_unsupported` | An envelope key is reserved for a future release but not yet implemented. `hint` names the tracking issue. | +| `task_body_decode` | The task body does not decode into the task's struct. | +| `missing_required_field` | A field tagged `required:"true"` is absent or zero. | +| `invalid_task_input` | A task's own `Validate()` rejected the combination of fields - conditional requirements, mutually-exclusive fields, enum values. | +| `template_render` | A `{{ ... }}` template failed to render. | +| `unsafe_input_value` | An input value would break the scalar it is substituted into. See [special characters in values](inputs.md#special-characters-in-values). | +| `input_missing` | (`--strict`) A `required: true` input has no default and no supplied value. | +| `invalid_input_name` | An input name is not a valid `{{ .name }}` variable - a hyphenated name, for example. | +| `reserved_input_name` | An input name collides with a built-in flag. | +| `register_duplicate` | Two tasks `register:` the same name. | +| `loop_var_outside_loop` | `.item` or `.index` is referenced outside a `loop:`. | +| `expr_compile` | A `when` / `changed_when` / `failed_when` predicate does not compile. | +| `unknown_play_reference` | (`--strict`) `--play` names a play that does not exist. | +| `unknown_start_at_task` | (`--strict`) `--start-at-task` names a task that does not exist. | +| `vars_file_error` | A `--vars-file` could not be read or parsed. | +| `argument_error` | The command's own arguments are invalid. | +| `read_error` | The recipe could not be read. | + +## Schemas + +The three streams have machine-readable JSON Schemas, so a consumer can validate what it parses +instead of trusting the tables above. Each is drafted against 2020-12 and describes **one line**, not +the whole stream: split on newlines and validate each line independently. + +| Stream | Schema | +|--------|--------| +| `apply --json`, `plan --json` | [`schemas/events-v1.schema.json`](schemas/events-v1.schema.json) | +| `apply --list-tasks --json`, `plan --list-tasks --json` | [`schemas/list-tasks-v1.schema.json`](schemas/list-tasks-v1.schema.json) | +| `validate --json` | [`schemas/validate-v1.schema.json`](schemas/validate-v1.schema.json) | + +`--list-tasks --json` has its own schema because it is a different stream, not a subset of the run +stream: no task executes, no server is contacted, its per-task event is `list_task` rather than +`task`, there is no `summary`, and its `play_skipped` carries `play` where the run stream's carries +`name` and omits `ts`. + +Every schema sets `additionalProperties: false`, and docket's own test suite validates real emitted +events against these files, so a field that exists in the code but not in the schema fails CI. + ## Composing with exit codes `--json` and `--detailed-exitcode` compose, so a pipeline can stream JSON to a dashboard while still @@ -63,7 +148,28 @@ branching on the [plan exit code](command-reference.md#docket-plan): docket plan --json --detailed-exitcode | tee plan.jsonl ``` +`apply` takes the same flag, which is the cheapest way to answer "did anything change?" without +parsing the stream at all: `0` means nothing changed, `2` means something did, `1` means an error. +Without it, `apply` exits `0` either way. The equivalent signal inside the stream is +`summary.changed > 0` for the run, or `task.changed` per task: + +```bash +docket apply --json --detailed-exitcode | tee apply.jsonl +# $? would be tee's status, not docket's; read the head of the pipeline instead. +case "${PIPESTATUS[0]}" in + 0) echo "no changes" ;; + 2) echo "changed" ;; + *) echo "failed" ;; +esac +``` + +A load-time failure - an unreadable recipe, a parse error, an unknown task type - happens before the +emitter starts, so it produces **no JSON on stdout** and a human-readable message on stderr. Treat a +non-zero exit with empty stdout as a failure whose detail is on stderr, or run +`docket validate --json` first to get the same problems as structured events. + ## See also - [Command reference](command-reference.md) - the `--json` and `--detailed-exitcode` flags +- [Wrapping docket from ansible-dokku](ansible-dokku.md) - a worked consumer of these streams - [Task envelope](task-envelope.md#ignore_errors-continue-past-a-failure) - how `ignore_errors` shows up as `"ignored": true` diff --git a/docs/schemas/events-v1.schema.json b/docs/schemas/events-v1.schema.json new file mode 100644 index 0000000..5abe3af --- /dev/null +++ b/docs/schemas/events-v1.schema.json @@ -0,0 +1,276 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/dokku/docket/main/docs/schemas/events-v1.schema.json", + "title": "docket apply/plan JSON-lines event", + "description": "One event emitted by `docket apply --json` or `docket plan --json`. The stream is JSON-lines: validate each line against this schema independently. See docs/json-output.md.", + "type": "object", + "required": ["version", "type"], + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Wire-format version. Branch on this before reading any other field." + }, + "type": { + "type": "string", + "enum": ["play_start", "play_skipped", "warning", "task", "summary"] + } + }, + "oneOf": [ + { "$ref": "#/$defs/play_start" }, + { "$ref": "#/$defs/play_skipped" }, + { "$ref": "#/$defs/warning" }, + { "$ref": "#/$defs/apply_task" }, + { "$ref": "#/$defs/plan_task" }, + { "$ref": "#/$defs/apply_summary" }, + { "$ref": "#/$defs/plan_summary" } + ], + "$defs": { + "version": { "type": "integer", "const": 1 }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "RFC3339 UTC instant." + }, + "commands": { + "type": "array", + "items": { "type": "string" }, + "description": "Resolved dokku command lines, masked. plan reports what apply would run; apply reports what it did run." + }, + "phase": { + "type": "string", + "enum": ["block", "rescue", "always"], + "description": "Set when the task is a child of a block/rescue/always group." + }, + "group": { + "type": "boolean", + "const": true, + "description": "Set when the event describes the group envelope itself rather than a leaf task." + }, + + "play_start": { + "type": "object", + "additionalProperties": false, + "required": ["version", "type", "name", "ts"], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "play_start" }, + "name": { "type": "string", "description": "Play name, masked." }, + "ts": { "$ref": "#/$defs/timestamp" }, + "host": { + "type": "string", + "description": "Remote target, present only when --host or DOKKU_HOST is in effect." + } + } + }, + + "play_skipped": { + "type": "object", + "additionalProperties": false, + "required": ["version", "type", "name", "ts"], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "play_skipped" }, + "name": { "type": "string" }, + "ts": { "$ref": "#/$defs/timestamp" }, + "when": { + "type": "string", + "description": "The play predicate source, masked. Absent when the play carried no when:." + }, + "reason": { "type": "string" } + } + }, + + "warning": { + "type": "object", + "additionalProperties": false, + "required": ["version", "type", "play", "name", "reason", "message", "ts"], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "warning" }, + "play": { "type": "string" }, + "name": { "type": "string" }, + "reason": { + "type": "string", + "enum": ["deprecated", "unknown_property", "probe_rejected"], + "description": "Stable machine key for the warning category." + }, + "message": { "type": "string" }, + "ts": { "$ref": "#/$defs/timestamp" } + } + }, + + "apply_task": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "type", + "play", + "name", + "status", + "changed", + "state", + "desired_state", + "duration_ms", + "ts" + ], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "task" }, + "play": { "type": "string" }, + "name": { "type": "string" }, + "status": { + "type": "string", + "enum": ["ok", "changed", "skipped", "error"] + }, + "changed": { + "type": "boolean", + "description": "Whether this task mutated server state. This is the per-task signal an Ansible wrapper maps onto `changed`." + }, + "state": { "type": "string" }, + "desired_state": { "type": "string" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "ts": { "$ref": "#/$defs/timestamp" }, + "error": { "type": "string", "description": "Present when status is error." }, + "skip_reason": { + "type": "string", + "description": "Present when status is skipped and a reason was recorded." + }, + "stdout": { + "type": "string", + "description": "Captured stdout of the failing command. Errors only." + }, + "stderr": { + "type": "string", + "description": "Captured stderr of the failing command. Errors only." + }, + "exit_code": { + "type": "integer", + "description": "Exit code of the failing command. Errors only." + }, + "ignored": { + "type": "boolean", + "const": true, + "description": "Set when ignore_errors swallowed this error. The task does not count toward the run's error total or its exit code." + }, + "commands": { "$ref": "#/$defs/commands" }, + "phase": { "$ref": "#/$defs/phase" }, + "group": { "$ref": "#/$defs/group" } + } + }, + + "plan_task": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "type", + "play", + "name", + "status", + "would_change", + "state", + "desired_state", + "duration_ms", + "ts" + ], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "task" }, + "play": { "type": "string" }, + "name": { "type": "string" }, + "status": { + "type": "string", + "enum": ["ok", "+", "~", "-", "skipped", "error"] + }, + "would_change": { + "type": "boolean", + "description": "Whether apply would mutate server state. This is the per-task signal a check_mode wrapper maps onto `changed`." + }, + "state": { + "type": "string", + "description": "Mirrors desired_state: plan never reads back a post-mutation state." + }, + "desired_state": { "type": "string" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "ts": { "$ref": "#/$defs/timestamp" }, + "error": { "type": "string", "description": "Present when status is error." }, + "reason": { + "type": "string", + "description": "Human summary of the drift, present on a drifting task." + }, + "mutations": { + "type": "array", + "items": { "type": "string" }, + "description": "Itemised operations apply would perform. Maps onto an Ansible diff." + }, + "commands": { "$ref": "#/$defs/commands" }, + "phase": { "$ref": "#/$defs/phase" }, + "group": { "$ref": "#/$defs/group" } + } + }, + + "apply_summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "type", + "tasks", + "changed", + "ok", + "skipped", + "errors", + "plays_skipped", + "duration_ms" + ], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "summary" }, + "tasks": { "type": "integer", "minimum": 0 }, + "changed": { + "type": "integer", + "minimum": 0, + "description": "Number of tasks that changed state. Greater than zero is equivalent to `apply --detailed-exitcode` returning 2." + }, + "ok": { "type": "integer", "minimum": 0 }, + "skipped": { "type": "integer", "minimum": 0 }, + "errors": { "type": "integer", "minimum": 0 }, + "plays_skipped": { "type": "integer", "minimum": 0 }, + "duration_ms": { "type": "integer", "minimum": 0 } + } + }, + + "plan_summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "type", + "tasks", + "would_change", + "in_sync", + "skipped", + "errors", + "plays_skipped", + "duration_ms" + ], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "summary" }, + "tasks": { "type": "integer", "minimum": 0 }, + "would_change": { + "type": "integer", + "minimum": 0, + "description": "Number of tasks that would change. Greater than zero is equivalent to `plan --detailed-exitcode` returning 2." + }, + "in_sync": { "type": "integer", "minimum": 0 }, + "skipped": { "type": "integer", "minimum": 0 }, + "errors": { "type": "integer", "minimum": 0 }, + "plays_skipped": { "type": "integer", "minimum": 0 }, + "duration_ms": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/docs/schemas/list-tasks-v1.schema.json b/docs/schemas/list-tasks-v1.schema.json new file mode 100644 index 0000000..3f23c75 --- /dev/null +++ b/docs/schemas/list-tasks-v1.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/dokku/docket/main/docs/schemas/list-tasks-v1.schema.json", + "title": "docket --list-tasks JSON-lines event", + "description": "One event emitted by `docket apply --list-tasks --json` or `docket plan --list-tasks --json`. This is a separate stream from the apply/plan run stream: no task runs, no server is contacted, and there is no summary event. Its `play_skipped` carries `play` where the run stream's carries `name`, and no `ts`. See docs/json-output.md.", + "type": "object", + "required": ["version", "type"], + "properties": { + "version": { "type": "integer", "const": 1 }, + "type": { "type": "string", "enum": ["play_skipped", "list_task"] } + }, + "oneOf": [ + { "$ref": "#/$defs/play_skipped" }, + { "$ref": "#/$defs/list_task" } + ], + "$defs": { + "version": { "type": "integer", "const": 1 }, + + "play_skipped": { + "type": "object", + "additionalProperties": false, + "required": ["version", "type", "play", "when", "reason"], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "play_skipped" }, + "play": { "type": "string" }, + "when": { "type": "string", "description": "The play predicate source." }, + "reason": { + "type": "string", + "description": "Either `when: ` for a false predicate or `when error: ` for one that failed to evaluate." + } + } + }, + + "list_task": { + "type": "object", + "additionalProperties": false, + "required": ["version", "type", "play", "name", "index"], + "properties": { + "version": { "$ref": "#/$defs/version" }, + "type": { "const": "list_task" }, + "play": { "type": "string" }, + "name": { "type": "string", "description": "Resolved display name of the task." }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Position within the play, or within the enclosing block/rescue/always clause." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Present only when the task carries tags." + }, + "group": { + "type": "boolean", + "const": true, + "description": "Set when the entry is a block/rescue/always group rather than a leaf task." + }, + "phase": { + "type": "string", + "enum": ["block", "rescue", "always"], + "description": "Set on a child of a group, naming the clause it belongs to." + }, + "deprecated": { "type": "boolean", "const": true }, + "deprecation": { + "type": "string", + "description": "The deprecation notice. --list-tasks --json carries it here instead of emitting a separate warning event." + }, + "skipped": { + "type": "boolean", + "const": true, + "description": "The task's when: evaluated false against the inputs." + }, + "unknown": { + "type": "boolean", + "const": true, + "description": "The task's when: references a registered value, so it cannot be resolved without running." + }, + "when_error": { + "type": "boolean", + "const": true, + "description": "The task's when: failed to evaluate." + }, + "when": { + "type": "string", + "description": "The predicate source. Present with skipped, unknown, or when_error." + }, + "loop_index": { + "type": "integer", + "minimum": 0, + "description": "Present on an expanded loop iteration." + }, + "loop_item": { + "description": "The loop item for this iteration. Any JSON value." + } + } + } + } +} diff --git a/docs/schemas/validate-v1.schema.json b/docs/schemas/validate-v1.schema.json new file mode 100644 index 0000000..2783476 --- /dev/null +++ b/docs/schemas/validate-v1.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/dokku/docket/main/docs/schemas/validate-v1.schema.json", + "title": "docket validate JSON-lines problem", + "description": "One problem emitted by `docket validate --json`. A clean recipe emits nothing at all and exits 0; each problem is one line and the command exits 1. See docs/json-output.md.", + "type": "object", + "additionalProperties": false, + "required": ["version", "type", "code", "message"], + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Wire-format version. Branch on this before reading any other field." + }, + "type": { "const": "validate_problem" }, + "code": { + "type": "string", + "description": "Stable machine key for the problem category.", + "enum": [ + "argument_error", + "block_empty", + "block_orphan_clause", + "block_shape", + "block_with_task_type", + "duplicate_key", + "duplicate_task_name", + "empty_task_body", + "envelope_key_type", + "envelope_key_unsupported", + "expr_compile", + "input_missing", + "invalid_input_name", + "invalid_task_input", + "json5_parse", + "loop_var_outside_loop", + "missing_required_field", + "read_error", + "recipe_shape", + "register_duplicate", + "reserved_input_name", + "task_body_decode", + "task_entry_shape", + "template_render", + "unknown_key", + "unknown_play_reference", + "unknown_start_at_task", + "unknown_task_type", + "unsafe_input_value", + "vars_file_error", + "yaml_parse" + ] + }, + "message": { "type": "string", "description": "Human-readable description, masked." }, + "play": { + "type": "string", + "description": "Play label, e.g. `play #1` or the play's name. Absent on problems that precede play parsing." + }, + "task": { + "type": "string", + "description": "Task label, e.g. `task #2 \"create app\"`. Absent on play-level and file-level problems." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "1-indexed line in the recipe. Absent when the problem has no source position. For a JSON5 recipe this indexes into the normalised YAML form, not the JSON5 source." + }, + "column": { "type": "integer", "minimum": 1 }, + "hint": { + "type": "string", + "description": "Optional remediation hint, e.g. a did-you-mean suggestion for a misspelled task type." + } + } +} diff --git a/go.mod b/go.mod index 4e1424b..3187f99 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/mcuadros/go-defaults v1.2.0 github.com/mitchellh/cli v1.1.5 github.com/posener/complete v1.2.3 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/pflag v1.0.10 github.com/titanous/json5 v1.0.0 golang.org/x/crypto v0.54.0 @@ -47,6 +48,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 6abfa05..5204e57 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vIsEOKiK+l/NSLN4uUtkyuxc6hgYpQeCWAeI= github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw= github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= @@ -106,6 +108,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= diff --git a/tasks/ansible_mapping_test.go b/tasks/ansible_mapping_test.go new file mode 100644 index 0000000..e2eba65 --- /dev/null +++ b/tasks/ansible_mapping_test.go @@ -0,0 +1,140 @@ +package tasks + +import ( + "os" + "regexp" + "sort" + "strings" + "testing" +) + +// docs/ansible-dokku.md tells a wrapper author which docket task each +// ansible-dokku module maps to, and which task types have no module at +// all. Between the two lists it has to account for every registered task +// type, or a wrapper reading it will silently believe docket cannot do +// something it can. +// +// Nothing regenerates that page (unlike docs/tasks/*.md, which `make +// docs` writes from the task definitions), so this test is what keeps it +// honest. It sits next to TestRegisteredTaskCount deliberately: adding a +// task type trips both at once, and the failure names the file to edit. +// The prior art for what happens without a guard is +// TestRegisteredTasksExist, whose hardcoded allowlist drifted to 56 of +// 73 without anyone noticing. + +const ansibleMappingDoc = "../docs/ansible-dokku.md" + +const ( + mappingHeading = "## Module mapping" + noModuleHeading = "## docket tasks with no ansible-dokku module" +) + +// backtickedTask matches a `dokku_...` token inside backticks. +var backtickedTask = regexp.MustCompile("`(dokku_[a-z0-9_]+)`") + +// sectionOf returns the lines of doc between the given "## " heading and +// the next one. +func sectionOf(t *testing.T, doc []string, heading string) []string { + t.Helper() + start := -1 + for i, line := range doc { + if strings.TrimSpace(line) == heading { + start = i + 1 + break + } + } + if start == -1 { + t.Fatalf("%s: heading %q not found; the mapping guard keys off it", ansibleMappingDoc, heading) + } + for i := start; i < len(doc); i++ { + if strings.HasPrefix(doc[i], "## ") { + return doc[start:i] + } + } + return doc[start:] +} + +// tasksInMappingTable pulls the docket-task column out of the module +// mapping table. Column 0 holds ansible-dokku module names, which +// deliberately share the dokku_ prefix, so only column 1 is read. +func tasksInMappingTable(t *testing.T, doc []string) map[string]bool { + t.Helper() + found := map[string]bool{} + rows := 0 + for _, line := range sectionOf(t, doc, mappingHeading) { + if !strings.HasPrefix(strings.TrimSpace(line), "|") { + continue + } + cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|") + if len(cells) < 2 { + continue + } + header := strings.TrimSpace(cells[0]) + if header == "ansible-dokku module" || strings.Trim(header, "-: ") == "" { + continue + } + rows++ + for _, m := range backtickedTask.FindAllStringSubmatch(cells[1], -1) { + found[m[1]] = true + } + } + if rows == 0 { + t.Fatalf("%s: no rows parsed under %q", ansibleMappingDoc, mappingHeading) + } + return found +} + +// tasksInNoModuleSection pulls every backticked task name out of the +// "no ansible-dokku module" section. +func tasksInNoModuleSection(t *testing.T, doc []string) map[string]bool { + t.Helper() + found := map[string]bool{} + for _, line := range sectionOf(t, doc, noModuleHeading) { + for _, m := range backtickedTask.FindAllStringSubmatch(line, -1) { + found[m[1]] = true + } + } + return found +} + +// TestAnsibleMappingCoversEveryRegisteredTask asserts that the two lists +// in docs/ansible-dokku.md together name every registered task type, and +// name nothing that is not registered. +func TestAnsibleMappingCoversEveryRegisteredTask(t *testing.T) { + raw, err := os.ReadFile(ansibleMappingDoc) + if err != nil { + t.Fatalf("read %s: %v", ansibleMappingDoc, err) + } + doc := strings.Split(string(raw), "\n") + + documented := tasksInMappingTable(t, doc) + for name := range tasksInNoModuleSection(t, doc) { + documented[name] = true + } + + var missing, unknown []string + for name := range RegisteredTasks { + if !documented[name] { + missing = append(missing, name) + } + } + for name := range documented { + if _, ok := RegisteredTasks[name]; !ok { + unknown = append(unknown, name) + } + } + sort.Strings(missing) + sort.Strings(unknown) + + if len(missing) > 0 { + t.Errorf("%s does not account for %d registered task(s): %s\n"+ + "Add each one to the module mapping table (if an ansible-dokku module reaches it) "+ + "or to the %q section.", + ansibleMappingDoc, len(missing), strings.Join(missing, ", "), noModuleHeading) + } + if len(unknown) > 0 { + t.Errorf("%s names %d task(s) that are not registered: %s\n"+ + "Fix the typo, or drop the row if the task was removed.", + ansibleMappingDoc, len(unknown), strings.Join(unknown, ", ")) + } +} diff --git a/tests/bats/ansible.bats b/tests/bats/ansible.bats new file mode 100644 index 0000000..2a575c6 --- /dev/null +++ b/tests/bats/ansible.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# +# The wrapper contract documented in docs/ansible-dokku.md (#409). These +# exercise the exact invocations that page tells an ansible-dokku wrapper +# to make, so a change in flag handling, payload parsing, or the +# validate --json shape breaks here rather than in the wrapper. +# +# Offline only: validate never contacts a server, and apply is exercised +# through --list-tasks. The live apply --detailed-exitcode cases live in +# json.bats, which already requires dokku. + +load test_helper + +setup() { + docket_build +} + +# The worked example from the "payload" section of the page: one play, +# one task, fully resolved, no inputs block. +payload() { + cat <<'EOF' +[ + { + "name": "dokku_config", + "tasks": [ + { + "name": "set config on api", + "dokku_config": { + "app": "api", + "restart": true, + "config": { "LOG_LEVEL": "info" }, + "state": "present" + } + } + ] + } +] +EOF +} + +@test "the documented payload validates" { + cd "$BATS_TEST_TMPDIR" + payload >payload.json + run bash -c "\"$(docket_bin)\" validate --tasks-format json5 - payload.json + run bash -c "\"$(docket_bin)\" validate --tasks-format json - payload.json + run bash -c "\"$(docket_bin)\" validate --tasks-format json5 --json - payload.json + run bash -c "\"$(docket_bin)\" apply --tasks-format json5 --list-tasks --json - /dev/null || fail "invalid JSON: $line" + done <<<"$output" + echo "$output" | jq -e 'select(.type == "list_task") | .name == "set config on api"' >/dev/null || + fail "expected the task name echoed back: $output" +} + +@test "a bad payload reports validate_problem events a wrapper can branch on" { + cd "$BATS_TEST_TMPDIR" + cat >bad.json <<'EOF' +[{"tasks": [{"name": "no app", "dokku_config": {"restart": true}}]}] +EOF + run bash -c "\"$(docket_bin)\" validate --tasks-format json5 --json - /dev/null || fail "expected a validate_problem: $output" + echo "$output" | jq -e '.code == "missing_required_field"' >/dev/null || fail "expected missing_required_field: $output" + echo "$output" | jq -e '.version == 1' >/dev/null || fail "expected version 1: $output" +} + +@test "an unknown task type reports a did-you-mean hint" { + cd "$BATS_TEST_TMPDIR" + cat >typo.json <<'EOF' +[{"tasks": [{"name": "typo", "dokku_appp": {"app": "api"}}]}] +EOF + run bash -c "\"$(docket_bin)\" validate --tasks-format json5 --json - /dev/null || fail "expected unknown_task_type: $output" + echo "$output" | jq -e '.hint | test("dokku_app")' >/dev/null || fail "expected a did-you-mean hint: $output" +} + +@test "an unescaped {{ in a payload value is a render error, not silent corruption" { + cd "$BATS_TEST_TMPDIR" + cat >braces.json <<'EOF' +[{"tasks": [{"name": "braces", "dokku_config": {"app": "api", "config": {"MSG": "hello {{ world"}}}]}] +EOF + run bash -c "\"$(docket_bin)\" validate --tasks-format json5 - escaped.json <<'EOF' +[{"tasks": [{"name": "v=[{{ `{{` }} .Values.name }}]", "dokku_app": {"app": "api"}}]}] +EOF + run bash -c "\"$(docket_bin)\" apply --tasks-format json5 --list-tasks - typo.json <<'EOF' +[{"tasks": [{"name": "typo", "dokku_appp": {"app": "api"}}]}] +EOF + run bash -c "\"$(docket_bin)\" apply --tasks-format json5 --json - /dev/null" + assert_failure + [ -z "$output" ] || fail "expected empty stdout on a load-time failure, got: $output" +} diff --git a/tests/bats/json.bats b/tests/bats/json.bats index ed45e52..60e108e 100644 --- a/tests/bats/json.bats +++ b/tests/bats/json.bats @@ -8,6 +8,7 @@ setup() { dokku_clean_app docket-test-json dokku_clean_app docket-test-json-clean dokku_clean_app docket-test-json-drift + dokku_clean_app docket-test-json-exit dokku_clean_app docket-test-json-mut dokku_clean_app docket-test-json-norestart dokku_clean_app docket-test-json-restart @@ -17,6 +18,7 @@ teardown() { dokku_clean_app docket-test-json dokku_clean_app docket-test-json-clean dokku_clean_app docket-test-json-drift + dokku_clean_app docket-test-json-exit dokku_clean_app docket-test-json-mut dokku_clean_app docket-test-json-norestart dokku_clean_app docket-test-json-restart @@ -260,6 +262,62 @@ EOF done <<<"$output" } +@test "docket apply --detailed-exitcode returns 2 when a task changed" { + write_tasks_file </dev/null || fail "invalid JSON: $line" + if [ "$(echo "$line" | jq -r '.type')" = "summary" ]; then + summary="$line" + fi + done <<<"$output" + [ -n "$summary" ] || fail "no summary event found" + echo "$summary" | jq -e '.changed == 1' >/dev/null || fail "summary should report 1 changed: $summary" +} + @test "docket apply --json masks sensitive values in commands" { write_tasks_file <<'EOF' ---