Skip to content

Commit c1df0ce

Browse files
authored
Merge pull request #391 from dokku/328-bug-probe-swallows-local-exec-errors-and-reports-resources-as-absent
fix: stop probe from swallowing local exec errors
2 parents 9ed9717 + 102bf67 commit c1df0ce

12 files changed

Lines changed: 242 additions & 32 deletions

File tree

commands/play_executor_test.go

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,15 @@ func TestMultiPlayPlayFilterComposesWithTags(t *testing.T) {
133133
tasks:
134134
- name: deploy-api
135135
tags: [deploy]
136-
dokku_app:
137-
app: docket-test-deploy-api
136+
dokku_stub: { key: deploy-api }
138137
- name: configure-api
139138
tags: [configure]
140-
dokku_app:
141-
app: docket-test-configure-api
139+
dokku_stub: { key: configure-api }
142140
- name: worker
143141
tasks:
144142
- name: deploy-worker
145143
tags: [deploy]
146-
dokku_app:
147-
app: docket-test-deploy-worker
144+
dokku_stub: { key: deploy-worker }
148145
`)
149146
stdout, _, _ := runPlan(t, path, "--play", "api", "--tags", "deploy")
150147
if !strings.Contains(stdout, "deploy-api") {
@@ -168,14 +165,12 @@ func TestMultiPlayPlayLevelTagsPropagateToTasks(t *testing.T) {
168165
tags: [api]
169166
tasks:
170167
- name: deploy-api
171-
dokku_app:
172-
app: docket-test-deploy-api
168+
dokku_stub: { key: deploy-api }
173169
- name: worker
174170
tags: [worker]
175171
tasks:
176172
- name: deploy-worker
177-
dokku_app:
178-
app: docket-test-deploy-worker
173+
dokku_stub: { key: deploy-worker }
179174
`)
180175
stdout, _, _ := runPlan(t, path, "--tags", "api")
181176
if !strings.Contains(stdout, "deploy-api") {
@@ -238,6 +233,41 @@ func TestPlanFailedWhenClearsError(t *testing.T) {
238233
}
239234
}
240235

236+
// TestPlanProbeErrorRendersMarkerAndExits pins the documented contract
237+
// that an uncleared probe error renders [!] on stderr and makes plan
238+
// exit 1, and that errors win over drift under --detailed-exitcode (exit
239+
// 1, not 2). This is the end-to-end guard for #328: a probe that could
240+
// not run must not be reported as absent with an optimistic [+] create.
241+
func TestPlanProbeErrorRendersMarkerAndExits(t *testing.T) {
242+
defer stubReset()
243+
stubSet("a", StubFixture{
244+
PlanError: errors.New(`exec: "dokku": executable file not found in $PATH`),
245+
})
246+
247+
path := writeMultiPlayTasks(t, `---
248+
- tasks:
249+
- name: needs-dokku
250+
dokku_stub: { key: a }
251+
`)
252+
253+
stdout, stderr, exit := runPlan(t, path)
254+
if exit != 1 {
255+
t.Fatalf("exit = %d, want 1; stdout=%s stderr=%s", exit, stdout, stderr)
256+
}
257+
if !strings.Contains(stderr, "[!]") {
258+
t.Errorf("expected [!] marker on stderr; got stdout=%s stderr=%s", stdout, stderr)
259+
}
260+
if strings.Contains(stdout, "[+]") {
261+
t.Errorf("plan must not predict [+] create for state it never read; got:\n%s", stdout)
262+
}
263+
264+
// --detailed-exitcode: errors win over drift, so exit stays 1, not 2.
265+
_, _, exit = runPlan(t, path, "--detailed-exitcode")
266+
if exit != 1 {
267+
t.Errorf("detailed-exitcode exit = %d, want 1 (errors win over drift)", exit)
268+
}
269+
}
270+
241271
// TestPlanChangedWhenTrueRecomputesStatus pins #313: changed_when: 'true'
242272
// on an in-sync task must flip the marker and the JSON status to a
243273
// would-change verdict, not leave the stale [ok] / "status":"ok" the

