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
9 changes: 8 additions & 1 deletion commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,12 +468,19 @@ func (c *ApplyCommand) executeLeafTask(env *tasks.TaskEnvelope, name string, ac
}

if msg := tasks.TaskDeprecation(env.Task); msg != "" {
ac.emitter.TaskWarning(ac.play.Name, name, msg)
ac.emitter.TaskWarning(ac.play.Name, name, "deprecated", msg)
}

state := env.Task.Execute()
ac.counts.Tasks++

// Probe diagnostics raised during planning (carried out on the state by
// ExecutePlan) surface as informational warning lines/events above the
// task's own result line.
for _, w := range state.Warnings {
ac.emitter.TaskWarning(ac.play.Name, name, w.Reason, w.Message)
}

postState, overrideErr := applyEnvelopeOverrides(env, state, ac.playExprCtx, ac.registered, failedTask)
if overrideErr != nil {
ac.counts.Errors++
Expand Down
2 changes: 1 addition & 1 deletion commands/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func (c *ExportCommand) Run(args []string) int {
return 1
}
for _, w := range res.Report.Warnings {
c.Ui.Warn(fmt.Sprintf("warning: %s", w))
c.Ui.Warn(fmt.Sprintf("warning: %s", subprocess.MaskString(w)))
}

// A nonexistent --app must not silently produce an empty recipe (which the
Expand Down
33 changes: 24 additions & 9 deletions commands/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ type EventEmitter interface {
// PlanTask emits one event per task in a `plan` run.
PlanTask(ev PlanTaskEvent)
// TaskWarning emits a non-fatal warning associated with a task, such
// as a task-type deprecation notice. It does not affect task counts
// or exit codes; apply / plan call it before the per-task event so
// the warning appears immediately above the task's result line.
TaskWarning(play, name, message string)
// as a task-type deprecation notice or a property probe diagnostic. It
// does not affect task counts or exit codes. reason is a stable machine
// key ("deprecated", or a tasks.WarnReason* value) the JSON emitter
// surfaces and the human emitter maps to a marker. The warning is
// emitted before the per-task event so it appears above the task's
// result line.
TaskWarning(play, name, reason, message string)
// ApplySummary emits the end-of-run footer for `apply`.
ApplySummary(c ApplyCounts, d time.Duration)
// PlanSummary emits the end-of-run footer for `plan`.
Expand Down Expand Up @@ -134,6 +137,12 @@ const (
// markers because the warning is informational and does not feed
// the run counts or exit code.
MarkerDeprecated Marker = "deprecated"

// MarkerWarning prefixes a TaskWarning line for a non-deprecation
// diagnostic (for example a property probe that found no matching
// report key). Like MarkerDeprecated it is informational and does not
// feed the run counts or exit code.
MarkerWarning Marker = "warning"
)

// markerWidth is the fixed column width that every bracketed marker is
Expand Down Expand Up @@ -200,20 +209,26 @@ func NewFormatter(ui cli.Ui, verbose bool) *Formatter {
MarkerDestroy: color.New(color.FgRed),
MarkerProbeError: color.New(color.FgRed),
MarkerDeprecated: color.New(color.FgYellow, color.Faint),
MarkerWarning: color.New(color.FgYellow, color.Faint),
},
}
}

