Skip to content
Open
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
1 change: 1 addition & 0 deletions cli/command/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
_ "github.com/docker/cli/cli/command/container"
_ "github.com/docker/cli/cli/command/context"
_ "github.com/docker/cli/cli/command/image"
_ "github.com/docker/cli/cli/command/job"
_ "github.com/docker/cli/cli/command/manifest"
_ "github.com/docker/cli/cli/command/network"
_ "github.com/docker/cli/cli/command/node"
Expand Down
48 changes: 48 additions & 0 deletions cli/command/job/cancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package job

import (
"context"
"fmt"

"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
jobsv0 "github.com/docker/cli/internal/jobsapi/v0"
"github.com/spf13/cobra"
)

// newCancelCommand wires the `docker job cancel JOB` cobra command. client is
// injected explicitly, following the same pattern as newRunCommand (see
// run.go).
func newCancelCommand(dockerCli command.Cli, client jobsClient) *cobra.Command {
cmd := &cobra.Command{
Use: "cancel JOB",
Short: "Cancel a job's in-flight run",
Args: cli.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runCancel(cmd.Context(), dockerCli, client, args[0])
},
ValidArgsFunction: cobra.NoFileCompletions,
DisableFlagsInUseLine: true,
}
return cmd
}

// runCancel cancels jobRef's in-flight run via client.Cancel. If a run was
// cancelled, its ID is printed on dockerCli.Out(), the same convention as
// runRun (see run.go). If no run was in flight, a message is printed instead
// and nil is returned: cancelling an idle job is a valid, idempotent
// request, not a failure.
func runCancel(ctx context.Context, dockerCli command.Cli, client jobsClient, jobRef string) error {
reply, err := client.Cancel(ctx, &jobsv0.CancelRequest{JobRef: jobRef})
if err != nil {
return wrapJobsError(err)
}

if reply.RunID == "" {
_, _ = fmt.Fprintf(dockerCli.Out(), "no run in flight for job %q\n", jobRef)
return nil
}

_, _ = fmt.Fprintln(dockerCli.Out(), reply.RunID)
return nil
}
159 changes: 159 additions & 0 deletions cli/command/job/cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package job

import (
"context"
"errors"
"io"
"strings"
"testing"

jobsv0 "github.com/docker/cli/internal/jobsapi/v0"
"github.com/docker/cli/internal/test"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)

// Output convention choice: on a successful cancellation (reply.RunID set),
// runCancel prints the cancelled run's ID on dockerCli.Out(), mirroring
// runRun's "print the created/affected resource's ID" convention (see
// run.go). When no run was in flight (reply.RunID == ""), printing an empty
// line would be confusing in a demo -- the user asked for a cancel and
// nothing happened, silently or with a blank line, which looks like a bug.
// Instead runCancel prints a clear, non-error message to dockerCli.Out()
// (`no run in flight for job "<jobRef>"`) and returns a nil error: asking to
// cancel an idle job is a valid, idempotent request, not a failure -- the
// same spirit as jobsv0.Jobs.Pause/Resume being documented idempotent.

func TestNewCancelCommandForwardsJobRef(t *testing.T) {
var gotReq *jobsv0.CancelRequest
client := &fakeClient{
cancelFunc: func(_ context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
gotReq = req
return &jobsv0.CancelReply{RunID: "run-1"}, nil
},
}
cli := test.NewFakeCli(nil)

cmd := newCancelCommand(cli, client)
cmd.SetArgs([]string{"backup"})
assert.NilError(t, cmd.Execute())

assert.Check(t, gotReq != nil, "client.Cancel was not called")
assert.Check(t, is.Equal(gotReq.JobRef, "backup"))
}

func TestNewCancelCommandPrintsCancelledRunID(t *testing.T) {
client := &fakeClient{
cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
return &jobsv0.CancelReply{RunID: "run-42"}, nil
},
}
cli := test.NewFakeCli(nil)

cmd := newCancelCommand(cli, client)
cmd.SetArgs([]string{"backup"})
assert.NilError(t, cmd.Execute())

assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-42\n"))
}

func TestNewCancelCommandNoRunInFlightPrintsMessageWithoutError(t *testing.T) {
client := &fakeClient{
cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
return &jobsv0.CancelReply{RunID: ""}, nil
},
}
cli := test.NewFakeCli(nil)

cmd := newCancelCommand(cli, client)
cmd.SetArgs([]string{"backup"})
assert.NilError(t, cmd.Execute())

assert.Check(t, is.Equal(cli.OutBuffer().String(), "no run in flight for job \"backup\"\n"))
}

func TestNewCancelCommandRequiresExactlyOneArg(t *testing.T) {
client := &fakeClient{}
cli := test.NewFakeCli(nil)

cmd := newCancelCommand(cli, client)
cmd.SetArgs(nil)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

assert.ErrorContains(t, err, "requires 1 argument")
}

func TestNewCancelCommandUnimplementedIsActionable(t *testing.T) {
client := &fakeClient{
cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs")
},
}
cli := test.NewFakeCli(nil)

cmd := newCancelCommand(cli, client)
cmd.SetArgs([]string{"backup"})
cmd.SetOut(io.Discard)
err := cmd.Execute()

assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`)
// The raw gRPC status dump must not leak to the user.
assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err)
assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err)
}

func TestNewCancelCommandOtherErrorsPassThroughUnwrapped(t *testing.T) {
errText := `job "backup" not found`
client := &fakeClient{
cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
return nil, errors.New(errText)
},
}
cli := test.NewFakeCli(nil)

cmd := newCancelCommand(cli, client)
cmd.SetArgs([]string{"backup"})
cmd.SetOut(io.Discard)
err := cmd.Execute()

assert.ErrorContains(t, err, errText)
}

// TestRunCancelDirect exercises runCancel directly (bypassing cobra arg
// parsing), following the pattern set by run_test.go/inspect_test.go of
// testing the RunE body as a plain function in addition to going through the
// cobra command.
func TestRunCancelDirect(t *testing.T) {
var gotReq *jobsv0.CancelRequest
client := &fakeClient{
cancelFunc: func(_ context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
gotReq = req
return &jobsv0.CancelReply{RunID: "run-7"}, nil
},
}
cli := test.NewFakeCli(nil)

err := runCancel(context.Background(), cli, client, "backup")

assert.NilError(t, err)
assert.Check(t, is.Equal(gotReq.JobRef, "backup"))
assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-7\n"))
}

func TestRunCancelDirectNoRunInFlight(t *testing.T) {
client := &fakeClient{
cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) {
return &jobsv0.CancelReply{RunID: ""}, nil
},
}
cli := test.NewFakeCli(nil)

err := runCancel(context.Background(), cli, client, "backup")

assert.NilError(t, err)
assert.Check(t, is.Equal(cli.OutBuffer().String(), "no run in flight for job \"backup\"\n"))
}
112 changes: 112 additions & 0 deletions cli/command/job/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26

package job

import (
"context"
"errors"
"fmt"

"github.com/docker/cli/cli/command"
jobsv0 "github.com/docker/cli/internal/jobsapi/v0"
"github.com/docker/cli/internal/jobsapi/v0/protogen"
extensionclient "github.com/moby/extensions/client"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// jobsClient is the subset of the jobsv0.Jobs contract consumed by the
// `docker job` commands. It is intentionally narrow: jobsv0.Jobs (the real
// implementation, obtained through github.com/moby/extensions/client against
// the daemon's jobs extension point) already satisfies it structurally, so no
// adapter type is needed in production code -- see the var _ jobsClient
// assertion in client_test.go. Do not widen it speculatively.
type jobsClient interface {
List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error)

// Inspect returns a job with its latest run summary.
Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error)

// ListRuns returns a job's runs, most recent first. Used as the
// fallback when the latest run's container is gone.
ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error)

// Run executes an existing job, returning the created run.
Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error)

// InspectRun returns a single run.
InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error)

// Cancel stops the job's in-flight run.
Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error)

// Prune removes idle manual jobs.
Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error)

// Remove deletes a job. It returns only an error: jobsv0.Jobs.Remove has
// no reply message.
Remove(ctx context.Context, req *jobsv0.RemoveRequest) error

// Wait blocks until a run reaches the requested condition and returns
// that run.
Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error)

// Pause suppresses a job's schedule trigger. Like Remove, it returns
// only an error: jobsv0.Jobs.Pause has no reply message.
Pause(ctx context.Context, req *jobsv0.PauseRequest) error

// Resume re-arms a paused job's schedule trigger. Like Remove, it
// returns only an error: jobsv0.Jobs.Resume has no reply message.
Resume(ctx context.Context, req *jobsv0.ResumeRequest) error
}

// newJobsClient dials the daemon's jobs extension point and returns a
// jobsClient backed by github.com/moby/extensions/client, plus a closer for
// the underlying connection that callers must invoke once done with it.
//
// Resolve is lazy: a daemon without the feature only surfaces
// codes.Unimplemented on the first RPC call (e.g. the first List), not on
// newJobsClient itself.
func newJobsClient(dockerCli command.Cli) (jobsClient, func() error, error) {
exts, err := extensionclient.New(dockerCli.Client(), extensionclient.WithGRPCPoint(protogen.ClientPoint))
if err != nil {
return nil, nil, err
}
client, err := extensionclient.Resolve(exts, jobsv0.Point)
if err != nil {
_ = exts.Close()
return nil, nil, err
}
return client, exts.Close, nil
}

// enableJobsFlag is the dockerd option enabling the daemon's jobs extension.
const enableJobsFlag = "--extension-enable org.mobyproject.jobs.v0"

// wrapJobsError turns the gRPC status errors of the Jobs API into actionable
// CLI errors instead of raw `rpc error: code = ...` dumps. Unimplemented means
// the extension is not enabled; Unavailable means the daemon is still starting
// (or the extension failed to activate), or cannot be reached at all, which is
// worth a retry. The other codes the contract defines carry their own message.
// Any other error (including nil) is returned unchanged.
func wrapJobsError(err error) error {
st, ok := status.FromError(err)
if !ok {
return err
}
switch st.Code() { //nolint:exhaustive // only the codes defined by the Jobs API contract are mapped
case codes.Unimplemented:
return errors.New("the daemon does not support jobs; start dockerd with " + enableJobsFlag + ` (or set "extensions.enable" in daemon.json)`)
case codes.Canceled:
// A cancelled context (Ctrl-C) must stay recognisable as such, so that the
// CLI exits quietly like it does for the other commands.
return context.Canceled
case codes.Unavailable:
return fmt.Errorf("the jobs API is unavailable (the daemon may be starting or unreachable), retry in a moment: %s", st.Message())
case codes.NotFound, codes.AlreadyExists, codes.FailedPrecondition, codes.InvalidArgument:
return errors.New(st.Message())
default:
return err
}
}
Loading
Loading