docs/command-reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ decide whether to mutate. A few tasks (notably `dokku_git_auth`, `dokku_registry
149149
`dokku_storage_ensure`) cannot read their state without running the underlying command, so they
150150
always report drift with a `(... not probed)` reason.
151151

152+
When the probe command cannot run at all - for example the local `dokku` CLI is not installed, or
153+
the configured SSH host is unreachable - the task renders `[!]` and `plan` exits `1`, rather than
154+
optimistically predicting `[+] create` for state it never actually read.
155+
152156
| Flag | Effect |
153157
|------|--------|
154158
| `--tasks <path>` | Use a specific recipe. Accepts a local path or an `http(s)://` URL (fetched over HTTP). |

subprocess/exec.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ type ExecCommandInput struct {
7979
type ExecError struct {
8080
Response ExecCommandResponse
8181
Err error
82+
83+
// Ran is true only when the command executed to completion and
84+
// Response.ExitCode is its real exit status. It is false when the
85+
// command could not be started (binary not found, permission denied)
86+
// or was cancelled, in which cases Response.ExitCode is not
87+
// meaningful. Probe() uses this to tell a dokku-level "state absent"
88+
// (Ran, non-zero exit) apart from a real execution failure that must
89+
// be propagated.
90+
Ran bool
8291
}
8392

8493
// Error returns the wrapped error's message so existing string-based
@@ -312,6 +321,10 @@ func defaultExecRunner(ctx context.Context, input ExecCommandInput) (ExecCommand
312321

313322
res, err := cmd.Execute(ctx)
314323
if err != nil {
324+
// The command could not be run to completion: the binary was not
325+
// found, was not executable, or the context was cancelled. The
326+
// exit code is not meaningful, so Ran stays false and callers such
327+
// as Probe surface the failure instead of reading it as absence.
315328
response := ExecCommandResponse{
316329
Command: resolved,
317330
Stdout: res.Stdout,
@@ -330,7 +343,7 @@ func defaultExecRunner(ctx context.Context, input ExecCommandInput) (ExecCommand
330343
ExitCode: res.ExitCode,
331344
Cancelled: res.Cancelled,
332345
}
333-
return response, &ExecError{Response: response, Err: errors.New(res.Stderr)}
346+
return response, &ExecError{Response: response, Err: errors.New(res.Stderr), Ran: true}
334347
}
335348

336349
return ExecCommandResponse{

subprocess/exec_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package subprocess
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"log"
78
"strings"
89
"testing"
@@ -170,6 +171,15 @@ func TestCallExecCommandFailure(t *testing.T) {
170171
if resp.ExitCode == 0 {
171172
t.Error("expected non-zero exit code")
172173
}
174+
// The command ran and exited non-zero, so the exit code is real:
175+
// ExecError.Ran must be true so Probe reads it as "state absent".
176+
var execErr *ExecError
177+
if !errors.As(err, &execErr) {
178+
t.Fatalf("expected *ExecError, got %T", err)
179+
}
180+
if !execErr.Ran {
181+
t.Error("ExecError.Ran should be true when the command ran and exited non-zero")
182+
}
173183
}
174184

175185
func TestCallExecCommandNotFound(t *testing.T) {
@@ -179,6 +189,16 @@ func TestCallExecCommandNotFound(t *testing.T) {
179189
if err == nil {
180190
t.Fatal("expected error for nonexistent command")
181191
}
192+
// The command could not be started, so there is no real exit code:
193+
// ExecError.Ran must be false so Probe propagates the failure instead
194+
// of reporting the probed state as absent.
195+
var execErr *ExecError
196+
if !errors.As(err, &execErr) {
197+
t.Fatalf("expected *ExecError, got %T", err)
198+
}
199+
if execErr.Ran {
200+
t.Error("ExecError.Ran should be false when the binary is not found")
201+
}
182202
}
183203

184204
func TestCallExecCommandWithEnv(t *testing.T) {

subprocess/ssh.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,18 @@ var (
217217
)
218218

219219
// Probe runs input as a state probe and reports whether it matched
220-
// (exit 0). A dokku-level non-zero exit is reported as `(false, nil)`,
221-
// i.e. "the probed state is absent," so callers can write idempotent
222-
// probes without unwrapping errors themselves. A transport-level
223-
// failure (`*SSHError`) is propagated as `(false, err)` so the caller
224-
// can short-circuit `Plan()` with `PlanResult{Error: err}` and let the
225-
// formatter render `! ssh: ...`.
220+
// (exit 0). A non-zero exit from a command that actually ran is reported
221+
// as `(false, nil)`, i.e. "the probed state is absent," so callers can
222+
// write idempotent probes without unwrapping errors themselves.
223+
//
224+
// Any failure that means the probe never produced a real answer is
225+
// propagated as `(false, err)` so the caller can short-circuit `Plan()`
226+
// with `PlanResult{Error: err}` and let the formatter render `[!]`. That
227+
// covers a transport-level failure (`*SSHError`), a command that could
228+
// not be executed at all (the dokku binary is missing or not
229+
// executable), and a cancelled probe. Distinguishing "ran and said no"
230+
// from "could not run" relies on `ExecError.Ran`, since binary-not-found
231+
// reports `ExitCode 0` and so cannot be told apart by exit code.
226232
//
227233
// Use this for any plan-time probe that today reads exit code only
228234
// (`apps:exists`, `network:exists`, `<service>:linked`, etc.). Probes
@@ -231,11 +237,22 @@ var (
231237
func Probe(input ExecCommandInput) (bool, error) {
232238
result, err := CallExecCommand(input)
233239
if err != nil {
240+
// Transport-level failure (ssh connect/auth/host-key): propagate
241+
// so the caller can render `! ssh: ...`.
234242
var sshErr *SSHError
235243
if errors.As(err, &sshErr) {
236244
return false, err
237245
}
238-
return false, nil
246+
// The command executed and exited non-zero: the probed state is
247+
// absent. Report (false, nil) so idempotent probes need not unwrap.
248+
var execErr *ExecError
249+
if errors.As(err, &execErr) && execErr.Ran {
250+
return false, nil
251+
}
252+
// Anything else - the command could not be executed (binary not
253+
// found, permission denied) or was cancelled - is a real failure
254+
// the caller must surface, not "state absent".
255+
return false, err
239256
}
240257
return result.ExitCode == 0, nil
241258
}
@@ -390,7 +407,10 @@ func classifySshResult(target sshTarget, remote []string, resp ExecCommandRespon
390407
}
391408
}
392409
if resp.ExitCode != 0 {
393-
return resp, &ExecError{Response: resp, Err: errors.New(resp.Stderr)}
410+
// The remote dokku command ran and exited non-zero (not a
411+
// transport failure). Ran marks the exit code as authoritative so
412+
// Probe treats it as "state absent" rather than an execution error.
413+
return resp, &ExecError{Response: resp, Err: errors.New(resp.Stderr), Ran: true}
394414
}
395415
return resp, nil
396416
}

subprocess/ssh_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package subprocess
22

33
import (
4+
"context"
45
"errors"
56
"strings"
67
"testing"
@@ -430,6 +431,62 @@ func TestProbeSshTransportErrorPropagates(t *testing.T) {
430431
}
431432
}
432433

434+
func TestProbeLocalExecErrorPropagates(t *testing.T) {
435+
// A local probe whose binary is not on PATH must surface the failure
436+
// rather than reporting the probed state as absent. This is the
437+
// no-dokku-installed scenario: without propagation, plan would print a
438+
// confident [+] create for every resource instead of [!].
439+
matched, err := Probe(ExecCommandInput{
440+
Command: "nonexistent-binary-docket-test-12345",
441+
Args: []string{"--quiet", "apps:exists", "anything"},
442+
})
443+
if matched {
444+
t.Error("Probe should report matched=false when the binary is missing")
445+
}
446+
if err == nil {
447+
t.Fatal("Probe should propagate a binary-not-found error, not swallow it as absent")
448+
}
449+
}
450+
451+
func TestProbeExecRanFlagControlsAbsence(t *testing.T) {
452+
// The Ran flag is the sole signal that separates "the command ran and
453+
// exited non-zero" (state absent) from "the command could not run"
454+
// (real failure). Inject each case via the swappable runner so the
455+
// behaviour is pinned without spawning a process.
456+
t.Run("ran non-zero reports absent", func(t *testing.T) {
457+
defer SetExecRunner(func(_ context.Context, _ ExecCommandInput) (ExecCommandResponse, error) {
458+
resp := ExecCommandResponse{ExitCode: 1}
459+
return resp, &ExecError{Response: resp, Err: errors.New("absent"), Ran: true}
460+
})()
461+
462+
matched, err := Probe(ExecCommandInput{Command: "dokku"})
463+
if err != nil {
464+
t.Fatalf("Probe should treat a ran non-zero exit as absent, got err %v", err)
465+
}
466+
if matched {
467+
t.Error("Probe should report matched=false for a ran non-zero exit")
468+
}
469+
})
470+
471+
t.Run("cancelled probe propagates", func(t *testing.T) {
472+
defer SetExecRunner(func(_ context.Context, _ ExecCommandInput) (ExecCommandResponse, error) {
473+
resp := ExecCommandResponse{ExitCode: -1, Cancelled: true}
474+
return resp, &ExecError{Response: resp, Err: context.Canceled}
475+
})()
476+
477+
matched, err := Probe(ExecCommandInput{Command: "dokku"})
478+
if matched {
479+
t.Error("Probe should report matched=false on a cancelled probe")
480+
}
481+
if err == nil {
482+
t.Fatal("Probe should propagate a cancelled probe, not swallow it as absent")
483+
}
484+
if !errors.Is(err, context.Canceled) {
485+
t.Errorf("propagated error should wrap context.Canceled, got %v", err)
486+
}
487+
})
488+
}
489+
433490
func TestSetAndGetDefaultHost(t *testing.T) {
434491
t.Cleanup(func() { SetDefaultHost("") })
435492
SetDefaultHost("alice@host:2222")

tasks/app_lock_task.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,10 @@ func (t AppLockTask) ExportApp(app string) ([]interface{}, error) {
131131
return []interface{}{AppLockTask{App: app, State: StatePresent}}, nil
132132
}
133133

134-
// appLocked checks if a dokku app is locked. Returns (false,
135-
// *subprocess.SSHError) on transport failure; (false, nil) when dokku
136-
// reports unlocked; (true, nil) when locked.
134+
// appLocked checks if a dokku app is locked. Returns (false, err) when
135+
// the probe could not run - a transport failure, a missing dokku binary,
136+
// or a cancellation; (false, nil) when dokku reports unlocked; (true,
137+
// nil) when locked.
137138
func appLocked(app string) (bool, error) {
138139
return subprocess.Probe(subprocess.ExecCommandInput{
139140
Command: "dokku",

tasks/app_task.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ func (t AppTask) ExportApp(app string) ([]interface{}, error) {
115115

116116
// appExists checks if an app exists. Returns (true, nil) when the app
117117
// is present, (false, nil) when dokku reports it absent, and
118-
// (false, *subprocess.SSHError) when the probe could not reach the
119-
// server. Plan() callers must short-circuit on the error.
118+
// (false, err) when the probe could not run - a transport failure, a
119+
// missing dokku binary, or a cancellation. Plan() callers must
120+
// short-circuit on the error.
120121
func appExists(appName string) (bool, error) {
121122
return subprocess.Probe(subprocess.ExecCommandInput{
122123
Command: "dokku",

tasks/network_task.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,10 @@ func (t NetworkTask) ExportGlobal() ([]interface{}, error) {
164164
return out, nil
165165
}
166166

167-
// networkExists checks if a Docker network exists. Returns (false,
168-
// *subprocess.SSHError) on transport failure; (false, nil) when dokku
169-
// reports the network absent; (true, nil) when present.
167+
// networkExists checks if a Docker network exists. Returns (false, err)
168+
// when the probe could not run - a transport failure, a missing dokku
169+
// binary, or a cancellation; (false, nil) when dokku reports the network
170+
// absent; (true, nil) when present.
170171
func networkExists(name string) (bool, error) {
171172
return subprocess.Probe(subprocess.ExecCommandInput{
172173
Command: "dokku",

tasks/service_create_task.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,10 @@ func (t ServiceCreateTask) Plan() PlanResult {
147147
})
148148
}
149149

150-
// serviceExists checks if a dokku service exists. Returns (false,
151-
// *subprocess.SSHError) on transport failure, (false, nil) when dokku
152-
// reports the service absent, (true, nil) when present.
150+
// serviceExists checks if a dokku service exists. Returns (false, err)
151+
// when the probe could not run - a transport failure, a missing dokku
152+
// binary, or a cancellation, (false, nil) when dokku reports the service
153+
// absent, (true, nil) when present.
153154
func serviceExists(service, name string) (bool, error) {
154155
return subprocess.Probe(subprocess.ExecCommandInput{
155156
Command: "dokku",

0 commit comments

Comments
 (0)