diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index 2b375938c9ec..7866aa49f838 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -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" diff --git a/cli/command/job/cancel.go b/cli/command/job/cancel.go new file mode 100644 index 000000000000..1f3ccf52bfc0 --- /dev/null +++ b/cli/command/job/cancel.go @@ -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 +} diff --git a/cli/command/job/cancel_test.go b/cli/command/job/cancel_test.go new file mode 100644 index 000000000000..a6209a547724 --- /dev/null +++ b/cli/command/job/cancel_test.go @@ -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 ""`) 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")) +} diff --git a/cli/command/job/client.go b/cli/command/job/client.go new file mode 100644 index 000000000000..1d7d5ab066e7 --- /dev/null +++ b/cli/command/job/client.go @@ -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 + } +} diff --git a/cli/command/job/client_test.go b/cli/command/job/client_test.go new file mode 100644 index 000000000000..9c605abb676c --- /dev/null +++ b/cli/command/job/client_test.go @@ -0,0 +1,167 @@ +package job + +import ( + "context" + "errors" + "testing" + + "github.com/containerd/errdefs" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// jobsv0.Jobs must keep satisfying jobsClient structurally: this line stops +// compiling the day the real interface no longer matches what the job +// package expects from it. +var _ jobsClient = jobsv0.Jobs(nil) + +// fakeClient is a test double for jobsClient, following the fakeClient +// pattern used across cli/command/* (see cli/command/volume/client_test.go): +// one Func field per method, a nil field falling back to a harmless +// zero-value response. +type fakeClient struct { + listFunc func(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) + inspectFunc func(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) + listRunsFunc func(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) + runFunc func(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) + inspectRunFunc func(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) + cancelFunc func(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) + pruneFunc func(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) + removeFunc func(ctx context.Context, req *jobsv0.RemoveRequest) error + waitFunc func(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) + pauseFunc func(ctx context.Context, req *jobsv0.PauseRequest) error + resumeFunc func(ctx context.Context, req *jobsv0.ResumeRequest) error +} + +func (c *fakeClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + if c.listFunc != nil { + return c.listFunc(ctx, req) + } + return &jobsv0.ListReply{}, nil +} + +func (c *fakeClient) Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + if c.inspectFunc != nil { + return c.inspectFunc(ctx, req) + } + return &jobsv0.InspectReply{}, nil +} + +func (c *fakeClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + if c.listRunsFunc != nil { + return c.listRunsFunc(ctx, req) + } + return &jobsv0.ListRunsReply{}, nil +} + +func (c *fakeClient) Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + if c.runFunc != nil { + return c.runFunc(ctx, req) + } + return &jobsv0.RunReply{}, nil +} + +func (c *fakeClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + if c.inspectRunFunc != nil { + return c.inspectRunFunc(ctx, req) + } + return &jobsv0.InspectRunReply{}, nil +} + +func (c *fakeClient) Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + if c.cancelFunc != nil { + return c.cancelFunc(ctx, req) + } + return &jobsv0.CancelReply{}, nil +} + +func (c *fakeClient) Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + if c.pruneFunc != nil { + return c.pruneFunc(ctx, req) + } + return &jobsv0.PruneReply{}, nil +} + +func (c *fakeClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) error { + if c.removeFunc != nil { + return c.removeFunc(ctx, req) + } + return nil +} + +func (c *fakeClient) Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + if c.waitFunc != nil { + return c.waitFunc(ctx, req) + } + return &jobsv0.WaitReply{}, nil +} + +func (c *fakeClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { + if c.pauseFunc != nil { + return c.pauseFunc(ctx, req) + } + return nil +} + +func (c *fakeClient) Resume(ctx context.Context, req *jobsv0.ResumeRequest) error { + if c.resumeFunc != nil { + return c.resumeFunc(ctx, req) + } + return nil +} + +var _ jobsClient = (*fakeClient)(nil) + +func TestWrapJobsError(t *testing.T) { + plain := errors.New("boom") + + testCases := []struct { + doc string + err error + want string // exact message; empty means the error is returned unchanged + }{ + {doc: "nil"}, + {doc: "non-status error", err: plain}, + { + doc: "unimplemented points at the enable flag", + err: status.Error(codes.Unimplemented, "unknown service"), + want: `the daemon does not support jobs; start dockerd with --extension-enable org.mobyproject.jobs.v0 (or set "extensions.enable" in daemon.json)`, + }, + { + doc: "unavailable is retryable and keeps the daemon message", + err: status.Error(codes.Unavailable, "reconciling runs"), + want: "the jobs API is unavailable (the daemon may be starting or unreachable), retry in a moment: reconciling runs", + }, + {doc: "not found", err: status.Error(codes.NotFound, `job "backup" not found`), want: `job "backup" not found`}, + {doc: "already exists", err: status.Error(codes.AlreadyExists, "name taken"), want: "name taken"}, + {doc: "failed precondition", err: status.Error(codes.FailedPrecondition, "job is already running"), want: "job is already running"}, + {doc: "invalid argument", err: status.Error(codes.InvalidArgument, "bad runs_removal"), want: "bad runs_removal"}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + got := wrapJobsError(tc.err) + + switch { + case tc.err == nil: + assert.NilError(t, got) + case tc.want == "": + assert.Check(t, is.Equal(got, tc.err)) + default: + assert.Error(t, got, tc.want) + } + }) + } +} + +// A cancelled context (Ctrl-C during a blocking call such as `docker job wait`) +// comes back from gRPC as a Canceled status, which the CLI's main only +// silences when it is recognisable as a cancellation. +func TestWrapJobsErrorCanceledIsRecognisedAsCancellation(t *testing.T) { + got := wrapJobsError(status.FromContextError(context.Canceled).Err()) + + assert.Check(t, errdefs.IsCanceled(got), "got %v", got) +} diff --git a/cli/command/job/cmd.go b/cli/command/job/cmd.go new file mode 100644 index 000000000000..f1aada53ae32 --- /dev/null +++ b/cli/command/job/cmd.go @@ -0,0 +1,179 @@ +package job + +import ( + "context" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/internal/commands" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +func init() { + commands.Register(newJobCommand) +} + +// newJobCommand returns a cobra command for `job` subcommands. +func newJobCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "job COMMAND", + Short: "Manage jobs", + Long: "Manage jobs, one-shot or scheduled containers run by the daemon.\n\n" + + "This requires the daemon's jobs extension, which is disabled by default: start\n" + + "dockerd with " + enableJobsFlag + ".", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + Annotations: map[string]string{"version": "1.57"}, + DisableFlagsInUseLine: true, + } + jobs := newLazyJobsClient(dockerCli) + cmd.PersistentPostRunE = func(*cobra.Command, []string) error { + return jobs.Close() + } + cmd.AddCommand( + newListCommand(dockerCli, jobs), + newLogsCommand(dockerCli, jobs), + newRunCommand(dockerCli, jobs), + newRunsCommand(dockerCli, jobs), + newInspectCommand(dockerCli, jobs), + newCancelCommand(dockerCli, jobs), + newPruneCommand(dockerCli, jobs), + newRmCommand(dockerCli, jobs), + newWaitCommand(dockerCli, jobs), + newPauseCommand(dockerCli, jobs), + newUnpauseCommand(dockerCli, jobs), + ) + return cmd +} + +// lazyJobsClient defers newJobsClient(dockerCli) -- and therefore +// dockerCli.Client() -- until a jobsClient method is actually invoked from a +// subcommand's RunE. `docker job` subcommands are registered while the +// cobra command tree is built, which happens before dockerCli.Initialize(); +// dialing eagerly at that point would call dockerCli.Client() too early and +// abort the whole process (Client() calls os.Exit(1) on failure), breaking +// every command, not just `job`. +// +// The dial is memoized: a single command invocation may call more than one +// jobsClient method (e.g. `docker job logs` falling back from Inspect to +// ListRuns), and each should reuse the same connection rather than dialing +// again. newJobCommand closes it after the invoked subcommand's RunE +// succeeds (cobra skips PersistentPostRunE on error, and the process exits +// right after). +type lazyJobsClient struct { + dockerCli command.Cli + + client jobsClient + closer func() error +} + +func newLazyJobsClient(dockerCli command.Cli) *lazyJobsClient { + return &lazyJobsClient{dockerCli: dockerCli} +} + +func (c *lazyJobsClient) resolve() (jobsClient, error) { + if c.client == nil { + client, closer, err := newJobsClient(c.dockerCli) + if err != nil { + return nil, err + } + c.client, c.closer = client, closer + } + return c.client, nil +} + +func (c *lazyJobsClient) Close() error { + if c.closer == nil { + return nil + } + return c.closer() +} + +func (c *lazyJobsClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.List(ctx, req) +} + +func (c *lazyJobsClient) Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Inspect(ctx, req) +} + +func (c *lazyJobsClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.ListRuns(ctx, req) +} + +func (c *lazyJobsClient) Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Run(ctx, req) +} + +func (c *lazyJobsClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.InspectRun(ctx, req) +} + +func (c *lazyJobsClient) Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Cancel(ctx, req) +} + +func (c *lazyJobsClient) Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Prune(ctx, req) +} + +func (c *lazyJobsClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) error { + client, err := c.resolve() + if err != nil { + return err + } + return client.Remove(ctx, req) +} + +func (c *lazyJobsClient) Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Wait(ctx, req) +} + +func (c *lazyJobsClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { + client, err := c.resolve() + if err != nil { + return err + } + return client.Pause(ctx, req) +} + +func (c *lazyJobsClient) Resume(ctx context.Context, req *jobsv0.ResumeRequest) error { + client, err := c.resolve() + if err != nil { + return err + } + return client.Resume(ctx, req) +} diff --git a/cli/command/job/cmd_test.go b/cli/command/job/cmd_test.go new file mode 100644 index 000000000000..3887c6b62968 --- /dev/null +++ b/cli/command/job/cmd_test.go @@ -0,0 +1,19 @@ +package job + +import ( + "testing" + + "github.com/docker/cli/internal/test" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// The jobs extension shipped with Engine API 1.57: the CLI hides the group and +// refuses it with a "requires API version" error on older daemons, instead of +// suggesting an --extension-enable flag they do not have. +func TestNewJobCommandRequiresAPIVersion(t *testing.T) { + cmd := newJobCommand(test.NewFakeCli(nil)) + + assert.Check(t, is.Equal(cmd.Annotations["version"], "1.57")) + assert.Check(t, is.Contains(cmd.Long, "--extension-enable org.mobyproject.jobs.v0")) +} diff --git a/cli/command/job/inspect.go b/cli/command/job/inspect.go new file mode 100644 index 000000000000..ac347464d470 --- /dev/null +++ b/cli/command/job/inspect.go @@ -0,0 +1,96 @@ +// 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" + "encoding/json" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// inspectOptions holds the parsed flags/args of `docker job inspect`. +type inspectOptions struct { + job string + run string +} + +// newInspectCommand wires the `docker job inspect [--run RUN] JOB` cobra +// command. client is injected explicitly, following the same pattern as +// newListCommand/newLogsCommand/newRunCommand (see list.go/logs.go/run.go). +func newInspectCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts inspectOptions + + cmd := &cobra.Command{ + Use: "inspect [OPTIONS] JOB", + Short: "Display detailed information on a job", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runInspect(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.StringVar(&opts.run, "run", "", "Inspect a specific run instead of the job") + return cmd +} + +// jobView is a jobsv0.Job as printed by `docker job inspect`: the container +// spec is a JSON document carried as bytes, so it is shown as that document +// rather than as the base64 string a []byte marshals to. The embedded +// structs' ContainerSpec and Spec fields are shadowed by the ones declared +// here. +type jobView struct { + jobsv0.Job + Spec *specView +} + +type specView struct { + jobsv0.JobSpec + ContainerSpec json.RawMessage +} + +func newJobView(j *jobsv0.Job) *jobView { + if j == nil { + return nil + } + v := &jobView{Job: *j} + if j.Spec != nil { + v.Spec = &specView{JobSpec: *j.Spec, ContainerSpec: j.Spec.ContainerSpec} + } + return v +} + +// runInspect prints the jobsv0.Job (or jobsv0.Run, with opts.run set) as +// indented JSON on dockerCli.Out() -- a single object, not wrapped in an +// array, unlike the generic cli/command/inspect package. +func runInspect(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *inspectOptions) error { + var v any + if opts.run != "" { + reply, err := client.InspectRun(ctx, &jobsv0.InspectRunRequest{JobRef: opts.job, RunRef: opts.run}) + if err != nil { + return wrapJobsError(err) + } + v = reply.Run + } else { + reply, err := client.Inspect(ctx, &jobsv0.InspectRequest{JobRef: opts.job}) + if err != nil { + return wrapJobsError(err) + } + v = newJobView(reply.Job) + } + + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + _, err = dockerCli.Out().Write(append(out, '\n')) + return err +} diff --git a/cli/command/job/inspect_test.go b/cli/command/job/inspect_test.go new file mode 100644 index 000000000000..f0df57cf63d9 --- /dev/null +++ b/cli/command/job/inspect_test.go @@ -0,0 +1,284 @@ +package job + +import ( + "context" + "encoding/json" + "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 format: `docker job inspect` dumps the raw jobsv0.Job (or +// jobsv0.Run, with --run) as indented JSON on dockerCli.Out(), 2-space +// indent, one object -- not wrapped in an array, unlike the generic +// cli/command/inspect package (which serves multi-reference --format/ +// template inspection across several docker CLI resources and is not a good +// fit for this single-object, template-less case). Tests assert on decoded +// JSON fields rather than the exact byte layout, to stay robust to +// formatting-only changes. + +func TestNewInspectCommandWithoutRunCallsInspectOnly(t *testing.T) { + var gotReq *jobsv0.InspectRequest + client := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + gotReq = req + return &jobsv0.InspectReply{Job: &jobsv0.Job{ID: "job-1", Name: "backup"}}, nil + }, + inspectRunFunc: func(context.Context, *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + t.Fatal("client.InspectRun must not be called without --run") + return nil, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Inspect was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + + var got jobsv0.Job + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.ID, "job-1")) + assert.Check(t, is.Equal(got.Name, "backup")) +} + +func TestNewInspectCommandWithRunCallsInspectRunOnly(t *testing.T) { + var gotReq *jobsv0.InspectRunRequest + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + t.Fatal("client.Inspect must not be called with --run") + return nil, nil + }, + inspectRunFunc: func(_ context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + gotReq = req + return &jobsv0.InspectRunReply{Run: &jobsv0.Run{ID: "run-1", JobID: "job-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"--run", "latest", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.InspectRun was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "latest")) + + var got jobsv0.Run + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.ID, "run-1")) + assert.Check(t, is.Equal(got.JobID, "job-1")) +} + +func TestNewInspectCommandWithArbitraryRunRefForwarded(t *testing.T) { + var gotReq *jobsv0.InspectRunRequest + client := &fakeClient{ + inspectRunFunc: func(_ context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + gotReq = req + return &jobsv0.InspectRunReply{Run: &jobsv0.Run{ID: "run-42"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"--run", "run-42", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(gotReq.RunRef, "run-42")) +} + +func TestNewInspectCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewInspectCommandUnimplementedOnInspectIsActionable(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(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 TestNewInspectCommandUnimplementedOnInspectRunIsActionable(t *testing.T) { + client := &fakeClient{ + inspectRunFunc: func(context.Context, *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"--run", "latest", "backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + 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 TestNewInspectCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + testCases := []struct { + doc string + run string + errText string + }{ + {doc: "job not found, without --run", errText: `job "backup" not found`}, + {doc: "run not found, with --run", run: "latest", errText: `run "latest" not found for job "backup"`}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return nil, errors.New(tc.errText) + }, + inspectRunFunc: func(context.Context, *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + return nil, errors.New(tc.errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + args := []string{"backup"} + if tc.run != "" { + args = append([]string{"--run", tc.run}, args...) + } + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, tc.errText) + }) + } +} + +// TestRunInspectDirect exercises runInspect directly (bypassing cobra arg +// parsing), following the pattern set by run_test.go/logs_test.go of testing +// the RunE body as a plain function in addition to going through the cobra +// command. +func TestRunInspectDirect(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ID: "job-1", Name: req.JobRef}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + var got jobsv0.Job + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.Name, "backup")) +} + +func TestRunInspectDirectWithRun(t *testing.T) { + client := &fakeClient{ + inspectRunFunc: func(_ context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + return &jobsv0.InspectRunReply{Run: &jobsv0.Run{ID: "run-1", JobID: req.JobRef}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup", run: "latest"}) + + assert.NilError(t, err) + var got jobsv0.Run + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.ID, "run-1")) + assert.Check(t, is.Equal(got.JobID, "backup")) +} + +// The container spec is a JSON document carried as bytes: it must be printed +// as that document, not as the base64 string a []byte marshals to. +func TestRunInspectPrintsContainerSpecAsJSON(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + ID: "job-1", + Name: "backup", + Spec: &jobsv0.JobSpec{ + ContainerSpec: []byte(`{"Image":"busybox","Cmd":["echo","hi"]}`), + TimeoutSeconds: 30, + }, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + var got struct { + Name string + Spec struct { + ContainerSpec map[string]any + TimeoutSeconds int64 + } + } + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got), "output: %s", cli.OutBuffer()) + assert.Check(t, is.Equal(got.Name, "backup")) + assert.Check(t, is.Equal(got.Spec.ContainerSpec["Image"], "busybox")) + assert.Check(t, is.Equal(got.Spec.TimeoutSeconds, int64(30)), "the other spec fields must be kept") +} + +func TestRunInspectJobWithoutSpec(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ID: "job-1", Name: "backup"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, strings.Contains(cli.OutBuffer().String(), `"Spec": null`), "output: %s", cli.OutBuffer()) +} + +// A reply without a job used to be printed as null. Building the view of the +// job must not turn that into a panic. +func TestRunInspectReplyWithoutJob(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "null\n")) +} diff --git a/cli/command/job/list.go b/cli/command/job/list.go new file mode 100644 index 000000000000..ddc1b16206e2 --- /dev/null +++ b/cli/command/job/list.go @@ -0,0 +1,128 @@ +package job + +import ( + "context" + "fmt" + "sort" + "strconv" + "time" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter/tabwriter" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + units "github.com/docker/go-units" + "github.com/spf13/cobra" +) + +// jobRow is one formatted row of `docker job ls`, matching the columns +// NAME TRIGGER STATE PAUSED LAST RUN EXIT NEXT FIRE. +type jobRow struct { + Name string + Trigger string + State string + Paused string + LastRun string + Exit string + NextFire string +} + +// newJobRow formats j into a jobRow. now is the reference time used to +// humanize durations (LAST RUN age, NEXT FIRE delay) with +// github.com/docker/go-units HumanDuration; production code passes +// time.Now(), tests inject a fixed value to stay deterministic. +func newJobRow(j jobsv0.Job, now time.Time) jobRow { + row := jobRow{ + Name: j.Name, + Trigger: jobTrigger(j), + State: j.State, + Paused: strconv.FormatBool(j.Paused), + LastRun: "-", + Exit: "-", + NextFire: "-", + } + + if j.LatestRun != nil { + row.LastRun = fmt.Sprintf("%s (%s ago)", j.LatestRun.State, units.HumanDuration(now.Sub(runAge(j.LatestRun)))) + if j.LatestRun.ExitCode != nil { + row.Exit = strconv.FormatInt(j.LatestRun.ExitCode.Value, 10) + } + } + + if !j.Paused && j.NextFireAtNano != 0 { + row.NextFire = "in " + units.HumanDuration(time.Unix(0, j.NextFireAtNano).Sub(now)) + } + + return row +} + +// jobTrigger returns the manual shorthand unless the job declares a schedule +// trigger, in which case it returns the raw cron expression. +func jobTrigger(j jobsv0.Job) string { + if isScheduled(&j) { + return j.Spec.Trigger.Schedule.Cron + } + return jobsv0.TriggerKindManual +} + +// isScheduled reports whether j declares a schedule trigger, as opposed to +// the manual shorthand. +func isScheduled(j *jobsv0.Job) bool { + return j.Spec != nil && j.Spec.Trigger != nil && j.Spec.Trigger.Schedule != nil +} + +// runAge returns the timestamp to compute a run's age from, in priority +// order: FinishedAtNano, StartedAtNano, CreatedAtNano. +func runAge(run *jobsv0.Run) time.Time { + nano := run.FinishedAtNano + if nano == 0 { + nano = run.StartedAtNano + } + if nano == 0 { + nano = run.CreatedAtNano + } + return time.Unix(0, nano) +} + +// newListCommand wires the `docker job ls` cobra command. client is injected +// explicitly (rather than dialed from dockerCli internally) so that it can be +// swapped for a fake in tests without touching gRPC; the real client is the +// lazy one built by the `docker job` parent command (see cmd.go) and threaded +// down to each subcommand constructor. +func newListCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + return &cobra.Command{ + Use: "ls [OPTIONS]", + Aliases: []string{"list"}, + Short: "List jobs", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runList(cmd.Context(), dockerCli, client) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } +} + +// runList calls client.List, sorts the result by Name, and renders it as a +// tabwriter table to dockerCli.Out(). +func runList(ctx context.Context, dockerCli command.Cli, client jobsClient) error { + reply, err := client.List(ctx, &jobsv0.ListRequest{}) + if err != nil { + return wrapJobsError(err) + } + + jobs := reply.Jobs + sort.Slice(jobs, func(i, j int) bool { + return jobs[i].Name < jobs[j].Name + }) + + now := time.Now() + w := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tTRIGGER\tSTATE\tPAUSED\tLAST RUN\tEXIT\tNEXT FIRE") + for _, j := range jobs { + row := newJobRow(j, now) + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row.Name, row.Trigger, row.State, row.Paused, row.LastRun, row.Exit, row.NextFire) + } + return w.Flush() +} diff --git a/cli/command/job/list_test.go b/cli/command/job/list_test.go new file mode 100644 index 000000000000..866cdd7a0ef6 --- /dev/null +++ b/cli/command/job/list_test.go @@ -0,0 +1,383 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "github.com/docker/go-units" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// jobsHeaderColumns is the exact header line expected on `docker job ls`: NAME TRIGGER STATE PAUSED LAST RUN EXIT NEXT FIRE. +var jobsHeaderColumns = []string{"NAME", "TRIGGER", "STATE", "PAUSED", "LAST RUN", "EXIT", "NEXT FIRE"} + +func TestNewJobRow(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + + testCases := []struct { + doc string + job jobsv0.Job + expected jobRow + }{ + { + doc: "manual job, never run", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{Manual: true}, + }, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "nil Spec is the manual shorthand", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: nil, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "nil Trigger on a Spec is also the manual shorthand", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: nil}, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "cron job, armed, not paused", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{ + Schedule: &jobsv0.ScheduleTrigger{Cron: "*/5 * * * *"}, + }, + }, + NextFireAtNano: now.Add(5 * time.Minute).UnixNano(), + }, + expected: jobRow{ + Name: "backup", + Trigger: "*/5 * * * *", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "in 5 minutes", + }, + }, + { + doc: "paused cron job with a stale NextFireAtNano must show NEXT FIRE -, not a computed value", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Paused: true, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{ + Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}, + }, + }, + // Residual/stale value from before the job was paused: must + // be ignored, not rendered. + NextFireAtNano: now.Add(3 * time.Minute).UnixNano(), + }, + expected: jobRow{ + Name: "backup", + Trigger: "* * * * *", + State: "idle", + Paused: "true", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "unpaused cron job currently running has NextFireAtNano == 0 -> NEXT FIRE -", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateRunning, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{ + Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}, + }, + }, + NextFireAtNano: 0, + }, + expected: jobRow{ + Name: "backup", + Trigger: "* * * * *", + State: "running", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "job never ran: LAST RUN and EXIT are both -", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: nil, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "run in flight: ExitCode nil -> EXIT -, LAST RUN uses StartedAtNano since FinishedAtNano is zero", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateRunning, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateRunning, + StartedAtNano: now.Add(-30 * time.Second).UnixNano(), + ExitCode: nil, + }, + }, + expected: jobRow{ + Name: "backup", + Trigger: "manual", + State: "running", + Paused: "false", + LastRun: "running (30 seconds ago)", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "successful run with exit code zero: EXIT must be 0, NOT - (no-exit-code vs exit-code-zero trap)", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + FinishedAtNano: now.Add(-2 * time.Minute).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, + expected: jobRow{ + Name: "backup", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "succeeded (2 minutes ago)", + Exit: "0", + NextFire: "-", + }, + }, + { + doc: "failed run with a nonzero exit code", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateFailed, + FinishedAtNano: now.Add(-1 * time.Hour).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 137}, + }, + }, + expected: jobRow{ + Name: "backup", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "failed (About an hour ago)", + Exit: "137", + NextFire: "-", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + row := newJobRow(tc.job, now) + assert.Check(t, is.DeepEqual(row, tc.expected)) + }) + } +} + +// TestNewJobRowNextFireUsesHumanDuration guards against hardcoding a +// human-readable string that only happens to match go-units' current +// implementation: it recomputes the expected string through +// units.HumanDuration itself, so the test tracks the library rather than a +// magic string. +func TestNewJobRowNextFireUsesHumanDuration(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + delay := 42 * time.Second + j := jobsv0.Job{ + Name: "backup", + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}}, + }, + NextFireAtNano: now.Add(delay).UnixNano(), + } + + row := newJobRow(j, now) + + assert.Check(t, is.Equal(row.NextFire, "in "+units.HumanDuration(delay))) +} + +func TestNewListCommandSortsByName(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return &jobsv0.ListReply{Jobs: []jobsv0.Job{ + {Name: "zeta", Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}}, + {Name: "alpha", Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}}, + {Name: "mike", Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}}, + }}, nil + }, + } + + cmd := newListCommand(cli, client) + cmd.SetOut(io.Discard) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + iAlpha := strings.Index(out, "alpha") + iMike := strings.Index(out, "mike") + iZeta := strings.Index(out, "zeta") + assert.Check(t, iAlpha >= 0 && iMike >= 0 && iZeta >= 0, "all three job names must appear in output:\n%s", out) + assert.Check(t, iAlpha < iMike, "expected alpha before mike:\n%s", out) + assert.Check(t, iMike < iZeta, "expected mike before zeta:\n%s", out) +} + +func TestNewListCommandHeader(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{} + + cmd := newListCommand(cli, client) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + for _, col := range jobsHeaderColumns { + assert.Check(t, strings.Contains(out, col), "expected header column %q in output:\n%s", col, out) + } +} + +func TestNewListCommandRendersJobFields(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return &jobsv0.ListReply{Jobs: []jobsv0.Job{ + { + Name: "backup", + State: jobsv0.JobStateIdle, + Paused: true, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}}, + }, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, + }}, nil + }, + } + + cmd := newListCommand(cli, client) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, is.Contains(out, "backup")) + assert.Check(t, is.Contains(out, "* * * * *")) + assert.Check(t, is.Contains(out, "idle")) + assert.Check(t, is.Contains(out, "true")) // Paused column + assert.Check(t, is.Contains(out, "succeeded")) + // The exit code column must show the literal 0, not a "-" placeholder. + assert.Check(t, hasTableCell(out, "0"), "expected a table cell with the exit code 0, not '-':\n%s", out) +} + +func TestNewListCommandUnimplementedIsActionable(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + + cmd := newListCommand(cli, client) + 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 TestNewListCommandOtherErrorsPassThrough(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return nil, errors.New("connection refused") + }, + } + + cmd := newListCommand(cli, client) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "connection refused") +} + +// hasTableCell reports whether out contains a whitespace-delimited cell +// equal to want. It exists because a plain strings.Contains(out, "0") would +// also match inside unrelated substrings (job names, cron fields, etc.). +func hasTableCell(out, want string) bool { + for _, field := range strings.Fields(out) { + if field == want { + return true + } + } + return false +} diff --git a/cli/command/job/logs.go b/cli/command/job/logs.go new file mode 100644 index 000000000000..86859f91e61d --- /dev/null +++ b/cli/command/job/logs.go @@ -0,0 +1,211 @@ +package job + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/moby/moby/api/pkg/stdcopy" + dockerclient "github.com/moby/moby/client" + "github.com/spf13/cobra" +) + +// pollInterval is how often waitForNextRun polls the job for a new run while +// following logs across cron occurrences. +const pollInterval = 2 * time.Second + +// logsOptions holds the parsed flags/args of `docker job logs`. +type logsOptions struct { + follow bool + job string +} + +// newLogsCommand wires the `docker job logs [-f] JOB` cobra command. client +// is injected explicitly, following the same pattern as newListCommand (see +// list.go). +func newLogsCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts logsOptions + + cmd := &cobra.Command{ + Use: "logs [OPTIONS] JOB", + Short: "Fetch the logs of a job's most recent run", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runLogs(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.BoolVarP(&opts.follow, "follow", "f", false, "Follow log output") + return cmd +} + +// runLogs resolves opts.job through client.Inspect, picks the run to read +// logs from via resolveLogsRun -- calling client.ListRuns as its listRuns +// fallback when needed -- then streams its container's logs via +// streamRunLogs. When opts.follow is set and the run's container exits +// (streamRunLogs returns after a clean EOF), runLogs polls the job via +// waitForNextRun until the next cron occurrence starts and re-attaches to +// it, so `docker job logs -f` keeps following across runs instead of +// exiting. A context cancellation (e.g. Ctrl-C) while waiting for the next +// run ends the loop cleanly, with no error surfaced to the user. +func runLogs(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *logsOptions) error { + inspectJob := func() (*jobsv0.Job, error) { + reply, err := client.Inspect(ctx, &jobsv0.InspectRequest{JobRef: opts.job}) + if err != nil { + return nil, err + } + return reply.Job, nil + } + + job, err := inspectJob() + if err != nil { + return wrapJobsError(err) + } + + run, err := resolveLogsRun(job, func() (*jobsv0.ListRunsReply, error) { + return client.ListRuns(ctx, &jobsv0.ListRunsRequest{JobRef: opts.job}) + }) + if err != nil { + return wrapJobsError(err) + } + if run.ID != job.LatestRun.ID { + _, _ = fmt.Fprintf(dockerCli.Err(), "Note: the container of the latest run %s is not available; showing the logs of run %s\n", job.LatestRun.ID, run.ID) + } + + // Only a schedule job can produce a next run on its own. + followNext := opts.follow && isScheduled(job) + for { + if err := streamRunLogs(ctx, dockerCli, run.ContainerID, opts.follow); err != nil { + return err + } + if !followNext { + return nil + } + + run, err = waitForNextRun(ctx, run.ID, inspectJob, sleepOrCancel) + if err != nil { + if ctx.Err() != nil { + return nil + } + return wrapJobsError(err) + } + } +} + +// streamRunLogs attaches to containerID's logs and demultiplexes them onto +// dockerCli.Out()/dockerCli.Err(), following the pattern of +// cli/command/container/logs.go's runLogs. It returns nil once the log +// stream ends with a clean EOF. +func streamRunLogs(ctx context.Context, dockerCli command.Cli, containerID string, follow bool) error { + c, err := dockerCli.Client().ContainerInspect(ctx, containerID, dockerclient.ContainerInspectOptions{}) + if errdefs.IsNotFound(err) { + // The container was removed (history eviction, job removal) after + // the run was looked up. + return errors.New("logs are no longer available: the run's container was removed") + } + if err != nil { + return err + } + + resp, err := dockerCli.Client().ContainerLogs(ctx, containerID, dockerclient.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + }) + if err != nil { + return err + } + defer func() { _ = resp.Close() }() + + if c.Container.Config.Tty { + _, err = io.Copy(dockerCli.Out(), resp) + } else { + _, err = stdcopy.StdCopy(dockerCli.Out(), dockerCli.Err(), resp) + } + return err +} + +// waitForNextRun polls inspect() until job.LatestRun's ID differs from +// currentRunID and its logs can be read (see hasContainer), returning that new +// run. A run is visible before its container is created (and never gets one if +// creation fails), and a fast job's container may already be gone by the time +// it is noticed: such runs are skipped rather than returned. +// +// It checks ctx.Err() before every poll so an already-cancelled (or +// cancelled-while-sleeping) context returns immediately, and tolerates +// job.LatestRun being nil (a job that hasn't run again yet) by continuing to +// poll instead of panicking. sleep is called between polls and its error +// (e.g. context cancellation) is propagated. +func waitForNextRun(ctx context.Context, currentRunID string, inspect func() (*jobsv0.Job, error), sleep func(context.Context) error) (*jobsv0.Run, error) { + for { + if err := ctx.Err(); err != nil { + return nil, err + } + + job, err := inspect() + if err != nil { + return nil, err + } + + if job.LatestRun != nil && job.LatestRun.ID != currentRunID && hasContainer(job.LatestRun) { + return job.LatestRun, nil + } + + if err := sleep(ctx); err != nil { + return nil, err + } + } +} + +// sleepOrCancel is waitForNextRun's production sleep: it waits pollInterval +// between polls, returning early with ctx.Err() if the context is cancelled +// first. +func sleepOrCancel(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + return nil + } +} + +// resolveLogsRun picks the run whose container `docker job logs` should read +// from: job.LatestRun when its logs can be read, otherwise the most recent run +// of the job's history (via listRuns, called lazily) that still has a +// container. job is the already-inspected job; this function does no I/O of +// its own. +func resolveLogsRun(job *jobsv0.Job, listRuns func() (*jobsv0.ListRunsReply, error)) (*jobsv0.Run, error) { + if job.LatestRun == nil { + return nil, fmt.Errorf("job %q has never run", job.Name) + } + if hasContainer(job.LatestRun) { + return job.LatestRun, nil + } + + reply, err := listRuns() + if err != nil { + return nil, err + } + for i := range reply.Runs { + if hasContainer(&reply.Runs[i]) { + return &reply.Runs[i], nil + } + } + return nil, fmt.Errorf("no logs available for job %q", job.Name) +} + +// hasContainer reports whether run's logs can still be read: its container +// has been created and not removed. +func hasContainer(run *jobsv0.Run) bool { + return run.ContainerID != "" && !run.ContainerGone +} diff --git a/cli/command/job/logs_followswitch_test.go b/cli/command/job/logs_followswitch_test.go new file mode 100644 index 000000000000..05080dd26566 --- /dev/null +++ b/cli/command/job/logs_followswitch_test.go @@ -0,0 +1,471 @@ +package job + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/containerd/errdefs" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + dockerclient "github.com/moby/moby/client" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// This file covers the `docker job logs -f` follow-switch behavior: once the +// container backing the followed run exits (ContainerLogs' stream ends with +// a clean EOF), `docker job logs -f` on a schedule job polls the job until a +// new run appears and re-attaches to it, instead of exiting. +// +// waitForNextRun takes an injectable sleep/inspect so its tests run in +// milliseconds with zero real time.Sleep. + +// ----------------------------------------------------------------------- +// streamRunLogs +// ----------------------------------------------------------------------- + +func TestStreamRunLogsWritesTTYOutputToStdout(t *testing.T) { + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + assert.Check(t, is.Equal(containerID, "abc123")) + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, _ dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + assert.Check(t, is.Equal(containerID, "abc123")) + return mockJobContainerLogsResult("hello\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "hello\n")) +} + +func TestStreamRunLogsForwardsFollowFlag(t *testing.T) { + var gotFollow bool + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(_ string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + gotFollow = opts.Follow + return mockJobContainerLogsResult(""), nil + }, + } + cli := test.NewFakeCli(docker) + + assert.NilError(t, streamRunLogs(context.Background(), cli, "abc123", true)) + assert.Check(t, gotFollow) +} + +func TestStreamRunLogsPropagatesContainerInspectError(t *testing.T) { + wantErr := errors.New("boom") + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{}, wantErr + }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + t.Fatal("ContainerLogs must not be called when ContainerInspect fails") + return nil, nil + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.ErrorIs(t, err, wantErr) +} + +func TestStreamRunLogsPropagatesContainerLogsError(t *testing.T) { + wantErr := errors.New("boom") + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return nil, wantErr + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.ErrorIs(t, err, wantErr) +} + +// ----------------------------------------------------------------------- +// waitForNextRun +// ----------------------------------------------------------------------- + +func TestWaitForNextRunReturnsImmediatelyWhenRunAlreadyChanged(t *testing.T) { + inspectCalls := 0 + inspect := func() (*jobsv0.Job, error) { + inspectCalls++ + return &jobsv0.Job{LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}}, nil + } + sleepCalls := 0 + sleep := func(context.Context) error { sleepCalls++; return nil } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-2")) + assert.Check(t, is.Equal(inspectCalls, 1)) + assert.Check(t, is.Equal(sleepCalls, 0), "sleep must not be called when the run already changed on the first poll") +} + +func TestWaitForNextRunPollsUntilRunChanges(t *testing.T) { + jobs := []*jobsv0.Job{ + {LatestRun: &jobsv0.Run{ID: "run-1"}}, + {LatestRun: &jobsv0.Run{ID: "run-1"}}, + {LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}}, + } + inspectCalls := 0 + inspect := func() (*jobsv0.Job, error) { + job := jobs[inspectCalls] + inspectCalls++ + return job, nil + } + sleepCalls := 0 + sleep := func(context.Context) error { sleepCalls++; return nil } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-2")) + assert.Check(t, is.Equal(inspectCalls, 3)) + assert.Check(t, is.Equal(sleepCalls, 2)) +} + +func TestWaitForNextRunReturnsImmediatelyWhenContextAlreadyCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + inspectCalled := false + inspect := func() (*jobsv0.Job, error) { + inspectCalled = true + return &jobsv0.Job{}, nil + } + sleep := func(context.Context) error { + t.Fatal("sleep must not be called when the context is already cancelled") + return nil + } + + run, err := waitForNextRun(ctx, "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, context.Canceled) + assert.Check(t, !inspectCalled, "inspect must not be called when the context is already cancelled") +} + +func TestWaitForNextRunPropagatesCancellationDuringSleep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inspect := func() (*jobsv0.Job, error) { + return &jobsv0.Job{LatestRun: &jobsv0.Run{ID: "run-1"}}, nil + } + sleep := func(ctx context.Context) error { + cancel() + return ctx.Err() + } + + run, err := waitForNextRun(ctx, "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestWaitForNextRunPropagatesInspectError(t *testing.T) { + wantErr := errors.New("connection refused") + inspect := func() (*jobsv0.Job, error) { return nil, wantErr } + sleep := func(context.Context) error { + t.Fatal("sleep must not be called when inspect fails") + return nil + } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, wantErr) +} + +func TestWaitForNextRunKeepsPollingWhenLatestRunIsNil(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inspectCalls := 0 + inspect := func() (*jobsv0.Job, error) { + inspectCalls++ + // A job that never ran again yet (e.g. a manually-triggered job) + // reports no LatestRun; polling must not panic on the nil deref + // and must keep going. + return &jobsv0.Job{LatestRun: nil}, nil + } + sleepCalls := 0 + sleep := func(ctx context.Context) error { + sleepCalls++ + if sleepCalls >= 3 { + cancel() + return ctx.Err() + } + return nil + } + + run, err := waitForNextRun(ctx, "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, context.Canceled) + assert.Check(t, inspectCalls >= 3, "expected polling to continue while LatestRun is nil, got %d inspect calls", inspectCalls) +} + +// ----------------------------------------------------------------------- +// runLogs: follow-switch integration +// ----------------------------------------------------------------------- + +func TestRunLogsWithoutFollowDoesNotPollForNextRun(t *testing.T) { + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + atomic.AddInt32(&inspectCalls, 1) + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(context.Background(), cli, jobs, &logsOptions{job: "backup", follow: false}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(1))) +} + +// TestRunLogsFollowSwitchesToNextRunOnContainerStop drives two full cron +// occurrences through runLogs(follow=true): run-1's container exits (clean +// ContainerLogs EOF), runLogs polls the job and re-attaches to run-2 as soon +// as it appears, then run-2's container also exits and the context is +// cancelled (simulating Ctrl-C) so the otherwise-infinite follow loop +// terminates. +func TestRunLogsFollowSwitchesToNextRunOnContainerStop(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + switch atomic.AddInt32(&inspectCalls, 1) { + case 1: + // Initial resolution in runLogs, before the first attach. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + case 2: + // First waitForNextRun poll, after run-1's container exits: + // run-2 already exists, switch immediately. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}, + }}, nil + default: + // Second waitForNextRun poll, after run-2's container + // exits: no new run yet. Cancel the context here to end + // the test the same way a Ctrl-C would. + cancel() + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}, + }}, nil + } + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + assert.Check(t, opts.Follow, "runLogs must request Follow on the docker API when opts.follow is set") + switch containerID { + case "c1": + return mockJobContainerLogsResult("[backup] run-1 done\n"), nil + case "c2": + return mockJobContainerLogsResult("[backup] run-2 done\n"), nil + default: + t.Fatalf("unexpected containerID %q", containerID) + return nil, nil + } + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(ctx, cli, jobs, &logsOptions{job: "backup", follow: true}) + + assert.NilError(t, err, "runLogs must exit cleanly (nil error) when the context is cancelled while waiting for the next run") + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] run-1 done\n[backup] run-2 done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(3))) +} + +// TestRunLogsFollowSkipsRunsWhoseContainerIsAlreadyGone covers the case where +// waitForNextRun picks up a new run whose container has already been removed +// (e.g. a fast job cleaned up right after a successful exit -- observed for +// real against a daemon where Compose removes successful run containers). +// runLogs must keep polling for the run after it instead of trying (and +// failing) to attach to the gone container. +func TestRunLogsFollowSkipsRunsWhoseContainerIsAlreadyGone(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + switch atomic.AddInt32(&inspectCalls, 1) { + case 1: + // Initial resolution in runLogs, before the first attach. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + case 2: + // First waitForNextRun poll: run-2 exists but its container + // is already gone -- runLogs must NOT attach to it, and + // must NOT surface an error either; it keeps polling. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "should-not-be-attached", ContainerGone: true}, + }}, nil + case 3: + // Second poll: run-3 has a live container, switch to it. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-3", ContainerID: "c3"}, + }}, nil + default: + // Third poll, after run-3's container exits: no new run + // yet. Cancel the context here to end the test the same + // way a Ctrl-C would. + cancel() + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-3", ContainerID: "c3"}, + }}, nil + } + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + if containerID == "should-not-be-attached" { + t.Fatalf("ContainerInspect must not be called for a run whose container is already gone") + } + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + if containerID == "should-not-be-attached" { + t.Fatalf("ContainerLogs must not be called for a run whose container is already gone") + } + switch containerID { + case "c1": + return mockJobContainerLogsResult("[backup] run-1 done\n"), nil + case "c3": + return mockJobContainerLogsResult("[backup] run-3 done\n"), nil + default: + t.Fatalf("unexpected containerID %q", containerID) + return nil, nil + } + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(ctx, cli, jobs, &logsOptions{job: "backup", follow: true}) + + assert.NilError(t, err, "runLogs must exit cleanly (nil error) when the context is cancelled while waiting for the next run") + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] run-1 done\n[backup] run-3 done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(4))) +} + +// scheduledSpec is the spec of a job with a cron schedule, the only kind of +// job `logs -f` can follow past the end of a run. +func scheduledSpec() *jobsv0.JobSpec { + return &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}}} +} + +// A manual job only runs on an explicit `docker job run`: once its run's +// container exits, `logs -f` has nothing left to follow and must return +// instead of polling forever. +func TestRunLogsFollowReturnsAfterStreamForManualJob(t *testing.T) { + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + atomic.AddInt32(&inspectCalls, 1) + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(context.Background(), cli, jobs, &logsOptions{job: "backup", follow: true}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(1)), "a manual job must not be polled for a next run") +} + +// A new run is visible as soon as its record is written, before its container +// exists. Attaching then would fail on an empty container ID, so the next run +// is only picked up once its container does exist. +func TestWaitForNextRunWaitsForTheRunContainer(t *testing.T) { + polls := []*jobsv0.Run{ + {ID: "run-2", State: jobsv0.RunStatePending}, + {ID: "run-2", State: jobsv0.RunStateRunning, ContainerID: "c2"}, + } + i := 0 + inspect := func() (*jobsv0.Job, error) { + run := polls[i] + i++ + return &jobsv0.Job{LatestRun: run}, nil + } + sleepCalls := 0 + sleep := func(context.Context) error { sleepCalls++; return nil } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-2")) + assert.Check(t, is.Equal(run.ContainerID, "c2")) + assert.Check(t, is.Equal(sleepCalls, 1)) +} + +func TestStreamRunLogsReportsRemovedContainerAsUnavailableLogs(t *testing.T) { + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{}, errdefs.ErrNotFound.WithMessage("No such container: abc123") + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.ErrorContains(t, err, "logs are no longer available") +} diff --git a/cli/command/job/logs_resolve_test.go b/cli/command/job/logs_resolve_test.go new file mode 100644 index 000000000000..d1ab3e4cb49c --- /dev/null +++ b/cli/command/job/logs_resolve_test.go @@ -0,0 +1,134 @@ +package job + +import ( + "errors" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestResolveLogsRunNeverRun(t *testing.T) { + job := &jobsv0.Job{Name: "migrate", LatestRun: nil} + fallbackCalled := false + listRuns := func() (*jobsv0.ListRunsReply, error) { + fallbackCalled = true + return &jobsv0.ListRunsReply{}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `job "migrate" has never run`) + assert.Check(t, !fallbackCalled, "listRuns must not be called when the job never ran") +} + +func TestResolveLogsRunReturnsLatestRunWhenContainerPresent(t *testing.T) { + latest := &jobsv0.Run{ID: "run-3", ContainerID: "abc123", ContainerGone: false} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + fallbackCalled := false + listRuns := func() (*jobsv0.ListRunsReply, error) { + fallbackCalled = true + return &jobsv0.ListRunsReply{}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-3")) + assert.Check(t, is.Equal(run.ContainerID, "abc123")) + assert.Check(t, !fallbackCalled, "listRuns must not be called when LatestRun's container is not gone") +} + +func TestResolveLogsRunFallsBackToListRunsWhenContainerGone(t *testing.T) { + latest := &jobsv0.Run{ID: "run-3", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + // Most recent first, matching ListRuns's documented ordering: + // run-3 (the gone latest run) is included in history too, run-2's + // container was also removed, run-1's container is still around. + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-3", ContainerGone: true}, + {ID: "run-2", ContainerGone: true}, + {ID: "run-1", ContainerID: "def456", ContainerGone: false}, + }}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-1")) + assert.Check(t, is.Equal(run.ContainerID, "def456")) +} + +func TestResolveLogsRunNoLogsAvailableWhenNoRunHasContainer(t *testing.T) { + latest := &jobsv0.Run{ID: "run-2", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-2", ContainerGone: true}, + {ID: "run-1", ContainerGone: true}, + }}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} + +func TestResolveLogsRunNoLogsAvailableWhenHistoryEmpty(t *testing.T) { + latest := &jobsv0.Run{ID: "run-1", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} + +func TestResolveLogsRunPropagatesListRunsError(t *testing.T) { + latest := &jobsv0.Run{ID: "run-1", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return nil, errors.New("connection refused") + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, "connection refused") +} + +func TestResolveLogsRunFallsBackWhenLatestRunHasNoContainerYet(t *testing.T) { + // A pending run, or one that failed before its container was created, has + // neither a ContainerID nor ContainerGone set. + job := &jobsv0.Job{Name: "backup", LatestRun: &jobsv0.Run{ID: "run-2", State: jobsv0.RunStateFailed, Error: "no such image"}} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-2", State: jobsv0.RunStateFailed}, + {ID: "run-1", ContainerID: "def456"}, + }}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-1")) +} + +func TestResolveLogsRunNoLogsAvailableWhenNoRunEverHadContainer(t *testing.T) { + job := &jobsv0.Job{Name: "backup", LatestRun: &jobsv0.Run{ID: "run-1", State: jobsv0.RunStatePending}} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{{ID: "run-1", State: jobsv0.RunStatePending}}}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} diff --git a/cli/command/job/logs_test.go b/cli/command/job/logs_test.go new file mode 100644 index 000000000000..596b9d703148 --- /dev/null +++ b/cli/command/job/logs_test.go @@ -0,0 +1,316 @@ +package job + +import ( + "context" + "io" + "strings" + "testing" + "time" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "github.com/moby/moby/api/types/container" + dockerclient "github.com/moby/moby/client" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// fakeDockerAPIClient is a test double for the subset of client.APIClient +// consumed by `docker job logs` (ContainerInspect, ContainerLogs), following +// the same embedding pattern as cli/command/container's fakeClient: embed +// the zero-value concrete dockerclient.Client (which satisfies +// client.APIClient) and override only what the command under test calls. +type fakeDockerAPIClient struct { + dockerclient.Client + inspectFunc func(containerID string) (dockerclient.ContainerInspectResult, error) + logFunc func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) +} + +func (f *fakeDockerAPIClient) ContainerInspect(_ context.Context, containerID string, _ dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) { + if f.inspectFunc != nil { + return f.inspectFunc(containerID) + } + return dockerclient.ContainerInspectResult{}, nil +} + +func (f *fakeDockerAPIClient) ContainerLogs(_ context.Context, containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + if f.logFunc != nil { + return f.logFunc(containerID, opts) + } + return mockJobContainerLogsResult(""), nil +} + +func mockJobContainerLogsResult(content string) dockerclient.ContainerLogsResult { + return io.NopCloser(strings.NewReader(content)) +} + +// ttyInspectResult is a ContainerInspect result for a TTY container, the +// only demux path exercised here -- see the FIXME in +// cli/command/container/logs_test.go about mocking the non-TTY stdcopy +// stream; this test suite follows the same simplification. +func ttyInspectResult() dockerclient.ContainerInspectResult { + return dockerclient.ContainerInspectResult{ + Container: container.InspectResponse{ + Config: &container.Config{Tty: true}, + }, + } +} + +func TestNewLogsCommandFollowFlagForwarded(t *testing.T) { + testCases := []struct { + doc string + args []string + wantFollow bool + }{ + {doc: "long flag", args: []string{"backup", "--follow"}, wantFollow: true}, + {doc: "short flag", args: []string{"backup", "-f"}, wantFollow: true}, + {doc: "flag omitted defaults to false", args: []string{"backup"}, wantFollow: false}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + var gotOpts dockerclient.ContainerLogsOptions + jobs := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + assert.Check(t, is.Equal(req.JobRef, "backup")) + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "abc123"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + gotOpts = opts + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + // A follow=true run only returns once its context is done: after + // streaming the current run to completion, `logs -f` waits for + // the job's next run to appear (see waitForNextRun) rather than + // returning, exactly like `docker logs -f` waits on a live + // container. This test only cares that the flag reaches + // ContainerLogsOptions, which happens on the first attach, so a + // short timeout stands in for the Ctrl-C that would normally end + // a real `-f` session. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs(tc.args) + cmd.SetOut(io.Discard) + assert.NilError(t, cmd.ExecuteContext(ctx)) + + assert.Check(t, is.Equal(gotOpts.Follow, tc.wantFollow)) + }) + } +} + +func TestNewLogsCommandResolvesContainerIDFromLatestRun(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "abc123", ContainerGone: false}, + }}, nil + }, + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + t.Fatal("ListRuns must not be called when LatestRun's container is not gone") + return nil, nil + }, + } + var gotContainerID string + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + gotContainerID = containerID + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, _ dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(gotContainerID, "abc123")) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done")) +} + +func TestNewLogsCommandFallsBackWhenContainerGone(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-2", ContainerGone: true}, + }}, nil + }, + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + assert.Check(t, is.Equal(req.JobRef, "backup")) + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-2", ContainerGone: true}, + {ID: "run-1", ContainerID: "def456", ContainerGone: false}, + }}, nil + }, + } + var gotContainerID string + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + gotContainerID = containerID + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, _ dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(gotContainerID, "def456")) +} + +func TestNewLogsCommandNeverRunError(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{Name: "migrate", LatestRun: nil}}, nil + }, + } + docker := &fakeDockerAPIClient{ + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + t.Fatal("ContainerLogs must not be called when the job never ran") + return nil, nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"migrate"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `job "migrate" has never run`) +} + +func TestNewLogsCommandNoLogsAvailableError(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerGone: true}, + }}, nil + }, + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{{ID: "run-1", ContainerGone: true}}}, nil + }, + } + docker := &fakeDockerAPIClient{ + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + t.Fatal("ContainerLogs must not be called when no run has a container") + return nil, nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} + +func TestNewLogsCommandUnimplementedIsActionable(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(&fakeDockerAPIClient{}) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + 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 TestNewLogsCommandStreamsContainerOutput(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "abc123"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done\n")) + assert.Check(t, is.Equal(cli.ErrBuffer().String(), "")) +} + +func TestNewLogsCommandNotesFallbackToOlderRun(t *testing.T) { + testCases := []struct { + doc string + latest jobsv0.Run + wantNoteOn []string // substrings expected on stderr; none means stderr stays empty + }{ + {doc: "latest run has logs", latest: jobsv0.Run{ID: "run-2", ContainerID: "def456"}}, + {doc: "latest run's container is gone", latest: jobsv0.Run{ID: "run-2", ContainerGone: true}, wantNoteOn: []string{"run-1", "run-2"}}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{Name: "backup", LatestRun: &tc.latest}}, nil + }, + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{tc.latest, {ID: "run-1", ContainerID: "abc123"}}}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done"), "the note must not pollute stdout") + if len(tc.wantNoteOn) == 0 { + assert.Check(t, is.Equal(cli.ErrBuffer().String(), "")) + } + for _, want := range tc.wantNoteOn { + assert.Check(t, is.Contains(cli.ErrBuffer().String(), want)) + } + }) + } +} diff --git a/cli/command/job/pause.go b/cli/command/job/pause.go new file mode 100644 index 000000000000..30a2c6e668e2 --- /dev/null +++ b/cli/command/job/pause.go @@ -0,0 +1,41 @@ +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" +) + +// newPauseCommand wires the `docker job pause JOB` cobra command. client is +// injected explicitly, following the same pattern as newRunCommand (see +// run.go). +func newPauseCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "pause JOB", + Short: "Suppress a job's schedule trigger", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPause(cmd.Context(), dockerCli, client, args[0]) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + return cmd +} + +// runPause pauses jobRef via client.Pause. client.Pause has no reply +// message, so on success jobRef is printed on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. +func runPause(ctx context.Context, dockerCli command.Cli, client jobsClient, jobRef string) error { + err := client.Pause(ctx, &jobsv0.PauseRequest{JobRef: jobRef}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), jobRef) + return nil +} diff --git a/cli/command/job/pause_test.go b/cli/command/job/pause_test.go new file mode 100644 index 000000000000..606a0e084b8a --- /dev/null +++ b/cli/command/job/pause_test.go @@ -0,0 +1,140 @@ +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: client.Pause has no reply message (like +// client.Remove), so on success runPause prints jobRef (the job name/ID +// passed as the single positional argument) on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. + +func TestNewPauseCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.PauseRequest + client := &fakeClient{ + pauseFunc: func(_ context.Context, req *jobsv0.PauseRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Pause was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) +} + +func TestNewPauseCommandPrintsJobOnSuccess(t *testing.T) { + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestNewPauseCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewPauseCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(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 TestNewPauseCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunPauseDirect exercises runPause directly (bypassing cobra arg +// parsing), following the pattern set by cancel_test.go/rm_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. +func TestRunPauseDirect(t *testing.T) { + var gotReq *jobsv0.PauseRequest + client := &fakeClient{ + pauseFunc: func(_ context.Context, req *jobsv0.PauseRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + err := runPause(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(), "backup\n")) +} + +func TestRunPauseDirectOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + err := runPause(context.Background(), cli, client, "backup") + + assert.ErrorContains(t, err, errText) + // Nothing should be printed on failure. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} diff --git a/cli/command/job/prune.go b/cli/command/job/prune.go new file mode 100644 index 000000000000..f494d36eb9bf --- /dev/null +++ b/cli/command/job/prune.go @@ -0,0 +1,76 @@ +package job + +import ( + "context" + "errors" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/prompt" + "github.com/spf13/cobra" +) + +// pruneOptions holds the parsed flags of `docker job prune`. +type pruneOptions struct { + labels []string + force bool +} + +// newPruneCommand wires the `docker job prune [--label ...]` cobra command. +// client is injected explicitly, following the same pattern as +// newRunCommand (see run.go). +func newPruneCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts pruneOptions + + cmd := &cobra.Command{ + Use: "prune [OPTIONS]", + Short: "Remove idle manual jobs", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runPrune(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.BoolVarP(&opts.force, "force", "f", false, "Do not prompt for confirmation") + flags.StringArrayVar(&opts.labels, "label", nil, "Only remove jobs matching a label (\"key\" or \"key=value\")") + return cmd +} + +const pruneWarning = `WARNING! This will remove all idle manual jobs. +Are you sure you want to continue?` + +// runPrune removes idle manual jobs matching opts.labels via client.Prune, +// printing each removed job's ID on its own line on dockerCli.Out() -- +// mirroring `docker container prune`/`docker volume prune`, including the +// confirmation prompt that opts.force skips. Nothing removed prints nothing +// and returns a nil error, keeping the command scriptable. +func runPrune(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *pruneOptions) error { + if !opts.force { + r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), pruneWarning) + if err != nil { + return err + } + if !r { + return cancelledErr{errors.New("job prune has been cancelled")} + } + } + + reply, err := client.Prune(ctx, &jobsv0.PruneRequest{Labels: opts.labels}) + if err != nil { + return wrapJobsError(err) + } + + for _, id := range reply.RemovedJobIDs { + _, _ = fmt.Fprintln(dockerCli.Out(), id) + } + return nil +} + +type cancelledErr struct{ error } + +func (cancelledErr) Cancelled() {} diff --git a/cli/command/job/prune_test.go b/cli/command/job/prune_test.go new file mode 100644 index 000000000000..e10d888a2255 --- /dev/null +++ b/cli/command/job/prune_test.go @@ -0,0 +1,204 @@ +package job + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/containerd/errdefs" + "github.com/docker/cli/cli/streams" + 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" +) + +// Flag convention: --label is repeatable (pflag StringArrayVar, not +// StringSliceVar) so a label value containing a comma is not silently split +// -- consistent with jobsv0.PruneRequest.Labels/ListRequest.Labels both being +// "key" or "key=value" filter entries, matched conjunctively (see jobs.go). +// +// Output convention choice: each removed job ID is printed on its own line +// on dockerCli.Out(), mirroring `docker container prune`/`docker volume +// prune` printing one identifier per line for what was removed. When nothing +// was pruned, runPrune prints nothing and returns a nil error -- unlike +// `docker system prune`'s reclaimed-space summary, there is no secondary +// metric worth reporting here (Prune only ever reports removed job IDs), so +// an empty, silent success keeps the command composable/scriptable (e.g. +// `docker job prune --label foo` in a script can just check the exit code). + +func TestNewPruneCommandWithoutLabelSendsEmptyRequest(t *testing.T) { + var gotReq *jobsv0.PruneRequest + client := &fakeClient{ + pruneFunc: func(_ context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + gotReq = req + return &jobsv0.PruneReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Prune was not called") + assert.Check(t, is.Len(gotReq.Labels, 0)) +} + +func TestNewPruneCommandForwardsRepeatedLabelFlag(t *testing.T) { + var gotReq *jobsv0.PruneRequest + client := &fakeClient{ + pruneFunc: func(_ context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + gotReq = req + return &jobsv0.PruneReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force", "--label", "foo", "--label", "bar=baz"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Prune was not called") + assert.Check(t, is.DeepEqual(gotReq.Labels, []string{"foo", "bar=baz"})) +} + +func TestNewPruneCommandPrintsRemovedJobIDs(t *testing.T) { + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + return &jobsv0.PruneReply{RemovedJobIDs: []string{"job-1", "job-2"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "job-1\njob-2\n")) +} + +func TestNewPruneCommandPrintsNothingWhenNoJobsRemoved(t *testing.T) { + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + return &jobsv0.PruneReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} + +func TestNewPruneCommandRejectsPositionalArgs(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "accepts no arguments") +} + +func TestNewPruneCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + 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) +} + +// TestRunPruneDirect exercises runPrune 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 TestRunPruneDirect(t *testing.T) { + var gotReq *jobsv0.PruneRequest + client := &fakeClient{ + pruneFunc: func(_ context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + gotReq = req + return &jobsv0.PruneReply{RemovedJobIDs: []string{"job-9"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runPrune(context.Background(), cli, client, &pruneOptions{labels: []string{"foo"}, force: true}) + + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(gotReq.Labels, []string{"foo"})) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "job-9\n")) +} + +func TestNewPruneCommandAsksForConfirmation(t *testing.T) { + testCases := []struct { + doc string + input string + wantPruned bool + }{ + {doc: "confirmed", input: "y\n", wantPruned: true}, + {doc: "declined", input: "n\n", wantPruned: false}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + var pruned bool + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + pruned = true + return &jobsv0.PruneReply{RemovedJobIDs: []string{"job-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader(tc.input)))) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.Check(t, is.Contains(cli.OutBuffer().String(), "WARNING! This will remove all idle manual jobs.")) + assert.Check(t, is.Equal(pruned, tc.wantPruned)) + if tc.wantPruned { + assert.NilError(t, err) + assert.Check(t, is.Contains(cli.OutBuffer().String(), "job-1\n")) + } else { + assert.Check(t, errdefs.IsCanceled(err), "got %v", err) + } + }) + } +} + +func TestNewPruneCommandForceSkipsConfirmation(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + // No input is provided: a prompt would be declined. + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"-f"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + assert.NilError(t, cmd.Execute()) + assert.Check(t, !strings.Contains(cli.OutBuffer().String(), "WARNING!"), "output: %q", cli.OutBuffer()) +} diff --git a/cli/command/job/rm.go b/cli/command/job/rm.go new file mode 100644 index 000000000000..bf3ad4967fbc --- /dev/null +++ b/cli/command/job/rm.go @@ -0,0 +1,55 @@ +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" +) + +// rmOptions holds the parsed flags of `docker job rm`. +type rmOptions struct { + job string + runs string +} + +// newRmCommand wires the `docker job rm [--runs ...] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newRunCommand (see run.go). +func newRmCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts rmOptions + + cmd := &cobra.Command{ + Use: "rm JOB", + Aliases: []string{"remove"}, + Short: "Remove a job", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runRm(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.StringVar(&opts.runs, "runs", "", `Run history retention: "keep", "remove", or "remove-finished" (default "keep")`) + return cmd +} + +// runRm removes opts.job via client.Remove. client.Remove has no reply +// message, so on success opts.job (the identifier passed by the caller) is +// printed on dockerCli.Out(), mirroring `docker volume rm`/`docker network +// rm`. +func runRm(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *rmOptions) error { + err := client.Remove(ctx, &jobsv0.RemoveRequest{JobRef: opts.job, RunsRemoval: opts.runs}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), opts.job) + return nil +} diff --git a/cli/command/job/rm_test.go b/cli/command/job/rm_test.go new file mode 100644 index 000000000000..8e337af44e67 --- /dev/null +++ b/cli/command/job/rm_test.go @@ -0,0 +1,197 @@ +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" +) + +// --runs is forwarded to jobsv0.RemoveRequest.RunsRemoval as a raw string, +// with no client-side mapping/enum: an unset flag sends an empty string, +// which the daemon defaults to "keep" (see jobsv0.RunsKeep and +// jobsv0.RemoveRequest.RunsRemoval in jobs.go). Validation of the value is +// left entirely to the daemon. +// +// Output convention choice: on success runRm prints opts.job (the job +// name/ID passed as the single positional argument) on dockerCli.Out(), +// mirroring `docker volume rm`/`docker network rm` (see +// cli/command/volume/remove.go and cli/command/network/remove.go), which +// both print the resource identifier as given by the caller via +// fmt.Fprintln(dockerCli.Out(), name) on a successful removal. + +func TestNewRmCommandForwardsJobRefWithoutRunsFlag(t *testing.T) { + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Remove was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunsRemoval, "")) +} + +func TestNewRmCommandForwardsRunsFlag(t *testing.T) { + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"--runs", "remove", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Remove was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunsRemoval, "remove")) +} + +func TestNewRmCommandForwardsRunsFlagRawValue(t *testing.T) { + // The CLI performs no validation/mapping of --runs: an arbitrary string + // is forwarded as-is, leaving the daemon to reject invalid values. + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"--runs", "bogus-value", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Remove was not called") + assert.Check(t, is.Equal(gotReq.RunsRemoval, "bogus-value")) +} + +func TestNewRmCommandPrintsJobOnSuccess(t *testing.T) { + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestNewRmCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewRmCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(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 TestNewRmCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunRmDirect exercises runRm directly (bypassing cobra arg parsing), +// following the pattern set by cancel_test.go/prune_test.go of testing the +// RunE body as a plain function in addition to going through the cobra +// command. +func TestRunRmDirect(t *testing.T) { + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + err := runRm(context.Background(), cli, client, &rmOptions{job: "backup", runs: jobsv0.RunsRemoveFinished}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunsRemoval, jobsv0.RunsRemoveFinished)) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestRunRmDirectOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + err := runRm(context.Background(), cli, client, &rmOptions{job: "backup"}) + + assert.ErrorContains(t, err, errText) + // Nothing should be printed on failure. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} + +// `docker container rm`, `volume rm` and `network rm` are all reachable as +// `remove`: keep the same spelling. +func TestNewRmCommandHasRemoveAlias(t *testing.T) { + cmd := newRmCommand(test.NewFakeCli(nil), &fakeClient{}) + + assert.Check(t, is.Contains(cmd.Aliases, "remove")) +} diff --git a/cli/command/job/run.go b/cli/command/job/run.go new file mode 100644 index 000000000000..e93f67cc9904 --- /dev/null +++ b/cli/command/job/run.go @@ -0,0 +1,53 @@ +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" +) + +// runOptions holds the parsed flags/args of `docker job run`. +type runOptions struct { + job string + reschedule bool +} + +// newRunCommand wires the `docker job run [--reschedule] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newListCommand/newLogsCommand (see list.go/logs.go). +func newRunCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts runOptions + + cmd := &cobra.Command{ + Use: "run [OPTIONS] JOB", + Short: "Run a job", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runRun(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.BoolVar(&opts.reschedule, "reschedule", false, "Reschedule the job's next occurrence from this run") + return cmd +} + +// runRun triggers a run of opts.job via client.Run, printing the created +// run's ID on success -- the same convention as `docker container commit` +// (see cli/command/container/commit.go). +func runRun(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *runOptions) error { + reply, err := client.Run(ctx, &jobsv0.RunRequest{JobRef: opts.job, Reschedule: opts.reschedule}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), reply.Run.ID) + return nil +} diff --git a/cli/command/job/run_test.go b/cli/command/job/run_test.go new file mode 100644 index 000000000000..e610d837d125 --- /dev/null +++ b/cli/command/job/run_test.go @@ -0,0 +1,153 @@ +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" +) + +func TestNewRunCommandForwardsJobRefAndDefaultsRescheduleFalse(t *testing.T) { + var gotReq *jobsv0.RunRequest + client := &fakeClient{ + runFunc: func(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + gotReq = req + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Run was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Reschedule, false)) +} + +func TestNewRunCommandRescheduleFlagForwarded(t *testing.T) { + var gotReq *jobsv0.RunRequest + client := &fakeClient{ + runFunc: func(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + gotReq = req + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"--reschedule", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Run was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Reschedule, true)) +} + +func TestNewRunCommandPrintsCreatedRunID(t *testing.T) { + client := &fakeClient{ + runFunc: func(context.Context, *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-42"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + // Convention: print only the created resource's ID, one line, nothing + // else -- same as `docker container commit` (see + // cli/command/container/commit.go) and `docker run`'s container ID. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-42\n")) +} + +func TestNewRunCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewRunCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + runFunc: func(context.Context, *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(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 TestNewRunCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + testCases := []struct { + doc string + errText string + }{ + {doc: "job not found", errText: `job "backup" not found`}, + {doc: "reschedule rejected for a job without a schedule trigger", errText: "reschedule is not supported for jobs without a schedule trigger"}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + client := &fakeClient{ + runFunc: func(context.Context, *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + return nil, errors.New(tc.errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, tc.errText) + }) + } +} + +// TestRunRunDirect exercises runRun directly (bypassing cobra arg parsing), +// following the pattern set by list.go/logs.go of testing the RunE body as a +// plain function in addition to going through the cobra command. +func TestRunRunDirect(t *testing.T) { + var gotReq *jobsv0.RunRequest + client := &fakeClient{ + runFunc: func(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + gotReq = req + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-7"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runRun(context.Background(), cli, client, &runOptions{job: "backup", reschedule: true}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Reschedule, true)) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-7\n")) +} diff --git a/cli/command/job/runs.go b/cli/command/job/runs.go new file mode 100644 index 000000000000..aeceb386f641 --- /dev/null +++ b/cli/command/job/runs.go @@ -0,0 +1,130 @@ +package job + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/cli/cli/command/formatter/tabwriter" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + units "github.com/docker/go-units" + "github.com/spf13/cobra" +) + +// runRow is one formatted row of `docker job runs`, matching the columns +// ITERATION STATE EXIT TRIGGER STARTED FINISHED CONTAINER. +type runRow struct { + Iteration string + State string + Exit string + Trigger string + Started string + Finished string + Container string +} + +// newRunRow formats r into a runRow. now is the reference time used to +// humanize STARTED/FINISHED with github.com/docker/go-units HumanDuration; +// production code passes time.Now(), tests inject a fixed value to stay +// deterministic. +func newRunRow(r jobsv0.Run, now time.Time) runRow { + row := runRow{ + Iteration: strconv.FormatUint(r.Iteration, 10), + State: r.State, + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "-", + Container: "-", + } + + if r.ExitCode != nil { + row.Exit = strconv.FormatInt(r.ExitCode.Value, 10) + } + if r.Trigger != nil { + row.Trigger = r.Trigger.Kind + } + if r.StartedAtNano != 0 { + row.Started = units.HumanDuration(now.Sub(time.Unix(0, r.StartedAtNano))) + " ago" + } + if r.FinishedAtNano != 0 { + row.Finished = units.HumanDuration(now.Sub(time.Unix(0, r.FinishedAtNano))) + " ago" + } + if r.ContainerID != "" { + row.Container = formatter.TruncateID(r.ContainerID) + } + + return row +} + +// runsOptions holds the parsed flags/args of `docker job runs`. +type runsOptions struct { + job string + limit int32 + before string +} + +// newRunsCommand wires the `docker job runs [OPTIONS] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newListCommand (see list.go). +func newRunsCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts runsOptions + + cmd := &cobra.Command{ + Use: "runs [OPTIONS] JOB", + Short: "List a job's runs", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runRuns(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.Int32Var(&opts.limit, "limit", 0, "Maximum number of runs to list (0 for the daemon default)") + flags.StringVar(&opts.before, "before", "", "List runs older than this cursor (from a previous page's NextCursor)") + return cmd +} + +// runRuns calls client.ListRuns and renders the returned page as a +// tabwriter table to dockerCli.Out(), in the order the daemon returned +// them (most-recent-first, per ListRuns's godoc) without re-sorting. A stale +// requested cursor is warned about on dockerCli.Err(); a non-empty +// NextCursor prints a paging hint on dockerCli.Out() after the table. +func runRuns(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *runsOptions) error { + reply, err := client.ListRuns(ctx, &jobsv0.ListRunsRequest{ + JobRef: opts.job, + Limit: opts.limit, + Before: opts.before, + }) + if err != nil { + return wrapJobsError(err) + } + + if reply.CursorStale { + _, _ = fmt.Fprintln(dockerCli.Err(), "Warning: the requested cursor is stale; showing the most recent runs instead") + } + + now := time.Now() + w := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ITERATION\tSTATE\tEXIT\tTRIGGER\tSTARTED\tFINISHED\tCONTAINER") + for _, r := range reply.Runs { + row := newRunRow(r, now) + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row.Iteration, row.State, row.Exit, row.Trigger, row.Started, row.Finished, row.Container) + } + if err := w.Flush(); err != nil { + return err + } + + if reply.NextCursor != "" { + _, _ = fmt.Fprintf(dockerCli.Out(), "\nFor the next page, run: docker job runs --before %s %s\n", reply.NextCursor, opts.job) + } + return nil +} diff --git a/cli/command/job/runs_test.go b/cli/command/job/runs_test.go new file mode 100644 index 000000000000..b78a3c03be21 --- /dev/null +++ b/cli/command/job/runs_test.go @@ -0,0 +1,462 @@ +package job + +import ( + "context" + "errors" + "io" + "strconv" + "strings" + "testing" + "time" + + "github.com/docker/cli/cli/command/formatter" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + units "github.com/docker/go-units" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// runsHeaderColumns is the exact header line expected on `docker job runs`: ITERATION STATE EXIT TRIGGER STARTED +// FINISHED CONTAINER. +var runsHeaderColumns = []string{"ITERATION", "STATE", "EXIT", "TRIGGER", "STARTED", "FINISHED", "CONTAINER"} + +func TestNewRunRow(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + + testCases := []struct { + doc string + run jobsv0.Run + expected runRow + }{ + { + doc: "pending run: never started, never finished, no container, no exit code", + run: jobsv0.Run{ + Iteration: 1, + State: jobsv0.RunStatePending, + }, + expected: runRow{ + Iteration: "1", + State: "pending", + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "-", + Container: "-", + }, + }, + { + doc: "running run: started, not finished, ExitCode nil, manual trigger", + run: jobsv0.Run{ + Iteration: 2, + State: jobsv0.RunStateRunning, + ContainerID: "abcdef0123456789abcdef", + StartedAtNano: now.Add(-30 * time.Second).UnixNano(), + Trigger: &jobsv0.TriggerEvidence{Kind: jobsv0.TriggerKindManual}, + }, + expected: runRow{ + Iteration: "2", + State: "running", + Exit: "-", + Trigger: "manual", + Started: "30 seconds ago", + Finished: "-", + Container: "abcdef012345", + }, + }, + { + doc: "succeeded run with exit code zero: EXIT must be 0, NOT - (no-exit-code vs exit-code-zero trap)", + run: jobsv0.Run{ + Iteration: 3, + State: jobsv0.RunStateSucceeded, + ContainerID: "1234567890ab1234567890ab", + StartedAtNano: now.Add(-5 * time.Minute).UnixNano(), + FinishedAtNano: now.Add(-2 * time.Minute).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 0}, + Trigger: &jobsv0.TriggerEvidence{Kind: jobsv0.TriggerKindSchedule}, + }, + expected: runRow{ + Iteration: "3", + State: "succeeded", + Exit: "0", + Trigger: "schedule", + Started: "5 minutes ago", + Finished: "2 minutes ago", + Container: "1234567890ab", + }, + }, + { + doc: "failed run with a nonzero exit code", + run: jobsv0.Run{ + Iteration: 4, + State: jobsv0.RunStateFailed, + ContainerID: "deadbeefdeadbeefdeadbeef", + StartedAtNano: now.Add(-1 * time.Hour).UnixNano(), + FinishedAtNano: now.Add(-59 * time.Minute).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 137}, + Trigger: &jobsv0.TriggerEvidence{Kind: jobsv0.TriggerKindManual}, + }, + expected: runRow{ + Iteration: "4", + State: "failed", + Exit: "137", + Trigger: "manual", + Started: "About an hour ago", + Finished: "59 minutes ago", + Container: "deadbeefdead", + }, + }, + { + doc: "run whose container was never created: failed straight from pending, no exit code, no container", + run: jobsv0.Run{ + Iteration: 5, + State: jobsv0.RunStateFailed, + FinishedAtNano: now.Add(-10 * time.Second).UnixNano(), + Error: "container create failed", + }, + expected: runRow{ + Iteration: "5", + State: "failed", + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "10 seconds ago", + Container: "-", + }, + }, + { + doc: "nil Trigger is handled defensively even though it is not expected in practice", + run: jobsv0.Run{ + Iteration: 6, + State: jobsv0.RunStateCancelled, + Trigger: nil, + }, + expected: runRow{ + Iteration: "6", + State: "cancelled", + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "-", + Container: "-", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + row := newRunRow(tc.run, now) + assert.Check(t, is.DeepEqual(row, tc.expected)) + }) + } +} + +// TestNewRunRowUsesHumanDuration guards against hardcoding a human-readable +// string that only happens to match go-units' current implementation: it +// recomputes the expected string through units.HumanDuration itself, so the +// test tracks the library rather than a magic string. +func TestNewRunRowUsesHumanDuration(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + startedAgo := 42 * time.Second + finishedAgo := 7 * time.Second + + r := jobsv0.Run{ + Iteration: 1, + State: jobsv0.RunStateSucceeded, + StartedAtNano: now.Add(-startedAgo).UnixNano(), + FinishedAtNano: now.Add(-finishedAgo).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 0}, + } + + row := newRunRow(r, now) + + assert.Check(t, is.Equal(row.Started, units.HumanDuration(startedAgo)+" ago")) + assert.Check(t, is.Equal(row.Finished, units.HumanDuration(finishedAgo)+" ago")) +} + +// TestNewRunRowContainerTruncation confirms CONTAINER reuses the repo's +// existing ID-truncation convention (formatter.TruncateID, wrapping +// github.com/moby/moby/client/pkg/stringid.TruncateID -- the same helper used +// by `docker ps` and friends) rather than a hand-rolled slice. +func TestNewRunRowContainerTruncation(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + containerID := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd" + + row := newRunRow(jobsv0.Run{ContainerID: containerID}, now) + + assert.Check(t, is.Equal(row.Container, formatter.TruncateID(containerID))) +} + +func TestNewRunsCommandForwardsRequest(t *testing.T) { + var gotReq *jobsv0.ListRunsRequest + client := &fakeClient{ + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + gotReq = req + return &jobsv0.ListRunsReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"--limit", "5", "--before", "cursor-123", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.ListRuns was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Limit, int32(5))) + assert.Check(t, is.Equal(gotReq.Before, "cursor-123")) +} + +func TestNewRunsCommandDefaultsLimitZeroAndBeforeEmpty(t *testing.T) { + var gotReq *jobsv0.ListRunsRequest + client := &fakeClient{ + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + gotReq = req + return &jobsv0.ListRunsReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.ListRuns was not called") + assert.Check(t, is.Equal(gotReq.Limit, int32(0))) + assert.Check(t, is.Equal(gotReq.Before, "")) +} + +func TestNewRunsCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewRunsCommandHeader(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{} + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + for _, col := range runsHeaderColumns { + assert.Check(t, strings.Contains(out, col), "expected header column %q in output:\n%s", col, out) + } +} + +func TestNewRunsCommandRendersRunsInReceivedOrder(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {Iteration: 9, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + {Iteration: 3, State: jobsv0.RunStateFailed, ExitCode: &jobsv0.ExitCode{Value: 1}}, + {Iteration: 7, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + i9 := strings.Index(out, "9") + i3 := strings.Index(out, "3") + i7 := strings.Index(out, "7") + assert.Check(t, i9 >= 0 && i3 >= 0 && i7 >= 0, "all three iterations must appear in output:\n%s", out) + // The daemon already returns most-recent-first; the CLI must NOT re-sort. + assert.Check(t, i9 < i3, "expected iteration 9 before 3 (no CLI re-sorting):\n%s", out) + assert.Check(t, i3 < i7, "expected iteration 3 before 7 (no CLI re-sorting):\n%s", out) +} + +func TestNewRunsCommandExitCodeZeroIsNotDash(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {Iteration: 1, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, hasTableCell(out, "0"), "expected a table cell with the exit code 0, not '-':\n%s", out) +} + +func TestNewRunsCommandCursorStaleWarnsOnStderrBeforeTable(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + CursorStale: true, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"--before", "evicted-cursor", "backup"}) + assert.NilError(t, cmd.Execute()) + + errOut := cli.ErrBuffer().String() + assert.Check(t, errOut != "", "expected a stale-cursor warning on stderr, got none") + assert.Check(t, strings.Contains(strings.ToLower(errOut), "stale"), "expected the warning to mention the stale cursor:\n%s", errOut) + + // The command must still succeed and render the table on stdout. + out := cli.OutBuffer().String() + assert.Check(t, is.Contains(out, "ITERATION")) +} + +func TestNewRunsCommandNoWarningWhenCursorNotStale(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + CursorStale: false, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.ErrBuffer().String(), "")) +} + +func TestNewRunsCommandNextCursorPrintsPagingHint(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + NextCursor: "next-page-cursor", + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, strings.Contains(out, "--before"), "expected a paging hint mentioning --before:\n%s", out) + assert.Check(t, strings.Contains(out, "next-page-cursor"), "expected the paging hint to interpolate the actual cursor value:\n%s", out) +} + +func TestNewRunsCommandNoNextCursorNoPagingHint(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + NextCursor: "", + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, !strings.Contains(out, "--before"), "did not expect a paging hint when NextCursor is empty:\n%s", out) +} + +func TestNewRunsCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(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 TestNewRunsCommandOtherErrorsPassThrough(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return nil, errors.New("job not found") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "job not found") +} + +// TestRunRunsDirect exercises runRuns directly (bypassing cobra arg +// parsing), following the pattern set by list.go/logs.go/run.go of testing +// the RunE body as a plain function in addition to going through the cobra +// command. +func TestRunRunsDirect(t *testing.T) { + var gotReq *jobsv0.ListRunsRequest + client := &fakeClient{ + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + gotReq = req + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {Iteration: 1, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runRuns(context.Background(), cli, client, &runsOptions{job: "backup", limit: 5, before: "cursor-1"}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Limit, int32(5))) + assert.Check(t, is.Equal(gotReq.Before, "cursor-1")) + assert.Check(t, is.Contains(cli.OutBuffer().String(), "ITERATION")) + assert.Check(t, is.Contains(cli.OutBuffer().String(), strconv.FormatUint(1, 10))) +} + +// --limit is an int32 on the wire: a value that does not fit must be refused +// instead of silently wrapping around (4294967296 would become 0, i.e. the +// daemon default). +func TestNewRunsCommandRejectsLimitOutOfInt32Range(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + t.Fatal("client.ListRuns must not be called with an out-of-range --limit") + return nil, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"--limit", "4294967296", "backup"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + assert.ErrorContains(t, cmd.Execute(), "--limit") +} diff --git a/cli/command/job/unpause.go b/cli/command/job/unpause.go new file mode 100644 index 000000000000..23f898545ce8 --- /dev/null +++ b/cli/command/job/unpause.go @@ -0,0 +1,41 @@ +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" +) + +// newUnpauseCommand wires the `docker job unpause JOB` cobra command. client is +// injected explicitly, following the same pattern as newRunCommand (see +// run.go). +func newUnpauseCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "unpause JOB", + Short: "Re-arm a paused job's schedule trigger", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUnpause(cmd.Context(), dockerCli, client, args[0]) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + return cmd +} + +// runUnpause unpauses jobRef via client.Resume. client.Resume has no reply +// message, so on success jobRef is printed on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. +func runUnpause(ctx context.Context, dockerCli command.Cli, client jobsClient, jobRef string) error { + err := client.Resume(ctx, &jobsv0.ResumeRequest{JobRef: jobRef}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), jobRef) + return nil +} diff --git a/cli/command/job/unpause_test.go b/cli/command/job/unpause_test.go new file mode 100644 index 000000000000..f0ed9fa7ad89 --- /dev/null +++ b/cli/command/job/unpause_test.go @@ -0,0 +1,140 @@ +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: client.Resume has no reply message (like +// client.Remove), so on success runUnpause prints jobRef (the job name/ID +// passed as the single positional argument) on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. + +func TestNewUnpauseCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.ResumeRequest + client := &fakeClient{ + resumeFunc: func(_ context.Context, req *jobsv0.ResumeRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Resume was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) +} + +func TestNewUnpauseCommandPrintsJobOnSuccess(t *testing.T) { + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestNewUnpauseCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewUnpauseCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(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 TestNewUnpauseCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunUnpauseDirect exercises runUnpause directly (bypassing cobra arg +// parsing), following the pattern set by cancel_test.go/rm_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. +func TestRunUnpauseDirect(t *testing.T) { + var gotReq *jobsv0.ResumeRequest + client := &fakeClient{ + resumeFunc: func(_ context.Context, req *jobsv0.ResumeRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + err := runUnpause(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(), "backup\n")) +} + +func TestRunUnpauseDirectOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + err := runUnpause(context.Background(), cli, client, "backup") + + assert.ErrorContains(t, err, errText) + // Nothing should be printed on failure. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} diff --git a/cli/command/job/wait.go b/cli/command/job/wait.go new file mode 100644 index 000000000000..1384186310e0 --- /dev/null +++ b/cli/command/job/wait.go @@ -0,0 +1,94 @@ +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" +) + +// waitOptions holds the parsed flags/args of `docker job wait`. +type waitOptions struct { + job string + run string + condition string +} + +// newWaitCommand wires the `docker job wait [OPTIONS] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newCancelCommand (see cancel.go). An empty --run or --condition defers to +// the daemon's defaults ("latest"/"terminal"). +func newWaitCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts waitOptions + + cmd := &cobra.Command{ + Use: "wait [OPTIONS] JOB", + Short: "Block until a job's run reaches a terminal state", + Long: `Block until a run of a job reaches a terminal state, then exit with the run's exit code. + +The job must exist: names of removed jobs do not resolve. Without --run, the +latest run is awaited; for a schedule job that has not run yet, that means +blocking until its next scheduled fire. + +A run stopped by a timeout or by "docker job cancel" makes the command fail +with status 1, whatever the exit code of its container. + +With --condition running, the ID of the run is printed once it is running. A +run that already finished is reported as usual, by its exit code, so scripts +capturing the output must check which one they got.`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runWait(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.StringVar(&opts.run, "run", "", `Run to wait for: a run ID, or "latest" (default "latest")`) + flags.StringVar(&opts.condition, "condition", "", `Condition to wait for: "terminal" or "running", i.e. the run left pending (default "terminal")`) + return cmd +} + +// runWait blocks until opts.job's run reaches opts.condition via client.Wait. +// A finished run's exit code is printed on dockerCli.Out() and mirrored into +// the process exit status via cli.StatusError, like cli/command/container/ +// attach.go's getExitStatus (a zero exit code returns nil). A running run (the +// "running" condition) has no exit code yet: its ID is printed instead, like +// `docker job run`. A run stopped by a timeout or a cancellation, or whose +// container never started, fails with status 1 and the reason on +// dockerCli.Err(): an exit code, when any, is the container's reaction to +// being stopped, not the job's outcome. +func runWait(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *waitOptions) error { + reply, err := client.Wait(ctx, &jobsv0.WaitRequest{JobRef: opts.job, RunRef: opts.run, Condition: opts.condition}) + if err != nil { + return wrapJobsError(err) + } + + run := reply.Run + switch { + case run.State == jobsv0.RunStateRunning: + _, _ = fmt.Fprintln(dockerCli.Out(), run.ID) + return nil + case run.State == jobsv0.RunStateTimedOut || run.State == jobsv0.RunStateCancelled: + _, _ = fmt.Fprintf(dockerCli.Err(), "run %s\n", run.State) + return cli.StatusError{StatusCode: 1} + case run.ExitCode == nil: + reason := run.Error + if reason == "" { + reason = run.State + } + _, _ = fmt.Fprintf(dockerCli.Err(), "run never started a container: %s\n", reason) + return cli.StatusError{StatusCode: 1} + } + + _, _ = fmt.Fprintln(dockerCli.Out(), run.ExitCode.Value) + if run.ExitCode.Value == 0 { + return nil + } + return cli.StatusError{StatusCode: int(run.ExitCode.Value)} +} diff --git a/cli/command/job/wait_test.go b/cli/command/job/wait_test.go new file mode 100644 index 000000000000..b079df12f489 --- /dev/null +++ b/cli/command/job/wait_test.go @@ -0,0 +1,313 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + clipkg "github.com/docker/cli/cli" + 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" +) + +// Unlike `docker container wait` (cli/command/container/wait.go), which only +// ever prints the exit code on stdout and never affects the CLI process's +// own exit status, `docker job wait` is meant to be scriptable +// (`docker job wait X && do-something`): the process must exit with the +// run's own exit code. That is done by returning cli.StatusError{StatusCode: +// N} from runWait, mirroring cli/command/container/attach.go's +// getExitStatus. As in that precedent, a zero exit code returns a nil error +// (not cli.StatusError{StatusCode: 0}), since StatusCode 0 combined with a +// non-nil error does not extract correctly (see the FIXME in +// cmd/docker/docker.go). + +func TestNewWaitCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.WaitRequest + client := &fakeClient{ + waitFunc: func(_ context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + gotReq = req + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Wait was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "")) + assert.Check(t, is.Equal(gotReq.Condition, "")) +} + +func TestNewWaitCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewWaitCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + 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 TestNewWaitCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return nil, errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunWaitDirect* exercise runWait directly (bypassing cobra arg +// parsing), following the pattern set by cancel_test.go/run_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. + +func TestRunWaitDirectForwardsJobRefOnly(t *testing.T) { + var gotReq *jobsv0.WaitRequest + client := &fakeClient{ + waitFunc: func(_ context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + gotReq = req + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, gotReq != nil, "client.Wait was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "")) + assert.Check(t, is.Equal(gotReq.Condition, "")) +} + +func TestRunWaitExitCodeZeroPrintsZeroAndReturnsNil(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "0\n")) + + var statusErr clipkg.StatusError + assert.Check(t, !errors.As(err, &statusErr), "runWait must not return cli.StatusError on a zero exit code") +} + +func TestRunWaitNonZeroExitCodePrintsCodeAndReturnsStatusError(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateFailed, + ExitCode: &jobsv0.ExitCode{Value: 3}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "3\n")) + + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 3)) +} + +func TestRunWaitNoExitCodePrintsNoNumberAndReturnsGenericStatusError(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateFailed, + Error: "failed to pull image", + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), ""), "no exit code to print when the run never started a container") + assert.Check(t, strings.Contains(cli.ErrBuffer().String(), "failed to pull image"), "error message should surface Run.Error: %v", cli.ErrBuffer().String()) + + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 1)) +} + +// TestRunWaitNoExitCodeAndNoErrorFallsBackToState covers a run that never +// started a container but also has no Run.Error -- e.g. cancelled while +// still pending (RunStateCancelled's godoc, unlike RunStatePending's, makes +// no promise that Error is set). The message must still be informative +// instead of ending in a bare, unexplained colon. +func TestRunWaitNoExitCodeAndNoErrorFallsBackToState(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateCancelled, + Error: "", + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), ""), "no exit code to print when the run never started a container") + assert.Check(t, strings.Contains(cli.ErrBuffer().String(), jobsv0.RunStateCancelled), "error message should fall back to Run.State when Run.Error is empty: %v", cli.ErrBuffer().String()) + + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 1)) +} + +func TestNewWaitCommandForwardsRunAndCondition(t *testing.T) { + var gotReq *jobsv0.WaitRequest + client := &fakeClient{ + waitFunc: func(_ context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + gotReq = req + return &jobsv0.WaitReply{Run: &jobsv0.Run{ID: "run-7", State: jobsv0.RunStateRunning}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"--run", "run-7", "--condition", "running", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Wait was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "run-7")) + assert.Check(t, is.Equal(gotReq.Condition, "running")) +} + +// With --condition running, a run that is up satisfies the wait: there is no +// exit code yet, so the run ID is printed (like `docker job run`) and the +// command succeeds. +func TestRunWaitConditionRunningPrintsRunID(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{Run: &jobsv0.Run{ID: "run-7", State: jobsv0.RunStateRunning}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup", condition: jobsv0.WaitConditionRunning}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-7\n")) +} + +// A run stopped by the daemon (timeout) or by Cancel carries the exit code of +// the container's last gasp -- 137, or even 0 when it handles SIGTERM. That +// must not be mistaken for the job's own success or failure. +func TestRunWaitTimedOutAndCancelledRunsFail(t *testing.T) { + testCases := []struct { + doc string + state string + exit int64 + }{ + {doc: "timed out, killed", state: jobsv0.RunStateTimedOut, exit: 137}, + {doc: "timed out, clean exit on SIGTERM", state: jobsv0.RunStateTimedOut, exit: 0}, + {doc: "cancelled, clean exit on SIGTERM", state: jobsv0.RunStateCancelled, exit: 0}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{Run: &jobsv0.Run{State: tc.state, ExitCode: &jobsv0.ExitCode{Value: tc.exit}}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) + assert.Check(t, strings.Contains(cli.ErrBuffer().String(), tc.state), "stderr should name the state: %q", cli.ErrBuffer().String()) + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 1)) + }) + } +} + +// With --condition running, a run that already finished still satisfies the +// wait (it jumped past the condition): it is reported like without the flag, +// with its exit code rather than its ID. +func TestRunWaitConditionRunningOnFinishedRunPrintsExitCode(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{Run: &jobsv0.Run{ID: "run-7", State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup", condition: jobsv0.WaitConditionRunning}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "0\n")) +} diff --git a/docs/reference/commandline/docker.md b/docs/reference/commandline/docker.md index 73ef948beae2..81d44974028e 100644 --- a/docs/reference/commandline/docker.md +++ b/docs/reference/commandline/docker.md @@ -28,6 +28,7 @@ The base command for the Docker CLI. | [`import`](import.md) | Import the contents from a tarball to create a filesystem image | | [`info`](info.md) | Display system-wide information | | [`inspect`](inspect.md) | Return low-level information on Docker objects | +| [`job`](job.md) | Manage jobs | | [`kill`](kill.md) | Kill one or more running containers | | [`load`](load.md) | Load an image from a tar archive or STDIN | | [`login`](login.md) | Authenticate to a registry | diff --git a/docs/reference/commandline/job.md b/docs/reference/commandline/job.md new file mode 100644 index 000000000000..8f2c21191813 --- /dev/null +++ b/docs/reference/commandline/job.md @@ -0,0 +1,28 @@ +# docker job + + +Manage jobs, one-shot or scheduled containers run by the daemon. + +This requires the daemon's jobs extension, which is disabled by default: start +dockerd with --extension-enable org.mobyproject.jobs.v0. + +### Subcommands + +| Name | Description | +|:----------------------------|:-------------------------------------------------| +| [`cancel`](job_cancel.md) | Cancel a job's in-flight run | +| [`inspect`](job_inspect.md) | Display detailed information on a job | +| [`logs`](job_logs.md) | Fetch the logs of a job's most recent run | +| [`ls`](job_ls.md) | List jobs | +| [`pause`](job_pause.md) | Suppress a job's schedule trigger | +| [`prune`](job_prune.md) | Remove idle manual jobs | +| [`rm`](job_rm.md) | Remove a job | +| [`run`](job_run.md) | Run a job | +| [`runs`](job_runs.md) | List a job's runs | +| [`unpause`](job_unpause.md) | Re-arm a paused job's schedule trigger | +| [`wait`](job_wait.md) | Block until a job's run reaches a terminal state | + + + + + diff --git a/docs/reference/commandline/job_cancel.md b/docs/reference/commandline/job_cancel.md new file mode 100644 index 000000000000..a1336c6beaa3 --- /dev/null +++ b/docs/reference/commandline/job_cancel.md @@ -0,0 +1,8 @@ +# docker job cancel + + +Cancel a job's in-flight run + + + + diff --git a/docs/reference/commandline/job_inspect.md b/docs/reference/commandline/job_inspect.md new file mode 100644 index 000000000000..ba46c80de851 --- /dev/null +++ b/docs/reference/commandline/job_inspect.md @@ -0,0 +1,14 @@ +# docker job inspect + + +Display detailed information on a job + +### Options + +| Name | Type | Default | Description | +|:--------|:---------|:--------|:------------------------------------------| +| `--run` | `string` | | Inspect a specific run instead of the job | + + + + diff --git a/docs/reference/commandline/job_logs.md b/docs/reference/commandline/job_logs.md new file mode 100644 index 000000000000..c11bb0dbda2d --- /dev/null +++ b/docs/reference/commandline/job_logs.md @@ -0,0 +1,14 @@ +# docker job logs + + +Fetch the logs of a job's most recent run + +### Options + +| Name | Type | Default | Description | +|:-----------------|:-------|:--------|:------------------| +| `-f`, `--follow` | `bool` | | Follow log output | + + + + diff --git a/docs/reference/commandline/job_ls.md b/docs/reference/commandline/job_ls.md new file mode 100644 index 000000000000..2b143f8f7e0e --- /dev/null +++ b/docs/reference/commandline/job_ls.md @@ -0,0 +1,12 @@ +# docker job ls + + +List jobs + +### Aliases + +`docker job ls`, `docker job list` + + + + diff --git a/docs/reference/commandline/job_pause.md b/docs/reference/commandline/job_pause.md new file mode 100644 index 000000000000..ede9911c9861 --- /dev/null +++ b/docs/reference/commandline/job_pause.md @@ -0,0 +1,8 @@ +# docker job pause + + +Suppress a job's schedule trigger + + + + diff --git a/docs/reference/commandline/job_prune.md b/docs/reference/commandline/job_prune.md new file mode 100644 index 000000000000..6f924a4c580c --- /dev/null +++ b/docs/reference/commandline/job_prune.md @@ -0,0 +1,15 @@ +# docker job prune + + +Remove idle manual jobs + +### Options + +| Name | Type | Default | Description | +|:----------------|:--------------|:--------|:---------------------------------------------------------| +| `-f`, `--force` | `bool` | | Do not prompt for confirmation | +| `--label` | `stringArray` | | Only remove jobs matching a label (`key` or `key=value`) | + + + + diff --git a/docs/reference/commandline/job_rm.md b/docs/reference/commandline/job_rm.md new file mode 100644 index 000000000000..832cc3e96a6e --- /dev/null +++ b/docs/reference/commandline/job_rm.md @@ -0,0 +1,18 @@ +# docker job rm + + +Remove a job + +### Aliases + +`docker job rm`, `docker job remove` + +### Options + +| Name | Type | Default | Description | +|:---------|:---------|:--------|:-------------------------------------------------------------------------------| +| `--runs` | `string` | | Run history retention: `keep`, `remove`, or `remove-finished` (default `keep`) | + + + + diff --git a/docs/reference/commandline/job_run.md b/docs/reference/commandline/job_run.md new file mode 100644 index 000000000000..65ecadf9abf0 --- /dev/null +++ b/docs/reference/commandline/job_run.md @@ -0,0 +1,14 @@ +# docker job run + + +Run a job + +### Options + +| Name | Type | Default | Description | +|:---------------|:-------|:--------|:---------------------------------------------------| +| `--reschedule` | `bool` | | Reschedule the job's next occurrence from this run | + + + + diff --git a/docs/reference/commandline/job_runs.md b/docs/reference/commandline/job_runs.md new file mode 100644 index 000000000000..a4e78c47a783 --- /dev/null +++ b/docs/reference/commandline/job_runs.md @@ -0,0 +1,15 @@ +# docker job runs + + +List a job's runs + +### Options + +| Name | Type | Default | Description | +|:-----------|:---------|:--------|:---------------------------------------------------------------------| +| `--before` | `string` | | List runs older than this cursor (from a previous page's NextCursor) | +| `--limit` | `int32` | `0` | Maximum number of runs to list (0 for the daemon default) | + + + + diff --git a/docs/reference/commandline/job_unpause.md b/docs/reference/commandline/job_unpause.md new file mode 100644 index 000000000000..14fcc3f38028 --- /dev/null +++ b/docs/reference/commandline/job_unpause.md @@ -0,0 +1,8 @@ +# docker job unpause + + +Re-arm a paused job's schedule trigger + + + + diff --git a/docs/reference/commandline/job_wait.md b/docs/reference/commandline/job_wait.md new file mode 100644 index 000000000000..474ad9a8119a --- /dev/null +++ b/docs/reference/commandline/job_wait.md @@ -0,0 +1,26 @@ +# docker job wait + + +Block until a run of a job reaches a terminal state, then exit with the run's exit code. + +The job must exist: names of removed jobs do not resolve. Without --run, the +latest run is awaited; for a schedule job that has not run yet, that means +blocking until its next scheduled fire. + +A run stopped by a timeout or by "docker job cancel" makes the command fail +with status 1, whatever the exit code of its container. + +With --condition running, the ID of the run is printed once it is running. A +run that already finished is reported as usual, by its exit code, so scripts +capturing the output must check which one they got. + +### Options + +| Name | Type | Default | Description | +|:--------------|:---------|:--------|:-----------------------------------------------------------------------------------------------| +| `--condition` | `string` | | Condition to wait for: `terminal` or `running`, i.e. the run left pending (default `terminal`) | +| `--run` | `string` | | Run to wait for: a run ID, or `latest` (default `latest`) | + + + + diff --git a/internal/jobsapi/v0/jobs.go b/internal/jobsapi/v0/jobs.go new file mode 100644 index 000000000000..eae3e7da00f7 --- /dev/null +++ b/internal/jobsapi/v0/jobs.go @@ -0,0 +1,558 @@ +// Copied from moby/moby, extpoints/jobs/api/v0/jobs.go at 402d0a16f1, for +// traceability. protogen/ is generated from this file: after regenerating it, +// re-add the go1.26 build constraint below to its files, otherwise the +// gocompat check fails. + +// 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 + +//go:generate go run github.com/moby/extensions/cmd/mobyextgen + +// Package jobsv0 defines the extension point through which the jobs extension +// exposes its client-facing API on the daemon socket. +// +// A Job is a daemon-managed resource holding a container spec plus a trigger +// declaration; each execution is a Run with a stable identity that survives +// container removal. Jobs fire manually or on a cron schedule; the daemon +// evaluates schedule triggers on its own clock, so scheduled work keeps firing +// while no client is connected. +// +// Wire-format conventions, constrained by the mobyextgen generator (no enums, +// oneof, or well-known types): +// - enumerations are documented strings (see the *State and policy constants); +// - timestamps are int64 Unix nanoseconds, zero meaning "not set"; +// - the container definition travels as a JSON payload (see JobSpec.ContainerSpec); +// - trigger kinds are mutually exclusive fields on a single message, and a +// trigger declaring no known kind is rejected rather than interpreted +// (see Trigger). +package jobsv0 + +import ( + "context" + + "github.com/moby/extensions" +) + +// Jobs is the jobs service. All methods are unary; blocking semantics +// (Wait) hold the request open until the condition is met. +// +// Errors are returned as gRPC status codes: InvalidArgument for a spec that +// fails validation, NotFound for an unknown job or run, AlreadyExists for a +// Create whose name is taken by a different spec (the message mentions both +// spec hashes for diagnostics only; the code is the contract), and +// FailedPrecondition for an execution refused by the concurrency policy. +type Jobs interface { + // Create registers a job without ever starting a run. It is idempotent on + // the spec hash: re-submitting the same name+spec is a no-op (Created is + // false), a new name registers the job (Created is true), and an existing + // name with a different spec fails with AlreadyExists. Registering a + // schedule job arms its trigger. + Create(ctx context.Context, req *CreateRequest) (*CreateReply, error) + + // Run executes an existing job, creating the next run. It fails with + // FailedPrecondition if the job is already running and its concurrency + // policy forbids overlap. + Run(ctx context.Context, req *RunRequest) (*RunReply, error) + + // CreateAndRun atomically registers a job if needed and starts a run. + // At least one of Name and Spec must be set; a missing name is generated + // by the daemon. Only manual jobs are served: a manual-trigger spec (or + // the nil-Trigger shorthand) is required, and a Name resolving to an + // existing schedule job is rejected with InvalidArgument. The name/spec + // resolution matrix mirrors Create: + // same spec runs the existing job, a different spec for an existing name + // fails with AlreadyExists. + CreateAndRun(ctx context.Context, req *CreateAndRunRequest) (*CreateAndRunReply, error) + + // Inspect returns a job with its latest run summary. + Inspect(ctx context.Context, req *InspectRequest) (*InspectReply, error) + + // List returns jobs matching all the given filters. + List(ctx context.Context, req *ListRequest) (*ListReply, error) + + // Pause suppresses a job's schedule trigger and discards a fire deferred + // by the queue concurrency policy. An in-flight run is not affected, and + // explicit Run calls still work. Idempotent; a no-op for manual jobs. + Pause(ctx context.Context, req *PauseRequest) error + + // Resume re-arms a paused job's schedule trigger from the next cron tick. + // Fires missed while paused are not backfilled. Idempotent. + Resume(ctx context.Context, req *ResumeRequest) error + + // Cancel stops the job's in-flight run. Triggers keep firing; use Pause + // to suppress them. + Cancel(ctx context.Context, req *CancelRequest) (*CancelReply, error) + + // Remove deletes a job, cancelling its in-flight run and disarming its + // trigger. Run history retention is controlled by RunsRemoval; for + // retention purposes the run cancelled by Remove itself counts as + // in-flight, so remove-finished keeps its record. + // The ID of an already removed job whose runs were kept is accepted with + // RunsRemoval remove, which purges that history. + Remove(ctx context.Context, req *RemoveRequest) error + + // Prune removes idle manual jobs. Schedule jobs are never pruned: an + // idle schedule job is armed, not abandoned. + Prune(ctx context.Context, req *PruneRequest) (*PruneReply, error) + + // ListRuns returns a job's runs, most recent first. + ListRuns(ctx context.Context, req *ListRunsRequest) (*ListRunsReply, error) + + // InspectRun returns a single run. + InspectRun(ctx context.Context, req *InspectRunRequest) (*InspectRunReply, error) + + // Wait blocks until the run satisfies the requested condition and + // returns the run as it is then. A run that jumps past the condition + // still satisfies it: waiting for running on a run that went straight + // from pending to a terminal state returns that terminal run. It returns + // immediately when the condition is already met. Cancelling the request + // context detaches the caller without affecting the run. + Wait(ctx context.Context, req *WaitRequest) (*WaitReply, error) +} + +// Point is single-cardinality: the jobs API has one authoritative provider, +// and the host rejects a second one at startup. +var Point = extensions.DefineSinglePoint[Jobs]("org.mobyproject.extension.jobs.api.v0") + +// Job states. A job is a trigger declaration: it is idle between fires, even +// when it has never run. Whether the last execution worked lives on the run. +const ( + // JobStateIdle means no run is currently executing. + JobStateIdle = "idle" + // JobStateRunning means a run is pending or running. + JobStateRunning = "running" +) + +// Run states. Terminal states (succeeded, failed, timed_out, cancelled) are +// sticky: a terminal run never changes again, re-execution creates a new run. +const ( + // RunStatePending covers the window between run-record creation and + // container start. A run that fails to create its container goes from + // pending straight to failed, with Error set and no exit code. + RunStatePending = "pending" + // RunStateRunning means the container is executing. + RunStateRunning = "running" + // RunStateSucceeded means the container exited zero. + RunStateSucceeded = "succeeded" + // RunStateFailed means the container exited nonzero after its restart + // policy was exhausted, or its creation failed. + RunStateFailed = "failed" + // RunStateTimedOut means the run exceeded JobSpec.TimeoutSeconds and was + // stopped by the daemon. + RunStateTimedOut = "timed_out" + // RunStateCancelled means the run was stopped by Cancel or by removing + // its job. + RunStateCancelled = "cancelled" +) + +// Concurrency policies, applied when a trigger fires while a run is already +// in flight. An empty value defaults to forbid. +const ( + // ConcurrencyForbid drops the new fire. + ConcurrencyForbid = "forbid" + // ConcurrencyQueue defers a single fire until the current run ends; + // further fires while one is queued are dropped. Pausing or removing + // the job discards the deferred fire. + ConcurrencyQueue = "queue" +) + +// Missed-fire policies, applied when the daemon starts up after schedule +// fires were missed. An empty value defaults to one. +const ( + // MissedFiresOne fires a single catch-up run, then re-arms from the next + // cron tick. + MissedFiresOne = "one" + // MissedFiresSkip drops missed fires and re-arms from the next cron tick. + MissedFiresSkip = "skip" +) + +// Trigger kinds, as recorded on run evidence and used in list filters. +const ( + // TriggerKindManual identifies jobs fired only by explicit Run calls. + TriggerKindManual = "manual" + // TriggerKindSchedule identifies jobs fired by the cron scheduler. + TriggerKindSchedule = "schedule" +) + +// Run-history removal modes for Remove. An empty value defaults to keep. +const ( + // RunsKeep retains all run records of the removed job, and their + // containers. + RunsKeep = "keep" + // RunsRemove drops all run records, including terminal ones, and + // removes their containers; the in-flight run's container is removed + // once it exits. + RunsRemove = "remove" + // RunsRemoveFinished drops terminal run records and their containers, + // and keeps in-flight ones. + RunsRemoveFinished = "remove-finished" +) + +// Wait conditions. An empty value defaults to terminal. +const ( + // WaitConditionTerminal waits until the run reaches a terminal state. + WaitConditionTerminal = "terminal" + // WaitConditionRunning waits until the run leaves pending. + WaitConditionRunning = "running" +) + +// JobSpec is the immutable definition of a job. The daemon canonicalizes the +// spec (applying defaults) before hashing it; the resulting hash is the job's +// identity for idempotent re-registration. +type JobSpec struct { + // ContainerSpec is the JSON-encoded container definition, in the exact + // format of the container-create API request body (Config, HostConfig, + // NetworkingConfig). It is decoded and validated by the same path as the + // container API; unknown fields are rejected. HostConfig.AutoRemove and + // the always/unless-stopped restart policies are rejected: run outcome + // capture requires the container to outlive its exit, and a job is + // expected to terminate. + ContainerSpec []byte `pb:"1"` + // Trigger declares what fires the job. A nil Trigger is the manual + // shorthand; a non-nil Trigger must declare exactly one kind (see + // Trigger). + Trigger *Trigger `pb:"2"` + // Labels are applied to the job itself, not to run containers. Run + // containers carry the reserved com.docker.job.id and + // com.docker.job.run-id labels instead. + Labels map[string]string `pb:"3"` + // TimeoutSeconds bounds a run's execution; past the deadline the daemon + // stops the container and the run ends timed_out. Zero means no timeout. + TimeoutSeconds int64 `pb:"4"` + // RemoveOnSuccess removes the run container after a successful exit, + // before the terminal run record is written, so the record reports + // ContainerGone. Logs are lost with the container; exit code and error + // are preserved on the run record. + RemoveOnSuccess bool `pb:"5"` + // RemoveOnFailure removes the run container after a failed exit. False + // by default so failed containers are kept for postmortem, until + // history eviction or Remove drops their run record. + RemoveOnFailure bool `pb:"6"` + // RunHistoryLimit caps retained run records, evicting the oldest + // terminal runs first, along with their containers. The in-flight run + // is never evicted. Zero means the daemon default of 10000; retaining + // no history is deliberately not supported. + RunHistoryLimit uint32 `pb:"7"` +} + +// Trigger declares what fires a job. Exactly one field must be set. +// +// A nil Trigger on the JobSpec is the manual shorthand; a non-nil Trigger +// that declares no known kind is rejected with InvalidArgument rather than +// interpreted. This is deliberate: protobuf silently drops fields it does not +// know, so a spec using a trigger kind from a newer contract version must +// fail loudly on an older daemon instead of silently registering as a manual +// job that never fires. Future trigger kinds (events) are added as new +// fields under this rule. +type Trigger struct { + // Manual declares that the job fires only on explicit Run calls. + // Mutually exclusive with Schedule. + Manual bool `pb:"1"` + // Schedule fires the job on a cron schedule. Mutually exclusive with + // Manual. + Schedule *ScheduleTrigger `pb:"2"` +} + +// ScheduleTrigger fires a job on the daemon's clock. +type ScheduleTrigger struct { + // Cron is a strict five-field crontab expression of numbers, lists, + // ranges, and steps. Names and shortcuts such as @daily are not + // accepted on the wire; clients expand them. + Cron string `pb:"1"` + // Timezone is an IANA timezone name for evaluating the expression. + // Empty means UTC, never the daemon host's local timezone, so a spec + // evaluates identically on every host. + Timezone string `pb:"2"` + // Concurrency is the policy applied when the schedule fires while a run + // is in flight. See the Concurrency constants; empty means forbid. + Concurrency string `pb:"3"` + // MissedFires is the policy applied to fires missed while the daemon was + // down. See the MissedFires constants; empty means one. + MissedFires string `pb:"4"` +} + +// Job is a registered job and its current state. +type Job struct { + // ID is the daemon-generated stable identifier. + ID string `pb:"1"` + // Name is unique across all jobs on the daemon. + Name string `pb:"2"` + // Spec is the job definition as submitted, before canonicalization. + Spec *JobSpec `pb:"3"` + // SpecHash is the canonical hash of the spec after daemon-side + // canonicalization. Callers can compare hashes to check spec equality + // before a Create. It is computed when the job is registered; another + // daemon version may hash the same spec differently, and Create + // compares specs with the running daemon's canonicalization. + SpecHash string `pb:"4"` + // State is the job's execution state. See the JobState constants. + State string `pb:"5"` + // Paused reports whether the schedule trigger is suppressed. + Paused bool `pb:"6"` + // NextFireAtNano is the next scheduled fire in Unix nanoseconds. Zero + // when the job is manual, paused, or currently running. + NextFireAtNano int64 `pb:"7"` + // CreatedAtNano is the registration time in Unix nanoseconds. + CreatedAtNano int64 `pb:"8"` + // UpdatedAtNano is the last state-change time in Unix nanoseconds. + UpdatedAtNano int64 `pb:"9"` + // LatestRun is the most recent run, nil if the job never ran. + LatestRun *Run `pb:"10"` +} + +// Run is a single execution attempt of a job. +type Run struct { + // ID is the daemon-generated stable identifier, valid across container + // removal and daemon restarts. + ID string `pb:"1"` + // JobID identifies the owning job. + JobID string `pb:"2"` + // Iteration is the 1-indexed position of this run in the job's history. + Iteration uint64 `pb:"3"` + // ContainerID is the container backing this run. Consumers read run logs + // from the standard container logs API using this ID; the jobs service + // does not proxy logs. + ContainerID string `pb:"4"` + // ContainerGone reports that the container was removed while the run + // record was kept; its logs are no longer available. + ContainerGone bool `pb:"5"` + // State is the run's execution state. See the RunState constants. + State string `pb:"6"` + // CreatedAtNano is the run-record creation time in Unix nanoseconds. The + // record is written before the container is created, so a run exists + // even when container creation fails. + CreatedAtNano int64 `pb:"7"` + // StartedAtNano is the container start time in Unix nanoseconds, zero if + // the container never started. + StartedAtNano int64 `pb:"8"` + // FinishedAtNano is the terminal-transition time in Unix nanoseconds, + // zero while the run is in flight. + FinishedAtNano int64 `pb:"9"` + // ExitCode is the container's exit code, nil while in flight or when the + // container never ran. + ExitCode *ExitCode `pb:"10"` + // Error describes why a run failed outside the container's own exit, + // such as a container-create failure or a container lost across a daemon + // restart. + Error string `pb:"11"` + // Trigger records what fired this run. + Trigger *TriggerEvidence `pb:"12"` +} + +// ExitCode wraps an exit code so that absence (run never exited) is +// distinguishable from zero (run exited successfully). +type ExitCode struct { + // Value is the container's exit code. + Value int64 `pb:"1"` +} + +// TriggerEvidence records what fired a run. +type TriggerEvidence struct { + // Kind is the trigger kind. See the TriggerKind constants. + Kind string `pb:"1"` + // ScheduledAtNano is the cron time the fire was due, in Unix + // nanoseconds. Zero for manual fires. + ScheduledAtNano int64 `pb:"2"` + // FiredAtNano is when the daemon actually fired the run, in Unix + // nanoseconds. + FiredAtNano int64 `pb:"3"` +} + +// CreateRequest registers a job. +type CreateRequest struct { + // Name is the job name, unique across the daemon. Required. + Name string `pb:"1"` + // Spec is the job definition. Required. + Spec *JobSpec `pb:"2"` +} + +// CreateReply reports the registered job. +type CreateReply struct { + // Job is the registered (or pre-existing identical) job. + Job *Job `pb:"1"` + // Created is false when an identical job already existed and the call + // was a no-op. + Created bool `pb:"2"` +} + +// RunRequest executes an existing job. +type RunRequest struct { + // JobRef is the job's ID or name. + JobRef string `pb:"1"` + // Reschedule makes this fire stand in for the job's next scheduled + // occurrence, which is skipped; later occurrences keep the cron + // alignment. Rejected for jobs without a schedule trigger. + Reschedule bool `pb:"2"` +} + +// RunReply reports the created run. +type RunReply struct { + // Run is the newly created run. ContainerID is set once the container + // is created. + Run *Run `pb:"1"` +} + +// CreateAndRunRequest atomically registers a job if needed and runs it. +type CreateAndRunRequest struct { + // Name is the job name. Optional when Spec is set; the daemon then + // generates one. + Name string `pb:"1"` + // Spec is the job definition. Optional when Name refers to an existing + // job. + Spec *JobSpec `pb:"2"` +} + +// CreateAndRunReply reports the resolved job and its new run. +type CreateAndRunReply struct { + // Job is the resolved job, carrying the daemon-generated name when the + // request had none. + Job *Job `pb:"1"` + // Run is the newly created run. + Run *Run `pb:"2"` + // Created is false when the job already existed. + Created bool `pb:"3"` +} + +// InspectRequest fetches one job. +type InspectRequest struct { + // JobRef is the job's ID or name. + JobRef string `pb:"1"` +} + +// InspectReply carries the inspected job. +type InspectReply struct { + // Job is the inspected job. + Job *Job `pb:"1"` +} + +// ListRequest filters jobs. All filters are conjunctive; within one filter, +// values are disjunctive, matching the filter semantics of the container API. +type ListRequest struct { + // Names filters on exact job names. + Names []string `pb:"1"` + // Labels filters on job labels, each entry either "key" or "key=value". + Labels []string `pb:"2"` + // States filters on job states. See the JobState constants. + States []string `pb:"3"` + // TriggerKinds filters on trigger kinds. See the TriggerKind constants. + TriggerKinds []string `pb:"4"` + // Paused filters on the paused flag: "true", "false", or empty for both. + Paused string `pb:"5"` + // LatestRunStates filters on the state of each job's latest run. See the + // RunState constants. + LatestRunStates []string `pb:"6"` +} + +// ListReply carries the matching jobs. +type ListReply struct { + // Jobs are the matching jobs. LatestRun is not trimmed: each entry + // carries the same fields as Inspect. + Jobs []Job `pb:"1"` +} + +// PauseRequest suppresses a job's schedule trigger. +type PauseRequest struct { + // JobRef is the job's ID or name. + JobRef string `pb:"1"` +} + +// ResumeRequest re-arms a paused job's schedule trigger. +type ResumeRequest struct { + // JobRef is the job's ID or name. + JobRef string `pb:"1"` +} + +// CancelRequest stops a job's in-flight run. +type CancelRequest struct { + // JobRef is the job's ID or name. + JobRef string `pb:"1"` +} + +// CancelReply reports the cancelled run. +type CancelReply struct { + // RunID is the ID of the run that was cancelled, empty if no run was in + // flight. + RunID string `pb:"1"` +} + +// RemoveRequest deletes a job. +type RemoveRequest struct { + // JobRef is the job's ID or name, or the ID of a removed job whose runs + // were kept (see Jobs.Remove). + JobRef string `pb:"1"` + // RunsRemoval controls run-history retention. See the Runs constants; + // empty means keep. + RunsRemoval string `pb:"2"` +} + +// PruneRequest removes idle manual jobs. +type PruneRequest struct { + // Labels restricts pruning to jobs matching all entries, each either + // "key" or "key=value". + Labels []string `pb:"1"` +} + +// PruneReply reports what was pruned. +type PruneReply struct { + // RemovedJobIDs are the IDs of the removed jobs. + RemovedJobIDs []string `pb:"1"` +} + +// ListRunsRequest pages through a job's runs, most recent first. +type ListRunsRequest struct { + // JobRef is the job's ID or name, or the ID of a removed job whose runs + // were kept. Names resolve only to live jobs. + JobRef string `pb:"1"` + // Limit caps the page size. Zero means the daemon default of 20; a + // negative value is rejected with InvalidArgument. + Limit int32 `pb:"2"` + // Before restricts the page to runs older than the given cursor; pass + // the previous reply's NextCursor to fetch the next page. + Before string `pb:"3"` +} + +// ListRunsReply carries one page of runs. +type ListRunsReply struct { + // Runs are the page's runs, most recent first. + Runs []Run `pb:"1"` + // NextCursor resumes the listing on the next call, empty on the last + // page. + NextCursor string `pb:"2"` + // CursorStale reports that the requested Before cursor was evicted from + // history; the listing restarted from the most recent run. + CursorStale bool `pb:"3"` +} + +// InspectRunRequest fetches one run. +type InspectRunRequest struct { + // JobRef is the job's ID or name, or the ID of a removed job whose runs + // were kept. Names resolve only to live jobs. + JobRef string `pb:"1"` + // RunRef is the run's ID, or "latest" for the most recent run. + RunRef string `pb:"2"` +} + +// InspectRunReply carries the inspected run. +type InspectRunReply struct { + // Run is the inspected run. + Run *Run `pb:"1"` +} + +// WaitRequest blocks until a run reaches a condition. +type WaitRequest struct { + // JobRef is the job's ID or name. + JobRef string `pb:"1"` + // RunRef is the run's ID, or "latest" (also the default when empty). + // For a schedule job with no runs yet, "latest" resolves to the next + // run to fire, so the call may block until the next cron tick. + RunRef string `pb:"2"` + // Condition is the state to wait for. See the WaitCondition constants; + // empty means terminal. + Condition string `pb:"3"` +} + +// WaitReply carries the run in its awaited state. +type WaitReply struct { + // Run is the run that reached the condition. + Run *Run `pb:"1"` +} diff --git a/internal/jobsapi/v0/jobs.proto b/internal/jobsapi/v0/jobs.proto new file mode 100644 index 000000000000..0d40b8152d53 --- /dev/null +++ b/internal/jobsapi/v0/jobs.proto @@ -0,0 +1,200 @@ +// Code generated by mobyextgen. DO NOT EDIT. + +syntax = "proto3"; + +package org.mobyproject.extension.jobs.api.v0; + +option go_package = "github.com/docker/cli/internal/jobsapi/v0/protogen"; + +service Jobs { + rpc Create(CreateRequest) returns (CreateReply); + rpc Run(RunRequest) returns (RunReply); + rpc CreateAndRun(CreateAndRunRequest) returns (CreateAndRunReply); + rpc Inspect(InspectRequest) returns (InspectReply); + rpc List(ListRequest) returns (ListReply); + rpc Pause(PauseRequest) returns (PauseResponse); + rpc Resume(ResumeRequest) returns (ResumeResponse); + rpc Cancel(CancelRequest) returns (CancelReply); + rpc Remove(RemoveRequest) returns (RemoveResponse); + rpc Prune(PruneRequest) returns (PruneReply); + rpc ListRuns(ListRunsRequest) returns (ListRunsReply); + rpc InspectRun(InspectRunRequest) returns (InspectRunReply); + rpc Wait(WaitRequest) returns (WaitReply); +} + +message CancelReply { + string run_id = 1; +} + +message CancelRequest { + string job_ref = 1; +} + +message CreateAndRunReply { + Job job = 1; + Run run = 2; + bool created = 3; +} + +message CreateAndRunRequest { + string name = 1; + JobSpec spec = 2; +} + +message CreateReply { + Job job = 1; + bool created = 2; +} + +message CreateRequest { + string name = 1; + JobSpec spec = 2; +} + +message ExitCode { + int64 value = 1; +} + +message InspectReply { + Job job = 1; +} + +message InspectRequest { + string job_ref = 1; +} + +message InspectRunReply { + Run run = 1; +} + +message InspectRunRequest { + string job_ref = 1; + string run_ref = 2; +} + +message Job { + string id = 1; + string name = 2; + JobSpec spec = 3; + string spec_hash = 4; + string state = 5; + bool paused = 6; + int64 next_fire_at_nano = 7; + int64 created_at_nano = 8; + int64 updated_at_nano = 9; + Run latest_run = 10; +} + +message JobSpec { + bytes container_spec = 1; + Trigger trigger = 2; + map labels = 3; + int64 timeout_seconds = 4; + bool remove_on_success = 5; + bool remove_on_failure = 6; + uint32 run_history_limit = 7; +} + +message ListReply { + repeated Job jobs = 1; +} + +message ListRequest { + repeated string names = 1; + repeated string labels = 2; + repeated string states = 3; + repeated string trigger_kinds = 4; + string paused = 5; + repeated string latest_run_states = 6; +} + +message ListRunsReply { + repeated Run runs = 1; + string next_cursor = 2; + bool cursor_stale = 3; +} + +message ListRunsRequest { + string job_ref = 1; + int32 limit = 2; + string before = 3; +} + +message PauseRequest { + string job_ref = 1; +} + +message PruneReply { + repeated string removed_job_ids = 1; +} + +message PruneRequest { + repeated string labels = 1; +} + +message RemoveRequest { + string job_ref = 1; + string runs_removal = 2; +} + +message ResumeRequest { + string job_ref = 1; +} + +message Run { + string id = 1; + string job_id = 2; + uint64 iteration = 3; + string container_id = 4; + bool container_gone = 5; + string state = 6; + int64 created_at_nano = 7; + int64 started_at_nano = 8; + int64 finished_at_nano = 9; + ExitCode exit_code = 10; + string error = 11; + TriggerEvidence trigger = 12; +} + +message RunReply { + Run run = 1; +} + +message RunRequest { + string job_ref = 1; + bool reschedule = 2; +} + +message ScheduleTrigger { + string cron = 1; + string timezone = 2; + string concurrency = 3; + string missed_fires = 4; +} + +message Trigger { + bool manual = 1; + ScheduleTrigger schedule = 2; +} + +message TriggerEvidence { + string kind = 1; + int64 scheduled_at_nano = 2; + int64 fired_at_nano = 3; +} + +message WaitReply { + Run run = 1; +} + +message WaitRequest { + string job_ref = 1; + string run_ref = 2; + string condition = 3; +} + +message PauseResponse {} + +message ResumeResponse {} + +message RemoveResponse {} diff --git a/internal/jobsapi/v0/protogen/jobs.pb.go b/internal/jobsapi/v0/protogen/jobs.pb.go new file mode 100644 index 000000000000..6cd45bae03cd --- /dev/null +++ b/internal/jobsapi/v0/protogen/jobs.pb.go @@ -0,0 +1,2132 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: internal/jobsapi/v0/jobs.proto + +// 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 protogen + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CancelReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelReply) Reset() { + *x = CancelReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelReply) ProtoMessage() {} + +func (x *CancelReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelReply.ProtoReflect.Descriptor instead. +func (*CancelReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{0} +} + +func (x *CancelReply) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +type CancelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelRequest) Reset() { + *x = CancelRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelRequest) ProtoMessage() {} + +func (x *CancelRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelRequest.ProtoReflect.Descriptor instead. +func (*CancelRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{1} +} + +func (x *CancelRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +type CreateAndRunReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Job *Job `protobuf:"bytes,1,opt,name=job,proto3" json:"job,omitempty"` + Run *Run `protobuf:"bytes,2,opt,name=run,proto3" json:"run,omitempty"` + Created bool `protobuf:"varint,3,opt,name=created,proto3" json:"created,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateAndRunReply) Reset() { + *x = CreateAndRunReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateAndRunReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateAndRunReply) ProtoMessage() {} + +func (x *CreateAndRunReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateAndRunReply.ProtoReflect.Descriptor instead. +func (*CreateAndRunReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{2} +} + +func (x *CreateAndRunReply) GetJob() *Job { + if x != nil { + return x.Job + } + return nil +} + +func (x *CreateAndRunReply) GetRun() *Run { + if x != nil { + return x.Run + } + return nil +} + +func (x *CreateAndRunReply) GetCreated() bool { + if x != nil { + return x.Created + } + return false +} + +type CreateAndRunRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Spec *JobSpec `protobuf:"bytes,2,opt,name=spec,proto3" json:"spec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateAndRunRequest) Reset() { + *x = CreateAndRunRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateAndRunRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateAndRunRequest) ProtoMessage() {} + +func (x *CreateAndRunRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateAndRunRequest.ProtoReflect.Descriptor instead. +func (*CreateAndRunRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateAndRunRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateAndRunRequest) GetSpec() *JobSpec { + if x != nil { + return x.Spec + } + return nil +} + +type CreateReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Job *Job `protobuf:"bytes,1,opt,name=job,proto3" json:"job,omitempty"` + Created bool `protobuf:"varint,2,opt,name=created,proto3" json:"created,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateReply) Reset() { + *x = CreateReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateReply) ProtoMessage() {} + +func (x *CreateReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateReply.ProtoReflect.Descriptor instead. +func (*CreateReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateReply) GetJob() *Job { + if x != nil { + return x.Job + } + return nil +} + +func (x *CreateReply) GetCreated() bool { + if x != nil { + return x.Created + } + return false +} + +type CreateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Spec *JobSpec `protobuf:"bytes,2,opt,name=spec,proto3" json:"spec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateRequest) Reset() { + *x = CreateRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateRequest) ProtoMessage() {} + +func (x *CreateRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateRequest.ProtoReflect.Descriptor instead. +func (*CreateRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{5} +} + +func (x *CreateRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateRequest) GetSpec() *JobSpec { + if x != nil { + return x.Spec + } + return nil +} + +type ExitCode struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value int64 `protobuf:"varint,1,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExitCode) Reset() { + *x = ExitCode{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExitCode) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExitCode) ProtoMessage() {} + +func (x *ExitCode) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExitCode.ProtoReflect.Descriptor instead. +func (*ExitCode) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{6} +} + +func (x *ExitCode) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +type InspectReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Job *Job `protobuf:"bytes,1,opt,name=job,proto3" json:"job,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InspectReply) Reset() { + *x = InspectReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InspectReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InspectReply) ProtoMessage() {} + +func (x *InspectReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InspectReply.ProtoReflect.Descriptor instead. +func (*InspectReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{7} +} + +func (x *InspectReply) GetJob() *Job { + if x != nil { + return x.Job + } + return nil +} + +type InspectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InspectRequest) Reset() { + *x = InspectRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InspectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InspectRequest) ProtoMessage() {} + +func (x *InspectRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InspectRequest.ProtoReflect.Descriptor instead. +func (*InspectRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{8} +} + +func (x *InspectRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +type InspectRunReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Run *Run `protobuf:"bytes,1,opt,name=run,proto3" json:"run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InspectRunReply) Reset() { + *x = InspectRunReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InspectRunReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InspectRunReply) ProtoMessage() {} + +func (x *InspectRunReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InspectRunReply.ProtoReflect.Descriptor instead. +func (*InspectRunReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{9} +} + +func (x *InspectRunReply) GetRun() *Run { + if x != nil { + return x.Run + } + return nil +} + +type InspectRunRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + RunRef string `protobuf:"bytes,2,opt,name=run_ref,json=runRef,proto3" json:"run_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InspectRunRequest) Reset() { + *x = InspectRunRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InspectRunRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InspectRunRequest) ProtoMessage() {} + +func (x *InspectRunRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InspectRunRequest.ProtoReflect.Descriptor instead. +func (*InspectRunRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{10} +} + +func (x *InspectRunRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +func (x *InspectRunRequest) GetRunRef() string { + if x != nil { + return x.RunRef + } + return "" +} + +type Job struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Spec *JobSpec `protobuf:"bytes,3,opt,name=spec,proto3" json:"spec,omitempty"` + SpecHash string `protobuf:"bytes,4,opt,name=spec_hash,json=specHash,proto3" json:"spec_hash,omitempty"` + State string `protobuf:"bytes,5,opt,name=state,proto3" json:"state,omitempty"` + Paused bool `protobuf:"varint,6,opt,name=paused,proto3" json:"paused,omitempty"` + NextFireAtNano int64 `protobuf:"varint,7,opt,name=next_fire_at_nano,json=nextFireAtNano,proto3" json:"next_fire_at_nano,omitempty"` + CreatedAtNano int64 `protobuf:"varint,8,opt,name=created_at_nano,json=createdAtNano,proto3" json:"created_at_nano,omitempty"` + UpdatedAtNano int64 `protobuf:"varint,9,opt,name=updated_at_nano,json=updatedAtNano,proto3" json:"updated_at_nano,omitempty"` + LatestRun *Run `protobuf:"bytes,10,opt,name=latest_run,json=latestRun,proto3" json:"latest_run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Job) Reset() { + *x = Job{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Job) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Job) ProtoMessage() {} + +func (x *Job) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Job.ProtoReflect.Descriptor instead. +func (*Job) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{11} +} + +func (x *Job) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Job) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Job) GetSpec() *JobSpec { + if x != nil { + return x.Spec + } + return nil +} + +func (x *Job) GetSpecHash() string { + if x != nil { + return x.SpecHash + } + return "" +} + +func (x *Job) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Job) GetPaused() bool { + if x != nil { + return x.Paused + } + return false +} + +func (x *Job) GetNextFireAtNano() int64 { + if x != nil { + return x.NextFireAtNano + } + return 0 +} + +func (x *Job) GetCreatedAtNano() int64 { + if x != nil { + return x.CreatedAtNano + } + return 0 +} + +func (x *Job) GetUpdatedAtNano() int64 { + if x != nil { + return x.UpdatedAtNano + } + return 0 +} + +func (x *Job) GetLatestRun() *Run { + if x != nil { + return x.LatestRun + } + return nil +} + +type JobSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + ContainerSpec []byte `protobuf:"bytes,1,opt,name=container_spec,json=containerSpec,proto3" json:"container_spec,omitempty"` + Trigger *Trigger `protobuf:"bytes,2,opt,name=trigger,proto3" json:"trigger,omitempty"` + Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + TimeoutSeconds int64 `protobuf:"varint,4,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + RemoveOnSuccess bool `protobuf:"varint,5,opt,name=remove_on_success,json=removeOnSuccess,proto3" json:"remove_on_success,omitempty"` + RemoveOnFailure bool `protobuf:"varint,6,opt,name=remove_on_failure,json=removeOnFailure,proto3" json:"remove_on_failure,omitempty"` + RunHistoryLimit uint32 `protobuf:"varint,7,opt,name=run_history_limit,json=runHistoryLimit,proto3" json:"run_history_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JobSpec) Reset() { + *x = JobSpec{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JobSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JobSpec) ProtoMessage() {} + +func (x *JobSpec) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JobSpec.ProtoReflect.Descriptor instead. +func (*JobSpec) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{12} +} + +func (x *JobSpec) GetContainerSpec() []byte { + if x != nil { + return x.ContainerSpec + } + return nil +} + +func (x *JobSpec) GetTrigger() *Trigger { + if x != nil { + return x.Trigger + } + return nil +} + +func (x *JobSpec) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *JobSpec) GetTimeoutSeconds() int64 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +func (x *JobSpec) GetRemoveOnSuccess() bool { + if x != nil { + return x.RemoveOnSuccess + } + return false +} + +func (x *JobSpec) GetRemoveOnFailure() bool { + if x != nil { + return x.RemoveOnFailure + } + return false +} + +func (x *JobSpec) GetRunHistoryLimit() uint32 { + if x != nil { + return x.RunHistoryLimit + } + return 0 +} + +type ListReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Jobs []*Job `protobuf:"bytes,1,rep,name=jobs,proto3" json:"jobs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListReply) Reset() { + *x = ListReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListReply) ProtoMessage() {} + +func (x *ListReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListReply.ProtoReflect.Descriptor instead. +func (*ListReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{13} +} + +func (x *ListReply) GetJobs() []*Job { + if x != nil { + return x.Jobs + } + return nil +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` + Labels []string `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty"` + States []string `protobuf:"bytes,3,rep,name=states,proto3" json:"states,omitempty"` + TriggerKinds []string `protobuf:"bytes,4,rep,name=trigger_kinds,json=triggerKinds,proto3" json:"trigger_kinds,omitempty"` + Paused string `protobuf:"bytes,5,opt,name=paused,proto3" json:"paused,omitempty"` + LatestRunStates []string `protobuf:"bytes,6,rep,name=latest_run_states,json=latestRunStates,proto3" json:"latest_run_states,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{14} +} + +func (x *ListRequest) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +func (x *ListRequest) GetLabels() []string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *ListRequest) GetStates() []string { + if x != nil { + return x.States + } + return nil +} + +func (x *ListRequest) GetTriggerKinds() []string { + if x != nil { + return x.TriggerKinds + } + return nil +} + +func (x *ListRequest) GetPaused() string { + if x != nil { + return x.Paused + } + return "" +} + +func (x *ListRequest) GetLatestRunStates() []string { + if x != nil { + return x.LatestRunStates + } + return nil +} + +type ListRunsReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Runs []*Run `protobuf:"bytes,1,rep,name=runs,proto3" json:"runs,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + CursorStale bool `protobuf:"varint,3,opt,name=cursor_stale,json=cursorStale,proto3" json:"cursor_stale,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRunsReply) Reset() { + *x = ListRunsReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRunsReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRunsReply) ProtoMessage() {} + +func (x *ListRunsReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRunsReply.ProtoReflect.Descriptor instead. +func (*ListRunsReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{15} +} + +func (x *ListRunsReply) GetRuns() []*Run { + if x != nil { + return x.Runs + } + return nil +} + +func (x *ListRunsReply) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +func (x *ListRunsReply) GetCursorStale() bool { + if x != nil { + return x.CursorStale + } + return false +} + +type ListRunsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + Before string `protobuf:"bytes,3,opt,name=before,proto3" json:"before,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRunsRequest) Reset() { + *x = ListRunsRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRunsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRunsRequest) ProtoMessage() {} + +func (x *ListRunsRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRunsRequest.ProtoReflect.Descriptor instead. +func (*ListRunsRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{16} +} + +func (x *ListRunsRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +func (x *ListRunsRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListRunsRequest) GetBefore() string { + if x != nil { + return x.Before + } + return "" +} + +type PauseRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PauseRequest) Reset() { + *x = PauseRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PauseRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseRequest) ProtoMessage() {} + +func (x *PauseRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseRequest.ProtoReflect.Descriptor instead. +func (*PauseRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{17} +} + +func (x *PauseRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +type PruneReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + RemovedJobIds []string `protobuf:"bytes,1,rep,name=removed_job_ids,json=removedJobIds,proto3" json:"removed_job_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PruneReply) Reset() { + *x = PruneReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PruneReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PruneReply) ProtoMessage() {} + +func (x *PruneReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PruneReply.ProtoReflect.Descriptor instead. +func (*PruneReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{18} +} + +func (x *PruneReply) GetRemovedJobIds() []string { + if x != nil { + return x.RemovedJobIds + } + return nil +} + +type PruneRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Labels []string `protobuf:"bytes,1,rep,name=labels,proto3" json:"labels,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PruneRequest) Reset() { + *x = PruneRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PruneRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PruneRequest) ProtoMessage() {} + +func (x *PruneRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PruneRequest.ProtoReflect.Descriptor instead. +func (*PruneRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{19} +} + +func (x *PruneRequest) GetLabels() []string { + if x != nil { + return x.Labels + } + return nil +} + +type RemoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + RunsRemoval string `protobuf:"bytes,2,opt,name=runs_removal,json=runsRemoval,proto3" json:"runs_removal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveRequest) Reset() { + *x = RemoveRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveRequest) ProtoMessage() {} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveRequest.ProtoReflect.Descriptor instead. +func (*RemoveRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{20} +} + +func (x *RemoveRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +func (x *RemoveRequest) GetRunsRemoval() string { + if x != nil { + return x.RunsRemoval + } + return "" +} + +type ResumeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeRequest) Reset() { + *x = ResumeRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeRequest) ProtoMessage() {} + +func (x *ResumeRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeRequest.ProtoReflect.Descriptor instead. +func (*ResumeRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{21} +} + +func (x *ResumeRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +type Run struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + JobId string `protobuf:"bytes,2,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + Iteration uint64 `protobuf:"varint,3,opt,name=iteration,proto3" json:"iteration,omitempty"` + ContainerId string `protobuf:"bytes,4,opt,name=container_id,json=containerId,proto3" json:"container_id,omitempty"` + ContainerGone bool `protobuf:"varint,5,opt,name=container_gone,json=containerGone,proto3" json:"container_gone,omitempty"` + State string `protobuf:"bytes,6,opt,name=state,proto3" json:"state,omitempty"` + CreatedAtNano int64 `protobuf:"varint,7,opt,name=created_at_nano,json=createdAtNano,proto3" json:"created_at_nano,omitempty"` + StartedAtNano int64 `protobuf:"varint,8,opt,name=started_at_nano,json=startedAtNano,proto3" json:"started_at_nano,omitempty"` + FinishedAtNano int64 `protobuf:"varint,9,opt,name=finished_at_nano,json=finishedAtNano,proto3" json:"finished_at_nano,omitempty"` + ExitCode *ExitCode `protobuf:"bytes,10,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Error string `protobuf:"bytes,11,opt,name=error,proto3" json:"error,omitempty"` + Trigger *TriggerEvidence `protobuf:"bytes,12,opt,name=trigger,proto3" json:"trigger,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Run) Reset() { + *x = Run{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Run) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Run) ProtoMessage() {} + +func (x *Run) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Run.ProtoReflect.Descriptor instead. +func (*Run) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{22} +} + +func (x *Run) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Run) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *Run) GetIteration() uint64 { + if x != nil { + return x.Iteration + } + return 0 +} + +func (x *Run) GetContainerId() string { + if x != nil { + return x.ContainerId + } + return "" +} + +func (x *Run) GetContainerGone() bool { + if x != nil { + return x.ContainerGone + } + return false +} + +func (x *Run) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Run) GetCreatedAtNano() int64 { + if x != nil { + return x.CreatedAtNano + } + return 0 +} + +func (x *Run) GetStartedAtNano() int64 { + if x != nil { + return x.StartedAtNano + } + return 0 +} + +func (x *Run) GetFinishedAtNano() int64 { + if x != nil { + return x.FinishedAtNano + } + return 0 +} + +func (x *Run) GetExitCode() *ExitCode { + if x != nil { + return x.ExitCode + } + return nil +} + +func (x *Run) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *Run) GetTrigger() *TriggerEvidence { + if x != nil { + return x.Trigger + } + return nil +} + +type RunReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Run *Run `protobuf:"bytes,1,opt,name=run,proto3" json:"run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RunReply) Reset() { + *x = RunReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunReply) ProtoMessage() {} + +func (x *RunReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunReply.ProtoReflect.Descriptor instead. +func (*RunReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{23} +} + +func (x *RunReply) GetRun() *Run { + if x != nil { + return x.Run + } + return nil +} + +type RunRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + Reschedule bool `protobuf:"varint,2,opt,name=reschedule,proto3" json:"reschedule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RunRequest) Reset() { + *x = RunRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunRequest) ProtoMessage() {} + +func (x *RunRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunRequest.ProtoReflect.Descriptor instead. +func (*RunRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{24} +} + +func (x *RunRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +func (x *RunRequest) GetReschedule() bool { + if x != nil { + return x.Reschedule + } + return false +} + +type ScheduleTrigger struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cron string `protobuf:"bytes,1,opt,name=cron,proto3" json:"cron,omitempty"` + Timezone string `protobuf:"bytes,2,opt,name=timezone,proto3" json:"timezone,omitempty"` + Concurrency string `protobuf:"bytes,3,opt,name=concurrency,proto3" json:"concurrency,omitempty"` + MissedFires string `protobuf:"bytes,4,opt,name=missed_fires,json=missedFires,proto3" json:"missed_fires,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ScheduleTrigger) Reset() { + *x = ScheduleTrigger{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ScheduleTrigger) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScheduleTrigger) ProtoMessage() {} + +func (x *ScheduleTrigger) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScheduleTrigger.ProtoReflect.Descriptor instead. +func (*ScheduleTrigger) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{25} +} + +func (x *ScheduleTrigger) GetCron() string { + if x != nil { + return x.Cron + } + return "" +} + +func (x *ScheduleTrigger) GetTimezone() string { + if x != nil { + return x.Timezone + } + return "" +} + +func (x *ScheduleTrigger) GetConcurrency() string { + if x != nil { + return x.Concurrency + } + return "" +} + +func (x *ScheduleTrigger) GetMissedFires() string { + if x != nil { + return x.MissedFires + } + return "" +} + +type Trigger struct { + state protoimpl.MessageState `protogen:"open.v1"` + Manual bool `protobuf:"varint,1,opt,name=manual,proto3" json:"manual,omitempty"` + Schedule *ScheduleTrigger `protobuf:"bytes,2,opt,name=schedule,proto3" json:"schedule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Trigger) Reset() { + *x = Trigger{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Trigger) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Trigger) ProtoMessage() {} + +func (x *Trigger) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Trigger.ProtoReflect.Descriptor instead. +func (*Trigger) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{26} +} + +func (x *Trigger) GetManual() bool { + if x != nil { + return x.Manual + } + return false +} + +func (x *Trigger) GetSchedule() *ScheduleTrigger { + if x != nil { + return x.Schedule + } + return nil +} + +type TriggerEvidence struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + ScheduledAtNano int64 `protobuf:"varint,2,opt,name=scheduled_at_nano,json=scheduledAtNano,proto3" json:"scheduled_at_nano,omitempty"` + FiredAtNano int64 `protobuf:"varint,3,opt,name=fired_at_nano,json=firedAtNano,proto3" json:"fired_at_nano,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerEvidence) Reset() { + *x = TriggerEvidence{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerEvidence) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerEvidence) ProtoMessage() {} + +func (x *TriggerEvidence) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerEvidence.ProtoReflect.Descriptor instead. +func (*TriggerEvidence) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{27} +} + +func (x *TriggerEvidence) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *TriggerEvidence) GetScheduledAtNano() int64 { + if x != nil { + return x.ScheduledAtNano + } + return 0 +} + +func (x *TriggerEvidence) GetFiredAtNano() int64 { + if x != nil { + return x.FiredAtNano + } + return 0 +} + +type WaitReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Run *Run `protobuf:"bytes,1,opt,name=run,proto3" json:"run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitReply) Reset() { + *x = WaitReply{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitReply) ProtoMessage() {} + +func (x *WaitReply) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WaitReply.ProtoReflect.Descriptor instead. +func (*WaitReply) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{28} +} + +func (x *WaitReply) GetRun() *Run { + if x != nil { + return x.Run + } + return nil +} + +type WaitRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobRef string `protobuf:"bytes,1,opt,name=job_ref,json=jobRef,proto3" json:"job_ref,omitempty"` + RunRef string `protobuf:"bytes,2,opt,name=run_ref,json=runRef,proto3" json:"run_ref,omitempty"` + Condition string `protobuf:"bytes,3,opt,name=condition,proto3" json:"condition,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitRequest) Reset() { + *x = WaitRequest{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitRequest) ProtoMessage() {} + +func (x *WaitRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WaitRequest.ProtoReflect.Descriptor instead. +func (*WaitRequest) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{29} +} + +func (x *WaitRequest) GetJobRef() string { + if x != nil { + return x.JobRef + } + return "" +} + +func (x *WaitRequest) GetRunRef() string { + if x != nil { + return x.RunRef + } + return "" +} + +func (x *WaitRequest) GetCondition() string { + if x != nil { + return x.Condition + } + return "" +} + +type PauseResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PauseResponse) Reset() { + *x = PauseResponse{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PauseResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseResponse) ProtoMessage() {} + +func (x *PauseResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseResponse.ProtoReflect.Descriptor instead. +func (*PauseResponse) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{30} +} + +type ResumeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeResponse) Reset() { + *x = ResumeResponse{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeResponse) ProtoMessage() {} + +func (x *ResumeResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeResponse.ProtoReflect.Descriptor instead. +func (*ResumeResponse) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{31} +} + +type RemoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveResponse) Reset() { + *x = RemoveResponse{} + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveResponse) ProtoMessage() {} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_jobsapi_v0_jobs_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveResponse.ProtoReflect.Descriptor instead. +func (*RemoveResponse) Descriptor() ([]byte, []int) { + return file_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{32} +} + +var File_internal_jobsapi_v0_jobs_proto protoreflect.FileDescriptor + +const file_internal_jobsapi_v0_jobs_proto_rawDesc = "" + + "\n" + + "\x1einternal/jobsapi/v0/jobs.proto\x12%org.mobyproject.extension.jobs.api.v0\"$\n" + + "\vCancelReply\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\"(\n" + + "\rCancelRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\"\xa9\x01\n" + + "\x11CreateAndRunReply\x12<\n" + + "\x03job\x18\x01 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.JobR\x03job\x12<\n" + + "\x03run\x18\x02 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.RunR\x03run\x12\x18\n" + + "\acreated\x18\x03 \x01(\bR\acreated\"m\n" + + "\x13CreateAndRunRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12B\n" + + "\x04spec\x18\x02 \x01(\v2..org.mobyproject.extension.jobs.api.v0.JobSpecR\x04spec\"e\n" + + "\vCreateReply\x12<\n" + + "\x03job\x18\x01 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.JobR\x03job\x12\x18\n" + + "\acreated\x18\x02 \x01(\bR\acreated\"g\n" + + "\rCreateRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12B\n" + + "\x04spec\x18\x02 \x01(\v2..org.mobyproject.extension.jobs.api.v0.JobSpecR\x04spec\" \n" + + "\bExitCode\x12\x14\n" + + "\x05value\x18\x01 \x01(\x03R\x05value\"L\n" + + "\fInspectReply\x12<\n" + + "\x03job\x18\x01 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.JobR\x03job\")\n" + + "\x0eInspectRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\"O\n" + + "\x0fInspectRunReply\x12<\n" + + "\x03run\x18\x01 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.RunR\x03run\"E\n" + + "\x11InspectRunRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\x12\x17\n" + + "\arun_ref\x18\x02 \x01(\tR\x06runRef\"\xfe\x02\n" + + "\x03Job\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12B\n" + + "\x04spec\x18\x03 \x01(\v2..org.mobyproject.extension.jobs.api.v0.JobSpecR\x04spec\x12\x1b\n" + + "\tspec_hash\x18\x04 \x01(\tR\bspecHash\x12\x14\n" + + "\x05state\x18\x05 \x01(\tR\x05state\x12\x16\n" + + "\x06paused\x18\x06 \x01(\bR\x06paused\x12)\n" + + "\x11next_fire_at_nano\x18\a \x01(\x03R\x0enextFireAtNano\x12&\n" + + "\x0fcreated_at_nano\x18\b \x01(\x03R\rcreatedAtNano\x12&\n" + + "\x0fupdated_at_nano\x18\t \x01(\x03R\rupdatedAtNano\x12I\n" + + "\n" + + "latest_run\x18\n" + + " \x01(\v2*.org.mobyproject.extension.jobs.api.v0.RunR\tlatestRun\"\xb6\x03\n" + + "\aJobSpec\x12%\n" + + "\x0econtainer_spec\x18\x01 \x01(\fR\rcontainerSpec\x12H\n" + + "\atrigger\x18\x02 \x01(\v2..org.mobyproject.extension.jobs.api.v0.TriggerR\atrigger\x12R\n" + + "\x06labels\x18\x03 \x03(\v2:.org.mobyproject.extension.jobs.api.v0.JobSpec.LabelsEntryR\x06labels\x12'\n" + + "\x0ftimeout_seconds\x18\x04 \x01(\x03R\x0etimeoutSeconds\x12*\n" + + "\x11remove_on_success\x18\x05 \x01(\bR\x0fremoveOnSuccess\x12*\n" + + "\x11remove_on_failure\x18\x06 \x01(\bR\x0fremoveOnFailure\x12*\n" + + "\x11run_history_limit\x18\a \x01(\rR\x0frunHistoryLimit\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"K\n" + + "\tListReply\x12>\n" + + "\x04jobs\x18\x01 \x03(\v2*.org.mobyproject.extension.jobs.api.v0.JobR\x04jobs\"\xbc\x01\n" + + "\vListRequest\x12\x14\n" + + "\x05names\x18\x01 \x03(\tR\x05names\x12\x16\n" + + "\x06labels\x18\x02 \x03(\tR\x06labels\x12\x16\n" + + "\x06states\x18\x03 \x03(\tR\x06states\x12#\n" + + "\rtrigger_kinds\x18\x04 \x03(\tR\ftriggerKinds\x12\x16\n" + + "\x06paused\x18\x05 \x01(\tR\x06paused\x12*\n" + + "\x11latest_run_states\x18\x06 \x03(\tR\x0flatestRunStates\"\x93\x01\n" + + "\rListRunsReply\x12>\n" + + "\x04runs\x18\x01 \x03(\v2*.org.mobyproject.extension.jobs.api.v0.RunR\x04runs\x12\x1f\n" + + "\vnext_cursor\x18\x02 \x01(\tR\n" + + "nextCursor\x12!\n" + + "\fcursor_stale\x18\x03 \x01(\bR\vcursorStale\"X\n" + + "\x0fListRunsRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\x12\x14\n" + + "\x05limit\x18\x02 \x01(\x05R\x05limit\x12\x16\n" + + "\x06before\x18\x03 \x01(\tR\x06before\"'\n" + + "\fPauseRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\"4\n" + + "\n" + + "PruneReply\x12&\n" + + "\x0fremoved_job_ids\x18\x01 \x03(\tR\rremovedJobIds\"&\n" + + "\fPruneRequest\x12\x16\n" + + "\x06labels\x18\x01 \x03(\tR\x06labels\"K\n" + + "\rRemoveRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\x12!\n" + + "\fruns_removal\x18\x02 \x01(\tR\vrunsRemoval\"(\n" + + "\rResumeRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\"\xda\x03\n" + + "\x03Run\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + + "\x06job_id\x18\x02 \x01(\tR\x05jobId\x12\x1c\n" + + "\titeration\x18\x03 \x01(\x04R\titeration\x12!\n" + + "\fcontainer_id\x18\x04 \x01(\tR\vcontainerId\x12%\n" + + "\x0econtainer_gone\x18\x05 \x01(\bR\rcontainerGone\x12\x14\n" + + "\x05state\x18\x06 \x01(\tR\x05state\x12&\n" + + "\x0fcreated_at_nano\x18\a \x01(\x03R\rcreatedAtNano\x12&\n" + + "\x0fstarted_at_nano\x18\b \x01(\x03R\rstartedAtNano\x12(\n" + + "\x10finished_at_nano\x18\t \x01(\x03R\x0efinishedAtNano\x12L\n" + + "\texit_code\x18\n" + + " \x01(\v2/.org.mobyproject.extension.jobs.api.v0.ExitCodeR\bexitCode\x12\x14\n" + + "\x05error\x18\v \x01(\tR\x05error\x12P\n" + + "\atrigger\x18\f \x01(\v26.org.mobyproject.extension.jobs.api.v0.TriggerEvidenceR\atrigger\"H\n" + + "\bRunReply\x12<\n" + + "\x03run\x18\x01 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.RunR\x03run\"E\n" + + "\n" + + "RunRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\x12\x1e\n" + + "\n" + + "reschedule\x18\x02 \x01(\bR\n" + + "reschedule\"\x86\x01\n" + + "\x0fScheduleTrigger\x12\x12\n" + + "\x04cron\x18\x01 \x01(\tR\x04cron\x12\x1a\n" + + "\btimezone\x18\x02 \x01(\tR\btimezone\x12 \n" + + "\vconcurrency\x18\x03 \x01(\tR\vconcurrency\x12!\n" + + "\fmissed_fires\x18\x04 \x01(\tR\vmissedFires\"u\n" + + "\aTrigger\x12\x16\n" + + "\x06manual\x18\x01 \x01(\bR\x06manual\x12R\n" + + "\bschedule\x18\x02 \x01(\v26.org.mobyproject.extension.jobs.api.v0.ScheduleTriggerR\bschedule\"u\n" + + "\x0fTriggerEvidence\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12*\n" + + "\x11scheduled_at_nano\x18\x02 \x01(\x03R\x0fscheduledAtNano\x12\"\n" + + "\rfired_at_nano\x18\x03 \x01(\x03R\vfiredAtNano\"I\n" + + "\tWaitReply\x12<\n" + + "\x03run\x18\x01 \x01(\v2*.org.mobyproject.extension.jobs.api.v0.RunR\x03run\"]\n" + + "\vWaitRequest\x12\x17\n" + + "\ajob_ref\x18\x01 \x01(\tR\x06jobRef\x12\x17\n" + + "\arun_ref\x18\x02 \x01(\tR\x06runRef\x12\x1c\n" + + "\tcondition\x18\x03 \x01(\tR\tcondition\"\x0f\n" + + "\rPauseResponse\"\x10\n" + + "\x0eResumeResponse\"\x10\n" + + "\x0eRemoveResponse2\x80\f\n" + + "\x04Jobs\x12r\n" + + "\x06Create\x124.org.mobyproject.extension.jobs.api.v0.CreateRequest\x1a2.org.mobyproject.extension.jobs.api.v0.CreateReply\x12i\n" + + "\x03Run\x121.org.mobyproject.extension.jobs.api.v0.RunRequest\x1a/.org.mobyproject.extension.jobs.api.v0.RunReply\x12\x84\x01\n" + + "\fCreateAndRun\x12:.org.mobyproject.extension.jobs.api.v0.CreateAndRunRequest\x1a8.org.mobyproject.extension.jobs.api.v0.CreateAndRunReply\x12u\n" + + "\aInspect\x125.org.mobyproject.extension.jobs.api.v0.InspectRequest\x1a3.org.mobyproject.extension.jobs.api.v0.InspectReply\x12l\n" + + "\x04List\x122.org.mobyproject.extension.jobs.api.v0.ListRequest\x1a0.org.mobyproject.extension.jobs.api.v0.ListReply\x12r\n" + + "\x05Pause\x123.org.mobyproject.extension.jobs.api.v0.PauseRequest\x1a4.org.mobyproject.extension.jobs.api.v0.PauseResponse\x12u\n" + + "\x06Resume\x124.org.mobyproject.extension.jobs.api.v0.ResumeRequest\x1a5.org.mobyproject.extension.jobs.api.v0.ResumeResponse\x12r\n" + + "\x06Cancel\x124.org.mobyproject.extension.jobs.api.v0.CancelRequest\x1a2.org.mobyproject.extension.jobs.api.v0.CancelReply\x12u\n" + + "\x06Remove\x124.org.mobyproject.extension.jobs.api.v0.RemoveRequest\x1a5.org.mobyproject.extension.jobs.api.v0.RemoveResponse\x12o\n" + + "\x05Prune\x123.org.mobyproject.extension.jobs.api.v0.PruneRequest\x1a1.org.mobyproject.extension.jobs.api.v0.PruneReply\x12x\n" + + "\bListRuns\x126.org.mobyproject.extension.jobs.api.v0.ListRunsRequest\x1a4.org.mobyproject.extension.jobs.api.v0.ListRunsReply\x12~\n" + + "\n" + + "InspectRun\x128.org.mobyproject.extension.jobs.api.v0.InspectRunRequest\x1a6.org.mobyproject.extension.jobs.api.v0.InspectRunReply\x12l\n" + + "\x04Wait\x122.org.mobyproject.extension.jobs.api.v0.WaitRequest\x1a0.org.mobyproject.extension.jobs.api.v0.WaitReplyB4Z2github.com/docker/cli/internal/jobsapi/v0/protogenb\x06proto3" + +var ( + file_internal_jobsapi_v0_jobs_proto_rawDescOnce sync.Once + file_internal_jobsapi_v0_jobs_proto_rawDescData []byte +) + +func file_internal_jobsapi_v0_jobs_proto_rawDescGZIP() []byte { + file_internal_jobsapi_v0_jobs_proto_rawDescOnce.Do(func() { + file_internal_jobsapi_v0_jobs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_jobsapi_v0_jobs_proto_rawDesc), len(file_internal_jobsapi_v0_jobs_proto_rawDesc))) + }) + return file_internal_jobsapi_v0_jobs_proto_rawDescData +} + +var file_internal_jobsapi_v0_jobs_proto_msgTypes = make([]protoimpl.MessageInfo, 34) +var file_internal_jobsapi_v0_jobs_proto_goTypes = []any{ + (*CancelReply)(nil), // 0: org.mobyproject.extension.jobs.api.v0.CancelReply + (*CancelRequest)(nil), // 1: org.mobyproject.extension.jobs.api.v0.CancelRequest + (*CreateAndRunReply)(nil), // 2: org.mobyproject.extension.jobs.api.v0.CreateAndRunReply + (*CreateAndRunRequest)(nil), // 3: org.mobyproject.extension.jobs.api.v0.CreateAndRunRequest + (*CreateReply)(nil), // 4: org.mobyproject.extension.jobs.api.v0.CreateReply + (*CreateRequest)(nil), // 5: org.mobyproject.extension.jobs.api.v0.CreateRequest + (*ExitCode)(nil), // 6: org.mobyproject.extension.jobs.api.v0.ExitCode + (*InspectReply)(nil), // 7: org.mobyproject.extension.jobs.api.v0.InspectReply + (*InspectRequest)(nil), // 8: org.mobyproject.extension.jobs.api.v0.InspectRequest + (*InspectRunReply)(nil), // 9: org.mobyproject.extension.jobs.api.v0.InspectRunReply + (*InspectRunRequest)(nil), // 10: org.mobyproject.extension.jobs.api.v0.InspectRunRequest + (*Job)(nil), // 11: org.mobyproject.extension.jobs.api.v0.Job + (*JobSpec)(nil), // 12: org.mobyproject.extension.jobs.api.v0.JobSpec + (*ListReply)(nil), // 13: org.mobyproject.extension.jobs.api.v0.ListReply + (*ListRequest)(nil), // 14: org.mobyproject.extension.jobs.api.v0.ListRequest + (*ListRunsReply)(nil), // 15: org.mobyproject.extension.jobs.api.v0.ListRunsReply + (*ListRunsRequest)(nil), // 16: org.mobyproject.extension.jobs.api.v0.ListRunsRequest + (*PauseRequest)(nil), // 17: org.mobyproject.extension.jobs.api.v0.PauseRequest + (*PruneReply)(nil), // 18: org.mobyproject.extension.jobs.api.v0.PruneReply + (*PruneRequest)(nil), // 19: org.mobyproject.extension.jobs.api.v0.PruneRequest + (*RemoveRequest)(nil), // 20: org.mobyproject.extension.jobs.api.v0.RemoveRequest + (*ResumeRequest)(nil), // 21: org.mobyproject.extension.jobs.api.v0.ResumeRequest + (*Run)(nil), // 22: org.mobyproject.extension.jobs.api.v0.Run + (*RunReply)(nil), // 23: org.mobyproject.extension.jobs.api.v0.RunReply + (*RunRequest)(nil), // 24: org.mobyproject.extension.jobs.api.v0.RunRequest + (*ScheduleTrigger)(nil), // 25: org.mobyproject.extension.jobs.api.v0.ScheduleTrigger + (*Trigger)(nil), // 26: org.mobyproject.extension.jobs.api.v0.Trigger + (*TriggerEvidence)(nil), // 27: org.mobyproject.extension.jobs.api.v0.TriggerEvidence + (*WaitReply)(nil), // 28: org.mobyproject.extension.jobs.api.v0.WaitReply + (*WaitRequest)(nil), // 29: org.mobyproject.extension.jobs.api.v0.WaitRequest + (*PauseResponse)(nil), // 30: org.mobyproject.extension.jobs.api.v0.PauseResponse + (*ResumeResponse)(nil), // 31: org.mobyproject.extension.jobs.api.v0.ResumeResponse + (*RemoveResponse)(nil), // 32: org.mobyproject.extension.jobs.api.v0.RemoveResponse + nil, // 33: org.mobyproject.extension.jobs.api.v0.JobSpec.LabelsEntry +} +var file_internal_jobsapi_v0_jobs_proto_depIdxs = []int32{ + 11, // 0: org.mobyproject.extension.jobs.api.v0.CreateAndRunReply.job:type_name -> org.mobyproject.extension.jobs.api.v0.Job + 22, // 1: org.mobyproject.extension.jobs.api.v0.CreateAndRunReply.run:type_name -> org.mobyproject.extension.jobs.api.v0.Run + 12, // 2: org.mobyproject.extension.jobs.api.v0.CreateAndRunRequest.spec:type_name -> org.mobyproject.extension.jobs.api.v0.JobSpec + 11, // 3: org.mobyproject.extension.jobs.api.v0.CreateReply.job:type_name -> org.mobyproject.extension.jobs.api.v0.Job + 12, // 4: org.mobyproject.extension.jobs.api.v0.CreateRequest.spec:type_name -> org.mobyproject.extension.jobs.api.v0.JobSpec + 11, // 5: org.mobyproject.extension.jobs.api.v0.InspectReply.job:type_name -> org.mobyproject.extension.jobs.api.v0.Job + 22, // 6: org.mobyproject.extension.jobs.api.v0.InspectRunReply.run:type_name -> org.mobyproject.extension.jobs.api.v0.Run + 12, // 7: org.mobyproject.extension.jobs.api.v0.Job.spec:type_name -> org.mobyproject.extension.jobs.api.v0.JobSpec + 22, // 8: org.mobyproject.extension.jobs.api.v0.Job.latest_run:type_name -> org.mobyproject.extension.jobs.api.v0.Run + 26, // 9: org.mobyproject.extension.jobs.api.v0.JobSpec.trigger:type_name -> org.mobyproject.extension.jobs.api.v0.Trigger + 33, // 10: org.mobyproject.extension.jobs.api.v0.JobSpec.labels:type_name -> org.mobyproject.extension.jobs.api.v0.JobSpec.LabelsEntry + 11, // 11: org.mobyproject.extension.jobs.api.v0.ListReply.jobs:type_name -> org.mobyproject.extension.jobs.api.v0.Job + 22, // 12: org.mobyproject.extension.jobs.api.v0.ListRunsReply.runs:type_name -> org.mobyproject.extension.jobs.api.v0.Run + 6, // 13: org.mobyproject.extension.jobs.api.v0.Run.exit_code:type_name -> org.mobyproject.extension.jobs.api.v0.ExitCode + 27, // 14: org.mobyproject.extension.jobs.api.v0.Run.trigger:type_name -> org.mobyproject.extension.jobs.api.v0.TriggerEvidence + 22, // 15: org.mobyproject.extension.jobs.api.v0.RunReply.run:type_name -> org.mobyproject.extension.jobs.api.v0.Run + 25, // 16: org.mobyproject.extension.jobs.api.v0.Trigger.schedule:type_name -> org.mobyproject.extension.jobs.api.v0.ScheduleTrigger + 22, // 17: org.mobyproject.extension.jobs.api.v0.WaitReply.run:type_name -> org.mobyproject.extension.jobs.api.v0.Run + 5, // 18: org.mobyproject.extension.jobs.api.v0.Jobs.Create:input_type -> org.mobyproject.extension.jobs.api.v0.CreateRequest + 24, // 19: org.mobyproject.extension.jobs.api.v0.Jobs.Run:input_type -> org.mobyproject.extension.jobs.api.v0.RunRequest + 3, // 20: org.mobyproject.extension.jobs.api.v0.Jobs.CreateAndRun:input_type -> org.mobyproject.extension.jobs.api.v0.CreateAndRunRequest + 8, // 21: org.mobyproject.extension.jobs.api.v0.Jobs.Inspect:input_type -> org.mobyproject.extension.jobs.api.v0.InspectRequest + 14, // 22: org.mobyproject.extension.jobs.api.v0.Jobs.List:input_type -> org.mobyproject.extension.jobs.api.v0.ListRequest + 17, // 23: org.mobyproject.extension.jobs.api.v0.Jobs.Pause:input_type -> org.mobyproject.extension.jobs.api.v0.PauseRequest + 21, // 24: org.mobyproject.extension.jobs.api.v0.Jobs.Resume:input_type -> org.mobyproject.extension.jobs.api.v0.ResumeRequest + 1, // 25: org.mobyproject.extension.jobs.api.v0.Jobs.Cancel:input_type -> org.mobyproject.extension.jobs.api.v0.CancelRequest + 20, // 26: org.mobyproject.extension.jobs.api.v0.Jobs.Remove:input_type -> org.mobyproject.extension.jobs.api.v0.RemoveRequest + 19, // 27: org.mobyproject.extension.jobs.api.v0.Jobs.Prune:input_type -> org.mobyproject.extension.jobs.api.v0.PruneRequest + 16, // 28: org.mobyproject.extension.jobs.api.v0.Jobs.ListRuns:input_type -> org.mobyproject.extension.jobs.api.v0.ListRunsRequest + 10, // 29: org.mobyproject.extension.jobs.api.v0.Jobs.InspectRun:input_type -> org.mobyproject.extension.jobs.api.v0.InspectRunRequest + 29, // 30: org.mobyproject.extension.jobs.api.v0.Jobs.Wait:input_type -> org.mobyproject.extension.jobs.api.v0.WaitRequest + 4, // 31: org.mobyproject.extension.jobs.api.v0.Jobs.Create:output_type -> org.mobyproject.extension.jobs.api.v0.CreateReply + 23, // 32: org.mobyproject.extension.jobs.api.v0.Jobs.Run:output_type -> org.mobyproject.extension.jobs.api.v0.RunReply + 2, // 33: org.mobyproject.extension.jobs.api.v0.Jobs.CreateAndRun:output_type -> org.mobyproject.extension.jobs.api.v0.CreateAndRunReply + 7, // 34: org.mobyproject.extension.jobs.api.v0.Jobs.Inspect:output_type -> org.mobyproject.extension.jobs.api.v0.InspectReply + 13, // 35: org.mobyproject.extension.jobs.api.v0.Jobs.List:output_type -> org.mobyproject.extension.jobs.api.v0.ListReply + 30, // 36: org.mobyproject.extension.jobs.api.v0.Jobs.Pause:output_type -> org.mobyproject.extension.jobs.api.v0.PauseResponse + 31, // 37: org.mobyproject.extension.jobs.api.v0.Jobs.Resume:output_type -> org.mobyproject.extension.jobs.api.v0.ResumeResponse + 0, // 38: org.mobyproject.extension.jobs.api.v0.Jobs.Cancel:output_type -> org.mobyproject.extension.jobs.api.v0.CancelReply + 32, // 39: org.mobyproject.extension.jobs.api.v0.Jobs.Remove:output_type -> org.mobyproject.extension.jobs.api.v0.RemoveResponse + 18, // 40: org.mobyproject.extension.jobs.api.v0.Jobs.Prune:output_type -> org.mobyproject.extension.jobs.api.v0.PruneReply + 15, // 41: org.mobyproject.extension.jobs.api.v0.Jobs.ListRuns:output_type -> org.mobyproject.extension.jobs.api.v0.ListRunsReply + 9, // 42: org.mobyproject.extension.jobs.api.v0.Jobs.InspectRun:output_type -> org.mobyproject.extension.jobs.api.v0.InspectRunReply + 28, // 43: org.mobyproject.extension.jobs.api.v0.Jobs.Wait:output_type -> org.mobyproject.extension.jobs.api.v0.WaitReply + 31, // [31:44] is the sub-list for method output_type + 18, // [18:31] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name +} + +func init() { file_internal_jobsapi_v0_jobs_proto_init() } +func file_internal_jobsapi_v0_jobs_proto_init() { + if File_internal_jobsapi_v0_jobs_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeFor[x]().PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_jobsapi_v0_jobs_proto_rawDesc), len(file_internal_jobsapi_v0_jobs_proto_rawDesc)), + NumEnums: 0, + NumMessages: 34, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_internal_jobsapi_v0_jobs_proto_goTypes, + DependencyIndexes: file_internal_jobsapi_v0_jobs_proto_depIdxs, + MessageInfos: file_internal_jobsapi_v0_jobs_proto_msgTypes, + }.Build() + File_internal_jobsapi_v0_jobs_proto = out.File + file_internal_jobsapi_v0_jobs_proto_goTypes = nil + file_internal_jobsapi_v0_jobs_proto_depIdxs = nil +} diff --git a/internal/jobsapi/v0/protogen/wire.gen.go b/internal/jobsapi/v0/protogen/wire.gen.go new file mode 100644 index 000000000000..670e0b6fde92 --- /dev/null +++ b/internal/jobsapi/v0/protogen/wire.gen.go @@ -0,0 +1,1265 @@ +// Code generated by mobyextgen. DO NOT EDIT. +// 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 protogen + +import ( + context "context" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + extensions "github.com/moby/extensions" + clientpoint "github.com/moby/extensions/clientpoint" + serverpoint "github.com/moby/extensions/serverpoint" + grpc "google.golang.org/grpc" +) + +// serviceName is the point's fully-qualified gRPC service name. +const serviceName = "org.mobyproject.extension.jobs.api.v0.Jobs" + +const ( + methodCreate = "/" + serviceName + "/Create" + methodRun = "/" + serviceName + "/Run" + methodCreateAndRun = "/" + serviceName + "/CreateAndRun" + methodInspect = "/" + serviceName + "/Inspect" + methodList = "/" + serviceName + "/List" + methodPause = "/" + serviceName + "/Pause" + methodResume = "/" + serviceName + "/Resume" + methodCancel = "/" + serviceName + "/Cancel" + methodRemove = "/" + serviceName + "/Remove" + methodPrune = "/" + serviceName + "/Prune" + methodListRuns = "/" + serviceName + "/ListRuns" + methodInspectRun = "/" + serviceName + "/InspectRun" + methodWait = "/" + serviceName + "/Wait" +) + +// JobsServer is the server side of the point's gRPC service. It is the +// proto-level shape of the point, not the point's Go interface: a contract +// method returning a bare error returns an empty response message here. +type JobsServer interface { + Create(context.Context, *CreateRequest) (*CreateReply, error) + Run(context.Context, *RunRequest) (*RunReply, error) + CreateAndRun(context.Context, *CreateAndRunRequest) (*CreateAndRunReply, error) + Inspect(context.Context, *InspectRequest) (*InspectReply, error) + List(context.Context, *ListRequest) (*ListReply, error) + Pause(context.Context, *PauseRequest) (*PauseResponse, error) + Resume(context.Context, *ResumeRequest) (*ResumeResponse, error) + Cancel(context.Context, *CancelRequest) (*CancelReply, error) + Remove(context.Context, *RemoveRequest) (*RemoveResponse, error) + Prune(context.Context, *PruneRequest) (*PruneReply, error) + ListRuns(context.Context, *ListRunsRequest) (*ListRunsReply, error) + InspectRun(context.Context, *InspectRunRequest) (*InspectRunReply, error) + Wait(context.Context, *WaitRequest) (*WaitReply, error) +} + +// serviceDesc describes the point's gRPC service to a server. HandlerType is +// what a registrar type-checks an implementation against, so registering the +// wrong provider for this point is caught at registration. +var serviceDesc = grpc.ServiceDesc{ + ServiceName: serviceName, + HandlerType: (*JobsServer)(nil), + Metadata: "internal/jobsapi/v0/jobs.proto", + Methods: []grpc.MethodDesc{ + {MethodName: "Create", Handler: handleCreate}, + {MethodName: "Run", Handler: handleRun}, + {MethodName: "CreateAndRun", Handler: handleCreateAndRun}, + {MethodName: "Inspect", Handler: handleInspect}, + {MethodName: "List", Handler: handleList}, + {MethodName: "Pause", Handler: handlePause}, + {MethodName: "Resume", Handler: handleResume}, + {MethodName: "Cancel", Handler: handleCancel}, + {MethodName: "Remove", Handler: handleRemove}, + {MethodName: "Prune", Handler: handlePrune}, + {MethodName: "ListRuns", Handler: handleListRuns}, + {MethodName: "InspectRun", Handler: handleInspectRun}, + {MethodName: "Wait", Handler: handleWait}, + }, +} + +func handleCreate(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(CreateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Create(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodCreate} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Create(ctx, req.(*CreateRequest)) + }) +} + +func handleRun(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(RunRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Run(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodRun} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Run(ctx, req.(*RunRequest)) + }) +} + +func handleCreateAndRun(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(CreateAndRunRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).CreateAndRun(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodCreateAndRun} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).CreateAndRun(ctx, req.(*CreateAndRunRequest)) + }) +} + +func handleInspect(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(InspectRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Inspect(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodInspect} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Inspect(ctx, req.(*InspectRequest)) + }) +} + +func handleList(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(ListRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).List(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodList} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).List(ctx, req.(*ListRequest)) + }) +} + +func handlePause(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(PauseRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Pause(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodPause} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Pause(ctx, req.(*PauseRequest)) + }) +} + +func handleResume(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(ResumeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Resume(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodResume} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Resume(ctx, req.(*ResumeRequest)) + }) +} + +func handleCancel(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(CancelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Cancel(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodCancel} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Cancel(ctx, req.(*CancelRequest)) + }) +} + +func handleRemove(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(RemoveRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Remove(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodRemove} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Remove(ctx, req.(*RemoveRequest)) + }) +} + +func handlePrune(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(PruneRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Prune(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodPrune} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Prune(ctx, req.(*PruneRequest)) + }) +} + +func handleListRuns(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(ListRunsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).ListRuns(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodListRuns} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).ListRuns(ctx, req.(*ListRunsRequest)) + }) +} + +func handleInspectRun(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(InspectRunRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).InspectRun(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodInspectRun} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).InspectRun(ctx, req.(*InspectRunRequest)) + }) +} + +func handleWait(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(WaitRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JobsServer).Wait(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodWait} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(JobsServer).Wait(ctx, req.(*WaitRequest)) + }) +} + +// JobsClient calls the point's gRPC service. It is exported so a client +// outside the framework - one calling a service an extension publishes on the +// API socket - can reach it with a plain gRPC client. +type JobsClient interface { + Create(ctx context.Context, in *CreateRequest, opts ...grpc.CallOption) (*CreateReply, error) + Run(ctx context.Context, in *RunRequest, opts ...grpc.CallOption) (*RunReply, error) + CreateAndRun(ctx context.Context, in *CreateAndRunRequest, opts ...grpc.CallOption) (*CreateAndRunReply, error) + Inspect(ctx context.Context, in *InspectRequest, opts ...grpc.CallOption) (*InspectReply, error) + List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListReply, error) + Pause(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*PauseResponse, error) + Resume(ctx context.Context, in *ResumeRequest, opts ...grpc.CallOption) (*ResumeResponse, error) + Cancel(ctx context.Context, in *CancelRequest, opts ...grpc.CallOption) (*CancelReply, error) + Remove(ctx context.Context, in *RemoveRequest, opts ...grpc.CallOption) (*RemoveResponse, error) + Prune(ctx context.Context, in *PruneRequest, opts ...grpc.CallOption) (*PruneReply, error) + ListRuns(ctx context.Context, in *ListRunsRequest, opts ...grpc.CallOption) (*ListRunsReply, error) + InspectRun(ctx context.Context, in *InspectRunRequest, opts ...grpc.CallOption) (*InspectRunReply, error) + Wait(ctx context.Context, in *WaitRequest, opts ...grpc.CallOption) (*WaitReply, error) +} + +// NewJobsClient returns a client for the point's gRPC service on cc. +func NewJobsClient(cc grpc.ClientConnInterface) JobsClient { return &serviceClient{cc: cc} } + +type serviceClient struct{ cc grpc.ClientConnInterface } + +func (c *serviceClient) Create(ctx context.Context, in *CreateRequest, opts ...grpc.CallOption) (*CreateReply, error) { + out := new(CreateReply) + if err := c.cc.Invoke(ctx, methodCreate, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Run(ctx context.Context, in *RunRequest, opts ...grpc.CallOption) (*RunReply, error) { + out := new(RunReply) + if err := c.cc.Invoke(ctx, methodRun, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) CreateAndRun(ctx context.Context, in *CreateAndRunRequest, opts ...grpc.CallOption) (*CreateAndRunReply, error) { + out := new(CreateAndRunReply) + if err := c.cc.Invoke(ctx, methodCreateAndRun, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Inspect(ctx context.Context, in *InspectRequest, opts ...grpc.CallOption) (*InspectReply, error) { + out := new(InspectReply) + if err := c.cc.Invoke(ctx, methodInspect, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListReply, error) { + out := new(ListReply) + if err := c.cc.Invoke(ctx, methodList, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Pause(ctx context.Context, in *PauseRequest, opts ...grpc.CallOption) (*PauseResponse, error) { + out := new(PauseResponse) + if err := c.cc.Invoke(ctx, methodPause, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Resume(ctx context.Context, in *ResumeRequest, opts ...grpc.CallOption) (*ResumeResponse, error) { + out := new(ResumeResponse) + if err := c.cc.Invoke(ctx, methodResume, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Cancel(ctx context.Context, in *CancelRequest, opts ...grpc.CallOption) (*CancelReply, error) { + out := new(CancelReply) + if err := c.cc.Invoke(ctx, methodCancel, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Remove(ctx context.Context, in *RemoveRequest, opts ...grpc.CallOption) (*RemoveResponse, error) { + out := new(RemoveResponse) + if err := c.cc.Invoke(ctx, methodRemove, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Prune(ctx context.Context, in *PruneRequest, opts ...grpc.CallOption) (*PruneReply, error) { + out := new(PruneReply) + if err := c.cc.Invoke(ctx, methodPrune, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) ListRuns(ctx context.Context, in *ListRunsRequest, opts ...grpc.CallOption) (*ListRunsReply, error) { + out := new(ListRunsReply) + if err := c.cc.Invoke(ctx, methodListRuns, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) InspectRun(ctx context.Context, in *InspectRunRequest, opts ...grpc.CallOption) (*InspectRunReply, error) { + out := new(InspectRunReply) + if err := c.cc.Invoke(ctx, methodInspectRun, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +func (c *serviceClient) Wait(ctx context.Context, in *WaitRequest, opts ...grpc.CallOption) (*WaitReply, error) { + out := new(WaitReply) + if err := c.cc.Invoke(ctx, methodWait, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +// ServerPoint serves the Jobs point: it registers the point's gRPC service for +// a provider with an SDK server. A binary passes it to (*sdk.Server).Register. +var ServerPoint = serverpoint.Registration{ + Point: jobsv0.Point.ID(), + Register: func(r grpc.ServiceRegistrar, impl any) { + r.RegisterService(&serviceDesc, &grpcServer{impl: impl.(jobsv0.Jobs)}) + }, +} + +// ClientProvider builds a broker provider for the Jobs point from an +// out-of-process gRPC connection. +func ClientProvider(conn grpc.ClientConnInterface) extensions.Provider { + return jobsv0.Point.Provide(NewClient(conn)) +} + +// ClientPoint registers ClientProvider for the Jobs point with a host. +// Single carries the contract's cardinality: the point admits one provider. +var ClientPoint = clientpoint.Registration{Point: jobsv0.Point.ID(), Provider: ClientProvider, Single: true} + +// NewClient returns a jobsv0.Jobs that calls the Jobs point over conn. +func NewClient(conn grpc.ClientConnInterface) jobsv0.Jobs { + return &grpcClient{client: NewJobsClient(conn)} +} + +// grpcServer serves an implementation of the contract's Go interface. +type grpcServer struct { + impl jobsv0.Jobs +} + +func (s *grpcServer) Create(ctx context.Context, req *CreateRequest) (*CreateReply, error) { + resp, err := s.impl.Create(ctx, createRequestFromProto(req)) + if err != nil { + return nil, err + } + return createReplyToProto(resp), nil +} + +func (s *grpcServer) Run(ctx context.Context, req *RunRequest) (*RunReply, error) { + resp, err := s.impl.Run(ctx, runRequestFromProto(req)) + if err != nil { + return nil, err + } + return runReplyToProto(resp), nil +} + +func (s *grpcServer) CreateAndRun(ctx context.Context, req *CreateAndRunRequest) (*CreateAndRunReply, error) { + resp, err := s.impl.CreateAndRun(ctx, createAndRunRequestFromProto(req)) + if err != nil { + return nil, err + } + return createAndRunReplyToProto(resp), nil +} + +func (s *grpcServer) Inspect(ctx context.Context, req *InspectRequest) (*InspectReply, error) { + resp, err := s.impl.Inspect(ctx, inspectRequestFromProto(req)) + if err != nil { + return nil, err + } + return inspectReplyToProto(resp), nil +} + +func (s *grpcServer) List(ctx context.Context, req *ListRequest) (*ListReply, error) { + resp, err := s.impl.List(ctx, listRequestFromProto(req)) + if err != nil { + return nil, err + } + return listReplyToProto(resp), nil +} + +func (s *grpcServer) Pause(ctx context.Context, req *PauseRequest) (*PauseResponse, error) { + if err := s.impl.Pause(ctx, pauseRequestFromProto(req)); err != nil { + return nil, err + } + return &PauseResponse{}, nil +} + +func (s *grpcServer) Resume(ctx context.Context, req *ResumeRequest) (*ResumeResponse, error) { + if err := s.impl.Resume(ctx, resumeRequestFromProto(req)); err != nil { + return nil, err + } + return &ResumeResponse{}, nil +} + +func (s *grpcServer) Cancel(ctx context.Context, req *CancelRequest) (*CancelReply, error) { + resp, err := s.impl.Cancel(ctx, cancelRequestFromProto(req)) + if err != nil { + return nil, err + } + return cancelReplyToProto(resp), nil +} + +func (s *grpcServer) Remove(ctx context.Context, req *RemoveRequest) (*RemoveResponse, error) { + if err := s.impl.Remove(ctx, removeRequestFromProto(req)); err != nil { + return nil, err + } + return &RemoveResponse{}, nil +} + +func (s *grpcServer) Prune(ctx context.Context, req *PruneRequest) (*PruneReply, error) { + resp, err := s.impl.Prune(ctx, pruneRequestFromProto(req)) + if err != nil { + return nil, err + } + return pruneReplyToProto(resp), nil +} + +func (s *grpcServer) ListRuns(ctx context.Context, req *ListRunsRequest) (*ListRunsReply, error) { + resp, err := s.impl.ListRuns(ctx, listRunsRequestFromProto(req)) + if err != nil { + return nil, err + } + return listRunsReplyToProto(resp), nil +} + +func (s *grpcServer) InspectRun(ctx context.Context, req *InspectRunRequest) (*InspectRunReply, error) { + resp, err := s.impl.InspectRun(ctx, inspectRunRequestFromProto(req)) + if err != nil { + return nil, err + } + return inspectRunReplyToProto(resp), nil +} + +func (s *grpcServer) Wait(ctx context.Context, req *WaitRequest) (*WaitReply, error) { + resp, err := s.impl.Wait(ctx, waitRequestFromProto(req)) + if err != nil { + return nil, err + } + return waitReplyToProto(resp), nil +} + +type grpcClient struct { + client JobsClient +} + +func (c *grpcClient) Create(ctx context.Context, req *jobsv0.CreateRequest) (*jobsv0.CreateReply, error) { + resp, err := c.client.Create(ctx, createRequestToProto(req)) + if err != nil { + return nil, err + } + return createReplyFromProto(resp), nil +} + +func (c *grpcClient) Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + resp, err := c.client.Run(ctx, runRequestToProto(req)) + if err != nil { + return nil, err + } + return runReplyFromProto(resp), nil +} + +func (c *grpcClient) CreateAndRun(ctx context.Context, req *jobsv0.CreateAndRunRequest) (*jobsv0.CreateAndRunReply, error) { + resp, err := c.client.CreateAndRun(ctx, createAndRunRequestToProto(req)) + if err != nil { + return nil, err + } + return createAndRunReplyFromProto(resp), nil +} + +func (c *grpcClient) Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + resp, err := c.client.Inspect(ctx, inspectRequestToProto(req)) + if err != nil { + return nil, err + } + return inspectReplyFromProto(resp), nil +} + +func (c *grpcClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + resp, err := c.client.List(ctx, listRequestToProto(req)) + if err != nil { + return nil, err + } + return listReplyFromProto(resp), nil +} + +func (c *grpcClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { + _, err := c.client.Pause(ctx, pauseRequestToProto(req)) + return err +} + +func (c *grpcClient) Resume(ctx context.Context, req *jobsv0.ResumeRequest) error { + _, err := c.client.Resume(ctx, resumeRequestToProto(req)) + return err +} + +func (c *grpcClient) Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + resp, err := c.client.Cancel(ctx, cancelRequestToProto(req)) + if err != nil { + return nil, err + } + return cancelReplyFromProto(resp), nil +} + +func (c *grpcClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) error { + _, err := c.client.Remove(ctx, removeRequestToProto(req)) + return err +} + +func (c *grpcClient) Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + resp, err := c.client.Prune(ctx, pruneRequestToProto(req)) + if err != nil { + return nil, err + } + return pruneReplyFromProto(resp), nil +} + +func (c *grpcClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + resp, err := c.client.ListRuns(ctx, listRunsRequestToProto(req)) + if err != nil { + return nil, err + } + return listRunsReplyFromProto(resp), nil +} + +func (c *grpcClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + resp, err := c.client.InspectRun(ctx, inspectRunRequestToProto(req)) + if err != nil { + return nil, err + } + return inspectRunReplyFromProto(resp), nil +} + +func (c *grpcClient) Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + resp, err := c.client.Wait(ctx, waitRequestToProto(req)) + if err != nil { + return nil, err + } + return waitReplyFromProto(resp), nil +} + +func cancelReplyToProto(in *jobsv0.CancelReply) *CancelReply { + if in == nil { + return nil + } + out := &CancelReply{} + out.RunId = in.RunID + return out +} + +func cancelReplyFromProto(in *CancelReply) *jobsv0.CancelReply { + if in == nil { + return nil + } + out := &jobsv0.CancelReply{} + out.RunID = in.GetRunId() + return out +} + +func cancelRequestToProto(in *jobsv0.CancelRequest) *CancelRequest { + if in == nil { + return nil + } + out := &CancelRequest{} + out.JobRef = in.JobRef + return out +} + +func cancelRequestFromProto(in *CancelRequest) *jobsv0.CancelRequest { + if in == nil { + return nil + } + out := &jobsv0.CancelRequest{} + out.JobRef = in.GetJobRef() + return out +} + +func createAndRunReplyToProto(in *jobsv0.CreateAndRunReply) *CreateAndRunReply { + if in == nil { + return nil + } + out := &CreateAndRunReply{} + out.Job = jobToProto(in.Job) + out.Run = runToProto(in.Run) + out.Created = in.Created + return out +} + +func createAndRunReplyFromProto(in *CreateAndRunReply) *jobsv0.CreateAndRunReply { + if in == nil { + return nil + } + out := &jobsv0.CreateAndRunReply{} + out.Job = jobFromProto(in.GetJob()) + out.Run = runFromProto(in.GetRun()) + out.Created = in.GetCreated() + return out +} + +func createAndRunRequestToProto(in *jobsv0.CreateAndRunRequest) *CreateAndRunRequest { + if in == nil { + return nil + } + out := &CreateAndRunRequest{} + out.Name = in.Name + out.Spec = jobSpecToProto(in.Spec) + return out +} + +func createAndRunRequestFromProto(in *CreateAndRunRequest) *jobsv0.CreateAndRunRequest { + if in == nil { + return nil + } + out := &jobsv0.CreateAndRunRequest{} + out.Name = in.GetName() + out.Spec = jobSpecFromProto(in.GetSpec()) + return out +} + +func createReplyToProto(in *jobsv0.CreateReply) *CreateReply { + if in == nil { + return nil + } + out := &CreateReply{} + out.Job = jobToProto(in.Job) + out.Created = in.Created + return out +} + +func createReplyFromProto(in *CreateReply) *jobsv0.CreateReply { + if in == nil { + return nil + } + out := &jobsv0.CreateReply{} + out.Job = jobFromProto(in.GetJob()) + out.Created = in.GetCreated() + return out +} + +func createRequestToProto(in *jobsv0.CreateRequest) *CreateRequest { + if in == nil { + return nil + } + out := &CreateRequest{} + out.Name = in.Name + out.Spec = jobSpecToProto(in.Spec) + return out +} + +func createRequestFromProto(in *CreateRequest) *jobsv0.CreateRequest { + if in == nil { + return nil + } + out := &jobsv0.CreateRequest{} + out.Name = in.GetName() + out.Spec = jobSpecFromProto(in.GetSpec()) + return out +} + +func exitCodeToProto(in *jobsv0.ExitCode) *ExitCode { + if in == nil { + return nil + } + out := &ExitCode{} + out.Value = in.Value + return out +} + +func exitCodeFromProto(in *ExitCode) *jobsv0.ExitCode { + if in == nil { + return nil + } + out := &jobsv0.ExitCode{} + out.Value = in.GetValue() + return out +} + +func inspectReplyToProto(in *jobsv0.InspectReply) *InspectReply { + if in == nil { + return nil + } + out := &InspectReply{} + out.Job = jobToProto(in.Job) + return out +} + +func inspectReplyFromProto(in *InspectReply) *jobsv0.InspectReply { + if in == nil { + return nil + } + out := &jobsv0.InspectReply{} + out.Job = jobFromProto(in.GetJob()) + return out +} + +func inspectRequestToProto(in *jobsv0.InspectRequest) *InspectRequest { + if in == nil { + return nil + } + out := &InspectRequest{} + out.JobRef = in.JobRef + return out +} + +func inspectRequestFromProto(in *InspectRequest) *jobsv0.InspectRequest { + if in == nil { + return nil + } + out := &jobsv0.InspectRequest{} + out.JobRef = in.GetJobRef() + return out +} + +func inspectRunReplyToProto(in *jobsv0.InspectRunReply) *InspectRunReply { + if in == nil { + return nil + } + out := &InspectRunReply{} + out.Run = runToProto(in.Run) + return out +} + +func inspectRunReplyFromProto(in *InspectRunReply) *jobsv0.InspectRunReply { + if in == nil { + return nil + } + out := &jobsv0.InspectRunReply{} + out.Run = runFromProto(in.GetRun()) + return out +} + +func inspectRunRequestToProto(in *jobsv0.InspectRunRequest) *InspectRunRequest { + if in == nil { + return nil + } + out := &InspectRunRequest{} + out.JobRef = in.JobRef + out.RunRef = in.RunRef + return out +} + +func inspectRunRequestFromProto(in *InspectRunRequest) *jobsv0.InspectRunRequest { + if in == nil { + return nil + } + out := &jobsv0.InspectRunRequest{} + out.JobRef = in.GetJobRef() + out.RunRef = in.GetRunRef() + return out +} + +func jobToProto(in *jobsv0.Job) *Job { + if in == nil { + return nil + } + out := &Job{} + out.Id = in.ID + out.Name = in.Name + out.Spec = jobSpecToProto(in.Spec) + out.SpecHash = in.SpecHash + out.State = in.State + out.Paused = in.Paused + out.NextFireAtNano = in.NextFireAtNano + out.CreatedAtNano = in.CreatedAtNano + out.UpdatedAtNano = in.UpdatedAtNano + out.LatestRun = runToProto(in.LatestRun) + return out +} + +func jobFromProto(in *Job) *jobsv0.Job { + if in == nil { + return nil + } + out := &jobsv0.Job{} + out.ID = in.GetId() + out.Name = in.GetName() + out.Spec = jobSpecFromProto(in.GetSpec()) + out.SpecHash = in.GetSpecHash() + out.State = in.GetState() + out.Paused = in.GetPaused() + out.NextFireAtNano = in.GetNextFireAtNano() + out.CreatedAtNano = in.GetCreatedAtNano() + out.UpdatedAtNano = in.GetUpdatedAtNano() + out.LatestRun = runFromProto(in.GetLatestRun()) + return out +} + +func jobSpecToProto(in *jobsv0.JobSpec) *JobSpec { + if in == nil { + return nil + } + out := &JobSpec{} + out.ContainerSpec = in.ContainerSpec + out.Trigger = triggerToProto(in.Trigger) + out.Labels = in.Labels + out.TimeoutSeconds = in.TimeoutSeconds + out.RemoveOnSuccess = in.RemoveOnSuccess + out.RemoveOnFailure = in.RemoveOnFailure + out.RunHistoryLimit = in.RunHistoryLimit + return out +} + +func jobSpecFromProto(in *JobSpec) *jobsv0.JobSpec { + if in == nil { + return nil + } + out := &jobsv0.JobSpec{} + out.ContainerSpec = in.GetContainerSpec() + out.Trigger = triggerFromProto(in.GetTrigger()) + out.Labels = in.GetLabels() + out.TimeoutSeconds = in.GetTimeoutSeconds() + out.RemoveOnSuccess = in.GetRemoveOnSuccess() + out.RemoveOnFailure = in.GetRemoveOnFailure() + out.RunHistoryLimit = in.GetRunHistoryLimit() + return out +} + +func listReplyToProto(in *jobsv0.ListReply) *ListReply { + if in == nil { + return nil + } + out := &ListReply{} + for i := range in.Jobs { + out.Jobs = append(out.Jobs, jobToProto(&in.Jobs[i])) + } + return out +} + +func listReplyFromProto(in *ListReply) *jobsv0.ListReply { + if in == nil { + return nil + } + out := &jobsv0.ListReply{} + for _, e := range in.GetJobs() { + out.Jobs = append(out.Jobs, *jobFromProto(e)) + } + return out +} + +func listRequestToProto(in *jobsv0.ListRequest) *ListRequest { + if in == nil { + return nil + } + out := &ListRequest{} + out.Names = in.Names + out.Labels = in.Labels + out.States = in.States + out.TriggerKinds = in.TriggerKinds + out.Paused = in.Paused + out.LatestRunStates = in.LatestRunStates + return out +} + +func listRequestFromProto(in *ListRequest) *jobsv0.ListRequest { + if in == nil { + return nil + } + out := &jobsv0.ListRequest{} + out.Names = in.GetNames() + out.Labels = in.GetLabels() + out.States = in.GetStates() + out.TriggerKinds = in.GetTriggerKinds() + out.Paused = in.GetPaused() + out.LatestRunStates = in.GetLatestRunStates() + return out +} + +func listRunsReplyToProto(in *jobsv0.ListRunsReply) *ListRunsReply { + if in == nil { + return nil + } + out := &ListRunsReply{} + for i := range in.Runs { + out.Runs = append(out.Runs, runToProto(&in.Runs[i])) + } + out.NextCursor = in.NextCursor + out.CursorStale = in.CursorStale + return out +} + +func listRunsReplyFromProto(in *ListRunsReply) *jobsv0.ListRunsReply { + if in == nil { + return nil + } + out := &jobsv0.ListRunsReply{} + for _, e := range in.GetRuns() { + out.Runs = append(out.Runs, *runFromProto(e)) + } + out.NextCursor = in.GetNextCursor() + out.CursorStale = in.GetCursorStale() + return out +} + +func listRunsRequestToProto(in *jobsv0.ListRunsRequest) *ListRunsRequest { + if in == nil { + return nil + } + out := &ListRunsRequest{} + out.JobRef = in.JobRef + out.Limit = in.Limit + out.Before = in.Before + return out +} + +func listRunsRequestFromProto(in *ListRunsRequest) *jobsv0.ListRunsRequest { + if in == nil { + return nil + } + out := &jobsv0.ListRunsRequest{} + out.JobRef = in.GetJobRef() + out.Limit = in.GetLimit() + out.Before = in.GetBefore() + return out +} + +func pauseRequestToProto(in *jobsv0.PauseRequest) *PauseRequest { + if in == nil { + return nil + } + out := &PauseRequest{} + out.JobRef = in.JobRef + return out +} + +func pauseRequestFromProto(in *PauseRequest) *jobsv0.PauseRequest { + if in == nil { + return nil + } + out := &jobsv0.PauseRequest{} + out.JobRef = in.GetJobRef() + return out +} + +func pruneReplyToProto(in *jobsv0.PruneReply) *PruneReply { + if in == nil { + return nil + } + out := &PruneReply{} + out.RemovedJobIds = in.RemovedJobIDs + return out +} + +func pruneReplyFromProto(in *PruneReply) *jobsv0.PruneReply { + if in == nil { + return nil + } + out := &jobsv0.PruneReply{} + out.RemovedJobIDs = in.GetRemovedJobIds() + return out +} + +func pruneRequestToProto(in *jobsv0.PruneRequest) *PruneRequest { + if in == nil { + return nil + } + out := &PruneRequest{} + out.Labels = in.Labels + return out +} + +func pruneRequestFromProto(in *PruneRequest) *jobsv0.PruneRequest { + if in == nil { + return nil + } + out := &jobsv0.PruneRequest{} + out.Labels = in.GetLabels() + return out +} + +func removeRequestToProto(in *jobsv0.RemoveRequest) *RemoveRequest { + if in == nil { + return nil + } + out := &RemoveRequest{} + out.JobRef = in.JobRef + out.RunsRemoval = in.RunsRemoval + return out +} + +func removeRequestFromProto(in *RemoveRequest) *jobsv0.RemoveRequest { + if in == nil { + return nil + } + out := &jobsv0.RemoveRequest{} + out.JobRef = in.GetJobRef() + out.RunsRemoval = in.GetRunsRemoval() + return out +} + +func resumeRequestToProto(in *jobsv0.ResumeRequest) *ResumeRequest { + if in == nil { + return nil + } + out := &ResumeRequest{} + out.JobRef = in.JobRef + return out +} + +func resumeRequestFromProto(in *ResumeRequest) *jobsv0.ResumeRequest { + if in == nil { + return nil + } + out := &jobsv0.ResumeRequest{} + out.JobRef = in.GetJobRef() + return out +} + +func runToProto(in *jobsv0.Run) *Run { + if in == nil { + return nil + } + out := &Run{} + out.Id = in.ID + out.JobId = in.JobID + out.Iteration = in.Iteration + out.ContainerId = in.ContainerID + out.ContainerGone = in.ContainerGone + out.State = in.State + out.CreatedAtNano = in.CreatedAtNano + out.StartedAtNano = in.StartedAtNano + out.FinishedAtNano = in.FinishedAtNano + out.ExitCode = exitCodeToProto(in.ExitCode) + out.Error = in.Error + out.Trigger = triggerEvidenceToProto(in.Trigger) + return out +} + +func runFromProto(in *Run) *jobsv0.Run { + if in == nil { + return nil + } + out := &jobsv0.Run{} + out.ID = in.GetId() + out.JobID = in.GetJobId() + out.Iteration = in.GetIteration() + out.ContainerID = in.GetContainerId() + out.ContainerGone = in.GetContainerGone() + out.State = in.GetState() + out.CreatedAtNano = in.GetCreatedAtNano() + out.StartedAtNano = in.GetStartedAtNano() + out.FinishedAtNano = in.GetFinishedAtNano() + out.ExitCode = exitCodeFromProto(in.GetExitCode()) + out.Error = in.GetError() + out.Trigger = triggerEvidenceFromProto(in.GetTrigger()) + return out +} + +func runReplyToProto(in *jobsv0.RunReply) *RunReply { + if in == nil { + return nil + } + out := &RunReply{} + out.Run = runToProto(in.Run) + return out +} + +func runReplyFromProto(in *RunReply) *jobsv0.RunReply { + if in == nil { + return nil + } + out := &jobsv0.RunReply{} + out.Run = runFromProto(in.GetRun()) + return out +} + +func runRequestToProto(in *jobsv0.RunRequest) *RunRequest { + if in == nil { + return nil + } + out := &RunRequest{} + out.JobRef = in.JobRef + out.Reschedule = in.Reschedule + return out +} + +func runRequestFromProto(in *RunRequest) *jobsv0.RunRequest { + if in == nil { + return nil + } + out := &jobsv0.RunRequest{} + out.JobRef = in.GetJobRef() + out.Reschedule = in.GetReschedule() + return out +} + +func scheduleTriggerToProto(in *jobsv0.ScheduleTrigger) *ScheduleTrigger { + if in == nil { + return nil + } + out := &ScheduleTrigger{} + out.Cron = in.Cron + out.Timezone = in.Timezone + out.Concurrency = in.Concurrency + out.MissedFires = in.MissedFires + return out +} + +func scheduleTriggerFromProto(in *ScheduleTrigger) *jobsv0.ScheduleTrigger { + if in == nil { + return nil + } + out := &jobsv0.ScheduleTrigger{} + out.Cron = in.GetCron() + out.Timezone = in.GetTimezone() + out.Concurrency = in.GetConcurrency() + out.MissedFires = in.GetMissedFires() + return out +} + +func triggerToProto(in *jobsv0.Trigger) *Trigger { + if in == nil { + return nil + } + out := &Trigger{} + out.Manual = in.Manual + out.Schedule = scheduleTriggerToProto(in.Schedule) + return out +} + +func triggerFromProto(in *Trigger) *jobsv0.Trigger { + if in == nil { + return nil + } + out := &jobsv0.Trigger{} + out.Manual = in.GetManual() + out.Schedule = scheduleTriggerFromProto(in.GetSchedule()) + return out +} + +func triggerEvidenceToProto(in *jobsv0.TriggerEvidence) *TriggerEvidence { + if in == nil { + return nil + } + out := &TriggerEvidence{} + out.Kind = in.Kind + out.ScheduledAtNano = in.ScheduledAtNano + out.FiredAtNano = in.FiredAtNano + return out +} + +func triggerEvidenceFromProto(in *TriggerEvidence) *jobsv0.TriggerEvidence { + if in == nil { + return nil + } + out := &jobsv0.TriggerEvidence{} + out.Kind = in.GetKind() + out.ScheduledAtNano = in.GetScheduledAtNano() + out.FiredAtNano = in.GetFiredAtNano() + return out +} + +func waitReplyToProto(in *jobsv0.WaitReply) *WaitReply { + if in == nil { + return nil + } + out := &WaitReply{} + out.Run = runToProto(in.Run) + return out +} + +func waitReplyFromProto(in *WaitReply) *jobsv0.WaitReply { + if in == nil { + return nil + } + out := &jobsv0.WaitReply{} + out.Run = runFromProto(in.GetRun()) + return out +} + +func waitRequestToProto(in *jobsv0.WaitRequest) *WaitRequest { + if in == nil { + return nil + } + out := &WaitRequest{} + out.JobRef = in.JobRef + out.RunRef = in.RunRef + out.Condition = in.Condition + return out +} + +func waitRequestFromProto(in *WaitRequest) *jobsv0.WaitRequest { + if in == nil { + return nil + } + out := &jobsv0.WaitRequest{} + out.JobRef = in.GetJobRef() + out.RunRef = in.GetRunRef() + out.Condition = in.GetCondition() + return out +} diff --git a/vendor.mod b/vendor.mod index cb5e2bf7aeab..6a0a5015cf6d 100644 --- a/vendor.mod +++ b/vendor.mod @@ -32,6 +32,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/mattn/go-runewidth v0.0.31 + github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f github.com/moby/go-archive v0.3.3 github.com/moby/moby/api v1.56.2-0.20261106011722-402d0a16f131 github.com/moby/moby/client v0.6.3-0.20261106011722-402d0a16f131 @@ -65,6 +66,8 @@ require ( golang.org/x/sys v0.50.0 golang.org/x/term v0.48.0 golang.org/x/text v0.44.0 + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.1 ) @@ -107,6 +110,4 @@ require ( golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/grpc v1.83.2 // indirect - google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/vendor.sum b/vendor.sum index c6d7b9e13b24..cff48ad7370a 100644 --- a/vendor.sum +++ b/vendor.sum @@ -86,6 +86,8 @@ github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5I github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f h1:KVamkmaHmI/Mopjhye+cPWTL4Td7d053aAp2hU53MXs= +github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f/go.mod h1:Zq+KLMU0GgWgGzFw9ouysZj1+lARdDl7Gy4nFQD/QNY= github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/moby/api v1.56.2-0.20261106011722-402d0a16f131 h1:tPz9ahj8F7AjQw62XQFP65kmoxF3rXTYSit3q06/yQc= diff --git a/vendor/github.com/moby/extensions/.golangci.yml b/vendor/github.com/moby/extensions/.golangci.yml new file mode 100644 index 000000000000..53c60e51f33e --- /dev/null +++ b/vendor/github.com/moby/extensions/.golangci.yml @@ -0,0 +1,11 @@ +version: "2" + +linters: + default: none + enable: + - copyloopvar + - errcheck + - intrange + - mirror + - perfsprint + - usestdlibvars diff --git a/vendor/github.com/moby/extensions/README.md b/vendor/github.com/moby/extensions/README.md new file mode 100644 index 000000000000..5ed29448b3ce --- /dev/null +++ b/vendor/github.com/moby/extensions/README.md @@ -0,0 +1,153 @@ +# Extensions + +> [!WARNING] +> ⚠️ This project is a work in progress. + +Moby extensions are one model for adding engine behavior. +They replace the older per-feature plugin systems for network, volume, and log drivers. + +## Mental model + +- A **point** is a versioned, namespaced Go interface and its message types. +- An **extension** implements one or more points. + It can be compiled into the daemon or run as a separate binary; the point and its callers do not change. +- The host registers extensions, resolves typed dependencies, initializes them in dependency order, and routes calls through points. +- Wire code is generated from the Go contract. + The generated proto and adapters let a separate-binary provider be written in another language. +- An ordinary point can also publish its generated gRPC service on `docker.sock`. + Publication is opt-in and uses the same point contract; it does not create a second source contract. + +## Principles + +- **Everything is a point.** + Hooks are points called during engine flows, and extension-to-extension calls use the same provider and routing model. +- **Placement is transparent.** + The same point contract works in-process or in a separate process; the runtime chooses direct Go or gRPC transport. +- **Dependencies are typed and explicit.** + The broker resolves declared point or extension dependencies before initialization; importing a package does not activate it. +- **Fan-out order is not a contract.** + A point that needs ordering defines its own phases or ordering rules. +- **Socket publication is opt-in.** + An extension publishes only the ordinary points it explicitly exposes, and those services must enforce their own access control. +- **Legacy plugins are replaced.** + Network, volume, and log drivers become extension points rather than separate plugin systems. + +## Ordinary Point publication + +The common contract is an ordinary `extensions.DefinePoint` or +`extensions.DefineSinglePoint`. The interface name determines the gRPC service +name: + +```go +package greeterv0 + +import ( + "context" + + "github.com/moby/extensions" +) + +type Greeter interface { + Greet(context.Context, *HelloRequest) (*HelloReply, error) +} + +type HelloRequest struct { + Name string `pb:"1"` +} + +type HelloReply struct { + Message string `pb:"1"` +} + +var Point = extensions.DefinePoint[Greeter]( + "org.mobyproject.extension.example.greeter.v0", +) +``` + +`mobyextgen` infers `.`, here +`org.mobyproject.extension.example.greeter.v0.Greeter`. The interface name is +part of the wire identity, so keep it stable within a Point version. + +Generated Point packages provide `ServerPoint`, `ClientPoint`, and handwritten +`NewClient(conn)`. An extension implements an ordinary Point and separately +offers it for Host-controlled publication: + +```go +var Extension = extensions.New(extensions.Declaration{ + ID: "org.example.greeter.v1", + Providers: []extensions.Provider{ + greeterv0.Point.Provide(greeter{}), + servicev0.Offer(greeterv0.Point), + }, +}) +``` + +One offer may name a subset of several implemented Points: + +```go +Providers: []extensions.Provider{ + foov1.Point.Provide(fooImpl{}), + barv1.Point.Provide(barImpl{}), + servicev0.Offer(foov1.Point), +} +``` + +An offer is not authorization. The Host defaults to deny and applies policy per +host-attested extension identity and Point: + +```go +AllowPublication: host.PublicationPolicyFunc(func(identity extensions.ExtensionIdentity, point extensions.PointID) bool { + return identity.ID == "org.example.greeter.v1" && + identity.Origin.Kind == extensions.ExtensionOriginExecutable && + point == greeterv0.Point.ID() +}), +``` + +For a separate binary, pass each implemented Point's generated adapter at the +process composition boundary: + +```go +sdk.Main(extension, greeterpb.ServerPoint) +``` + +For an in-process extension, supply the adapter through `host.Options.PointServers`. +An in-process provider has no private SDK gRPC server, so the Host needs this +generated registration logic to translate protobuf requests into calls on the +Go implementation. +Offered process Points need `ClientPoint` wiring only when the daemon also calls +them internally. +External callers use the generated handwritten client: + +```go +client := greeterpb.NewClient(hostConn) +reply, err := client.Greet(ctx, &greeterv0.HelloRequest{Name: "world"}) +``` + +The current backend is gRPC. For a launched extension, the SDK registers the +generated service, the daemon records its fully qualified name, and the proxy +forwards the raw gRPC stream, including metadata and status. Arbitrary raw gRPC +publication is not part of the extension API. + +## Glossary + +| Term | Definition | +|---|---| +| **Point** | A versioned, namespaced Go interface and message types implemented by extensions. A breaking change gets a new point version; `.v0` is experimental. | +| **Extension** | A deployable unit, built into the daemon or run as a separate binary, that declares providers, dependencies, and lifecycle. | +| **Extension identity** | The logical extension id plus the host-attested origin (`builtin` or `executable`). The extension declares only its logical id; the host validates the id and supplies the origin. | +| **Provider** | An extension's implementation of one point. It has no separate id and is identified by its extension id; an extension implements a point at most once. | +| **ClientPoint** | Generated host wiring that turns an extension connection into a provider of an ordinary point. | +| **ServerPoint** | Generated SDK or callback wiring that serves an ordinary point's gRPC service. | +| **Publication** | A Host policy allowing an extension-offered ordinary Point to become externally reachable. | +| **Consumer / dependent** | The engine or another extension that resolves and calls a point. | +| **Dependency** | A declared need resolved before initialization. A point dependency needs a provider; an extension dependency names one extension. | +| **Broker** | The host component that registers extensions, resolves dependencies, initializes them, and shuts them down. | +| **Adapter** | Generated code that makes an out-of-process gRPC provider look like the same in-process Go interface to consumers. | +| **Engine** | The host daemon, which consumes points, provides callbacks, routes calls, and publishes opted-in services on its socket. | +| **Legacy plugin** | The older Moby plugin system being replaced, or containerd plugins when discussed as prior art. The new unit is an extension. | + +## Start here + +- The principles and glossary above provide the short normative rules and definitions. +- [DESIGN.md](./docs/DESIGN.md) - current behavior, constraints, wire protocol, and discovery security. +- [AUTHORING.md](./docs/AUTHORING.md) - procedures, commands, code, and checklists. diff --git a/vendor/github.com/moby/extensions/client/client.go b/vendor/github.com/moby/extensions/client/client.go new file mode 100644 index 000000000000..e42a2302a60e --- /dev/null +++ b/vendor/github.com/moby/extensions/client/client.go @@ -0,0 +1,226 @@ +// Package client provides typed, lazy clients for extension points. +// +// Generated wire bindings expose a ClientPoint registration containing the +// runtime gRPC adapter factory. +// A handwritten extensions.Point supplies the compile-time result type used by +// Resolve. +// Each Client owns a separate lazy connection derived from an Engine dialer. +// Resolve only constructs a local proxy, so unavailable or unpublished services +// report errors when a resolved method is invoked rather than during Resolve. +// +// A generated greeter binding can be configured and resolved as follows: +// +// extensions, err := client.New( +// engine, +// client.WithGRPCPoint(greeterpb.ClientPoint), +// ) +// if err != nil { +// return err +// } +// defer extensions.Close() +// +// greeter, err := client.Resolve(extensions, greeterv0.Point) +// if err != nil { +// return err +// } +// +// reply, err := greeter.Greet( +// ctx, +// &greeterv0.HelloRequest{Name: "world"}, +// ) +package client + +import ( + "context" + "errors" + "fmt" + "net" + "reflect" + "sync" + + "github.com/moby/extensions" + "github.com/moby/extensions/clientpoint" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const engineTarget = "passthrough:///moby-engine" + +// Engine supplies the engine connection dialer used by a Client. +// +// The dialer is kept lazy by the client and is invoked by gRPC only when the +// client is used. +type Engine interface { + Dialer() func(context.Context) (net.Conn, error) +} + +// Opt configures a Client. +type Opt func(*config) error + +type config struct { + points map[extensions.PointID]clientpoint.Registration +} + +// WithGRPCPoint configures the generated ClientPoint registration for a point. +// Generated wire packages expose ClientPoint as the host-side gRPC wiring; +// Resolve receives the handwritten typed extensions.Point separately so its Go +// interface type remains available to the caller. +// Registration.Single is host cardinality metadata and is intentionally ignored +// by this client. +func WithGRPCPoint(registration clientpoint.Registration) Opt { + return func(cfg *config) error { + if cfg == nil { + return errors.New("client: nil configuration") + } + if err := extensions.ValidatePointID(registration.Point); err != nil { + return fmt.Errorf("client: invalid gRPC point registration: %w", err) + } + if registration.Provider == nil { + return fmt.Errorf("client: point %q has nil provider", registration.Point) + } + if cfg.points == nil { + cfg.points = make(map[extensions.PointID]clientpoint.Registration) + } + if _, exists := cfg.points[registration.Point]; exists { + return fmt.Errorf("client: duplicate gRPC point registration for point %q", registration.Point) + } + cfg.points[registration.Point] = registration + return nil + } +} + +type pointState struct { + registration clientpoint.Registration + provider extensions.Provider + ready bool +} + +// Client owns one lazy gRPC connection to the engine and the configured point +// adapters. The connection is private to the Client and is never borrowed from +// or closed by the Engine. +type Client struct { + mu sync.Mutex + conn *grpc.ClientConn + points map[extensions.PointID]*pointState + closed bool + closeErr error +} + +// New creates a Client without dialing the engine. +// +// The connection is separately owned by the returned Client and remains lazy; +// availability errors therefore occur when a resolved provider is invoked, not +// during construction. New performs no speculative transport connection or +// preflight. +func New(engine Engine, opts ...Opt) (*Client, error) { + if isNil(engine) { + return nil, errors.New("client: engine is nil") + } + + cfg := config{points: make(map[extensions.PointID]clientpoint.Registration)} + for _, opt := range opts { + if opt == nil { + return nil, errors.New("client: nil option") + } + if err := opt(&cfg); err != nil { + return nil, err + } + } + dialer := engine.Dialer() + if dialer == nil { + return nil, errors.New("client: engine dialer is nil") + } + + conn, err := grpc.NewClient( + engineTarget, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return dialer(ctx) + }), + ) + if err != nil { + return nil, fmt.Errorf("client: create gRPC client: %w", err) + } + + points := make(map[extensions.PointID]*pointState, len(cfg.points)) + for point, registration := range cfg.points { + points[point] = &pointState{registration: registration} + } + return &Client{conn: conn, points: points}, nil +} + +// Resolve returns the typed provider registered for point. +// +// The generated ClientPoint registration and handwritten extensions.Point are +// intentionally separate: ClientPoint supplies the gRPC adapter, while point +// supplies the compile-time provider interface type. The adapter is invoked at +// most once and only when this point is first resolved. Provider construction +// does not preflight the engine, so transport availability errors are returned +// by the provider's method invocation. +func Resolve[T any](client *Client, point extensions.Point[T]) (T, error) { + var zero T + if client == nil { + return zero, errors.New("client: client is nil") + } + + pointID := point.ID() + client.mu.Lock() + defer client.mu.Unlock() + + if client.closed { + return zero, errors.New("client: client is closed") + } + state, configured := client.points[pointID] + if !configured { + return zero, fmt.Errorf("client: point %q is not configured", pointID) + } + if !state.ready { + state.ready = true + state.provider = state.registration.Provider(client.conn) + } + + provider := state.provider + if provider.Point != pointID { + return zero, fmt.Errorf("client: provider point %q does not match requested point %q", provider.Point, pointID) + } + impl, ok := provider.Impl.(T) + if !ok { + return zero, fmt.Errorf("client: provider for point %q has implementation type %T, want %v", pointID, provider.Impl, reflect.TypeFor[T]()) + } + return impl, nil +} + +// Close closes the Client's private gRPC connection. +// +// Close is nil-safe and idempotent. It waits for an in-progress Resolve before +// closing, and later Resolve calls fail without invoking another adapter. +func (client *Client) Close() error { + if client == nil { + return nil + } + + client.mu.Lock() + defer client.mu.Unlock() + if client.closed { + return client.closeErr + } + client.closed = true + if client.conn == nil { + return nil + } + client.closeErr = client.conn.Close() + return client.closeErr +} + +func isNil(value any) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} diff --git a/vendor/github.com/moby/extensions/clientpoint/clientpoint.go b/vendor/github.com/moby/extensions/clientpoint/clientpoint.go new file mode 100644 index 000000000000..30f866723fc3 --- /dev/null +++ b/vendor/github.com/moby/extensions/clientpoint/clientpoint.go @@ -0,0 +1,20 @@ +// Package clientpoint defines the host-side registration for generated +// out-of-process point providers. It has no dependency on the host runtime so +// generated code can reference it. +package clientpoint + +import ( + "github.com/moby/extensions" + "google.golang.org/grpc" +) + +// Provider builds an in-broker provider from a gRPC connection. +type Provider func(grpc.ClientConnInterface) extensions.Provider + +// Registration pairs a point id with its client provider. Single records the +// contract's one-provider cardinality. +type Registration struct { + Point extensions.PointID + Provider Provider + Single bool +} diff --git a/vendor/github.com/moby/extensions/core.go b/vendor/github.com/moby/extensions/core.go new file mode 100644 index 000000000000..271c07ea9060 --- /dev/null +++ b/vendor/github.com/moby/extensions/core.go @@ -0,0 +1,333 @@ +package extensions + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "sync" +) + +// ExtensionID identifies a deployable extension. +type ExtensionID string + +// ExtensionOriginKind identifies how the host obtained an extension. +type ExtensionOriginKind string + +const ( + // ExtensionOriginBuiltin identifies an extension compiled into the host. + ExtensionOriginBuiltin ExtensionOriginKind = "builtin" + // ExtensionOriginExecutable identifies an extension launched as an + // executable. + ExtensionOriginExecutable ExtensionOriginKind = "executable" +) + +// ExtensionOrigin identifies how the host obtained an extension and its +// kind-specific attested details. +type ExtensionOrigin struct { + Kind ExtensionOriginKind + Executable *ExecutableOrigin +} + +// ExecutableOrigin contains host-attested details for an executable origin. +type ExecutableOrigin struct { + // Path is the exact path the host executed, as discovered from the + // extension directory. It is not normalized: a relative directory or a + // symlink yields that relative or symlink path. + Path string +} + +// ExtensionIdentity is the host-attested identity of an extension. +// The extension declares ID, but the host supplies and validates the complete +// identity, including Origin. +type ExtensionIdentity struct { + ID ExtensionID + Origin ExtensionOrigin +} + +// ValidateExtensionIdentity returns an error unless identity contains a valid +// extension ID and a recognized host-attested origin. +func ValidateExtensionIdentity(identity ExtensionIdentity) error { + if err := ValidateExtensionID(identity.ID); err != nil { + return err + } + switch identity.Origin.Kind { + case "": + return errors.New("extension origin kind is required") + case ExtensionOriginBuiltin: + if identity.Origin.Executable != nil { + return errors.New("builtin extension origin must not include executable payload") + } + case ExtensionOriginExecutable: + if identity.Origin.Executable == nil { + return errors.New("executable extension origin requires executable payload") + } + if identity.Origin.Executable.Path == "" { + return errors.New("executable origin path is required") + } + default: + return fmt.Errorf("invalid extension origin kind %q: want %q or %q", identity.Origin.Kind, ExtensionOriginBuiltin, ExtensionOriginExecutable) + } + return nil +} + +// PointID identifies an extension point contract. +type PointID string + +// IsMetadataPoint reports whether point carries framework metadata rather than +// a callable capability. +// Host provider admission never consults policy for metadata points; only +// publication policy governs them. +// Currently the servicev0 offer marker is the only metadata point; its package +// tests keep this ID in sync with the point definition. +func IsMetadataPoint(point PointID) bool { + return point == "org.mobyproject.extension.service.v0" +} + +// Name returns the point's short name, the segment before its version. +func (id PointID) Name() string { + segments := strings.Split(string(id), ".") + if len(segments) < 2 { + return string(id) + } + return segments[len(segments)-2] +} + +// pointIDPattern requires a lowercase reverse-DNS-style id ending in a vN +// version. Segments may contain digits, hyphens, and underscores. +var pointIDPattern = lazyRegexp(`^[a-z][a-z0-9]*(\.[a-z0-9_-]+)+\.v[0-9]+$`) + +// ValidatePointID reports whether id is a well-formed, versioned Point ID. +func ValidatePointID(id PointID) error { + if id == "" { + return errors.New("point id is required") + } + if !pointIDPattern().MatchString(string(id)) { + return fmt.Errorf("invalid point id %q: want a versioned reverse-DNS name like org.example.api.v1", id) + } + return nil +} + +// extensionIDPattern requires a lowercase reverse-DNS name with a mandatory vN +// version. The version is a namespace element, not a semantic version: changing +// com.foo.v1 to com.foo.v2 creates a new extension, binary, and configuration. +// The restricted characters also make the id safe as a config key and filename. +var extensionIDPattern = lazyRegexp(`^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+\.v[0-9]+$`) + +// ValidateExtensionID reports whether id is well-formed before it is used as a +// configuration key or binary name. +func ValidateExtensionID(id ExtensionID) error { + if id == "" { + return errors.New("extension id is required") + } + if !extensionIDPattern().MatchString(string(id)) { + return fmt.Errorf("invalid extension id %q: want a versioned reverse-DNS name like org.example.myext.v1, lowercase, no path-hostile characters", id) + } + return nil +} + +func lazyRegexp(pattern string) func() *regexp.Regexp { + return sync.OnceValue(func() *regexp.Regexp { + re, err := regexp.Compile(pattern) + if err != nil { + panic(err) + } + return re + }) +} + +// Dependency declares one extension dependency. +type Dependency struct { + Point PointID + Extension ExtensionID + Optional bool +} + +// Provider declares an in-process implementation for a point. +type Provider struct { + Point PointID + Impl any +} + +// ResolvedProvider is a provider returned from a lookup with its host-attested +// extension identity. +type ResolvedProvider struct { + Identity ExtensionIdentity + Impl any +} + +// EffectiveProviders applies origin precedence for single-provider resolution. +// Built-ins are used only when no executable provider exists. +// Fan-out and by-id lookups do not apply this precedence. +func EffectiveProviders(providers []ResolvedProvider) []ResolvedProvider { + var executables, builtins []ResolvedProvider + for _, p := range providers { + if p.Identity.Origin.Kind == ExtensionOriginBuiltin { + builtins = append(builtins, p) + } else { + executables = append(executables, p) + } + } + if len(executables) == 0 { + return builtins + } + return executables +} + +// TypedProvider is a provider returned through a typed point handle. +type TypedProvider[T any] struct { + Identity ExtensionIdentity + Impl T +} + +// Point binds a point ID to the Go interface implemented by its providers. +type Point[T any] struct { + id PointID +} + +// DefinePoint binds id to provider interface T and panics for an invalid point +// id. Point ids are fixed in source, so invalid ids are programming errors. +func DefinePoint[T any](id PointID) Point[T] { + if ValidatePointID(id) != nil { + panic(fmt.Sprintf("extensions: invalid point id %q: want ....vN, e.g. org.mobyproject.extension.volume.driver.v1", id)) + } + return Point[T]{id: id} +} + +// DefineSinglePoint declares a point with one deciding provider instead of a +// fan-out. The wire generator carries this cardinality into ClientPoint so the +// host can reject multiple executable providers at startup. +func DefineSinglePoint[T any](id PointID) Point[T] { + return DefinePoint[T](id) +} + +// ID returns the point identifier. +func (p Point[T]) ID() PointID { + return p.id +} + +// Provide returns a provider declaration for impl. +func (p Point[T]) Provide(impl T) Provider { + return Provider{Point: p.id, Impl: impl} +} + +// Dependency returns a required dependency declaration for the point: at least +// one provider must exist before the dependent initializes. +func (p Point[T]) Dependency() Dependency { + return Dependency{Point: p.id} +} + +// OptionalDependency returns an optional dependency declaration for the point: +// the dependent still initializes, ordered after any providers, when none exist. +func (p Point[T]) OptionalDependency() Dependency { + return Dependency{Point: p.id, Optional: true} +} + +// ByExtension returns the point provider implemented by extension. +func (p Point[T]) ByExtension(r Resolver, extension ExtensionID) (T, error) { + provider, err := r.Provider(p.id, extension) + if err != nil { + var zero T + return zero, err + } + return typedProvider[T](p.id, extension, provider) +} + +// Single returns the only point provider, after origin precedence +// ([EffectiveProviders]): a built-in provider does not count against the +// one-provider limit, it stands in when no executable provider exists. +func (p Point[T]) Single(r Resolver) (T, error) { + providers := EffectiveProviders(r.Providers(p.id)) + var zero T + switch len(providers) { + case 0: + return zero, fmt.Errorf("point %q has no providers", p.id) + case 1: + return typedProvider[T](p.id, providers[0].Identity.ID, providers[0].Impl) + default: + return zero, fmt.Errorf("point %q has multiple providers", p.id) + } +} + +// Enabled reports whether any extension provides the point. +func (p Point[T]) Enabled(r Resolver) bool { + return len(r.Providers(p.id)) > 0 +} + +// All returns all point providers, including built-in and executable providers. +func (p Point[T]) All(r Resolver) ([]TypedProvider[T], error) { + providers := r.Providers(p.id) + typed := make([]TypedProvider[T], 0, len(providers)) + for _, provider := range providers { + impl, err := typedProvider[T](p.id, provider.Identity.ID, provider.Impl) + if err != nil { + return nil, err + } + typed = append(typed, TypedProvider[T]{Identity: provider.Identity, Impl: impl}) + } + return typed, nil +} + +func typedProvider[T any](point PointID, extension ExtensionID, provider any) (T, error) { + typed, ok := provider.(T) + if ok { + return typed, nil + } + var zero T + if extension == "" { + return zero, fmt.Errorf("point %q provider has type %T", point, provider) + } + return zero, fmt.Errorf("extension %q provider for point %q has type %T", extension, point, provider) +} + +// Resolver exposes provider lookup to extension initializers. Selection and +// cardinality are handled by the typed [Point] accessors. +type Resolver interface { + Provider(PointID, ExtensionID) (any, error) + Providers(PointID) []ResolvedProvider +} + +// Registrar registers extensions. +type Registrar interface { + Register(Extension) error +} + +// RegisterAll registers exts with registrar. +func RegisterAll(registrar Registrar, exts ...Extension) error { + for _, ext := range exts { + if err := registrar.Register(ext); err != nil { + return err + } + } + return nil +} + +// Config is parsed per-extension configuration delivered by id to Init or the +// out-of-process startup handshake. +type Config = map[string]any + +// Extension is a host-managed extension declaration. +type Extension interface { + // Declaration returns the extension's id, providers, dependencies, and + // conflicts, plus its optional Init and Shutdown. + Declaration() Declaration +} + +// Declaration declares an extension, its providers and dependencies, and its +// optional lifecycle callbacks. +type Declaration struct { + ID ExtensionID + Providers []Provider + Dependencies []Dependency + Conflicts []ExtensionID + Init func(context.Context, Config, Resolver) error + Shutdown func(context.Context) error +} + +// New wraps a static Declaration as an [Extension]. +func New(d Declaration) Extension { return staticExtension{d} } + +type staticExtension struct{ decl Declaration } + +func (e staticExtension) Declaration() Declaration { return e.decl } diff --git a/vendor/github.com/moby/extensions/fanout.go b/vendor/github.com/moby/extensions/fanout.go new file mode 100644 index 000000000000..3c2c15a0bf52 --- /dev/null +++ b/vendor/github.com/moby/extensions/fanout.go @@ -0,0 +1,96 @@ +package extensions + +import ( + "context" + "fmt" + "time" + + "github.com/containerd/log" +) + +// Policy controls provider deadlines and error handling for [Each] and [Fold]. +type Policy struct { + // Timeout bounds each provider call independently. Zero leaves the calls + // bounded only by the caller's context. + // + // It is enforced for out-of-process providers, which receive the deadline + // over gRPC. An in-process provider is passed the same context but only + // trusted to honor it: abandoning a direct Go call would leave the provider + // mutating shared engine state after the caller moved on. State a + // fail-closed point's guarantee accordingly. + Timeout time.Duration + + // FailOpen continues with the remaining providers when one returns an error. + // Veto points should leave it false. + FailOpen bool + + // Action names what the provider's error did to the operation, for the + // wrapped error: ` provider "" : `. Leave it empty + // for a plain failure. + Action string +} + +// wrap attributes err to the extension that produced it. +func (p Policy) wrap(point PointID, extension ExtensionID, err error) error { + if p.Action == "" { + return fmt.Errorf("%s provider %q: %w", point.Name(), extension, err) + } + return fmt.Errorf("%s provider %q %s: %w", point.Name(), extension, p.Action, err) +} + +// Each calls fn once per provider of p under policy. Use it for validation, +// veto, or notification points. +// +// Fan-out order is unspecified. A point that needs a defined order must define +// it (see the guidance in the extensions authoring guide). +func Each[T any](ctx context.Context, p Point[T], r Resolver, policy Policy, fn func(context.Context, T) error) error { + _, err := Fold(ctx, p, r, policy, struct{}{}, func(ctx context.Context, impl T, _ struct{}) (struct{}, error) { + return struct{}{}, fn(ctx, impl) + }) + return err +} + +// Fold threads acc through every provider of p and returns the final value under +// policy. Each provider sees the value produced by its predecessors. +// +// A provider that fails under a fail-closed policy aborts the fold and its +// partial value is discarded; under a fail-open one its contribution is dropped +// and the fold continues from the value it was given. +func Fold[T, A any](ctx context.Context, p Point[T], r Resolver, policy Policy, acc A, fn func(context.Context, T, A) (A, error)) (A, error) { + providers, err := p.All(r) + if err != nil { + return acc, err + } + for _, provider := range providers { + next, err := callProvider(ctx, p.id, policy, provider, acc, fn) + if err != nil { + if !policy.FailOpen { + // Discard the partial value rather than hand back a half-applied + // one: for a composing point such as the create-spec hook that + // would be an OCI spec carrying some providers' changes but not + // the rest, which is worse than no answer. + var zero A + return zero, err + } + log.G(ctx).WithError(err).Warn("extensions: skipping failed provider") + continue + } + acc = next + } + return acc, nil +} + +// callProvider gives each provider its own timeout and releases it when the +// call returns. +func callProvider[T, A any](ctx context.Context, point PointID, policy Policy, provider TypedProvider[T], acc A, fn func(context.Context, T, A) (A, error)) (A, error) { + if policy.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, policy.Timeout) + defer cancel() + } + next, err := fn(ctx, provider.Impl, acc) + if err != nil { + return acc, policy.wrap(point, provider.Identity.ID, err) + } + return next, nil +} diff --git a/vendor/github.com/moby/extensions/serverpoint/serverpoint.go b/vendor/github.com/moby/extensions/serverpoint/serverpoint.go new file mode 100644 index 000000000000..f1106d490d49 --- /dev/null +++ b/vendor/github.com/moby/extensions/serverpoint/serverpoint.go @@ -0,0 +1,18 @@ +// Package serverpoint defines the SDK-side registration for generated +// out-of-process point providers. It has no dependency on the SDK server so +// generated code can reference it. +package serverpoint + +import ( + "github.com/moby/extensions" + "google.golang.org/grpc" +) + +// Register registers a point's gRPC service on r. +type Register func(r grpc.ServiceRegistrar, impl any) + +// Registration pairs a point id with its server registration. +type Registration struct { + Point extensions.PointID + Register Register +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 708b2282a0fb..643f95258883 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -161,6 +161,12 @@ github.com/mattn/go-runewidth # github.com/moby/docker-image-spec v1.3.1 ## explicit; go 1.18 github.com/moby/docker-image-spec/specs-go/v1 +# github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f +## explicit; go 1.26.0 +github.com/moby/extensions +github.com/moby/extensions/client +github.com/moby/extensions/clientpoint +github.com/moby/extensions/serverpoint # github.com/moby/go-archive v0.3.3 ## explicit; go 1.25 github.com/moby/go-archive