// TaskWarning renders a `[deprecated] <name> (<message>)` line above the
// TaskWarning renders a `[<marker>] <name> (<message>)` line above the
// task's result line. It is informational, so the line goes to
// Ui.Output (not Ui.Error) and does not affect counts. The message is
// masked against the global sensitive set.
func (f *Formatter) TaskWarning(_, name, message string) {
// Ui.Output (not Ui.Error) and does not affect counts. The marker is
// `[deprecated]` for a deprecation notice and `[warning]` for any other
// diagnostic. The message is masked against the global sensitive set.
func (f *Formatter) TaskWarning(_, name, reason, message string) {
if message == "" {
return
}
marker := MarkerWarning
if reason == "deprecated" {
marker = MarkerDeprecated
}
suffix := "(" + message + ")"
f.TaskLine(MarkerDeprecated, name, suffix)
f.TaskLine(marker, name, suffix)
}

// Verbose reports whether the formatter is in verbose mode.
Expand Down
11 changes: 6 additions & 5 deletions commands/output_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,11 @@ func (e *JSONEmitter) PlanTask(ev PlanTaskEvent) {
}

// TaskWarning emits a `warning` event keyed by `reason` and tied to a
// specific task. Today the only reason is "deprecated"; the event is
// emitted before the task's own `task` event so consumers can correlate
// by ordering.
func (e *JSONEmitter) TaskWarning(play, name, message string) {
// specific task. reason is "deprecated" for a deprecation notice or a
// tasks.WarnReason* value for a probe diagnostic; the event is emitted
// before the task's own `task` event so consumers can correlate by
// ordering.
func (e *JSONEmitter) TaskWarning(play, name, reason, message string) {
if message == "" {
return
}
Expand All @@ -187,7 +188,7 @@ func (e *JSONEmitter) TaskWarning(play, name, message string) {
"type": "warning",
"play": subprocess.MaskString(play),
"name": subprocess.MaskString(name),
"reason": "deprecated",
"reason": reason,
"message": subprocess.MaskString(message),
"ts": nowRFC3339(),
})
Expand Down
25 changes: 23 additions & 2 deletions commands/output_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ func TestJSONEmitterEveryEventHasVersion1(t *testing.T) {
// TestEmitterInterfaceSatisfied compiles iff Formatter and JSONEmitter both
func TestJSONEmitterTaskWarning(t *testing.T) {
e, ui := emitterTestUI()
e.TaskWarning("tasks", "ensure storage", "use dokku_storage_entry instead")
e.TaskWarning("tasks", "ensure storage", "deprecated", "use dokku_storage_entry instead")
ev := decodeOnly(t, ui.OutputWriter.String())

if got, want := ev["version"], float64(1); got != want {
Expand Down Expand Up @@ -478,12 +478,33 @@ func TestJSONEmitterTaskWarning(t *testing.T) {

func TestJSONEmitterTaskWarningEmptyIsNoOp(t *testing.T) {
e, ui := emitterTestUI()
e.TaskWarning("tasks", "ensure storage", "")
e.TaskWarning("tasks", "ensure storage", "deprecated", "")
if ui.OutputWriter.String() != "" {
t.Errorf("expected no output for empty message, got %q", ui.OutputWriter.String())
}
}

// TestJSONEmitterTaskWarningProbeReason pins that a non-deprecation warning
// carries its own reason through the event and that the message is masked. (#353)
func TestJSONEmitterTaskWarningProbeReason(t *testing.T) {
subprocess.SetGlobalSensitive([]string{"s3cr3t"})
t.Cleanup(func() { subprocess.SetGlobalSensitive(nil) })

e, ui := emitterTestUI()
e.TaskWarning("tasks", "set token", "probe_rejected", "rejected probe near value s3cr3t")
ev := decodeOnly(t, ui.OutputWriter.String())

if got, want := ev["reason"], "probe_rejected"; got != want {
t.Errorf("reason = %v, want %v", got, want)
}
if msg, _ := ev["message"].(string); strings.Contains(msg, "s3cr3t") {
t.Errorf("message leaked secret: %q", msg)
}
if msg, _ := ev["message"].(string); !strings.Contains(msg, "***") {
t.Errorf("message should carry mask placeholder, got %q", msg)
}
}

// satisfy the EventEmitter contract. Catches signature drift at build time.
func TestEmitterInterfaceSatisfied(t *testing.T) {
var _ EventEmitter = (*Formatter)(nil)
Expand Down
31 changes: 29 additions & 2 deletions commands/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ func TestApplyTaskErrorOmitsEmptyStdout(t *testing.T) {

func TestFormatterTaskWarningRendersDeprecatedMarker(t *testing.T) {
f, ui := newTestFormatter(false)
f.TaskWarning("tasks", "ensure storage", "use dokku_storage_entry instead")
f.TaskWarning("tasks", "ensure storage", "deprecated", "use dokku_storage_entry instead")
got := ui.OutputWriter.String()
if !strings.Contains(got, "[deprecated]") {
t.Errorf("expected [deprecated] marker, got %q", got)
Expand All @@ -398,9 +398,36 @@ func TestFormatterTaskWarningRendersDeprecatedMarker(t *testing.T) {
}
}

// TestFormatterTaskWarningRendersWarningMarker pins that a non-deprecation
// reason renders the [warning] marker (not [deprecated]) on stdout, and masks
// the message. (#353)
func TestFormatterTaskWarningRendersWarningMarker(t *testing.T) {
subprocess.SetGlobalSensitive([]string{"s3cr3t"})
t.Cleanup(func() { subprocess.SetGlobalSensitive(nil) })

f, ui := newTestFormatter(false)
f.TaskWarning("tasks", "set token", "probe_rejected", "rejected probe near value s3cr3t")
got := ui.OutputWriter.String()
if !strings.Contains(got, "[warning]") {
t.Errorf("expected [warning] marker, got %q", got)
}
if strings.Contains(got, "[deprecated]") {
t.Errorf("non-deprecation warning must not use [deprecated], got %q", got)
}
if strings.Contains(got, "s3cr3t") {
t.Errorf("warning line leaked secret: %q", got)
}
if !strings.Contains(got, "***") {
t.Errorf("expected masked placeholder in line, got %q", got)
}
if ui.ErrorWriter.String() != "" {
t.Errorf("informational warning must not write to stderr, got %q", ui.ErrorWriter.String())
}
}

func TestFormatterTaskWarningEmptyMessageIsNoOp(t *testing.T) {
f, ui := newTestFormatter(false)
f.TaskWarning("tasks", "ensure storage", "")
f.TaskWarning("tasks", "ensure storage", "deprecated", "")
if ui.OutputWriter.String() != "" {
t.Errorf("expected no output for empty message, got %q", ui.OutputWriter.String())
}
Expand Down
8 changes: 7 additions & 1 deletion commands/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,12 +393,18 @@ func planLeaf(env *tasks.TaskEnvelope, name string, pc *planContext, failedTask
}

if msg := tasks.TaskDeprecation(env.Task); msg != "" {
pc.emitter.TaskWarning(pc.play.Name, name, msg)
pc.emitter.TaskWarning(pc.play.Name, name, "deprecated", msg)
}

result := env.Task.Plan()
pc.counts.Tasks++

// Probe diagnostics raised during planning surface as informational
// warning lines/events above the task's own result line.
for _, w := range result.Warnings {
pc.emitter.TaskWarning(pc.play.Name, name, w.Reason, w.Message)
}

synth := tasks.TaskOutputState{
Changed: !result.InSync,
Error: result.Error,
Expand Down
6 changes: 6 additions & 0 deletions commands/stub_task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func (t StubTask) Plan() tasks.PlanResult {
return tasks.PlanResult{
Status: tasks.PlanStatusModify,
DesiredState: tasks.StatePresent,
Warnings: fixture.Warnings,
}
}

Expand Down Expand Up @@ -85,6 +86,7 @@ func (t StubTask) Execute() tasks.TaskOutputState {
Stdout: fixture.Stdout,
Stderr: fixture.Stderr,
ExitCode: fixture.ExitCode,
Warnings: fixture.Warnings,
}
}

Expand All @@ -99,6 +101,10 @@ type StubFixture struct {
Stderr string
ExitCode int
MismatchState bool
// Warnings are echoed onto the drift PlanResult (plan mode) and the
// success TaskOutputState (apply mode) so tests can drive the run loops'
// warning drain. #353.
Warnings []tasks.PlanWarning
}

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

import (
"strings"
"testing"

"github.com/dokku/docket/tasks"
)

// These tests pin #353: a probe diagnostic a task's Plan() surfaces on
// PlanResult.Warnings (carried onto TaskOutputState for apply) is drained by
// the run loops through EventEmitter.TaskWarning, so it renders as a `[warning]`
// line (human) / `warning` event (JSON) correlated with its task rather than a
// raw log line. Masking is covered by the emitter-level tests; here we pin the
// routing, the reason, and the "warning precedes task" ordering.

const probeWarningMessage = "dokku registry:report rejected probe for property \"password\""

func stubWithWarning() StubFixture {
return StubFixture{
Changed: true,
Warnings: []tasks.PlanWarning{
{Reason: tasks.WarnReasonProbeRejected, Message: probeWarningMessage},
},
}
}

const warningRecipe = `---
- tasks:
- name: set token
dokku_stub: { key: a }
`

func TestPlanSurfacesProbeWarning(t *testing.T) {
defer stubReset()
stubSet("a", stubWithWarning())
path := writeTasksFile(t, warningRecipe)

stdout, _, _ := runPlan(t, path)
if !strings.Contains(stdout, "[warning]") {
t.Errorf("expected [warning] line in plan output; got:\n%s", stdout)
}
if !strings.Contains(stdout, probeWarningMessage) {
t.Errorf("expected warning message in plan output; got:\n%s", stdout)
}
// The warning line must precede the task's own result line.
if wi, ti := strings.Index(stdout, "[warning]"), strings.Index(stdout, "[~]"); wi < 0 || ti < 0 || wi > ti {
t.Errorf("warning should precede the task line; got:\n%s", stdout)
}

jsonOut, _, _ := runPlan(t, path, "--json")
assertWarningEventPrecedesTask(t, jsonOut)
}

func TestApplySurfacesProbeWarning(t *testing.T) {
defer stubReset()
stubSet("a", stubWithWarning())
path := writeTasksFile(t, warningRecipe)

stdout, _, _ := runApply(t, path)
if !strings.Contains(stdout, "[warning]") {
t.Errorf("expected [warning] line in apply output; got:\n%s", stdout)
}
if !strings.Contains(stdout, probeWarningMessage) {
t.Errorf("expected warning message in apply output; got:\n%s", stdout)
}

jsonOut, _, _ := runApply(t, path, "--json")
assertWarningEventPrecedesTask(t, jsonOut)
}

// assertWarningEventPrecedesTask checks the JSON stream carries a `warning`
// event with the probe reason and message, emitted before its `task` event.
func assertWarningEventPrecedesTask(t *testing.T, jsonOut string) {
t.Helper()
warnIdx, taskIdx := -1, -1
for i, ev := range decodeLines(t, jsonOut) {
switch ev["type"] {
case "warning":
warnIdx = i
if ev["reason"] != tasks.WarnReasonProbeRejected {
t.Errorf("warning reason = %v, want %v", ev["reason"], tasks.WarnReasonProbeRejected)
}
if msg, _ := ev["message"].(string); !strings.Contains(msg, probeWarningMessage) {
t.Errorf("warning message = %q, want to contain %q", msg, probeWarningMessage)
}
case "task":
if taskIdx == -1 {
taskIdx = i
}
}
}
if warnIdx == -1 {
t.Fatalf("no warning event in JSON stream:\n%s", jsonOut)
}
if taskIdx == -1 || warnIdx > taskIdx {
t.Errorf("warning event should precede task event; warnIdx=%d taskIdx=%d\n%s", warnIdx, taskIdx, jsonOut)
}
}
7 changes: 7 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ layout as `apply`, with a marker set focused on drift:
| `[-]` | `apply` would remove existing state. |
| `[!]` | The read-state probe itself errored, so drift is unknown. |

A task may also be preceded by an informational `[deprecated]` or `[warning]` line (a task-type
deprecation notice, or a property probe diagnostic such as an unknown report key). These do not
count toward the summary or the exit code.

Tasks that perform several operations itemize them under the task line:

```text
Expand Down Expand Up @@ -184,6 +188,9 @@ gets a status marker:
| `[skipped]` | Filtered out by `when:` or `--start-at-task`. |
| `[error]` | Errored. |

As in `plan`, a task may be preceded by an informational `[deprecated]` or `[warning]` line that
does not count toward the summary or the exit code.

A play header precedes the task lines, and a summary closes the run:

```text
Expand Down
Loading