From 16d148aa3a7ff0829265a39bbd46181200cf35e0 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:44:10 +0200 Subject: [PATCH 01/19] jobs: warn and ignore, reject active scheduled jobs on up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose does not run jobs yet. Declared jobs are ignored with a warning naming them — except on up when the project carries active (profile-enabled) scheduled jobs: silently not scheduling them would break the user's expectations, unlike manual jobs which simply wait for an explicit trigger, so up fails with "scheduled jobs are not supported in this version". Signed-off-by: Nicolas De Loof --- cmd/compose/compose.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 290be9b8918..51fc2c85c8a 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -25,6 +25,7 @@ import ( "os" "os/signal" "path/filepath" + "sort" "strconv" "strings" "syscall" @@ -378,6 +379,15 @@ func (o *ProjectOptions) ToProject( return nil, metrics, err } + if jobs := project.AllJobs(); len(jobs) > 0 { + names := make([]string, 0, len(jobs)) + for name := range jobs { + names = append(names, name) + } + sort.Strings(names) + logrus.Warnf("jobs are not supported yet and will be ignored: %s", strings.Join(names, ", ")) + } + return project, metrics, nil } From c20183ae87f0745b6c59448e9bbf34bb20823bb7 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:54:52 +0200 Subject: [PATCH 02/19] run: execute manual-trigger jobs exactly like services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job is a ContainerSpec plus a WorkloadSpec — the same layers a service is made of — so `compose run ` materializes it as a service for the one-off machinery: WithSelectedJob activates the job's profile and narrows the project to its dependencies, then the job joins Services under its own name. Dependencies declared by the job start exactly as they would for a service, and the exit code flows back as usual. Only manual-trigger jobs are runnable: a schedule-only job is refused with an explicit error. The up-side warning moves next to the scheduled-jobs rejection and now points at `docker compose run` as the way to trigger manual jobs, instead of calling them ignored. The one-off project loading needs a second, unselected pass when the target is not a service: the service selector cannot resolve a job. Signed-off-by: Nicolas De Loof --- cmd/compose/compose.go | 10 ---------- cmd/compose/run.go | 5 +++++ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 51fc2c85c8a..290be9b8918 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -25,7 +25,6 @@ import ( "os" "os/signal" "path/filepath" - "sort" "strconv" "strings" "syscall" @@ -379,15 +378,6 @@ func (o *ProjectOptions) ToProject( return nil, metrics, err } - if jobs := project.AllJobs(); len(jobs) > 0 { - names := make([]string, 0, len(jobs)) - for name := range jobs { - names = append(names, name) - } - sort.Strings(names) - logrus.Warnf("jobs are not supported yet and will be ignored: %s", strings.Join(names, ", ")) - } - return project, metrics, nil } diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 9246566a734..8c6db24526f 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -278,6 +278,11 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { // their containers. func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, p *ProjectOptions, service string) (*types.Project, error) { project, _, err := p.ToProject(ctx, dockerCli, backend, []string{service}, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) + if err != nil && strings.Contains(err.Error(), "no such service") { + // the run target may be a job, which the service selector cannot + // resolve: reload unselected and let materializeManualJob decide + project, _, err = p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) + } if err != nil { // The run target may be a job — invisible to the service selector. // Only retry unselected for that specific selection failure: any From d1cbf69ffa5cccf5227717412d5b90c118337e07 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 27 Aug 2026 14:05:08 +0200 Subject: [PATCH 03/19] jobs: wire up/run/down to the engine's jobs gRPC API Replace the "warn and reject" stub from the previous commits with real calls into the engine's jobs gRPC extension, via the official moby/extensions client: `up` registers scheduled jobs idempotently, `run ` triggers manual jobs with CreateAndRun+Wait, streaming their logs and propagating the exit code, and `down` deregisters a project's jobs. Also fixes a pre-existing bug in container label construction that produced a non-deterministic depends_on label, which this feature's spec-hash idempotency check turned into spurious "job has changed" errors on an unmodified re-up. Signed-off-by: Guillaume Lours --- cmd/compose/create.go | 5 - cmd/compose/run.go | 15 + cmd/compose/start.go | 6 - cmd/compose/up.go | 35 +- cmd/compose/up_test.go | 21 +- go.mod | 3 +- go.sum | 2 + internal/jobsapi/errors.go | 53 + internal/jobsapi/jobs.go | 552 +++++ internal/jobsapi/protogen/jobs.pb.go | 2129 +++++++++++++++++ internal/jobsapi/protogen/wire.gen.go | 1262 ++++++++++ pkg/api/api.go | 8 + pkg/api/labels.go | 2 + pkg/compose/compose.go | 10 + pkg/compose/create.go | 16 +- pkg/compose/down.go | 5 + pkg/compose/down_test.go | 13 + pkg/compose/jobs.go | 398 +++ pkg/compose/observed_state.go | 14 +- pkg/compose/up.go | 10 +- pkg/e2e/jobs_test.go | 21 +- .../TestUpRegistersScheduledJobs/compose.yaml | 15 + .../TestUpRejectsScheduledJobs/compose.yaml | 12 - pkg/mocks/mock_docker_compose_api.go | 30 + 24 files changed, 4558 insertions(+), 79 deletions(-) create mode 100644 internal/jobsapi/errors.go create mode 100644 internal/jobsapi/jobs.go create mode 100644 internal/jobsapi/protogen/jobs.pb.go create mode 100644 internal/jobsapi/protogen/wire.gen.go create mode 100644 pkg/compose/jobs.go create mode 100644 pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml delete mode 100644 pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml diff --git a/cmd/compose/create.go b/cmd/compose/create.go index 211ec2baecd..f839ec92d81 100644 --- a/cmd/compose/create.go +++ b/cmd/compose/create.go @@ -108,11 +108,6 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac } func runCreate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, createOpts createOptions, buildOpts buildOptions, project *types.Project, services []string) error { - // same contract as up: an active scheduled job is refused before any - // resource is created — silently not scheduling would break expectations - if err := rejectScheduledJobs(project); err != nil { - return err - } if err := createOpts.Apply(project); err != nil { return err } diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 8c6db24526f..655ddb7faf0 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -440,6 +440,21 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op Index: 0, } + if _, ok := project.AllJobs()[options.Service]; ok { + if options.name != "" { + logrus.Warnf("--name has no effect on a job: the engine names its run containers itself") + } + exitCode, err := backend.RunJob(ctx, project, options.Service, runOpts) + if exitCode != 0 { + errMsg := "" + if err != nil { + errMsg = err.Error() + } + return cli.StatusError{StatusCode: exitCode, Status: errMsg} + } + return err + } + for name, service := range project.Services { if name == options.Service { service.StdinOpen = options.interactive diff --git a/cmd/compose/start.go b/cmd/compose/start.go index f3f69cec347..062efb680d2 100644 --- a/cmd/compose/start.go +++ b/cmd/compose/start.go @@ -56,12 +56,6 @@ func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *Backen if err != nil { return err } - if project != nil { - // with a file, refuse active scheduled jobs like up and create do - if err := rejectScheduledJobs(project); err != nil { - return err - } - } var timeout time.Duration if opts.waitTimeout > 0 { diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 3716b5c8be2..abf89cc3921 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -242,9 +242,6 @@ func runUp( return err } - if err := rejectScheduledJobs(project); err != nil { - return err - } warnIgnoredJobs(project) err := createOptions.Apply(project) @@ -356,34 +353,26 @@ func runUp( }) } -// warnIgnoredJobs names the declared jobs up will not act on: manual jobs -// wait for an explicit `compose run ` trigger. +// warnIgnoredJobs names the declared manual jobs up will not act on: they +// wait for an explicit `compose run ` trigger. Scheduled jobs are not +// ignored — they are registered with the engine (see pkg/compose Up). func warnIgnoredJobs(project *types.Project) { - jobs := project.Jobs - if len(jobs) == 0 { + names := manualJobNames(project) + if len(names) == 0 { return } - names := make([]string, 0, len(jobs)) - for name := range jobs { - names = append(names, name) - } - sort.Strings(names) logrus.Warnf("jobs are not started by up; trigger them with `docker compose run`: %s", strings.Join(names, ", ")) } -// rejectScheduledJobs refuses to bring a project up when it declares active -// scheduled jobs: silently not scheduling them would break the user's -// expectations, unlike manual jobs which simply wait for an explicit trigger. -func rejectScheduledJobs(project *types.Project) error { - names := make([]string, 0, len(project.Jobs)) - for name, job := range project.Jobs { - if job.Triggers != nil && len(job.Triggers.Schedule) > 0 { +// manualJobNames returns the sorted names of the project's manual-trigger jobs. +func manualJobNames(project *types.Project) []string { + jobs := project.Jobs + names := make([]string, 0, len(jobs)) + for name, job := range jobs { + if job.Triggers != nil && job.Triggers.Manual != nil && *job.Triggers.Manual { names = append(names, name) } } - if len(names) == 0 { - return nil - } sort.Strings(names) - return fmt.Errorf("scheduled jobs are not supported in this version: %s", strings.Join(names, ", ")) + return names } diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go index 98ac8949db9..66338ad9203 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -180,20 +180,19 @@ services: assert.Assert(t, !strings.Contains(fmt.Sprint(err), "invalid ip address"), fmt.Sprint(err)) } -func TestRejectScheduledJobs(t *testing.T) { +func TestManualJobNames(t *testing.T) { yes := true manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: &yes}} scheduled := types.JobConfig{Triggers: &types.TriggerConfig{ Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, }} - assert.NilError(t, rejectScheduledJobs(&types.Project{})) - assert.NilError(t, rejectScheduledJobs(&types.Project{Jobs: types.Jobs{"migrate": manual}})) - // profile-disabled scheduled jobs don't block up - assert.NilError(t, rejectScheduledJobs(&types.Project{DisabledJobs: types.Jobs{"backup": scheduled}})) - - err := rejectScheduledJobs(&types.Project{Jobs: types.Jobs{"backup": scheduled, "sync": scheduled, "migrate": manual}}) - assert.Error(t, err, "scheduled jobs are not supported in this version: backup, sync") + assert.DeepEqual(t, manualJobNames(&types.Project{}), []string{}) + assert.DeepEqual(t, manualJobNames(&types.Project{Jobs: types.Jobs{"backup": scheduled}}), []string{}) + assert.DeepEqual(t, + manualJobNames(&types.Project{Jobs: types.Jobs{"backup": scheduled, "sync": manual, "migrate": manual}}), + []string{"migrate", "sync"}, + ) } // warnIgnoredJobs must only name profile-enabled jobs: a job disabled by @@ -203,9 +202,11 @@ func TestRejectScheduledJobs(t *testing.T) { func TestWarnIgnoredJobs(t *testing.T) { hook := logrustest.NewGlobal() + yes := true + manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: &yes}} warnIgnoredJobs(&types.Project{ - Jobs: types.Jobs{"migrate": {}}, - DisabledJobs: types.Jobs{"backup": {}}, + Jobs: types.Jobs{"migrate": manual}, + DisabledJobs: types.Jobs{"backup": manual}, }) assert.Equal(t, len(hook.AllEntries()), 1) diff --git a/go.mod b/go.mod index d8e25cf4e82..fcc934cdc22 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/mattn/go-shellwords v1.0.16 github.com/mitchellh/go-ps v1.0.0 github.com/moby/buildkit v0.33.1 + github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f github.com/moby/go-archive v0.3.3 github.com/moby/moby/api v1.56.1 github.com/moby/moby/client v0.6.2 @@ -56,6 +57,7 @@ require ( golang.org/x/sync v0.24.0 golang.org/x/sys v0.48.0 google.golang.org/grpc v1.84.0 + google.golang.org/protobuf v1.36.12 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.1 ) @@ -128,7 +130,6 @@ require ( golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/protobuf v1.36.12 // indirect gopkg.in/ini.v1 v1.67.3 // indirect ) diff --git a/go.sum b/go.sum index 395e90fd383..d941a6006f2 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,8 @@ github.com/moby/buildkit v0.33.1 h1:UUrdifmRdRadykO+f5l8BT1CseUXst2sJHV7AmkjjxE= github.com/moby/buildkit v0.33.1/go.mod h1:584wW8T/WG4O+lpXUCoDaWEc5e+rTGC3iP+l9mNcX8E= 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/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= diff --git a/internal/jobsapi/errors.go b/internal/jobsapi/errors.go new file mode 100644 index 00000000000..4069abeee71 --- /dev/null +++ b/internal/jobsapi/errors.go @@ -0,0 +1,53 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package jobsv0 + +import ( + "github.com/containerd/errdefs" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// MapError translates the Jobs API's gRPC status codes into containerd/errdefs +// errors, so callers can reuse the same errdefs.IsXxx checks Compose already +// applies to the container/network/volume APIs. jobsv0.Jobs methods return +// raw gRPC errors (extensionclient.Resolve is lazy: unavailability only +// surfaces as codes.Unimplemented on the first real call), so callers must +// map each returned error explicitly. +func MapError(err error) error { + if err == nil { + return nil + } + st, ok := status.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.Unimplemented: + return errdefs.ErrNotImplemented.WithMessage("the engine does not support jobs; start dockerd with --feature jobs") + case codes.AlreadyExists: + return errdefs.ErrAlreadyExists.WithMessage(st.Message()) + case codes.FailedPrecondition: + return errdefs.ErrFailedPrecondition.WithMessage(st.Message()) + case codes.InvalidArgument: + return errdefs.ErrInvalidArgument.WithMessage(st.Message()) + case codes.NotFound: + return errdefs.ErrNotFound.WithMessage(st.Message()) + default: + return err + } +} diff --git a/internal/jobsapi/jobs.go b/internal/jobsapi/jobs.go new file mode 100644 index 00000000000..eb23fc17907 --- /dev/null +++ b/internal/jobsapi/jobs.go @@ -0,0 +1,552 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//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 (unary +// methods only; 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. 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. + 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. +// +//mobyextgen:service=Jobs +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 an explicit Cancel. + 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. + 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. + RunsKeep = "keep" + // RunsRemove drops all run records, including terminal ones. + RunsRemove = "remove" + // RunsRemoveFinished drops terminal run records 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, + // once the terminal run record is written. 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. + RemoveOnFailure bool `pb:"6"` + // RunHistoryLimit caps retained run records, evicting the oldest + // terminal runs first. 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 POSIX crontab expression. 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. + 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. + 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. + 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. + 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 an idle schedule job, "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/protogen/jobs.pb.go b/internal/jobsapi/protogen/jobs.pb.go new file mode 100644 index 00000000000..46b4129a3cb --- /dev/null +++ b/internal/jobsapi/protogen/jobs.pb.go @@ -0,0 +1,2129 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: extpoints/jobs/api/v0/jobs.proto + +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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_v0_jobs_proto_rawDescGZIP(), []int{32} +} + +var File_extpoints_jobs_api_v0_jobs_proto protoreflect.FileDescriptor + +const file_extpoints_jobs_api_v0_jobs_proto_rawDesc = "" + + "\n" + + " extpoints/jobs/api/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.WaitReplyB8Z6github.com/moby/moby/v2/extpoints/jobs/api/v0/protogenb\x06proto3" + +var ( + file_extpoints_jobs_api_v0_jobs_proto_rawDescOnce sync.Once + file_extpoints_jobs_api_v0_jobs_proto_rawDescData []byte +) + +func file_extpoints_jobs_api_v0_jobs_proto_rawDescGZIP() []byte { + file_extpoints_jobs_api_v0_jobs_proto_rawDescOnce.Do(func() { + file_extpoints_jobs_api_v0_jobs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_extpoints_jobs_api_v0_jobs_proto_rawDesc), len(file_extpoints_jobs_api_v0_jobs_proto_rawDesc))) + }) + return file_extpoints_jobs_api_v0_jobs_proto_rawDescData +} + +var file_extpoints_jobs_api_v0_jobs_proto_msgTypes = make([]protoimpl.MessageInfo, 34) +var file_extpoints_jobs_api_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_extpoints_jobs_api_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_extpoints_jobs_api_v0_jobs_proto_init() } +func file_extpoints_jobs_api_v0_jobs_proto_init() { + if File_extpoints_jobs_api_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_extpoints_jobs_api_v0_jobs_proto_rawDesc), len(file_extpoints_jobs_api_v0_jobs_proto_rawDesc)), + NumEnums: 0, + NumMessages: 34, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_extpoints_jobs_api_v0_jobs_proto_goTypes, + DependencyIndexes: file_extpoints_jobs_api_v0_jobs_proto_depIdxs, + MessageInfos: file_extpoints_jobs_api_v0_jobs_proto_msgTypes, + }.Build() + File_extpoints_jobs_api_v0_jobs_proto = out.File + file_extpoints_jobs_api_v0_jobs_proto_goTypes = nil + file_extpoints_jobs_api_v0_jobs_proto_depIdxs = nil +} diff --git a/internal/jobsapi/protogen/wire.gen.go b/internal/jobsapi/protogen/wire.gen.go new file mode 100644 index 00000000000..1336439855d --- /dev/null +++ b/internal/jobsapi/protogen/wire.gen.go @@ -0,0 +1,1262 @@ +// Code generated by mobyextgen. DO NOT EDIT. +package protogen + +import ( + context "context" + extensions "github.com/moby/extensions" + clientpoint "github.com/moby/extensions/clientpoint" + serverpoint "github.com/moby/extensions/serverpoint" + jobsv0 "github.com/docker/compose/v5/internal/jobsapi" + 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: "extpoints/jobs/api/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/pkg/api/api.go b/pkg/api/api.go index d4c9b79fbe4..b6845f84ba0 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -176,6 +176,14 @@ type ImageManager interface { type Runner interface { // RunOneOffContainer creates a service oneoff container and starts its dependencies RunOneOffContainer(ctx context.Context, project *types.Project, opts RunOptions) (int, error) + // RunJob triggers a manual-trigger job's Run on the engine's jobs API, + // starts its dependencies first, and returns the Run's exit code. options + // carries the same CLI overrides RunOneOffContainer accepts (User, + // Command, Entrypoint, WorkingDir, CapAdd/CapDrop, Environment, Labels, + // NoDeps); Service is ignored, name is authoritative. Name has no effect: + // the jobs API assigns the run container's identity itself, there is no + // per-run naming hook to plug --name into. + RunJob(ctx context.Context, project *types.Project, name string, options RunOptions) (int, error) // Exec executes a command in a running service container Exec(ctx context.Context, projectName string, options RunOptions) (int, error) // Attach STDIN,STDOUT,STDERR to a running service container diff --git a/pkg/api/labels.go b/pkg/api/labels.go index a45267348c9..5a4e40f1395 100644 --- a/pkg/api/labels.go +++ b/pkg/api/labels.go @@ -88,6 +88,8 @@ const ( // declared hook with the runner container the reconciliation plan // prepared for it. HookIndexLabel = "com.docker.compose.hook-index" + // JobLabel allow to track resource related to a compose job + JobLabel = "com.docker.compose.job" ) // ComposeVersion is the compose tool version as declared by label VersionLabel diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index a78c8043653..6eb8f4a7927 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -32,6 +32,7 @@ import ( clitypes "github.com/docker/cli/cli/config/types" "github.com/docker/cli/cli/flags" "github.com/docker/cli/cli/streams" + extensionclient "github.com/moby/extensions/client" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" @@ -39,6 +40,7 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" + jobsv0 "github.com/docker/compose/v5/internal/jobsapi" "github.com/docker/compose/v5/internal/registry" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/dryrun" @@ -313,6 +315,11 @@ type composeService struct { // logStreamDrainTimeout bounds the shutdown drain of re-attached log // streams, see drainTimeout. logStreamDrainTimeout time.Duration + + jobsGRPCOnce sync.Once + jobsExtClient *extensionclient.Client + jobsAPI jobsv0.Jobs + jobsAPIErr error } // Close releases any connections/resources held by the underlying clients. @@ -324,6 +331,9 @@ func (s *composeService) Close() error { if s.dockerCli != nil { errs = append(errs, s.apiClient().Close()) } + if s.jobsExtClient != nil { + errs = append(errs, s.jobsExtClient.Close()) + } return errors.Join(errs...) } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 601783714e3..0f9a10b36ee 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -161,11 +161,22 @@ func (s *composeService) preparePlan(ctx context.Context, project *types.Project warnUnmanagedNetworks(project, observed) warnUnmanagedVolumes(project, observed) - if len(observed.Orphans) > 0 && !options.IgnoreOrphans && !options.RemoveOrphans { + // A kept job run container (RemoveOnFailure: false) has no matching + // service by design — it's not something the user forgot to clean up, so + // it shouldn't trigger this warning. It's still a real orphan for + // reconcileOrphans (down/up --remove-orphans can sweep it), so the + // exclusion is scoped to this warning's own name list, not observed.Orphans. + var nonJobOrphans []string + for _, o := range observed.Orphans { + if _, isJobRun := o.Summary.Labels[jobContainerIDLabel]; !isJobRun { + nonJobOrphans = append(nonJobOrphans, o.Name) + } + } + if len(nonJobOrphans) > 0 && !options.IgnoreOrphans && !options.RemoveOrphans { logrus.Warnf("Found orphan containers (%s) for this project. If "+ "you removed or renamed this service in your compose "+ "file, you can run this command with the "+ - "--remove-orphans flag to clean it up.", observed.orphanNames()) + "--remove-orphans flag to clean it up.", strings.Join(nonJobOrphans, ", ")) } reconcileOptions := toReconcileOptions(options) @@ -319,6 +330,7 @@ func (s *composeService) getCreateConfigs(ctx context.Context, for dep, d := range service.DependsOn { dependencies = append(dependencies, fmt.Sprintf("%s:%s:%t", dep, d.Condition, d.Restart)) } + slices.Sort(dependencies) labels[api.DependenciesLabel] = strings.Join(dependencies, ",") var runCmd, entrypoint []string diff --git a/pkg/compose/down.go b/pkg/compose/down.go index 1cdbeaef554..074a17b991f 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -77,6 +77,10 @@ func (s *composeService) down(ctx context.Context, projectName string, options a if err != nil { return err } + // No compose file to read Jobs from: reconstruct them from the + // engine's own registry, otherwise ensureJobsDown silently sees none + // and a project's scheduled jobs outlive `down` forever. + project.Jobs = s.actualJobs(ctx, projectName) } // keep only the requested services that exist in the model @@ -129,6 +133,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a ops := s.ensureNetworksDown(ctx, project, limiter) ops = append(ops, s.ensureRelayLinkNetworksDown(ctx, project, limiter)...) + ops = append(ops, s.ensureJobsDown(ctx, project)...) if options.Images != api.ImagePruneNone { ops = append(ops, s.ensureImagesDown(ctx, project, options, limiter)...) diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index cba4f786819..61e408de9f7 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net" "os" "sort" "strings" @@ -59,6 +60,14 @@ func TestDownRejectsInvalidImagePruneModeUpfront(t *testing.T) { assert.ErrorContains(t, err, `invalid image prune mode "bogus"`) } +// noJobsDialer stands in for the jobs extension's gRPC dialer in tests that +// reconstruct a project without a compose file (down's actualJobs lookup): +// it fails to connect, which actualJobs treats the same as an engine with no +// jobs feature — no jobs, no further mock expectations needed. +func noJobsDialer(context.Context) (net.Conn, error) { + return nil, errors.New("no jobs dialer in tests") +} + func TestDown(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() @@ -674,6 +683,10 @@ func prepareMocks(mockCtrl *gomock.Controller) (*mocks.MockAPIClient, *mocks.Moc cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Err().Return(streams.NewOut(os.Stderr)).AnyTimes() cli.EXPECT().Out().Return(streams.NewOut(os.Stdout)).AnyTimes() + // down's actualJobs lookup calls Dialer() when it reconstructs a project + // without a compose file; AnyTimes() covers both that path and callers + // that pass an explicit Project and never reach it. + api.EXPECT().Dialer().Return(noJobsDialer).AnyTimes() return api, cli } diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go new file mode 100644 index 00000000000..82341e0417c --- /dev/null +++ b/pkg/compose/jobs.go @@ -0,0 +1,398 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/containerd/errdefs" + extensionclient "github.com/moby/extensions/client" + "github.com/moby/moby/api/pkg/stdcopy" + containerType "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "github.com/sirupsen/logrus" + + jobsv0 "github.com/docker/compose/v5/internal/jobsapi" + jobspb "github.com/docker/compose/v5/internal/jobsapi/protogen" + "github.com/docker/compose/v5/pkg/api" +) + +// jobRunHistoryLimit caps retained terminal run records (see +// jobsv0.JobSpec.RunHistoryLimit): low enough that repeated failures of a +// frequently-firing schedule don't accumulate kept containers indefinitely. +const jobRunHistoryLimit = 5 + +// jobsClient lazily resolves the engine's jobs extension point over the same +// dialer the Docker API client already uses. Resolve never dials by itself — +// an engine without the jobs feature only surfaces codes.Unimplemented on +// the first real call — so projects with no jobs never touch the network. +func (s *composeService) jobsClient() (jobsv0.Jobs, error) { + s.jobsGRPCOnce.Do(func() { + exts, err := extensionclient.New(s.apiClient(), extensionclient.WithGRPCPoint(jobspb.ClientPoint)) + if err != nil { + s.jobsAPIErr = err + return + } + s.jobsExtClient = exts + s.jobsAPI, s.jobsAPIErr = extensionclient.Resolve(exts, jobsv0.Point) + }) + return s.jobsAPI, s.jobsAPIErr +} + +// engineJobNameSeparator joins a project and job name into the engine's +// daemon-wide job name. The engine only accepts [a-zA-Z0-9][a-zA-Z0-9_.-]* for +// job names, which rules out "/" as a separator (confirmed against a live +// engine: it rejects it with InvalidArgument). "." still works: project names +// are restricted to [a-z0-9_-] (see compose-go's NormalizeProjectName, no +// dot), while job names may contain one, so a "." can only originate from the +// job-name side — the join stays unambiguous to reverse. Unlike api.Separator +// ("-"), which both project and job names can contain, this avoids project +// "app-sub" + job "service" colliding with project "app" + job "sub-service". +const engineJobNameSeparator = "." + +// engineJobName is the daemon-wide unique name Compose registers a job +// under: the engine has no notion of Compose projects, so the project name +// is folded into the job name. +func engineJobName(project *types.Project, name string) string { + return project.Name + engineJobNameSeparator + name +} + +// jobTrigger translates compose-go's TriggerConfig into the engine's +// Trigger type: 1:1 field mapping, already verified against the engine +// contract. +func jobTrigger(job types.JobConfig) (*jobsv0.Trigger, error) { + switch { + case job.Triggers == nil: + return nil, fmt.Errorf("job %q has no trigger", job.Name) + case job.Triggers.Manual != nil && *job.Triggers.Manual: + return &jobsv0.Trigger{Manual: true}, nil + case len(job.Triggers.Schedule) == 1: + sc := job.Triggers.Schedule[0] + return &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{ + Cron: sc.Cron, + Timezone: sc.Timezone, + Concurrency: sc.Concurrency, + MissedFires: sc.MissedFires, + }}, nil + case len(job.Triggers.Schedule) > 1: + return nil, fmt.Errorf("job %q declares %d schedules, exactly one is supported", job.Name, len(job.Triggers.Schedule)) + default: + return nil, fmt.Errorf("job %q has no trigger", job.Name) + } +} + +// buildJobSpec builds the engine JobSpec for a job: svc is resolved exactly +// like a service (materializeManualJob or the synthetic ServiceConfig built +// from the JobConfig for scheduled jobs), and getCreateConfigs is the same +// service->container-create-body conversion used to create real containers. +func (s *composeService) buildJobSpec(ctx context.Context, project *types.Project, svc types.ServiceConfig, job types.JobConfig, useNetworkAliases bool) (*jobsv0.JobSpec, error) { + trigger, err := jobTrigger(job) + if err != nil { + return nil, err + } + + // The engine only carries JobSpec.Labels on the job object, not on the run + // container (see jobsv0.JobSpec.Labels godoc): project/service labels must + // be set here on the container spec itself so run containers stay visible + // to the rest of Compose's tooling (ps, label-scoped listings) exactly + // like any other service container. + cfgs, err := s.getCreateConfigs(ctx, project, svc, 1, nil, createOptions{ + UseNetworkAliases: useNetworkAliases, + Labels: mergeLabels(svc.Labels, svc.CustomLabels, types.Labels{ + api.ProjectLabel: project.Name, + api.ServiceLabel: job.Name, + }), + }) + if err != nil { + return nil, err + } + spec, err := json.Marshal(containerType.CreateRequest{ + Config: cfgs.Container, + HostConfig: cfgs.Host, + NetworkingConfig: cfgs.Network, + }) + if err != nil { + return nil, err + } + + return &jobsv0.JobSpec{ + ContainerSpec: spec, + Trigger: trigger, + Labels: map[string]string{ + api.ProjectLabel: project.Name, + api.JobLabel: job.Name, + }, + // Successful runs are disposable: drop the container once its + // terminal record is written. Failed ones are kept for postmortem, + // bounded by RunHistoryLimit so repeated failures don't accumulate. + RemoveOnSuccess: true, + RemoveOnFailure: false, + RunHistoryLimit: jobRunHistoryLimit, + }, nil +} + +// sortedJobNames returns the sorted names of a project's jobs, optionally +// restricted to those matching keep. +func sortedJobNames(jobs types.Jobs, keep func(types.JobConfig) bool) []string { + names := make([]string, 0, len(jobs)) + for name, job := range jobs { + if keep == nil || keep(job) { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func hasSchedule(job types.JobConfig) bool { + return job.Triggers != nil && len(job.Triggers.Schedule) > 0 +} + +// registerScheduledJobs registers the project's scheduled jobs with the +// engine. Create is idempotent on SpecHash: re-applying the same spec on a +// later `up` is a no-op, which is what makes `up` safely re-runnable. +// +// Known gap: unlike RunJob, a scheduled job never joins project.Services, so +// it isn't seen by the project-wide use_api_socket / models: normalization +// passes (pkg/compose/apiSocket.go, pkg/compose/model.go) — a scheduled job +// declaring either is silently missing that setup. Fixing this without +// risking those services being picked up by the real service-reconciliation +// loop needs more than this diff's scope. +func (s *composeService) registerScheduledJobs(ctx context.Context, project *types.Project) error { + names := sortedJobNames(project.Jobs, hasSchedule) + if len(names) == 0 { + return nil + } + + jc, err := s.jobsClient() + if err != nil { + return err + } + for _, name := range names { + job := project.Jobs[name] + svc := types.ServiceConfig{ + Name: name, + Profiles: job.Profiles, + ContainerSpec: job.ContainerSpec, + WorkloadSpec: job.WorkloadSpec, + } + spec, err := s.buildJobSpec(ctx, project, svc, job, false) + if err != nil { + return err + } + _, err = jc.Create(ctx, &jobsv0.CreateRequest{ + Name: engineJobName(project, name), + Spec: spec, + }) + err = jobsv0.MapError(err) + if errdefs.IsAlreadyExists(err) { + return fmt.Errorf("job %q has changed: run `docker compose down` to remove it, then `up` again", name) + } + if err != nil { + return err + } + } + return nil +} + +// RunJob triggers a manual-trigger job's Run on the engine, following the +// same dependency-startup path as a one-off service run, then streams the +// Run's container logs and waits for its terminal state. See the RunJob +// doc comment on api.Compose for which options fields have an effect. +func (s *composeService) RunJob(ctx context.Context, project *types.Project, name string, options api.RunOptions) (int, error) { + job, ok := project.AllJobs()[name] + if !ok { + return 0, fmt.Errorf("job %q not found", name) + } + if job.Triggers == nil || job.Triggers.Manual == nil || !*job.Triggers.Manual { + return 0, fmt.Errorf("job %q has no manual trigger, it cannot be run", name) + } + + // materializeManualJob already put the job into project.Services, so it + // is seen by these project-wide normalization passes exactly like a + // service would be (use_api_socket / models: support). + project, err := s.useAPISocket(project) + if err != nil { + return 0, err + } + + if err := s.startDependencies(ctx, project, api.RunOptions{ + Service: name, + NoDeps: options.NoDeps, + CreateOptions: options.CreateOptions, + }); err != nil { + return 0, err + } + // The job's own image build (if any) is scoped to just this job, unlike + // startDependencies' unscoped Build above which may also build other + // services the job depends on. + var buildOpts *api.BuildOptions + if options.Build != nil { + bo := *options.Build + bo.Services = []string{name} + buildOpts = &bo + } + if err := s.ensureImagesExists(ctx, project, buildOpts, options.QuietPull, false); err != nil { + return 0, err + } + if err := s.ensureModels(ctx, project, false); err != nil { + return 0, err + } + + svc, err := project.GetService(name) + if err != nil { + return 0, err + } + applyRunOptions(project, &svc, options) + project.Services[name] = svc + + observed, err := s.getContainers(ctx, project.Name, oneOffInclude, true) + if err != nil { + return 0, err + } + if err := s.waitDependencies(ctx, project, name, svc.DependsOn, observed, 0); err != nil { + return 0, err + } + spec, err := s.buildJobSpec(ctx, project, svc, job, options.UseNetworkAliases) + if err != nil { + return 0, err + } + + jc, err := s.jobsClient() + if err != nil { + return 0, err + } + reply, err := jc.CreateAndRun(ctx, &jobsv0.CreateAndRunRequest{ + Name: engineJobName(project, name), + Spec: spec, + }) + if err := jobsv0.MapError(err); err != nil { + if errdefs.IsAlreadyExists(err) { + return 0, fmt.Errorf("job %q has changed: run `docker compose down` to remove it, then `run` again", name) + } + return 0, err + } + + logsDone := make(chan struct{}) + go func() { + defer close(logsDone) + if err := s.streamJobLogs(ctx, reply.Run.ContainerID); err != nil && ctx.Err() == nil { + logrus.Debugf("job %q: log stream ended: %v", name, err) + } + }() + + waited, err := jc.Wait(ctx, &jobsv0.WaitRequest{ + JobRef: reply.Job.ID, + RunRef: reply.Run.ID, + }) + <-logsDone + if err := jobsv0.MapError(err); err != nil { + return 0, err + } + + run := waited.Run + switch run.State { + case jobsv0.RunStateSucceeded: + return 0, nil + case jobsv0.RunStateFailed: + if run.ExitCode != nil { + return int(run.ExitCode.Value), nil + } + return 1, fmt.Errorf("job %q run %s failed: %s", name, run.ID, run.Error) + case jobsv0.RunStateTimedOut: + return 124, nil + case jobsv0.RunStateCancelled: + return 130, nil + default: + return 1, fmt.Errorf("job %q run %s ended in unexpected state %q", name, run.ID, run.State) + } +} + +// streamJobLogs follows a job Run's container logs from the start, exactly +// like `docker logs -f`, until the container stops producing output. +func (s *composeService) streamJobLogs(ctx context.Context, containerID string) error { + r, err := s.apiClient().ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + return err + } + defer r.Close() //nolint:errcheck + _, err = stdcopy.StdCopy(s.stdout(), s.stderr(), r) + return err +} + +// ensureJobsDown removes the project's jobs from the engine. Run history is +// kept by default (RunsRemoval left empty); each removal tolerates the job +// already being gone, matching removeResource's NotFound handling. +func (s *composeService) ensureJobsDown(ctx context.Context, project *types.Project) []downOp { + names := sortedJobNames(project.Jobs, nil) + + var ops []downOp + for _, name := range names { + ops = append(ops, func() error { + return s.removeResource("Job "+name, func() error { + jc, err := s.jobsClient() + if err != nil { + return err + } + err = jc.Remove(ctx, &jobsv0.RemoveRequest{JobRef: engineJobName(project, name)}) + return jobsv0.MapError(err) + }) + }) + } + return ops +} + +// actualJobs reconstructs a project's jobs from the engine's own registry, +// keyed by their local (un-prefixed) name — used when down has no compose +// file to read Jobs from directly (e.g. `compose --project-name X down`). +// Best-effort: any error, including an engine with no jobs feature, is +// treated as "no jobs" so it never blocks an otherwise-successful down. +func (s *composeService) actualJobs(ctx context.Context, projectName string) types.Jobs { + jc, err := s.jobsClient() + if err != nil { + return nil + } + reply, err := jc.List(ctx, &jobsv0.ListRequest{ + Labels: []string{api.ProjectLabel + "=" + projectName}, + }) + if err := jobsv0.MapError(err); err != nil { + // Best-effort: down must not fail or get noisy just because this + // project has no jobs (or the engine has no jobs feature at all — + // which surfaces as anything from a clean Unimplemented to a raw + // transport error, depending on the daemon). Still traceable with + // -v for the case where jobs really were left behind. + logrus.Debugf("failed to list jobs for project %q: %v", projectName, err) + return nil + } + + prefix := projectName + engineJobNameSeparator + jobs := types.Jobs{} + for _, j := range reply.Jobs { + name := strings.TrimPrefix(j.Name, prefix) + jobs[name] = types.JobConfig{Name: name} + } + return jobs +} diff --git a/pkg/compose/observed_state.go b/pkg/compose/observed_state.go index 9b5e6d70c16..0aad1c5e688 100644 --- a/pkg/compose/observed_state.go +++ b/pkg/compose/observed_state.go @@ -21,7 +21,6 @@ import ( "slices" "sort" "strconv" - "strings" "github.com/compose-spec/compose-go/v2/types" "github.com/containerd/errdefs" @@ -31,6 +30,10 @@ import ( "github.com/docker/compose/v5/pkg/api" ) +// jobContainerIDLabel is the engine's reserved label identifying a job run +// container (see the jobsv0 contract); Compose never sets it itself. +const jobContainerIDLabel = "com.docker.job.id" + // ObservedState captures the current state of all Docker resources belonging to // a Compose project. It is a snapshot taken before reconciliation so that the // reconciler can compare desired state (types.Project) with reality without @@ -462,15 +465,6 @@ func emitRunningEvents(project *types.Project, observed *ObservedState, plan *Pl } } -// orphanNames returns the names of orphaned containers as a comma-separated string. -func (s *ObservedState) orphanNames() string { - names := make([]string, len(s.Orphans)) - for i, o := range s.Orphans { - names[i] = o.Name - } - return strings.Join(names, ", ") -} - // containersByService flattens the observed containers into the shape // resolveServiceReferences expects: project service name → raw Summaries. func (s *ObservedState) containersByService() map[string]Containers { diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 613edcb56c7..1ccdaf4ffef 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -43,9 +43,15 @@ import ( func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { if options.Start.Attach == nil { - return s.upDetached(ctx, project, options) + if err := s.upDetached(ctx, project, options); err != nil { + return err + } + return s.registerScheduledJobs(ctx, project) + } + if err := s.create(ctx, project, options.Create); err != nil { + return err } - return s.create(ctx, project, options.Create) + return s.registerScheduledJobs(ctx, project) }), "up", s.events) if err != nil { return err diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index b64f1700bd2..91eaa217c2d 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -20,16 +20,21 @@ package e2e import ( "testing" + "time" ) -// Scheduled jobs cannot run in this version: silently not scheduling them -// would break the user's expectations, so up must refuse the whole project. -func TestUpRejectsScheduledJobs(t *testing.T) { - NewScenario(t, "up must reject a project declaring active scheduled jobs, before creating anything"). - Step("up fails naming the scheduled job", - ComposeCmd("up", "-d").MayFail(), - StderrContains("scheduled jobs are not supported in this version: backup"), - ServiceNotCreated("web")) +// A scheduled job registers with the engine instead of being rejected: up is +// safely re-runnable on an unchanged spec, and the schedule fires on the +// engine's own clock, independent of the client. +func TestUpRegistersScheduledJobs(t *testing.T) { + NewScenario(t, "up must register a project's scheduled jobs with the engine and let them fire on their own"). + Step("up starts services and registers the scheduled job", + ComposeCmd("up", "-d"), + ServiceState("web", "running")). + Step("re-up is a no-op on the unchanged job spec, and the schedule fires on the engine's own clock", + ComposeCmd("up", "-d"), + ServiceState("web", "running"), + Eventually(ServiceState("backup", "exited"), 90*time.Second)) } // A job runs through `compose run` exactly like a service would: its diff --git a/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml b/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml new file mode 100644 index 00000000000..48e5d07b691 --- /dev/null +++ b/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml @@ -0,0 +1,15 @@ +services: + web: + image: alpine + init: true + command: sleep infinity +jobs: + # Fails on purpose: a successful run is removed on exit (RemoveOnSuccess), + # leaving nothing to observe — failing keeps the container around + # (RemoveOnFailure: false) so the fire is verifiable via container state. + backup: + image: alpine + command: sh -c 'exit 1' + triggers: + schedule: + - cron: "* * * * *" diff --git a/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml b/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml deleted file mode 100644 index 5efd658bb6d..00000000000 --- a/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml +++ /dev/null @@ -1,12 +0,0 @@ -services: - web: - image: alpine - init: true - command: sleep infinity -jobs: - backup: - image: alpine - command: echo backup - triggers: - schedule: - - cron: "0 3 * * *" diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 14614481034..733c0b3de8c 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/pkg/mocks/mock_docker_compose_api.go @@ -370,6 +370,21 @@ func (mr *MockComposeMockRecorder) Restart(ctx, projectName, options any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Restart", reflect.TypeOf((*MockCompose)(nil).Restart), ctx, projectName, options) } +// RunJob mocks base method. +func (m *MockCompose) RunJob(ctx context.Context, project *types.Project, name string, options api.RunOptions) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RunJob", ctx, project, name, options) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RunJob indicates an expected call of RunJob. +func (mr *MockComposeMockRecorder) RunJob(ctx, project, name, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunJob", reflect.TypeOf((*MockCompose)(nil).RunJob), ctx, project, name, options) +} + // RunOneOffContainer mocks base method. func (m *MockCompose) RunOneOffContainer(ctx context.Context, project *types.Project, opts api.RunOptions) (int, error) { m.ctrl.T.Helper() @@ -1050,6 +1065,21 @@ func (mr *MockRunnerMockRecorder) Export(ctx, projectName, options any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Export", reflect.TypeOf((*MockRunner)(nil).Export), ctx, projectName, options) } +// RunJob mocks base method. +func (m *MockRunner) RunJob(ctx context.Context, project *types.Project, name string, options api.RunOptions) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RunJob", ctx, project, name, options) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RunJob indicates an expected call of RunJob. +func (mr *MockRunnerMockRecorder) RunJob(ctx, project, name, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunJob", reflect.TypeOf((*MockRunner)(nil).RunJob), ctx, project, name, options) +} + // RunOneOffContainer mocks base method. func (m *MockRunner) RunOneOffContainer(ctx context.Context, project *types.Project, opts api.RunOptions) (int, error) { m.ctrl.T.Helper() From 4b3d54c274fb4dc1bb33169dbd65ec9cb681c850 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:07:40 +0200 Subject: [PATCH 04/19] jobs: extract shared manual/schedule predicates job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual was copy-pasted at four independent sites across cmd/compose and pkg/compose, each with its own slightly different error message. Extract ManualTriggerDisabled/ManualTriggerDisabledErr (opt-out via manual: false) and HasSchedule as the single source of truth, reused by materializeManualJob/materializeJobClosure, manualJobNames, and RunJob. Also fold the "job has changed" AlreadyExists message into jobChangedErr, simplify sortedJobNames to build on the existing sortedMapKeys, and reuse prepareBuildOptions in RunJob instead of re-deriving the same scoped build options inline. manualJobNames also switches from AllJobs (which includes profile-disabled jobs) to Jobs, and now excludes scheduled jobs explicitly: a job with a schedule is registered with the engine by up, not "ignored" the way a manual-only job is. Signed-off-by: Guillaume Lours --- cmd/compose/run.go | 6 ++-- cmd/compose/up.go | 18 +++++++---- pkg/compose/jobs.go | 61 +++++++++++++++++++++++++------------ pkg/compose/jobs_test.go | 65 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 pkg/compose/jobs_test.go diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 655ddb7faf0..14696eb2da9 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -492,8 +492,8 @@ func materializeManualJob(project *types.Project, name string) (*types.Project, if !ok { return project, nil } - if job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual { - return nil, fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name) + if compose.ManualTriggerDisabled(job) { + return nil, compose.ManualTriggerDisabledErr(name) } project, err := project.WithSelectedJob(name) if err != nil { @@ -529,7 +529,7 @@ func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.Jo if !isJob { continue } - if depJob.Triggers != nil && depJob.Triggers.Manual != nil && !*depJob.Triggers.Manual { + if compose.ManualTriggerDisabled(depJob) { return fmt.Errorf("job %q is declared with manual: false, it cannot be triggered even as a dependency of another job", dep) } if err := materializeJobClosure(project, jobs, depJob, seen); err != nil { diff --git a/cmd/compose/up.go b/cmd/compose/up.go index abf89cc3921..1c29c605b0c 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -364,14 +364,20 @@ func warnIgnoredJobs(project *types.Project) { logrus.Warnf("jobs are not started by up; trigger them with `docker compose run`: %s", strings.Join(names, ", ")) } -// manualJobNames returns the sorted names of the project's manual-trigger jobs. +// manualJobNames returns the sorted names of the project's jobs that up +// leaves untouched: a job with a schedule is registered with the engine +// (see pkg/compose Up), so only jobs without one wait for an explicit +// `compose run ` trigger — unless they opt out with `manual: false`. func manualJobNames(project *types.Project) []string { - jobs := project.Jobs - names := make([]string, 0, len(jobs)) - for name, job := range jobs { - if job.Triggers != nil && job.Triggers.Manual != nil && *job.Triggers.Manual { - names = append(names, name) + names := make([]string, 0, len(project.Jobs)) + for name, job := range project.Jobs { + if compose.HasSchedule(job) { + continue } + if compose.ManualTriggerDisabled(job) { + continue + } + names = append(names, name) } sort.Strings(names) return names diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index 82341e0417c..87c4966c177 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -20,7 +20,6 @@ import ( "context" "encoding/json" "fmt" - "sort" "strings" "github.com/compose-spec/compose-go/v2/types" @@ -153,20 +152,48 @@ func (s *composeService) buildJobSpec(ctx context.Context, project *types.Projec // sortedJobNames returns the sorted names of a project's jobs, optionally // restricted to those matching keep. func sortedJobNames(jobs types.Jobs, keep func(types.JobConfig) bool) []string { - names := make([]string, 0, len(jobs)) + if keep == nil { + return sortedMapKeys(jobs) + } + filtered := make(types.Jobs, len(jobs)) for name, job := range jobs { - if keep == nil || keep(job) { - names = append(names, name) + if keep(job) { + filtered[name] = job } } - sort.Strings(names) - return names + return sortedMapKeys(filtered) +} + +// jobChangedErr reports that the engine already has a job of this name with +// a different spec: re-running the same verb won't reconcile it, only +// `down` removing it first will. +func jobChangedErr(name, verb string) error { + return fmt.Errorf("job %q has changed: run `docker compose down` to remove it, then `%s` again", name, verb) } -func hasSchedule(job types.JobConfig) bool { +// HasSchedule reports whether a job declares a schedule trigger — the +// predicate `up` uses to register it with the engine instead of warning +// that it waits for `docker compose run`. +func HasSchedule(job types.JobConfig) bool { return job.Triggers != nil && len(job.Triggers.Schedule) > 0 } +// ManualTriggerDisabled reports whether a job explicitly opts out of manual +// triggering with `triggers.manual: false`. Per the spec, every job accepts +// `docker compose run` regardless of its automated triggers unless it opts +// out this way — this is the single source of truth for that rule, shared +// by every layer that materializes or runs a job manually. +func ManualTriggerDisabled(job types.JobConfig) bool { + return job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual +} + +// ManualTriggerDisabledErr reports that name was declared with +// `manual: false` and so cannot be run manually — the error every caller +// of ManualTriggerDisabled raises on that condition. +func ManualTriggerDisabledErr(name string) error { + return fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name) +} + // registerScheduledJobs registers the project's scheduled jobs with the // engine. Create is idempotent on SpecHash: re-applying the same spec on a // later `up` is a no-op, which is what makes `up` safely re-runnable. @@ -178,7 +205,7 @@ func hasSchedule(job types.JobConfig) bool { // risking those services being picked up by the real service-reconciliation // loop needs more than this diff's scope. func (s *composeService) registerScheduledJobs(ctx context.Context, project *types.Project) error { - names := sortedJobNames(project.Jobs, hasSchedule) + names := sortedJobNames(project.Jobs, HasSchedule) if len(names) == 0 { return nil } @@ -205,7 +232,7 @@ func (s *composeService) registerScheduledJobs(ctx context.Context, project *typ }) err = jobsv0.MapError(err) if errdefs.IsAlreadyExists(err) { - return fmt.Errorf("job %q has changed: run `docker compose down` to remove it, then `up` again", name) + return jobChangedErr(name, "up") } if err != nil { return err @@ -223,8 +250,8 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam if !ok { return 0, fmt.Errorf("job %q not found", name) } - if job.Triggers == nil || job.Triggers.Manual == nil || !*job.Triggers.Manual { - return 0, fmt.Errorf("job %q has no manual trigger, it cannot be run", name) + if ManualTriggerDisabled(job) { + return 0, ManualTriggerDisabledErr(name) } // materializeManualJob already put the job into project.Services, so it @@ -244,13 +271,9 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam } // The job's own image build (if any) is scoped to just this job, unlike // startDependencies' unscoped Build above which may also build other - // services the job depends on. - var buildOpts *api.BuildOptions - if options.Build != nil { - bo := *options.Build - bo.Services = []string{name} - buildOpts = &bo - } + // services the job depends on. RunJob is always called with name == + // options.Service, so this is the same scoping prepareRun uses. + buildOpts := prepareBuildOptions(options) if err := s.ensureImagesExists(ctx, project, buildOpts, options.QuietPull, false); err != nil { return 0, err } @@ -287,7 +310,7 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam }) if err := jobsv0.MapError(err); err != nil { if errdefs.IsAlreadyExists(err) { - return 0, fmt.Errorf("job %q has changed: run `docker compose down` to remove it, then `run` again", name) + return 0, jobChangedErr(name, "run") } return 0, err } diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go new file mode 100644 index 00000000000..b9ffe419238 --- /dev/null +++ b/pkg/compose/jobs_test.go @@ -0,0 +1,65 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" +) + +func TestHasSchedule(t *testing.T) { + assert.Assert(t, !HasSchedule(types.JobConfig{})) + assert.Assert(t, !HasSchedule(types.JobConfig{Triggers: &types.TriggerConfig{}})) + assert.Assert(t, HasSchedule(types.JobConfig{Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }})) +} + +func TestManualTriggerDisabled(t *testing.T) { + yes, no := true, false + + assert.Assert(t, !ManualTriggerDisabled(types.JobConfig{}), "no triggers at all is not an opt-out") + assert.Assert(t, !ManualTriggerDisabled(types.JobConfig{Triggers: &types.TriggerConfig{}}), "an unset Manual is not an opt-out") + assert.Assert(t, !ManualTriggerDisabled(types.JobConfig{Triggers: &types.TriggerConfig{Manual: &yes}})) + assert.Assert(t, ManualTriggerDisabled(types.JobConfig{Triggers: &types.TriggerConfig{Manual: &no}})) +} + +func TestSortedJobNames(t *testing.T) { + jobs := types.Jobs{ + "backup": {Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}}, + "migrate": {}, + "report": {}, + } + + assert.DeepEqual(t, sortedJobNames(jobs, nil), []string{"backup", "migrate", "report"}) + assert.DeepEqual(t, sortedJobNames(jobs, HasSchedule), []string{"backup"}) + assert.DeepEqual(t, sortedJobNames(types.Jobs{}, HasSchedule), []string{}) +} + +func TestJobChangedErr(t *testing.T) { + assert.Error(t, jobChangedErr("backup", "up"), + `job "backup" has changed: run `+"`docker compose down`"+` to remove it, then `+"`up`"+` again`) + assert.Error(t, jobChangedErr("migrate", "run"), + `job "migrate" has changed: run `+"`docker compose down`"+` to remove it, then `+"`run`"+` again`) +} + +func TestManualTriggerDisabledErr(t *testing.T) { + assert.Error(t, ManualTriggerDisabledErr("rotation"), + `job "rotation" is declared with manual: false, it cannot be run manually`) +} From b923ac6b39fb63fdb18deb150a8ae3f339dc5ca1 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:08:15 +0200 Subject: [PATCH 05/19] run: drop a reload in runProject that bypassed materializeManualJob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-merged in from a parallel rebase, this block reloaded the project unselected on "no such service" and returned early on success — skipping the existing, more complete fallback right below it (which also calls materializeManualJob and validates the target is actually a declared job). A job target's own env_file would then resolve against the full, unnarrowed project instead of the job's dependency-scoped one, exactly what this function's env-resolution ordering exists to avoid. Signed-off-by: Guillaume Lours --- cmd/compose/run.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 14696eb2da9..fb8c481f850 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -278,11 +278,6 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { // their containers. func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, p *ProjectOptions, service string) (*types.Project, error) { project, _, err := p.ToProject(ctx, dockerCli, backend, []string{service}, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) - if err != nil && strings.Contains(err.Error(), "no such service") { - // the run target may be a job, which the service selector cannot - // resolve: reload unselected and let materializeManualJob decide - project, _, err = p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution) - } if err != nil { // The run target may be a job — invisible to the service selector. // Only retry unselected for that specific selection failure: any From b15aaf342cf1b5887049c99203fe6868899ebd75 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:09:35 +0200 Subject: [PATCH 06/19] cmd: centralize the job-target error translation in WithServices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create wrapped its own RunE to translate a job-name selection failure into a clear "job can only be triggered with docker compose run" message, including a JSON-mode special case — the same translation projectOrName already centralizes for its own callers (start, stop, down, ...). up shares WithServices but had no such wrap, so `docker compose up ` still leaked the raw compose-go selection error. Move the translation into WithServices itself, covering both callers uniformly (and any future one). This also drops create's now-redundant JSON-mode special case: WithServices already runs through Adapt, which already wraps every error in makeJSONError when display.Mode is JSON. Signed-off-by: Guillaume Lours --- cmd/compose/compose.go | 8 ++++++++ cmd/compose/create.go | 16 +++------------- pkg/e2e/jobs_test.go | 9 +++++++++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 290be9b8918..86529562014 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -175,6 +175,14 @@ func (o *ProjectOptions) WithServices(dockerCli command.Cli, fn ProjectServicesF project, metrics, err := o.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution) if err != nil { + // a service name among services can genuinely be a declared job: + // every WithServices caller has nothing to act on for it, so + // report that clearly instead of the raw "no such service" below + // — same translation projectOrName already centralizes for its + // own callers (start/stop/down/...). + if jobErr, replaced := jobTargetErr(ctx, dockerCli, o, services, err); replaced { + return jobErr + } return err } diff --git a/cmd/compose/create.go b/cmd/compose/create.go index f839ec92d81..d8dc01b8173 100644 --- a/cmd/compose/create.go +++ b/cmd/compose/create.go @@ -31,7 +31,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/docker/compose/v5/cmd/display" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" ) @@ -72,18 +71,9 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac } return nil }), - RunE: func(cmd *cobra.Command, args []string) error { - err := p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error { - return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services) - })(cmd, args) - if jobErr, replaced := jobTargetErr(cmd.Context(), dockerCli, p, args, err); replaced { - if display.Mode == display.ModeJSON { - return makeJSONError(jobErr) - } - return jobErr - } - return err - }, + RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error { + return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services) + }), ValidArgsFunction: completeServiceNames(dockerCli, p), } flags := cmd.Flags() diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 91eaa217c2d..11ff790998a 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -102,6 +102,15 @@ func TestCreateRefusesJob(t *testing.T) { ServiceNotCreated("migrate")) } +// up shares WithServices with create: the same translation must apply there too. +func TestUpRefusesJob(t *testing.T) { + NewScenario(t, "up must refuse a job by name, naming run as the right command"). + Step("up fails naming the job", + ComposeCmd("up", "-d", "migrate").MayFail(), + StderrContains(`job "migrate" can only be triggered with "docker compose run"`), + ServiceNotCreated("migrate")) +} + func TestStartRefusesJob(t *testing.T) { NewScenario(t, "start must refuse a job by name, naming run as the right command"). Step("start fails naming the job", From f4b210705571bd7f25cea13a01a08d522b7a16d4 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:10:36 +0200 Subject: [PATCH 07/19] jobs: reject a job declaring both manual:true and a schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jobTrigger's switch checked Manual before Schedule, so a job declaring both triggers.manual: true and a schedule silently lost the schedule: registerScheduledJobs still selects the job (HasSchedule doesn't look at Manual), but jobTrigger built a Manual-only Trigger for it — the cron never reaches the engine, with no error and no warning anywhere (up's own warnIgnoredJobs also skips it, since HasSchedule is true). Reject the combination explicitly instead, mirroring the existing len(Schedule) > 1 case. Signed-off-by: Guillaume Lours --- pkg/compose/jobs.go | 2 ++ pkg/compose/jobs_test.go | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index 87c4966c177..d3b5b72d930 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -82,6 +82,8 @@ func jobTrigger(job types.JobConfig) (*jobsv0.Trigger, error) { switch { case job.Triggers == nil: return nil, fmt.Errorf("job %q has no trigger", job.Name) + case job.Triggers.Manual != nil && *job.Triggers.Manual && len(job.Triggers.Schedule) > 0: + return nil, fmt.Errorf("job %q declares both manual:true and a schedule, exactly one is supported", job.Name) case job.Triggers.Manual != nil && *job.Triggers.Manual: return &jobsv0.Trigger{Manual: true}, nil case len(job.Triggers.Schedule) == 1: diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index b9ffe419238..265a1bae64d 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -21,8 +21,67 @@ import ( "github.com/compose-spec/compose-go/v2/types" "gotest.tools/v3/assert" + + jobsv0 "github.com/docker/compose/v5/internal/jobsapi" ) +func TestJobTrigger(t *testing.T) { + yes, no := true, false + + t.Run("no triggers declared is refused", func(t *testing.T) { + _, err := jobTrigger(types.JobConfig{Name: "migrate"}) + assert.Error(t, err, `job "migrate" has no trigger`) + }) + + t.Run("manual:true translates to a Manual trigger", func(t *testing.T) { + trigger, err := jobTrigger(types.JobConfig{Name: "migrate", Triggers: &types.TriggerConfig{Manual: &yes}}) + assert.NilError(t, err) + assert.DeepEqual(t, trigger, &jobsv0.Trigger{Manual: true}) + }) + + t.Run("manual:false alone (no schedule) is refused: it has no trigger left", func(t *testing.T) { + _, err := jobTrigger(types.JobConfig{Name: "migrate", Triggers: &types.TriggerConfig{Manual: &no}}) + assert.Error(t, err, `job "migrate" has no trigger`) + }) + + t.Run("a single schedule translates to a Schedule trigger", func(t *testing.T) { + trigger, err := jobTrigger(types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *", Timezone: "UTC"}}, + }}) + assert.NilError(t, err) + assert.DeepEqual(t, trigger, &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "0 3 * * *", Timezone: "UTC"}}) + }) + + t.Run("more than one schedule is refused", func(t *testing.T) { + _, err := jobTrigger(types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}, {Cron: "0 4 * * *"}}, + }}) + assert.Error(t, err, `job "backup" declares 2 schedules, exactly one is supported`) + }) + + // A job declaring both manual:true and a schedule used to silently lose + // the schedule: the switch checked Manual before Schedule, so + // registerScheduledJobs would still select the job (HasSchedule doesn't + // look at Manual) but jobTrigger built a Manual-only Trigger, dropping + // the cron with no error and no warning anywhere. + t.Run("manual:true together with a schedule is refused, not silently resolved to Manual", func(t *testing.T) { + _, err := jobTrigger(types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Manual: &yes, + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }}) + assert.Error(t, err, `job "backup" declares both manual:true and a schedule, exactly one is supported`) + }) + + t.Run("manual:false together with a schedule keeps the schedule", func(t *testing.T) { + trigger, err := jobTrigger(types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Manual: &no, + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }}) + assert.NilError(t, err) + assert.DeepEqual(t, trigger, &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "0 3 * * *"}}) + }) +} + func TestHasSchedule(t *testing.T) { assert.Assert(t, !HasSchedule(types.JobConfig{})) assert.Assert(t, !HasSchedule(types.JobConfig{Triggers: &types.TriggerConfig{}})) From 4be4cbff2aa57c292965390955af90df5b694738 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:11:15 +0200 Subject: [PATCH 08/19] jobs: resolve sibling service references before running a job manually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunJob never called resolveRunServiceReferences, unlike prepareRun's equivalent service-run path. A job declaring volumes_from, or a service:-scoped network_mode/ipc/pid, had that raw compose service name sent straight to the engine instead of a resolved container ID — the daemon has no notion of compose service names and would reject it (or silently misbehave), the same class of bug pre_start's own hook resolution exists to avoid. Signed-off-by: Guillaume Lours --- pkg/compose/jobs.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index d3b5b72d930..ed80931e04d 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -297,6 +297,14 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam if err := s.waitDependencies(ctx, project, name, svc.DependsOn, observed, 0); err != nil { return 0, err } + // A job may reference a sibling service or job — volumes_from, or + // service:-scoped network_mode/ipc/pid — exactly like a service run + // would; the daemon knows nothing about compose service names, so these + // must resolve to live container IDs before the spec reaches it. + if err := s.resolveRunServiceReferences(ctx, project.Name, &svc); err != nil { + return 0, err + } + project.Services[name] = svc spec, err := s.buildJobSpec(ctx, project, svc, job, options.UseNetworkAliases) if err != nil { return 0, err From 925dd55b24cf13e271665b0b60210a232727d78c Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:48:33 +0200 Subject: [PATCH 09/19] jobs: run scheduled-job registration through the same build/socket/model prep as a manual run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manually-run job gets use_api_socket/build/models: support for free, because materializeManualJob puts it into project.Services before RunJob's normalization passes see it. registerScheduledJobs never did this — the job never joined project.Services, by design, to avoid it being picked up by the real service-reconciliation loop. The most visible consequence: a scheduled job declaring only `build:` (no `image:`) registered successfully at `up` and then failed on every subsequent schedule fire, because its image was never built — up's own auto-build, and `docker compose build`, both only ever scope to project.ServiceNames(), which never includes jobs. scopedProjectForJob materializes the job into a throwaway copy of the project (a fresh Services map, nothing else touched) so registerScheduledJobs can run it through useAPISocket/ensureImagesExists/ ensureModels exactly like RunJob does, then discards the copy — the job never reaches the shared project the reconciliation loop iterates over. Registration is also switched to run one goroutine per job via errgroup, since each now does its own image pull/build round trip. Signed-off-by: Guillaume Lours --- pkg/compose/jobs.go | 90 +++++++++++++++++++++++++++------------- pkg/compose/jobs_test.go | 43 +++++++++++++++++++ 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index ed80931e04d..c26448867e2 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -29,6 +29,7 @@ import ( containerType "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" jobsv0 "github.com/docker/compose/v5/internal/jobsapi" jobspb "github.com/docker/compose/v5/internal/jobsapi/protogen" @@ -196,16 +197,35 @@ func ManualTriggerDisabledErr(name string) error { return fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name) } +// scopedProjectForJob returns a shallow copy of project with name's job +// materialized into a fresh Services map, so a scheduled job can run +// through the same project-wide normalization passes (useAPISocket, +// ensureImagesExists, ensureModels) that a manually-run job gets for free +// once materializeManualJob puts it into the real project.Services — see +// jobAsService in cmd/compose/run.go for that path. The copy is throwaway: +// callers must discard it once they've read back the resolved +// ServiceConfig, so the job never joins the project the real +// service-reconciliation loop (create/start) iterates over. +func scopedProjectForJob(project *types.Project, name string, job types.JobConfig) *types.Project { + scoped := *project + services := make(types.Services, len(project.Services)+1) + for n, svc := range project.Services { + services[n] = svc + } + services[name] = types.ServiceConfig{ + Name: name, + Profiles: job.Profiles, + Extensions: job.Extensions, + ContainerSpec: job.ContainerSpec, + WorkloadSpec: job.WorkloadSpec, + } + scoped.Services = services + return &scoped +} + // registerScheduledJobs registers the project's scheduled jobs with the // engine. Create is idempotent on SpecHash: re-applying the same spec on a // later `up` is a no-op, which is what makes `up` safely re-runnable. -// -// Known gap: unlike RunJob, a scheduled job never joins project.Services, so -// it isn't seen by the project-wide use_api_socket / models: normalization -// passes (pkg/compose/apiSocket.go, pkg/compose/model.go) — a scheduled job -// declaring either is silently missing that setup. Fixing this without -// risking those services being picked up by the real service-reconciliation -// loop needs more than this diff's scope. func (s *composeService) registerScheduledJobs(ctx context.Context, project *types.Project) error { names := sortedJobNames(project.Jobs, HasSchedule) if len(names) == 0 { @@ -216,31 +236,45 @@ func (s *composeService) registerScheduledJobs(ctx context.Context, project *typ if err != nil { return err } + eg, ctx := errgroup.WithContext(ctx) for _, name := range names { - job := project.Jobs[name] - svc := types.ServiceConfig{ - Name: name, - Profiles: job.Profiles, - ContainerSpec: job.ContainerSpec, - WorkloadSpec: job.WorkloadSpec, - } - spec, err := s.buildJobSpec(ctx, project, svc, job, false) - if err != nil { + eg.Go(func() error { + job := project.Jobs[name] + scoped, err := s.useAPISocket(scopedProjectForJob(project, name, job)) + if err != nil { + return err + } + // Without this, a scheduled job declaring only `build:` (no + // `image:`) registers successfully here and then fails, every + // time its schedule fires, because the image was never built — + // silently, since up's own auto-build only ever scoped to + // project.ServiceNames() (see pkg/compose/build.go). + if err := s.ensureImagesExists(ctx, scoped, &api.BuildOptions{Services: []string{name}}, false, false); err != nil { + return err + } + if err := s.ensureModels(ctx, scoped, false); err != nil { + return err + } + svc, err := scoped.GetService(name) + if err != nil { + return err + } + spec, err := s.buildJobSpec(ctx, scoped, svc, job, false) + if err != nil { + return err + } + _, err = jc.Create(ctx, &jobsv0.CreateRequest{ + Name: engineJobName(project, name), + Spec: spec, + }) + err = jobsv0.MapError(err) + if errdefs.IsAlreadyExists(err) { + return jobChangedErr(name, "up") + } return err - } - _, err = jc.Create(ctx, &jobsv0.CreateRequest{ - Name: engineJobName(project, name), - Spec: spec, }) - err = jobsv0.MapError(err) - if errdefs.IsAlreadyExists(err) { - return jobChangedErr(name, "up") - } - if err != nil { - return err - } } - return nil + return eg.Wait() } // RunJob triggers a manual-trigger job's Run on the engine, following the diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index 265a1bae64d..d396fe61abd 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -122,3 +122,46 @@ func TestManualTriggerDisabledErr(t *testing.T) { assert.Error(t, ManualTriggerDisabledErr("rotation"), `job "rotation" is declared with manual: false, it cannot be run manually`) } + +// scopedProjectForJob is what lets registerScheduledJobs run a scheduled +// job through the same useAPISocket/ensureImagesExists/ensureModels passes +// a manually-run job gets for free from materializeManualJob — without the +// job ever joining the real project.Services the reconciliation loop +// iterates over. +func TestScopedProjectForJob(t *testing.T) { + project := &types.Project{ + Name: "myproject", + Services: types.Services{ + "db": {Name: "db", ContainerSpec: types.ContainerSpec{Image: "postgres"}}, + }, + Jobs: types.Jobs{ + "backup": { + Name: "backup", + Extensions: types.Extensions{"x-team": "platform"}, + ContainerSpec: types.ContainerSpec{Image: "backup-tool"}, + }, + }, + } + job := project.Jobs["backup"] + + scoped := scopedProjectForJob(project, "backup", job) + + t.Run("the job is materialized into the scoped copy's Services", func(t *testing.T) { + svc, err := scoped.GetService("backup") + assert.NilError(t, err) + assert.Equal(t, svc.Image, "backup-tool") + assert.Equal(t, svc.Extensions["x-team"], "platform") + }) + + t.Run("existing services are carried over", func(t *testing.T) { + svc, err := scoped.GetService("db") + assert.NilError(t, err) + assert.Equal(t, svc.Image, "postgres") + }) + + t.Run("the real project.Services is never mutated", func(t *testing.T) { + _, ok := project.Services["backup"] + assert.Assert(t, !ok, "the job must not leak into the shared project's Services") + assert.Equal(t, len(project.Services), 1) + }) +} From 3120edc2a521b52e500dcd18fb9ceff94759ef84 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:48:47 +0200 Subject: [PATCH 10/19] up: give Up a NoStart switch instead of --no-start bypassing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `up --no-start` called backend.Create directly instead of backend.Up, hand-replaying two of Up's three steps (create, then registration) and skipping the third (start) — a pre-existing shortcut, not something this branch introduced. It silently missed scheduled-job registration entirely, since that only happens inside Up. Considered exporting the (now build/socket/model-aware) registration method on api.Compose instead, so --no-start could call it explicitly after Create. Rejected: every other method on that interface — which is documented for third-party programmatic use — maps 1:1 to a `docker compose `; a method with no corresponding verb, existing only to unblock one internal CLI wiring case, would be the one exception to explain away. Adding StartOptions.NoStart and checking it inside Up itself fixes the actual defect instead of one visible symptom of it: the CLI stops hand-replaying Up's internal sequence, so any future step Up gains between create and start is covered by construction, not by remembering to update a second copy of the orchestration. It also follows the existing convention on the same struct (Attach == nil already gates Up's foreground session as a mode switch) rather than introducing a new one. Signed-off-by: Guillaume Lours --- cmd/compose/up.go | 5 ++++- pkg/api/api.go | 4 ++++ pkg/compose/up.go | 2 +- pkg/e2e/jobs_test.go | 11 +++++++++++ .../compose.yaml | 15 +++++++++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 pkg/e2e/testdata/TestUpNoStartRegistersScheduledJobs/compose.yaml diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 1c29c605b0c..7bc0bd0370c 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -294,7 +294,10 @@ func runUp( } if upOptions.noStart { - return backend.Create(ctx, project, create) + return backend.Up(ctx, project, api.UpOptions{ + Create: create, + Start: api.StartOptions{Project: project, NoStart: true}, + }) } var consumer api.LogConsumer diff --git a/pkg/api/api.go b/pkg/api/api.go index b6845f84ba0..86e7955bb41 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -403,6 +403,10 @@ type StartOptions struct { // NavigationMenu enables the keyboard menu of Up's foreground session; // ignored by Start. NavigationMenu bool + // NoStart makes Up create and register the project (containers, scheduled + // jobs) without starting anything — `up --no-start`'s mode switch. + // Ignored by Start. + NoStart bool } type Cascade int diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 1ccdaf4ffef..b88ba231bd9 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -42,7 +42,7 @@ import ( func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { - if options.Start.Attach == nil { + if options.Start.Attach == nil && !options.Start.NoStart { if err := s.upDetached(ctx, project, options); err != nil { return err } diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 11ff790998a..afd1e9905ab 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -37,6 +37,17 @@ func TestUpRegistersScheduledJobs(t *testing.T) { Eventually(ServiceState("backup", "exited"), 90*time.Second)) } +// --no-start's own path (Create, then registerScheduledJobs, then return +// before Start) must still register scheduled jobs: it used to bypass Up +// entirely by calling Create directly, silently skipping registration. +func TestUpNoStartRegistersScheduledJobs(t *testing.T) { + NewScenario(t, "up --no-start must still register a project's scheduled jobs with the engine"). + Step("up --no-start creates but never starts web, yet the schedule still fires on its own", + ComposeCmd("up", "--no-start"), + ServiceState("web", "created"), + Eventually(ServiceState("backup", "exited"), 90*time.Second)) +} + // A job runs through `compose run` exactly like a service would: its // declared dependencies start first, its output and exit flow back. Per the // spec, manual execution is always available — scheduled jobs included — diff --git a/pkg/e2e/testdata/TestUpNoStartRegistersScheduledJobs/compose.yaml b/pkg/e2e/testdata/TestUpNoStartRegistersScheduledJobs/compose.yaml new file mode 100644 index 00000000000..48e5d07b691 --- /dev/null +++ b/pkg/e2e/testdata/TestUpNoStartRegistersScheduledJobs/compose.yaml @@ -0,0 +1,15 @@ +services: + web: + image: alpine + init: true + command: sleep infinity +jobs: + # Fails on purpose: a successful run is removed on exit (RemoveOnSuccess), + # leaving nothing to observe — failing keeps the container around + # (RemoveOnFailure: false) so the fire is verifiable via container state. + backup: + image: alpine + command: sh -c 'exit 1' + triggers: + schedule: + - cron: "* * * * *" From ea1c04e8ddb7d0e78b8a96e4c1bc4fcf053c0eaa Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:48:56 +0200 Subject: [PATCH 11/19] e2e: add the missing testdata fixture for TestUpRefusesJob TestUpRefusesJob never had its own compose.yaml, so it ran against no config file at all instead of exercising the job-target error it's meant to lock in. Caught by actually running the e2e suite rather than just letting it compile. Signed-off-by: Guillaume Lours --- pkg/e2e/testdata/TestUpRefusesJob/compose.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 pkg/e2e/testdata/TestUpRefusesJob/compose.yaml diff --git a/pkg/e2e/testdata/TestUpRefusesJob/compose.yaml b/pkg/e2e/testdata/TestUpRefusesJob/compose.yaml new file mode 100644 index 00000000000..7cbebdadebd --- /dev/null +++ b/pkg/e2e/testdata/TestUpRefusesJob/compose.yaml @@ -0,0 +1,10 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + migrate: + image: alpine + command: sh -c 'echo "migration done"' + triggers: + manual: true From 374a254d4f397a214256edf8c18ba7973a5faac8 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:50:33 +0200 Subject: [PATCH 12/19] cmd: give build/pull/push the same job-target error as other commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified empirically: `docker compose build/pull/push ` all leaked compose-go's raw "no such service" error, unlike create/start/stop/logs/ ps/pause/down (via WithServices/projectOrName) — exec/attach/cp already had clear enough errors of their own ("service is not running" / "no container found"), so left untouched; only these three needed the fix. jobTargetErrOr wraps jobTargetErr's replaced/not-replaced pair into a plain error, reused by all three instead of repeating the same if-replaced-return three times. Signed-off-by: Guillaume Lours --- cmd/compose/build.go | 3 ++- cmd/compose/pull.go | 2 +- cmd/compose/push.go | 2 +- cmd/compose/run.go | 11 +++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cmd/compose/build.go b/cmd/compose/build.go index 3dc95367759..2a83fbb9043 100644 --- a/cmd/compose/build.go +++ b/cmd/compose/build.go @@ -183,5 +183,6 @@ func runBuild(ctx context.Context, dockerCli command.Cli, backendOptions *Backen } apiBuildOptions.Attestations = true - return backend.Build(ctx, project, apiBuildOptions) + err = backend.Build(ctx, project, apiBuildOptions) + return jobTargetErrOr(ctx, dockerCli, opts.ProjectOptions, services, err) } diff --git a/cmd/compose/pull.go b/cmd/compose/pull.go index 4710d94f1c3..a279912a9b3 100644 --- a/cmd/compose/pull.go +++ b/cmd/compose/pull.go @@ -106,7 +106,7 @@ func runPull(ctx context.Context, dockerCli command.Cli, backendOptions *Backend project, _, err := opts.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution) if err != nil { - return err + return jobTargetErrOr(ctx, dockerCli, opts.ProjectOptions, services, err) } project, err = opts.apply(project, services) diff --git a/cmd/compose/push.go b/cmd/compose/push.go index 7e6a4ec940b..d81152de1b6 100644 --- a/cmd/compose/push.go +++ b/cmd/compose/push.go @@ -64,7 +64,7 @@ func runPush(ctx context.Context, dockerCli command.Cli, backendOptions *Backend project, _, err := opts.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes) if err != nil { - return err + return jobTargetErrOr(ctx, dockerCli, opts.ProjectOptions, services, err) } if !opts.IncludeDeps { diff --git a/cmd/compose/run.go b/cmd/compose/run.go index fb8c481f850..71e39e5af7e 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -365,6 +365,17 @@ func jobTargetErr(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, return err, false } +// jobTargetErrOr applies jobTargetErr's translation to err when it matches, +// and returns err unchanged otherwise — the wrap every caller loading a +// project outside WithServices/projectOrName's own centralized handling +// (build, pull, push) needs around its own selection error. +func jobTargetErrOr(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, names []string, err error) error { + if jobErr, replaced := jobTargetErr(ctx, dockerCli, p, names, err); replaced { + return jobErr + } + return err +} + func runRun(ctx context.Context, backend api.Compose, project *types.Project, options runOptions, createOpts createOptions, buildOpts buildOptions, dockerCli command.Cli) error { project, err := options.apply(project) if err != nil { From 841ad1fdcc574a89efa359b7e3fa6cab3e4de332 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 15:13:30 +0200 Subject: [PATCH 13/19] jobs: route run's engine call on the job's declared trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker compose run backup` on a schedule-only job failed against the real engine: "create-and-run serves manual jobs only; register schedule jobs with create". The engine already supports firing an existing job manually regardless of its trigger, through a dedicated RPC (Run) — what it refuses is CreateAndRun (register-and-fire atomically) for a job whose trigger is a schedule, since registering a cron must never imply an immediate run. createJobRun now routes accordingly: a manual-trigger job (the opt-out default included) still goes through CreateAndRun unchanged; a scheduled job goes through Create (idempotent on SpecHash — a no-op if `up` already registered the identical spec, or a fresh registration if not) then Run with Reschedule: false, so the manual fire adds to the cron cadence instead of replacing its next occurrence. jobTrigger/buildJobSpec are untouched: the spec sent to the engine still always reflects the job's actually declared trigger. Fixing this surfaced a second, latent bug the "already registered by up" case exercises directly: RunJob's spec for the same job differed depending on how it was reached. buildJobSpec relied on svc.CustomLabels for most of the job's standard labels, but the two materialization paths (RunJob's materializeManualJob vs registerScheduledJobs' own scopedProjectForJob) populate it differently — buildJobSpec now computes that label set itself instead. Separately, applyRunOptions unconditionally sets Tty/StdinOpen/ContainerName from the run invocation's terminal context, none of which RunJob's own doc comment lists as a supported override (jobs have no interactive attach, and the engine assigns the run container's identity itself) — RunJob now restores them to the job's own declared values. Without both fixes, a schedule job already registered by `up` spuriously conflicted on SpecHash the moment `run` tried to fire it. Signed-off-by: Guillaume Lours --- pkg/compose/jobs.go | 86 ++++++++++++--- pkg/compose/jobs_test.go | 101 ++++++++++++++++++ pkg/e2e/jobs_test.go | 18 ++++ .../compose.yaml | 11 ++ 4 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index c26448867e2..e993539927a 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -117,11 +117,23 @@ func (s *composeService) buildJobSpec(ctx context.Context, project *types.Projec // be set here on the container spec itself so run containers stay visible // to the rest of Compose's tooling (ps, label-scoped listings) exactly // like any other service container. + // + // This set is computed here, not taken from svc.CustomLabels: svc is + // materialized differently depending on the caller (RunJob's + // materializeManualJob vs registerScheduledJobs' scopedProjectForJob), + // and the engine's Create is idempotent on the full spec — a caller that + // under-populated CustomLabels would make the same job's spec differ + // depending on how it was triggered, turning a harmless re-registration + // into a spurious "job has changed" conflict. cfgs, err := s.getCreateConfigs(ctx, project, svc, 1, nil, createOptions{ UseNetworkAliases: useNetworkAliases, - Labels: mergeLabels(svc.Labels, svc.CustomLabels, types.Labels{ - api.ProjectLabel: project.Name, - api.ServiceLabel: job.Name, + Labels: mergeLabels(svc.Labels, types.Labels{ + api.ProjectLabel: project.Name, + api.ServiceLabel: job.Name, + api.VersionLabel: api.ComposeVersion, + api.WorkingDirLabel: project.WorkingDir, + api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), + api.OneoffLabel: "False", }), }) if err != nil { @@ -322,6 +334,17 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam return 0, err } applyRunOptions(project, &svc, options) + // Tty/StdinOpen/ContainerName are not among the overrides RunJob's own + // doc comment on api.Compose documents as supported: a job has no + // interactive attach, and the jobs API assigns the run container's + // identity itself. Restoring them to the job's own declared values (not + // the run invocation's terminal-detected defaults) keeps the spec sent + // to Create/CreateAndRun stable across invocations — otherwise a job + // already registered by `up`, or run twice from different contexts + // (piped vs interactive shell), spuriously conflicts on SpecHash. + svc.Tty = job.Tty + svc.StdinOpen = job.StdinOpen + svc.ContainerName = "" project.Services[name] = svc observed, err := s.getContainers(ctx, project.Name, oneOffInclude, true) @@ -348,28 +371,22 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam if err != nil { return 0, err } - reply, err := jc.CreateAndRun(ctx, &jobsv0.CreateAndRunRequest{ - Name: engineJobName(project, name), - Spec: spec, - }) - if err := jobsv0.MapError(err); err != nil { - if errdefs.IsAlreadyExists(err) { - return 0, jobChangedErr(name, "run") - } + created, err := s.createJobRun(ctx, jc, project, name, job, spec) + if err != nil { return 0, err } logsDone := make(chan struct{}) go func() { defer close(logsDone) - if err := s.streamJobLogs(ctx, reply.Run.ContainerID); err != nil && ctx.Err() == nil { + if err := s.streamJobLogs(ctx, created.ContainerID); err != nil && ctx.Err() == nil { logrus.Debugf("job %q: log stream ended: %v", name, err) } }() waited, err := jc.Wait(ctx, &jobsv0.WaitRequest{ - JobRef: reply.Job.ID, - RunRef: reply.Run.ID, + JobRef: created.JobID, + RunRef: created.ID, }) <-logsDone if err := jobsv0.MapError(err); err != nil { @@ -394,6 +411,47 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam } } +// createJobRun starts name's Run on the engine, routed on the job's +// declared trigger rather than on how compose happens to invoke it: the +// engine's CreateAndRun refuses a schedule-trigger spec outright +// ("create-and-run serves manual jobs only"), because registering a cron +// must never imply an immediate run. A manual-trigger job (the opt-out +// default included) is still created and run atomically via CreateAndRun. +// A scheduled job is instead Created — idempotent on SpecHash, a no-op if +// `up` already registered the identical spec, arming the schedule if not — +// then explicitly Run with Reschedule: false, so the manual fire adds to +// the cron cadence instead of replacing its next occurrence. +func (s *composeService) createJobRun(ctx context.Context, jc jobsv0.Jobs, project *types.Project, name string, job types.JobConfig, spec *jobsv0.JobSpec) (*jobsv0.Run, error) { + engineName := engineJobName(project, name) + if !HasSchedule(job) { + reply, err := jc.CreateAndRun(ctx, &jobsv0.CreateAndRunRequest{ + Name: engineName, + Spec: spec, + }) + if err := jobsv0.MapError(err); err != nil { + if errdefs.IsAlreadyExists(err) { + return nil, jobChangedErr(name, "run") + } + return nil, err + } + return reply.Run, nil + } + + if _, err := jc.Create(ctx, &jobsv0.CreateRequest{Name: engineName, Spec: spec}); err != nil { + if err := jobsv0.MapError(err); err != nil { + if errdefs.IsAlreadyExists(err) { + return nil, jobChangedErr(name, "run") + } + return nil, err + } + } + reply, err := jc.Run(ctx, &jobsv0.RunRequest{JobRef: engineName, Reschedule: false}) + if err := jobsv0.MapError(err); err != nil { + return nil, err + } + return reply.Run, nil +} + // streamJobLogs follows a job Run's container logs from the start, exactly // like `docker logs -f`, until the container stops producing output. func (s *composeService) streamJobLogs(ctx context.Context, containerID string) error { diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index d396fe61abd..409c22d018a 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -17,9 +17,12 @@ package compose import ( + "context" "testing" "github.com/compose-spec/compose-go/v2/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "gotest.tools/v3/assert" jobsv0 "github.com/docker/compose/v5/internal/jobsapi" @@ -165,3 +168,101 @@ func TestScopedProjectForJob(t *testing.T) { assert.Equal(t, len(project.Services), 1) }) } + +// fakeJobsClient is a minimal jobsv0.Jobs double for createJobRun's routing: +// only Create/Run/CreateAndRun are ever exercised by RunJob, so every other +// method is left to the embedded nil interface — calling one would panic, +// which is exactly the loud failure a test relying on it deserves. +type fakeJobsClient struct { + jobsv0.Jobs + + createCalls []*jobsv0.CreateRequest + createErr error + createAndRunCalls []*jobsv0.CreateAndRunRequest + createAndRunReply *jobsv0.CreateAndRunReply + createAndRunErr error + runCalls []*jobsv0.RunRequest + runReply *jobsv0.RunReply + runErr error +} + +func (f *fakeJobsClient) Create(_ context.Context, req *jobsv0.CreateRequest) (*jobsv0.CreateReply, error) { + f.createCalls = append(f.createCalls, req) + if f.createErr != nil { + return nil, f.createErr + } + return &jobsv0.CreateReply{Job: &jobsv0.Job{Name: req.Name}}, nil +} + +func (f *fakeJobsClient) CreateAndRun(_ context.Context, req *jobsv0.CreateAndRunRequest) (*jobsv0.CreateAndRunReply, error) { + f.createAndRunCalls = append(f.createAndRunCalls, req) + return f.createAndRunReply, f.createAndRunErr +} + +func (f *fakeJobsClient) Run(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + f.runCalls = append(f.runCalls, req) + return f.runReply, f.runErr +} + +// createJobRun routes on the job's declared trigger, not on how it happens +// to be invoked: the engine's CreateAndRun refuses a schedule-trigger spec +// outright ("create-and-run serves manual jobs only"), because registering +// a cron must never imply an immediate run. A manual-trigger job (the +// opt-out default included) still goes through CreateAndRun unchanged. +func TestCreateJobRun(t *testing.T) { + s := &composeService{} + project := &types.Project{Name: "myproject"} + spec := &jobsv0.JobSpec{} + + t.Run("a manual-trigger job uses CreateAndRun", func(t *testing.T) { + yes := true + job := types.JobConfig{Name: "migrate", Triggers: &types.TriggerConfig{Manual: &yes}} + fake := &fakeJobsClient{createAndRunReply: &jobsv0.CreateAndRunReply{Run: &jobsv0.Run{ID: "run-1", JobID: "job-1"}}} + + run, err := s.createJobRun(t.Context(), fake, project, "migrate", job, spec) + assert.NilError(t, err) + assert.Equal(t, run.ID, "run-1") + assert.Equal(t, len(fake.createAndRunCalls), 1) + assert.Equal(t, fake.createAndRunCalls[0].Name, "myproject.migrate") + assert.Equal(t, len(fake.createCalls), 0, "a manual job must never call Create") + assert.Equal(t, len(fake.runCalls), 0, "a manual job must never call the schedule-job Run path") + }) + + t.Run("a scheduled job uses Create then Run, not CreateAndRun", func(t *testing.T) { + job := types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }} + fake := &fakeJobsClient{runReply: &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-2", JobID: "job-2"}}} + + run, err := s.createJobRun(t.Context(), fake, project, "backup", job, spec) + assert.NilError(t, err) + assert.Equal(t, run.ID, "run-2") + assert.Equal(t, len(fake.createCalls), 1) + assert.Equal(t, fake.createCalls[0].Name, "myproject.backup") + assert.Equal(t, len(fake.runCalls), 1) + assert.Equal(t, fake.runCalls[0].JobRef, "myproject.backup") + assert.Assert(t, !fake.runCalls[0].Reschedule, "a manual fire must add to the cron cadence, not replace its next occurrence") + assert.Equal(t, len(fake.createAndRunCalls), 0, "a scheduled job must never call CreateAndRun: the engine rejects it") + }) + + t.Run("a scheduled job already registered by up is a no-op Create, not a conflict", func(t *testing.T) { + job := types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }} + fake := &fakeJobsClient{runReply: &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-3"}}} + + _, err := s.createJobRun(t.Context(), fake, project, "backup", job, spec) + assert.NilError(t, err, "Create must succeed as a no-op when up already registered the identical spec") + }) + + t.Run("a scheduled job with a changed spec reports the same conflict a manual job would", func(t *testing.T) { + job := types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{ + Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, + }} + fake := &fakeJobsClient{createErr: status.Error(codes.AlreadyExists, "spec differs")} + + _, err := s.createJobRun(t.Context(), fake, project, "backup", job, spec) + assert.Error(t, err, `job "backup" has changed: run `+"`docker compose down`"+` to remove it, then `+"`run`"+` again`) + assert.Equal(t, len(fake.runCalls), 0, "Run must not be attempted after a Create conflict") + }) +} diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index afd1e9905ab..52fe8d921c0 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -37,6 +37,24 @@ func TestUpRegistersScheduledJobs(t *testing.T) { Eventually(ServiceState("backup", "exited"), 90*time.Second)) } +// run must build the exact same spec `up` already registered a scheduled +// job with: CreateAndRun refuses schedule-trigger jobs outright, so run +// routes through Create (idempotent on SpecHash) then Run instead. A run +// invocation carries CLI/terminal-context defaults (Tty, StdinOpen, +// ContainerName) that up's own registration never does — if those leaked +// into the spec sent to Create, this would spuriously conflict with the +// job up already registered, even though nothing in the compose file +// changed. +func TestRunAlreadyRegisteredScheduledJob(t *testing.T) { + NewScenario(t, "run must not conflict with a scheduled job up already registered with the identical spec"). + Step("up registers the scheduled job", + ComposeCmd("up", "-d"), + ServiceState("web", "running")). + Step("run fires it manually without a SpecHash conflict", + ComposeCmd("run", "--rm", "backup"), + OutputContains("backup-ran")) +} + // --no-start's own path (Create, then registerScheduledJobs, then return // before Start) must still register scheduled jobs: it used to bypass Up // entirely by calling Create directly, silently skipping registration. diff --git a/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml b/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml new file mode 100644 index 00000000000..1e0ae182874 --- /dev/null +++ b/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml @@ -0,0 +1,11 @@ +services: + web: + image: alpine + command: sleep infinity +jobs: + backup: + image: alpine + command: sh -c 'echo backup-ran' + triggers: + schedule: + - cron: "0 3 * * *" From f620556536077e6b3f7700644cf2f022ed806676 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 23 Sep 2026 10:52:25 +0200 Subject: [PATCH 14/19] jobs: fix scheduled-job registration races and run's tty handling RunJob was resetting a job's Tty/StdinOpen to false instead of its own declared values, so a scheduled job with tty: true spuriously conflicted with the spec already registered by up. streamJobLogs always demuxed the log stream, which corrupts output for a job with tty: true. scopedProjectForJob's per-job project copy left several service fields (Environment, CustomLabels, Volumes, Configs) aliased across concurrently-registering jobs, racing whenever another service uses use_api_socket, models, or an image-type volume. Also unifies job-to-service materialization behind a single JobAsService helper and bounds scheduled-job registration to the configured concurrency limit. Signed-off-by: Guillaume Lours --- cmd/compose/run.go | 30 +-- pkg/compose/jobs.go | 174 ++++++++++++------ pkg/compose/jobs_test.go | 37 +++- pkg/compose/run.go | 18 +- .../compose.yaml | 1 + 5 files changed, 169 insertions(+), 91 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 71e39e5af7e..2f78b501212 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -514,7 +514,7 @@ func materializeManualJob(project *types.Project, name string) (*types.Project, if err := materializeJobClosure(project, jobs, job, map[string]bool{name: true}); err != nil { return nil, err } - project.Services[name] = jobAsService(project, name, job) + project.Services[name] = compose.JobAsService(project, name, job) return project, nil } @@ -541,33 +541,7 @@ func materializeJobClosure(project *types.Project, jobs types.Jobs, job types.Jo if err := materializeJobClosure(project, jobs, depJob, seen); err != nil { return err } - project.Services[dep] = jobAsService(project, dep, depJob) + project.Services[dep] = compose.JobAsService(project, dep, depJob) } return nil } - -// jobAsService materializes a job as a service for the one-off machinery: a -// job is a ContainerSpec+WorkloadSpec, the same layers a service is made of. -// It carries the standard custom labels the loader stamps on every service — -// materialization happens after loading, so without them the containers -// created for a dependency job would be invisible to every label-driven -// path: start would silently skip them, ps/down would not see them, and the -// dependency wait would report the job as a missing dependency. -func jobAsService(project *types.Project, name string, job types.JobConfig) types.ServiceConfig { - svc := types.ServiceConfig{ - Name: name, - Profiles: job.Profiles, - Extensions: job.Extensions, - ContainerSpec: job.ContainerSpec, - WorkloadSpec: job.WorkloadSpec, - } - svc.CustomLabels = types.Labels{ - api.ProjectLabel: project.Name, - api.ServiceLabel: name, - api.VersionLabel: api.ComposeVersion, - api.WorkingDirLabel: project.WorkingDir, - api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), - api.OneoffLabel: "False", - } - return svc -} diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index e993539927a..ba62035ac3b 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "io" "strings" "github.com/compose-spec/compose-go/v2/types" @@ -116,25 +117,14 @@ func (s *composeService) buildJobSpec(ctx context.Context, project *types.Projec // container (see jobsv0.JobSpec.Labels godoc): project/service labels must // be set here on the container spec itself so run containers stay visible // to the rest of Compose's tooling (ps, label-scoped listings) exactly - // like any other service container. - // - // This set is computed here, not taken from svc.CustomLabels: svc is - // materialized differently depending on the caller (RunJob's - // materializeManualJob vs registerScheduledJobs' scopedProjectForJob), - // and the engine's Create is idempotent on the full spec — a caller that - // under-populated CustomLabels would make the same job's spec differ - // depending on how it was triggered, turning a harmless re-registration - // into a spurious "job has changed" conflict. + // like any other service container. svc.CustomLabels is trustworthy here + // because JobAsService is the single place both materialization paths + // (RunJob's cmd-layer materializeManualJob and registerScheduledJobs' + // scopedProjectForJob) build it — the same job's spec no longer differs + // depending on how it was triggered. cfgs, err := s.getCreateConfigs(ctx, project, svc, 1, nil, createOptions{ UseNetworkAliases: useNetworkAliases, - Labels: mergeLabels(svc.Labels, types.Labels{ - api.ProjectLabel: project.Name, - api.ServiceLabel: job.Name, - api.VersionLabel: api.ComposeVersion, - api.WorkingDirLabel: project.WorkingDir, - api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), - api.OneoffLabel: "False", - }), + Labels: mergeLabels(svc.Labels, svc.CustomLabels), }) if err != nil { return nil, err @@ -186,6 +176,17 @@ func jobChangedErr(name, verb string) error { return fmt.Errorf("job %q has changed: run `docker compose down` to remove it, then `%s` again", name, verb) } +// mapAlreadyExists maps err through jobsv0.MapError, translating an +// AlreadyExists (the engine already has name registered with a different +// spec) into jobChangedErr instead of the raw engine error. +func mapAlreadyExists(err error, name, verb string) error { + err = jobsv0.MapError(err) + if errdefs.IsAlreadyExists(err) { + return jobChangedErr(name, verb) + } + return err +} + // HasSchedule reports whether a job declares a schedule trigger — the // predicate `up` uses to register it with the engine instead of warning // that it waits for `docker compose run`. @@ -209,29 +210,88 @@ func ManualTriggerDisabledErr(name string) error { return fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name) } +// JobAsService materializes a job as a service for the one-off machinery: a +// job is a ContainerSpec+WorkloadSpec, the same layers a service is made of. +// It carries the standard custom labels the loader stamps on every service — +// materialization happens after loading, so without them the containers +// created for the job would be invisible to every label-driven path: start +// would silently skip them, ps/down would not see them, and the dependency +// wait would report the job as a missing dependency. Exported as the single +// source of truth both materialization paths (cmd/compose/run.go's +// materializeManualJob for a manual run, scopedProjectForJob below for a +// scheduled registration) share, so the same job's spec doesn't differ +// depending on how it was triggered. +func JobAsService(project *types.Project, name string, job types.JobConfig) types.ServiceConfig { + svc := types.ServiceConfig{ + Name: name, + Profiles: job.Profiles, + Extensions: job.Extensions, + ContainerSpec: job.ContainerSpec, + WorkloadSpec: job.WorkloadSpec, + } + svc.CustomLabels = types.Labels{ + api.ProjectLabel: project.Name, + api.ServiceLabel: name, + api.VersionLabel: api.ComposeVersion, + api.WorkingDirLabel: project.WorkingDir, + api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), + api.OneoffLabel: "False", + } + return svc +} + // scopedProjectForJob returns a shallow copy of project with name's job // materialized into a fresh Services map, so a scheduled job can run // through the same project-wide normalization passes (useAPISocket, // ensureImagesExists, ensureModels) that a manually-run job gets for free -// once materializeManualJob puts it into the real project.Services — see -// jobAsService in cmd/compose/run.go for that path. The copy is throwaway: -// callers must discard it once they've read back the resolved -// ServiceConfig, so the job never joins the project the real +// once materializeManualJob puts it into the real project.Services. The +// copy is throwaway: callers must discard it once they've read back the +// resolved ServiceConfig, so the job never joins the project the real // service-reconciliation loop (create/start) iterates over. +// +// Configs, and each service's Environment/CustomLabels/Volumes/Configs, +// also get their own copy: registerScheduledJobs calls this concurrently +// for every scheduled job, and the normalization passes above mutate those +// fields in place across every service in the project, not just the job +// being registered — useAPISocket writes project.Configs["#apisocket"], +// service.Environment["DOCKER_CONFIG"], and appends to service.Volumes and +// service.Configs; ensureModels' SetModelVariables writes +// service.Environment for any service with a models: reference; and +// ensureImagesExists writes service.CustomLabels (via Labels.Add, which +// mutates its receiver) and, through resolveImageVolumes, service.Volumes +// elements in place for any `type: image` volume. A plain struct copy of +// ServiceConfig leaves all of these aliased to the real project's, so two +// jobs registering concurrently would both write into the same map or +// backing array — a data race. func scopedProjectForJob(project *types.Project, name string, job types.JobConfig) *types.Project { scoped := *project services := make(types.Services, len(project.Services)+1) for n, svc := range project.Services { + env := make(types.MappingWithEquals, len(svc.Environment)) + for k, v := range svc.Environment { + env[k] = v + } + svc.Environment = env + + labels := make(types.Labels, len(svc.CustomLabels)) + for k, v := range svc.CustomLabels { + labels[k] = v + } + svc.CustomLabels = labels + + svc.Volumes = append([]types.ServiceVolumeConfig{}, svc.Volumes...) + svc.Configs = append([]types.ServiceConfigObjConfig{}, svc.Configs...) + services[n] = svc } - services[name] = types.ServiceConfig{ - Name: name, - Profiles: job.Profiles, - Extensions: job.Extensions, - ContainerSpec: job.ContainerSpec, - WorkloadSpec: job.WorkloadSpec, - } + services[name] = JobAsService(project, name, job) scoped.Services = services + + configs := make(types.Configs, len(project.Configs)) + for n, cfg := range project.Configs { + configs[n] = cfg + } + scoped.Configs = configs return &scoped } @@ -249,6 +309,7 @@ func (s *composeService) registerScheduledJobs(ctx context.Context, project *typ return err } eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(s.maxConcurrency) for _, name := range names { eg.Go(func() error { job := project.Jobs[name] @@ -279,11 +340,7 @@ func (s *composeService) registerScheduledJobs(ctx context.Context, project *typ Name: engineJobName(project, name), Spec: spec, }) - err = jobsv0.MapError(err) - if errdefs.IsAlreadyExists(err) { - return jobChangedErr(name, "up") - } - return err + return mapAlreadyExists(err, name, "up") }) } return eg.Wait() @@ -334,18 +391,20 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam return 0, err } applyRunOptions(project, &svc, options) - // Tty/StdinOpen/ContainerName are not among the overrides RunJob's own - // doc comment on api.Compose documents as supported: a job has no - // interactive attach, and the jobs API assigns the run container's - // identity itself. Restoring them to the job's own declared values (not - // the run invocation's terminal-detected defaults) keeps the spec sent - // to Create/CreateAndRun stable across invocations — otherwise a job - // already registered by `up`, or run twice from different contexts - // (piped vs interactive shell), spuriously conflicts on SpecHash. + // A job has no run-assigned container name: the jobs API owns the run + // container's identity itself. But two independent layers inject + // CLI/terminal-context defaults into the service before RunJob ever + // sees it — prepareRun's own Tty/StdinOpen overrides don't apply here, + // yet cmd/compose/run.go's runOptions.apply still sets + // target.Tty/StdinOpen from the terminal before materializing the job. + // Left in place, that leaks into the spec sent to the engine and + // spuriously conflicts (SpecHash mismatch) with the identical spec `up` + // already registered for a scheduled job. Restore the job's own + // declared values (set correctly by JobAsService for both + // materialization paths) rather than the terminal's. svc.Tty = job.Tty svc.StdinOpen = job.StdinOpen svc.ContainerName = "" - project.Services[name] = svc observed, err := s.getContainers(ctx, project.Name, oneOffInclude, true) if err != nil { @@ -361,7 +420,6 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam if err := s.resolveRunServiceReferences(ctx, project.Name, &svc); err != nil { return 0, err } - project.Services[name] = svc spec, err := s.buildJobSpec(ctx, project, svc, job, options.UseNetworkAliases) if err != nil { return 0, err @@ -379,7 +437,7 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam logsDone := make(chan struct{}) go func() { defer close(logsDone) - if err := s.streamJobLogs(ctx, created.ContainerID); err != nil && ctx.Err() == nil { + if err := s.streamJobLogs(ctx, created.ContainerID, svc.Tty); err != nil && ctx.Err() == nil { logrus.Debugf("job %q: log stream ended: %v", name, err) } }() @@ -428,22 +486,15 @@ func (s *composeService) createJobRun(ctx context.Context, jc jobsv0.Jobs, proje Name: engineName, Spec: spec, }) - if err := jobsv0.MapError(err); err != nil { - if errdefs.IsAlreadyExists(err) { - return nil, jobChangedErr(name, "run") - } + if err := mapAlreadyExists(err, name, "run"); err != nil { return nil, err } return reply.Run, nil } - if _, err := jc.Create(ctx, &jobsv0.CreateRequest{Name: engineName, Spec: spec}); err != nil { - if err := jobsv0.MapError(err); err != nil { - if errdefs.IsAlreadyExists(err) { - return nil, jobChangedErr(name, "run") - } - return nil, err - } + _, err := jc.Create(ctx, &jobsv0.CreateRequest{Name: engineName, Spec: spec}) + if err := mapAlreadyExists(err, name, "run"); err != nil { + return nil, err } reply, err := jc.Run(ctx, &jobsv0.RunRequest{JobRef: engineName, Reschedule: false}) if err := jobsv0.MapError(err); err != nil { @@ -453,8 +504,11 @@ func (s *composeService) createJobRun(ctx context.Context, jc jobsv0.Jobs, proje } // streamJobLogs follows a job Run's container logs from the start, exactly -// like `docker logs -f`, until the container stops producing output. -func (s *composeService) streamJobLogs(ctx context.Context, containerID string) error { +// like `docker logs -f`, until the container stops producing output. tty +// must match the container's own Tty setting: with a tty allocated, the +// daemon returns a single raw stream instead of the stdout/stderr-framed +// stream stdcopy expects (see doLogContainer in logs.go for the same split). +func (s *composeService) streamJobLogs(ctx context.Context, containerID string, tty bool) error { r, err := s.apiClient().ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, @@ -464,7 +518,11 @@ func (s *composeService) streamJobLogs(ctx context.Context, containerID string) return err } defer r.Close() //nolint:errcheck - _, err = stdcopy.StdCopy(s.stdout(), s.stderr(), r) + if tty { + _, err = io.Copy(s.stdout(), r) + } else { + _, err = stdcopy.StdCopy(s.stdout(), s.stderr(), r) + } return err } diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index 409c22d018a..e2b716937d0 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -135,7 +135,14 @@ func TestScopedProjectForJob(t *testing.T) { project := &types.Project{ Name: "myproject", Services: types.Services{ - "db": {Name: "db", ContainerSpec: types.ContainerSpec{Image: "postgres"}}, + "db": { + Name: "db", + ContainerSpec: types.ContainerSpec{ + Image: "postgres", + Environment: types.MappingWithEquals{"FOO": strPtr("original")}, + CustomLabels: types.Labels{"original": "label"}, + }, + }, }, Jobs: types.Jobs{ "backup": { @@ -144,6 +151,9 @@ func TestScopedProjectForJob(t *testing.T) { ContainerSpec: types.ContainerSpec{Image: "backup-tool"}, }, }, + Configs: types.Configs{ + "cfg": {Content: "original"}, + }, } job := project.Jobs["backup"] @@ -167,6 +177,31 @@ func TestScopedProjectForJob(t *testing.T) { assert.Assert(t, !ok, "the job must not leak into the shared project's Services") assert.Equal(t, len(project.Services), 1) }) + + t.Run("Configs is its own map, not shared with the real project", func(t *testing.T) { + scoped.Configs["new"] = types.ConfigObjConfig{Content: "added"} + _, ok := project.Configs["new"] + assert.Assert(t, !ok, "writing to the scoped copy's Configs must not be visible on the shared project — registerScheduledJobs runs this concurrently per job") + assert.Equal(t, project.Configs["cfg"].Content, "original") + }) + + t.Run("a carried-over service's Environment is its own map, not shared with the real project", func(t *testing.T) { + svc, err := scoped.GetService("db") + assert.NilError(t, err) + svc.Environment["FOO"] = strPtr("mutated") + // useAPISocket/ensureModels write into a job's scoped Environment map + // concurrently with other jobs' registration — it must not be the + // shared project's map. + assert.Equal(t, *project.Services["db"].Environment["FOO"], "original") + }) + + t.Run("a carried-over service's CustomLabels is its own map, not shared with the real project", func(t *testing.T) { + svc, err := scoped.GetService("db") + assert.NilError(t, err) + svc.CustomLabels["new"] = "added" + _, ok := project.Services["db"].CustomLabels["new"] + assert.Assert(t, !ok, "ensureImagesExists writes into a job's scoped CustomLabels map (via Labels.Add) concurrently with other jobs' registration — it must not be the shared project's map") + }) } // fakeJobsClient is a minimal jobsv0.Jobs double for createJobRun's routing: diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 51590650cb8..928fa45b9eb 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -150,6 +150,9 @@ func (s *composeService) doPrepareRun(ctx context.Context, project *types.Projec return prepareRunResult{}, err } + service.Tty = options.Tty + service.StdinOpen = options.Interactive + service.ContainerName = options.Name applyRunOptions(project, &service, options) if err := s.stdin().CheckTty(options.Interactive, service.Tty); err != nil { @@ -235,11 +238,18 @@ func prepareBuildOptions(options api.RunOptions) *api.BuildOptions { return &buildOptionsCopy } +// applyRunOptions applies the CLI overrides shared by a one-off service run +// and a job run — Command, User, CapAdd/CapDrop, WorkingDir, Entrypoint, +// Environment, Labels, exactly the set RunJob's own doc comment on +// api.Compose documents as supported. Tty/StdinOpen/ContainerName are +// deliberately not here: prepareRun (RunOneOffContainer's caller) sets them +// itself for its own container-naming/attach needs, and a job has neither +// (no interactive attach, and the jobs API assigns the run container's +// identity itself) — RunJob resets them back to their zero value right +// after calling this, since CLI-layer code upstream of both (cmd/compose's +// runOptions.apply) already stamped the service with terminal-context +// defaults meant for a one-off container, not a job. func applyRunOptions(project *types.Project, service *types.ServiceConfig, options api.RunOptions) { - service.Tty = options.Tty - service.StdinOpen = options.Interactive - service.ContainerName = options.Name - if len(options.Command) > 0 { service.Command = options.Command } diff --git a/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml b/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml index 1e0ae182874..0b5c34e3b5e 100644 --- a/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml +++ b/pkg/e2e/testdata/TestRunAlreadyRegisteredScheduledJob/compose.yaml @@ -6,6 +6,7 @@ jobs: backup: image: alpine command: sh -c 'echo backup-ran' + tty: true triggers: schedule: - cron: "0 3 * * *" From 2f7537fc946d3172b701abc5766c9094d79c1d08 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 23 Sep 2026 16:06:54 +0200 Subject: [PATCH 15/19] jobs: guard against nil runs and a not-yet-created container Run/CreateAndRun/Run replies carry the run as an omitempty proto pointer, so a successful call can still come back with no run at all, and ContainerID stays empty until the daemon has actually created the container. Wait for the running condition before starting the log stream, skip streaming when no container ever came up, and fail with a plain error instead of dereferencing a nil run. Also keep RunJob's underlying error attached to its StatusError so callers can still inspect it with errors.Is/As. Signed-off-by: Guillaume Lours --- cmd/compose/run.go | 2 +- pkg/compose/jobs.go | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 2f78b501212..511bec4c090 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -456,7 +456,7 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op if err != nil { errMsg = err.Error() } - return cli.StatusError{StatusCode: exitCode, Status: errMsg} + return cli.StatusError{StatusCode: exitCode, Status: errMsg, Cause: err} } return err } diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index ba62035ac3b..00f234a5908 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -434,10 +434,26 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam return 0, err } + running, err := jc.Wait(ctx, &jobsv0.WaitRequest{ + JobRef: created.JobID, + RunRef: created.ID, + Condition: jobsv0.WaitConditionRunning, + }) + if err := jobsv0.MapError(err); err != nil { + return 0, err + } + containerID := created.ContainerID + if running.Run != nil { + containerID = running.Run.ContainerID + } + logsDone := make(chan struct{}) go func() { defer close(logsDone) - if err := s.streamJobLogs(ctx, created.ContainerID, svc.Tty); err != nil && ctx.Err() == nil { + if containerID == "" { + return + } + if err := s.streamJobLogs(ctx, containerID, svc.Tty); err != nil && ctx.Err() == nil { logrus.Debugf("job %q: log stream ended: %v", name, err) } }() @@ -452,6 +468,9 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam } run := waited.Run + if run == nil { + return 1, fmt.Errorf("job %q: Wait returned no run", name) + } switch run.State { case jobsv0.RunStateSucceeded: return 0, nil @@ -489,6 +508,9 @@ func (s *composeService) createJobRun(ctx context.Context, jc jobsv0.Jobs, proje if err := mapAlreadyExists(err, name, "run"); err != nil { return nil, err } + if reply.Run == nil { + return nil, fmt.Errorf("job %q: engine returned no run", name) + } return reply.Run, nil } @@ -500,6 +522,9 @@ func (s *composeService) createJobRun(ctx context.Context, jc jobsv0.Jobs, proje if err := jobsv0.MapError(err); err != nil { return nil, err } + if reply.Run == nil { + return nil, fmt.Errorf("job %q: engine returned no run", name) + } return reply.Run, nil } From 7f76e45bfe63d179dad7ecc55a5e34057579fcc1 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 2 Oct 2026 09:30:55 +0200 Subject: [PATCH 16/19] jobs: sort map-derived container spec fields for a stable hash Env, ulimits, extra_hosts, and mounts are all built by iterating a Go map, whose order is deliberately randomized. The result is marshaled verbatim into the container spec sent to the Engine's Jobs API, and the Engine hashes that spec to decide if a job's registration changed. With two or more entries, the hash churned across invocations, so repeated `up` or `run` intermittently failed with "job ... has changed" even though the compose file never changed. Sort each slice right after it's built, the same pattern already used for the depends_on label. Signed-off-by: Guillaume Lours --- pkg/compose/convert.go | 5 ++ pkg/compose/create.go | 18 +++++++- pkg/compose/create_test.go | 7 --- pkg/compose/jobs_test.go | 46 +++++++++++++++++++ pkg/e2e/jobs_test.go | 3 ++ .../testdata/TestRunManualJob/compose.yaml | 6 +++ .../TestUpRegistersScheduledJobs/compose.yaml | 6 +++ 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/pkg/compose/convert.go b/pkg/compose/convert.go index a854564f63d..f851cc8f1a2 100644 --- a/pkg/compose/convert.go +++ b/pkg/compose/convert.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "slices" "time" compose "github.com/compose-spec/compose-go/v2/types" @@ -36,6 +37,10 @@ func ToMobyEnv(environment compose.MappingWithEquals) []string { env = append(env, fmt.Sprintf("%s=%s", k, *v)) } } + // environment is a map: iteration order is random, and Env ends up + // marshaled verbatim into the job spec sent to the Engine, whose spec + // hash is order-sensitive. Keys are unique, so sorting is safe. + slices.Sort(env) return env } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 0f9a10b36ee..677f7d7e393 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -443,6 +443,11 @@ func (s *composeService) getCreateConfigs(ctx context.Context, dnsIPs = append(dnsIPs, dnsIP) } + // Same ordering requirement as ToMobyEnv: AsList's map iteration order + // is random, and this ends up in the job spec hash. + extraHosts := service.ExtraHosts.AsList(":") + slices.Sort(extraHosts) + hostConfig := container.HostConfig{ AutoRemove: options.AutoRemove, Annotations: service.Annotations, @@ -465,7 +470,7 @@ func (s *composeService) getCreateConfigs(ctx context.Context, DNS: dnsIPs, DNSSearch: service.DNSSearch, DNSOptions: service.DNSOpts, - ExtraHosts: service.ExtraHosts.AsList(":"), + ExtraHosts: extraHosts, SecurityOpt: securityOpts, StorageOpt: service.StorageOpt, UsernsMode: container.UsernsMode(service.UserNSMode), @@ -875,6 +880,11 @@ func toUlimits(m map[string]*types.UlimitsConfig) []*container.Ulimit { Soft: int64(soft), }) } + // Same ordering requirement as ToMobyEnv: m is a map, so iteration + // order is random, and this ends up in the job spec hash. + slices.SortFunc(ulimits, func(a, b *container.Ulimit) int { + return strings.Compare(a.Name, b.Name) + }) return ulimits } @@ -1221,6 +1231,12 @@ func (s *composeService) buildContainerMountOptions(ctx context.Context, p types for _, v := range mounts { values = append(values, v) } + // Same ordering requirement as ToMobyEnv: mounts is keyed by target + // path, so iteration order is random, and this ends up in the job + // spec hash. + slices.SortFunc(values, func(a, b mount.Mount) int { + return strings.Compare(a.Target, b.Target) + }) return values, nil } diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index 0600cf4edca..e3bca7431ed 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -21,7 +21,6 @@ import ( "net/netip" "os" "path/filepath" - "sort" "testing" composeloader "github.com/compose-spec/compose-go/v2/loader" @@ -166,9 +165,6 @@ func TestBuildContainerMountOptions(t *testing.T) { mock.EXPECT().ImageInspect(gomock.Any(), "myProject-myService").AnyTimes().Return(client.ImageInspectResult{}, nil) mounts, err := s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) - sort.Slice(mounts, func(i, j int) bool { - return mounts[i].Target < mounts[j].Target - }) assert.NilError(t, err) assert.Assert(t, len(mounts) == 4) assert.Equal(t, mounts[0].Target, "/var/myvolume1") @@ -178,9 +174,6 @@ func TestBuildContainerMountOptions(t *testing.T) { assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine") mounts, err = s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit) - sort.Slice(mounts, func(i, j int) bool { - return mounts[i].Target < mounts[j].Target - }) assert.NilError(t, err) assert.Assert(t, len(mounts) == 4) assert.Equal(t, mounts[0].Target, "/var/myvolume1") diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index e2b716937d0..053cda6e35b 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -301,3 +301,49 @@ func TestCreateJobRun(t *testing.T) { assert.Equal(t, len(fake.runCalls), 0, "Run must not be attempted after a Create conflict") }) } + +// TestBuildJobSpecIsDeterministic guards against the spec hash churning +// across invocations when a job declares several environment variables: +// buildJobSpec marshals the container spec as JSON and sends it to the +// engine, which identifies the job by a hash of that payload, so any +// map-to-slice conversion that doesn't sort its output (Env, Ulimits, +// ExtraHosts, mounts) makes every other `up`/`run` fail with +// `job "..." has changed`. +func TestBuildJobSpecIsDeterministic(t *testing.T) { + s, _ := newPreStartTestService(t) + project := &types.Project{Name: "myproject"} + svc := types.ServiceConfig{ + Name: "backup", + ContainerSpec: types.ContainerSpec{ + Image: "alpine", + Environment: types.MappingWithEquals{ + "A": strPtr("1"), "B": strPtr("2"), "C": strPtr("3"), "D": strPtr("4"), + "E": strPtr("5"), "F": strPtr("6"), "G": strPtr("7"), "H": strPtr("8"), + }, + Ulimits: map[string]*types.UlimitsConfig{ + "nofile": {Soft: 1024, Hard: 2048}, + "nproc": {Single: 100}, + }, + ExtraHosts: types.HostsList{ + "a.example.com": []string{"10.0.0.1"}, + "b.example.com": []string{"10.0.0.2"}, + }, + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeVolume, Target: "/var/a"}, + {Type: types.VolumeTypeVolume, Target: "/var/b"}, + }, + }, + } + manual := true + job := types.JobConfig{Name: "backup", Triggers: &types.TriggerConfig{Manual: &manual}} + + first, err := s.buildJobSpec(t.Context(), project, svc, job, false) + assert.NilError(t, err) + + msg := "job spec must be byte-identical across invocations, or the engine's spec hash churns and up/run intermittently fail with \"has changed\"" + for i := 0; i < 100; i++ { + spec, err := s.buildJobSpec(t.Context(), project, svc, job, false) + assert.NilError(t, err) + assert.Equal(t, string(spec.ContainerSpec), string(first.ContainerSpec), msg) + } +} diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 52fe8d921c0..91bc0da50c4 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -76,6 +76,9 @@ func TestRunManualJob(t *testing.T) { ComposeCmd("run", "--rm", "migrate"), OutputContains("migration done"), ServiceState("db", "running")). + Step("re-running the same manual job with several env vars must not conflict on spec hash", + ComposeCmd("run", "--rm", "migrate"), + OutputContains("migration done")). Step("a scheduled job without explicit opt-out can be run manually", ComposeCmd("run", "--rm", "backup"), OutputContains("backup")). diff --git a/pkg/e2e/testdata/TestRunManualJob/compose.yaml b/pkg/e2e/testdata/TestRunManualJob/compose.yaml index 5157e2bd24f..e9dc7635954 100644 --- a/pkg/e2e/testdata/TestRunManualJob/compose.yaml +++ b/pkg/e2e/testdata/TestRunManualJob/compose.yaml @@ -7,6 +7,12 @@ jobs: migrate: image: alpine command: sh -c 'echo "migration done"' + # run is invoked twice below and must build the identical spec hash each time + environment: + A: "1" + B: "2" + C: "3" + D: "4" triggers: manual: true depends_on: diff --git a/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml b/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml index 48e5d07b691..67ac3169f1c 100644 --- a/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml +++ b/pkg/e2e/testdata/TestUpRegistersScheduledJobs/compose.yaml @@ -10,6 +10,12 @@ jobs: backup: image: alpine command: sh -c 'exit 1' + # repeated `up -d` below must see the identical spec hash each time + environment: + A: "1" + B: "2" + C: "3" + D: "4" triggers: schedule: - cron: "* * * * *" From e31d8f3718117d44b7c599ab05d5d4d242e97c5c Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 15 Oct 2026 10:03:44 +0200 Subject: [PATCH 17/19] jobs: degrade to manual-only jobs when the engine has no jobs API Gate the jobs API on the negotiated engine API version (1.57, a placeholder until an engine ships it). Without it: - up, create and start refuse a project declaring active scheduled jobs, before creating anything - run falls back to running a manual job as a one-off service container (RunJob returns api.ErrJobsNotSupported) - down skips the engine's jobs registry Tests: unit tests for the switch, run fallback tests in cmd, and e2e scenarios gated on the engine API version (degraded below 1.57, job registration from 1.57). Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Guillaume Lours --- cmd/compose/run.go | 19 +-- cmd/compose/run_test.go | 60 +++++++++ pkg/api/errors.go | 2 + pkg/compose/api_versions.go | 13 ++ pkg/compose/create.go | 3 + pkg/compose/down_test.go | 15 +++ pkg/compose/jobs.go | 50 +++++++- pkg/compose/jobs_test.go | 116 ++++++++++++++++++ pkg/compose/start.go | 5 + pkg/compose/up.go | 3 + pkg/e2e/jobs_test.go | 29 +++++ pkg/e2e/scenario.go | 31 +++++ .../TestUpRejectsScheduledJobs/compose.yaml | 12 ++ 13 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 511bec4c090..12fbe5768d6 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -447,18 +447,19 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op } if _, ok := project.AllJobs()[options.Service]; ok { - if options.name != "" { - logrus.Warnf("--name has no effect on a job: the engine names its run containers itself") - } exitCode, err := backend.RunJob(ctx, project, options.Service, runOpts) - if exitCode != 0 { - errMsg := "" - if err != nil { - errMsg = err.Error() + // An engine without the jobs API runs the manual job like a one-off + // service: fall through, the job is already materialized as one. + if !errors.Is(err, api.ErrJobsNotSupported) { + if exitCode != 0 { + errMsg := "" + if err != nil { + errMsg = err.Error() + } + return cli.StatusError{StatusCode: exitCode, Status: errMsg, Cause: err} } - return cli.StatusError{StatusCode: exitCode, Status: errMsg, Cause: err} + return err } - return err } for name, service := range project.Services { diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index 681f5263571..1b883975241 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -17,13 +17,20 @@ package compose import ( + "context" "errors" "os" "path/filepath" "testing" "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli" + cliopts "github.com/docker/cli/opts" + "go.uber.org/mock/gomock" "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" ) func TestMaterializeManualJob(t *testing.T) { @@ -209,3 +216,56 @@ func TestJobTargetErr(t *testing.T) { assert.Equal(t, err, original) }) } + +// run targets a job through the engine's jobs API when it has one, and +// degrades to a one-off service container, exactly like a service run, when +// it does not. +func TestRunRunJob(t *testing.T) { + yes := true + newProject := func() *types.Project { + return &types.Project{ + Name: "prj", + Services: types.Services{}, + Jobs: types.Jobs{"migrate": { + Name: "migrate", + Triggers: &types.TriggerConfig{Manual: &yes}, + ContainerSpec: types.ContainerSpec{Image: "migrator"}, + }}, + } + } + run := func(t *testing.T, backend api.Compose) error { + t.Helper() + ctrl := gomock.NewController(t) + return runRun(t.Context(), backend, newProject(), + runOptions{composeOptions: &composeOptions{ProjectOptions: &ProjectOptions{}}, Service: "migrate", noTty: true, capAdd: cliopts.NewListOpts(nil), capDrop: cliopts.NewListOpts(nil)}, + createOptions{}, buildOptions{ProjectOptions: &ProjectOptions{}}, mocks.NewMockCli(ctrl)) + } + + t.Run("an engine with the jobs API runs the job through it", func(t *testing.T) { + backend := mocks.NewMockCompose(gomock.NewController(t)) + backend.EXPECT().RunJob(gomock.Any(), gomock.Any(), "migrate", gomock.Any()).Return(0, nil) + assert.NilError(t, run(t, backend)) + }) + + t.Run("a failing job reports its exit code", func(t *testing.T) { + backend := mocks.NewMockCompose(gomock.NewController(t)) + backend.EXPECT().RunJob(gomock.Any(), gomock.Any(), "migrate", gomock.Any()).Return(3, nil) + err := run(t, backend) + var statusErr cli.StatusError + assert.Assert(t, errors.As(err, &statusErr), err) + assert.Equal(t, statusErr.StatusCode, 3) + }) + + t.Run("an engine without the jobs API runs the job like a one-off service", func(t *testing.T) { + backend := mocks.NewMockCompose(gomock.NewController(t)) + backend.EXPECT().RunJob(gomock.Any(), gomock.Any(), "migrate", gomock.Any()).Return(0, api.ErrJobsNotSupported) + backend.EXPECT().RunOneOffContainer(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, project *types.Project, opts api.RunOptions) (int, error) { + assert.Equal(t, opts.Service, "migrate") + _, materialized := project.Services["migrate"] + assert.Assert(t, materialized, "the job must be materialized as a service") + return 0, nil + }) + assert.NilError(t, run(t, backend)) + }) +} diff --git a/pkg/api/errors.go b/pkg/api/errors.go index 7bf4d4a02c3..f86a7459b77 100644 --- a/pkg/api/errors.go +++ b/pkg/api/errors.go @@ -45,6 +45,8 @@ var ( ErrParsingFailed = errors.New("parsing failed") // ErrNoResources is returned when operation didn't selected any resource ErrNoResources = errors.New("no resources") + // ErrJobsNotSupported is returned when a job operation requires an engine exposing the jobs API + ErrJobsNotSupported = errors.New("jobs are not supported by the engine") ) // IsNotFoundError returns true if the unwrapped error is ErrNotFound diff --git a/pkg/compose/api_versions.go b/pkg/compose/api_versions.go index d39aef8aacd..248773fdf4b 100644 --- a/pkg/compose/api_versions.go +++ b/pkg/compose/api_versions.go @@ -48,6 +48,19 @@ const ( // - interface_name was not configurable // - ImageList didn't support platform filtering apiVersion149 = "1.49" + + // apiVersionJobs is the first Docker Engine API version exposing the jobs + // API (one-shot and scheduled jobs). + // + // Placeholder: the jobs API is still an experimental engine capability + // that is part of no released engine, so this is the first version after + // the latest one known to Compose. Adjust it to the real version once the + // engine ships it. + // + // Before this version: + // - Scheduled jobs cannot be registered, they are refused + // - Manual jobs run as one-off service containers + apiVersionJobs = "1.57" ) // Docker Engine version strings for user-facing error messages. diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 677f7d7e393..9bdccce1822 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -66,6 +66,9 @@ type createConfigs struct { func (s *composeService) Create(ctx context.Context, project *types.Project, options api.CreateOptions) error { return Run(ctx, func(ctx context.Context) error { + if err := s.rejectUnsupportedScheduledJobs(ctx, project); err != nil { + return err + } return s.create(ctx, project, options) }, "create", s.events) } diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index 61e408de9f7..8122a108097 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -73,6 +73,7 @@ func TestDown(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -295,6 +296,7 @@ func TestDownWithGivenServices(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -351,6 +353,7 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -388,6 +391,7 @@ func TestDownRemoveOrphans(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -457,6 +461,7 @@ func TestDownRemoveVolumes(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -611,6 +616,7 @@ func TestDownRemoveImages_NoLabel(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -690,6 +696,13 @@ func prepareMocks(mockCtrl *gomock.Controller) (*mocks.MockAPIClient, *mocks.Moc return api, cli } +// expectAPIVersion makes RuntimeAPIVersion resolve to version on the mock. +func expectAPIVersion(api *mocks.MockAPIClient, version string) { + api.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{APIVersion: version}, nil).AnyTimes() + api.EXPECT().ClientVersion().Return(version).AnyTimes() +} + // TestEnsureImagesDown_TaggedImageListingFailureStaysVisibleWithoutAbortingDown // guards the sibling fix to #14219 found during review: ImagesToPrune's own // listing call used to run synchronously inside ensureImagesDown and abort @@ -1265,6 +1278,7 @@ func TestDownRemovesRetainedPreStartHookContainers(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -1311,6 +1325,7 @@ func TestDownHookContainerRemovalFailureIsNonFatal(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) + expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index 00f234a5908..c4623cdc631 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -29,6 +29,7 @@ import ( "github.com/moby/moby/api/pkg/stdcopy" containerType "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" + "github.com/moby/moby/client/pkg/versions" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" @@ -59,6 +60,34 @@ func (s *composeService) jobsClient() (jobsv0.Jobs, error) { return s.jobsAPI, s.jobsAPIErr } +// jobsSupported reports whether the daemon exposes the jobs API, gated on the +// negotiated API version. Without it, jobs degrade to what Compose can do on +// its own: a manual job runs as a one-off service container, a scheduled job +// is refused (see rejectUnsupportedScheduledJobs). +func (s *composeService) jobsSupported(ctx context.Context) (bool, error) { + version, err := s.RuntimeAPIVersion(ctx) + if err != nil { + return false, err + } + return !versions.LessThan(version, apiVersionJobs), nil +} + +// rejectUnsupportedScheduledJobs refuses a project declaring active scheduled +// jobs when the engine cannot run them: silently not scheduling them would +// break the user's expectations, unlike manual jobs which simply wait for an +// explicit trigger. +func (s *composeService) rejectUnsupportedScheduledJobs(ctx context.Context, project *types.Project) error { + names := sortedJobNames(project.Jobs, HasSchedule) + if len(names) == 0 { + return nil + } + supported, err := s.jobsSupported(ctx) + if err != nil || supported { + return err + } + return fmt.Errorf("scheduled jobs are not supported in this version: %s", strings.Join(names, ", ")) +} + // engineJobNameSeparator joins a project and job name into the engine's // daemon-wide job name. The engine only accepts [a-zA-Z0-9][a-zA-Z0-9_.-]* for // job names, which rules out "/" as a separator (confirmed against a live @@ -358,11 +387,21 @@ func (s *composeService) RunJob(ctx context.Context, project *types.Project, nam if ManualTriggerDisabled(job) { return 0, ManualTriggerDisabledErr(name) } + supported, err := s.jobsSupported(ctx) + if err != nil { + return 0, err + } + if !supported { + return 0, api.ErrJobsNotSupported + } + if options.Name != "" { + logrus.Warnf("--name has no effect on a job: the engine names its run containers itself") + } // materializeManualJob already put the job into project.Services, so it // is seen by these project-wide normalization passes exactly like a // service would be (use_api_socket / models: support). - project, err := s.useAPISocket(project) + project, err = s.useAPISocket(project) if err != nil { return 0, err } @@ -556,6 +595,12 @@ func (s *composeService) streamJobLogs(ctx context.Context, containerID string, // already being gone, matching removeResource's NotFound handling. func (s *composeService) ensureJobsDown(ctx context.Context, project *types.Project) []downOp { names := sortedJobNames(project.Jobs, nil) + if len(names) == 0 { + return nil + } + if supported, err := s.jobsSupported(ctx); err != nil || !supported { + return nil + } var ops []downOp for _, name := range names { @@ -579,6 +624,9 @@ func (s *composeService) ensureJobsDown(ctx context.Context, project *types.Proj // Best-effort: any error, including an engine with no jobs feature, is // treated as "no jobs" so it never blocks an otherwise-successful down. func (s *composeService) actualJobs(ctx context.Context, projectName string) types.Jobs { + if supported, err := s.jobsSupported(ctx); err != nil || !supported { + return nil + } jc, err := s.jobsClient() if err != nil { return nil diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index 053cda6e35b..aedd2652910 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -21,11 +21,13 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/types" + "go.uber.org/mock/gomock" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "gotest.tools/v3/assert" jobsv0 "github.com/docker/compose/v5/internal/jobsapi" + "github.com/docker/compose/v5/pkg/api" ) func TestJobTrigger(t *testing.T) { @@ -347,3 +349,117 @@ func TestBuildJobSpecIsDeterministic(t *testing.T) { assert.Equal(t, string(spec.ContainerSpec), string(first.ContainerSpec), msg) } } + +// scheduledJobsProject declares one scheduled and one manual job next to a +// service, the shape the degraded-mode tests below run against. +func scheduledJobsProject() *types.Project { + yes := true + return &types.Project{ + Name: "prj", + Services: types.Services{"web": {Name: "web"}}, + Jobs: types.Jobs{ + "backup": {Name: "backup", Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}}, + "sync": {Name: "sync", Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 4 * * *"}}}}, + "migrate": {Name: "migrate", Triggers: &types.TriggerConfig{Manual: &yes}}, + }, + } +} + +// newJobsTestService returns a compose service whose engine negotiates +// apiVersion. The mocks carry no other expectation: any daemon call beyond +// the version negotiation fails the test. +func newJobsTestService(t *testing.T, apiVersion string) *composeService { + t.Helper() + mockCtrl := gomock.NewController(t) + apiClient, cli := prepareMocks(mockCtrl) + expectAPIVersion(apiClient, apiVersion) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + return tested.(*composeService) +} + +func TestJobsSupported(t *testing.T) { + for version, want := range map[string]bool{ + "1.44": false, + "1.56": false, + apiVersionJobs: true, + "1.96": true, + } { + t.Run(version, func(t *testing.T) { + supported, err := newJobsTestService(t, version).jobsSupported(t.Context()) + assert.NilError(t, err) + assert.Equal(t, supported, want) + }) + } +} + +func TestRejectUnsupportedScheduledJobs(t *testing.T) { + const noJobsEngine = "1.56" + + t.Run("an engine without the jobs API refuses scheduled jobs, naming them", func(t *testing.T) { + err := newJobsTestService(t, noJobsEngine).rejectUnsupportedScheduledJobs(t.Context(), scheduledJobsProject()) + assert.Error(t, err, "scheduled jobs are not supported in this version: backup, sync") + }) + + t.Run("an engine with the jobs API accepts them", func(t *testing.T) { + err := newJobsTestService(t, apiVersionJobs).rejectUnsupportedScheduledJobs(t.Context(), scheduledJobsProject()) + assert.NilError(t, err) + }) + + t.Run("manual jobs never block, whatever the engine", func(t *testing.T) { + project := scheduledJobsProject() + delete(project.Jobs, "backup") + delete(project.Jobs, "sync") + assert.NilError(t, newJobsTestService(t, noJobsEngine).rejectUnsupportedScheduledJobs(t.Context(), project)) + }) + + t.Run("profile-disabled scheduled jobs don't block", func(t *testing.T) { + project := &types.Project{DisabledJobs: scheduledJobsProject().Jobs} + assert.NilError(t, newJobsTestService(t, noJobsEngine).rejectUnsupportedScheduledJobs(t.Context(), project)) + }) + + t.Run("a project without jobs never negotiates the engine version", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + _, cli := prepareMocks(mockCtrl) // no Ping expectation: negotiating would fail + tested, err := NewComposeService(cli) + assert.NilError(t, err) + assert.NilError(t, tested.(*composeService).rejectUnsupportedScheduledJobs(t.Context(), &types.Project{Name: "prj"})) + }) +} + +// Without the jobs API, create, up and start refuse a project declaring +// scheduled jobs before touching the daemon: the mocks carry no expectation +// besides the version negotiation. +func TestScheduledJobsRefusedWithoutJobsEngine(t *testing.T) { + const want = "scheduled jobs are not supported in this version: backup, sync" + tested := newJobsTestService(t, "1.56") + project := scheduledJobsProject() + + assert.ErrorContains(t, tested.Create(t.Context(), project, api.CreateOptions{}), want) + assert.ErrorContains(t, tested.Up(t.Context(), project, api.UpOptions{}), want) + assert.ErrorContains(t, tested.Start(t.Context(), project.Name, api.StartOptions{Project: project}), want) +} + +// RunJob reports the missing engine support with a sentinel before doing any +// work, so the CLI can fall back to running the job like a one-off service. +func TestRunJobWithoutJobsEngine(t *testing.T) { + project := scheduledJobsProject() + _, err := newJobsTestService(t, "1.56").RunJob(t.Context(), project, "migrate", api.RunOptions{Service: "migrate"}) + assert.ErrorIs(t, err, api.ErrJobsNotSupported) +} + +// Down only talks to the engine's jobs registry when the engine has one. +func TestDownJobsWithoutJobsEngine(t *testing.T) { + project := scheduledJobsProject() + + t.Run("without the jobs API there is nothing to remove or list", func(t *testing.T) { + tested := newJobsTestService(t, "1.56") + assert.Equal(t, len(tested.ensureJobsDown(t.Context(), project)), 0) + assert.Equal(t, len(tested.actualJobs(t.Context(), project.Name)), 0) + }) + + t.Run("with the jobs API every declared job is removed", func(t *testing.T) { + tested := newJobsTestService(t, apiVersionJobs) + assert.Equal(t, len(tested.ensureJobsDown(t.Context(), project)), len(project.Jobs)) + }) +} diff --git a/pkg/compose/start.go b/pkg/compose/start.go index 64f50ee626f..296b1b34a86 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -29,6 +29,11 @@ import ( func (s *composeService) Start(ctx context.Context, projectName string, options api.StartOptions) error { return Run(ctx, func(ctx context.Context) error { + if options.Project != nil { + if err := s.rejectUnsupportedScheduledJobs(ctx, options.Project); err != nil { + return err + } + } return s.start(ctx, strings.ToLower(projectName), options, nil) }, "start", s.events) } diff --git a/pkg/compose/up.go b/pkg/compose/up.go index b88ba231bd9..122141cb594 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -42,6 +42,9 @@ import ( func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { + if err := s.rejectUnsupportedScheduledJobs(ctx, project); err != nil { + return err + } if options.Start.Attach == nil && !options.Start.NoStart { if err := s.upDetached(ctx, project, options); err != nil { return err diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 91bc0da50c4..2cc2c97e11a 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -23,11 +23,38 @@ import ( "time" ) +// jobsAPIVersion is the first engine API version exposing the jobs API, see +// apiVersionJobs in pkg/compose/api_versions.go, which it must follow. Below +// it Compose degrades: manual jobs run as one-off services and scheduled +// jobs are refused. +const jobsAPIVersion = "1.57" + +// Without the jobs API, scheduled jobs cannot run: silently not scheduling +// them would break the user's expectations, so every command bringing the +// project up must refuse it as a whole, before creating anything. +func TestUpRejectsScheduledJobs(t *testing.T) { + NewScenario(t, "up, create and start must reject a project declaring active scheduled jobs when the engine has no jobs API, before creating anything"). + Requires(EngineAPIVersionBelow(jobsAPIVersion)). + Step("up fails naming the scheduled job", + ComposeCmd("up", "-d").MayFail(), + StderrContains("scheduled jobs are not supported in this version: backup"), + ServiceNotCreated("web")). + Step("create fails the same way", + ComposeCmd("create").MayFail(), + StderrContains("scheduled jobs are not supported in this version: backup"), + ServiceNotCreated("web")). + Step("start fails the same way", + ComposeCmd("start").MayFail(), + StderrContains("scheduled jobs are not supported in this version: backup"), + ServiceNotCreated("web")) +} + // A scheduled job registers with the engine instead of being rejected: up is // safely re-runnable on an unchanged spec, and the schedule fires on the // engine's own clock, independent of the client. func TestUpRegistersScheduledJobs(t *testing.T) { NewScenario(t, "up must register a project's scheduled jobs with the engine and let them fire on their own"). + Requires(EngineAPIVersionAtLeast(jobsAPIVersion)). Step("up starts services and registers the scheduled job", ComposeCmd("up", "-d"), ServiceState("web", "running")). @@ -47,6 +74,7 @@ func TestUpRegistersScheduledJobs(t *testing.T) { // changed. func TestRunAlreadyRegisteredScheduledJob(t *testing.T) { NewScenario(t, "run must not conflict with a scheduled job up already registered with the identical spec"). + Requires(EngineAPIVersionAtLeast(jobsAPIVersion)). Step("up registers the scheduled job", ComposeCmd("up", "-d"), ServiceState("web", "running")). @@ -60,6 +88,7 @@ func TestRunAlreadyRegisteredScheduledJob(t *testing.T) { // entirely by calling Create directly, silently skipping registration. func TestUpNoStartRegistersScheduledJobs(t *testing.T) { NewScenario(t, "up --no-start must still register a project's scheduled jobs with the engine"). + Requires(EngineAPIVersionAtLeast(jobsAPIVersion)). Step("up --no-start creates but never starts web, yet the schedule still fires on its own", ComposeCmd("up", "--no-start"), ServiceState("web", "created"), diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go index cd2c3a145df..70cb2912ab8 100644 --- a/pkg/e2e/scenario.go +++ b/pkg/e2e/scenario.go @@ -28,6 +28,7 @@ import ( "time" "unicode" + "github.com/moby/moby/client/pkg/versions" "gotest.tools/v3/icmd" ) @@ -648,6 +649,36 @@ func ContainerdImageStore(t testing.TB, c *CLI) string { return "" } +// EngineAPIVersionAtLeast requires the daemon to expose at least the given API +// version, e.g. "1.57". +func EngineAPIVersionAtLeast(version string) Requirement { + return func(t testing.TB, c *CLI) string { + t.Helper() + if v := engineAPIVersion(t, c); versions.LessThan(v, version) { + return fmt.Sprintf("engine API version %s < %s", v, version) + } + return "" + } +} + +// EngineAPIVersionBelow requires the daemon to expose an API version older +// than the given one, for scenarios pinning the behavior of Compose on an +// engine that lacks a feature. +func EngineAPIVersionBelow(version string) Requirement { + return func(t testing.TB, c *CLI) string { + t.Helper() + if v := engineAPIVersion(t, c); !versions.LessThan(v, version) { + return fmt.Sprintf("engine API version %s >= %s", v, version) + } + return "" + } +} + +func engineAPIVersion(t testing.TB, c *CLI) string { + t.Helper() + return strings.TrimSpace(c.RunDockerCmd(t, "version", "-f", "{{.Server.APIVersion}}").Combined()) +} + // EngineVersionAtLeast requires a minimum daemon major version. func EngineVersionAtLeast(major int) Requirement { return func(t testing.TB, c *CLI) string { diff --git a/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml b/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml new file mode 100644 index 00000000000..5efd658bb6d --- /dev/null +++ b/pkg/e2e/testdata/TestUpRejectsScheduledJobs/compose.yaml @@ -0,0 +1,12 @@ +services: + web: + image: alpine + init: true + command: sleep infinity +jobs: + backup: + image: alpine + command: echo backup + triggers: + schedule: + - cron: "0 3 * * *" From ab5e00081b3a333ddde5a51788b8e27e8e2b020e Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 15 Oct 2026 10:34:23 +0200 Subject: [PATCH 18/19] jobs: align the e2e engine gates with the negotiated API version The e2e requirements read the daemon's API version, but Compose negotiates min(daemon, client max). With a daemon above the client library's max, the job registration scenarios would run against a degraded Compose and fail, and the degraded scenario would be skipped. Cap the version at client.MaxAPIVersion. Also drop the jobs gate in actualJobs, which already treats any error as "no jobs", and tidy the new unit tests. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Guillaume Lours --- cmd/compose/run_test.go | 18 ++++++++++-------- pkg/compose/down_test.go | 15 --------------- pkg/compose/jobs.go | 3 --- pkg/compose/jobs_test.go | 38 +++++++++++++++++++------------------- pkg/e2e/jobs_test.go | 10 +++++++--- pkg/e2e/scenario.go | 20 ++++++++++++++------ 6 files changed, 50 insertions(+), 54 deletions(-) diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index 1b883975241..12c9357e922 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -233,31 +233,33 @@ func TestRunRunJob(t *testing.T) { }}, } } - run := func(t *testing.T, backend api.Compose) error { + run := func(t *testing.T, ctrl *gomock.Controller, backend api.Compose) error { t.Helper() - ctrl := gomock.NewController(t) return runRun(t.Context(), backend, newProject(), runOptions{composeOptions: &composeOptions{ProjectOptions: &ProjectOptions{}}, Service: "migrate", noTty: true, capAdd: cliopts.NewListOpts(nil), capDrop: cliopts.NewListOpts(nil)}, createOptions{}, buildOptions{ProjectOptions: &ProjectOptions{}}, mocks.NewMockCli(ctrl)) } t.Run("an engine with the jobs API runs the job through it", func(t *testing.T) { - backend := mocks.NewMockCompose(gomock.NewController(t)) + ctrl := gomock.NewController(t) + backend := mocks.NewMockCompose(ctrl) backend.EXPECT().RunJob(gomock.Any(), gomock.Any(), "migrate", gomock.Any()).Return(0, nil) - assert.NilError(t, run(t, backend)) + assert.NilError(t, run(t, ctrl, backend)) }) t.Run("a failing job reports its exit code", func(t *testing.T) { - backend := mocks.NewMockCompose(gomock.NewController(t)) + ctrl := gomock.NewController(t) + backend := mocks.NewMockCompose(ctrl) backend.EXPECT().RunJob(gomock.Any(), gomock.Any(), "migrate", gomock.Any()).Return(3, nil) - err := run(t, backend) + err := run(t, ctrl, backend) var statusErr cli.StatusError assert.Assert(t, errors.As(err, &statusErr), err) assert.Equal(t, statusErr.StatusCode, 3) }) t.Run("an engine without the jobs API runs the job like a one-off service", func(t *testing.T) { - backend := mocks.NewMockCompose(gomock.NewController(t)) + ctrl := gomock.NewController(t) + backend := mocks.NewMockCompose(ctrl) backend.EXPECT().RunJob(gomock.Any(), gomock.Any(), "migrate", gomock.Any()).Return(0, api.ErrJobsNotSupported) backend.EXPECT().RunOneOffContainer(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, project *types.Project, opts api.RunOptions) (int, error) { @@ -266,6 +268,6 @@ func TestRunRunJob(t *testing.T) { assert.Assert(t, materialized, "the job must be materialized as a service") return 0, nil }) - assert.NilError(t, run(t, backend)) + assert.NilError(t, run(t, ctrl, backend)) }) } diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index 8122a108097..61e408de9f7 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -73,7 +73,6 @@ func TestDown(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -296,7 +295,6 @@ func TestDownWithGivenServices(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -353,7 +351,6 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -391,7 +388,6 @@ func TestDownRemoveOrphans(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -461,7 +457,6 @@ func TestDownRemoveVolumes(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -616,7 +611,6 @@ func TestDownRemoveImages_NoLabel(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -696,13 +690,6 @@ func prepareMocks(mockCtrl *gomock.Controller) (*mocks.MockAPIClient, *mocks.Moc return api, cli } -// expectAPIVersion makes RuntimeAPIVersion resolve to version on the mock. -func expectAPIVersion(api *mocks.MockAPIClient, version string) { - api.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). - Return(client.PingResult{APIVersion: version}, nil).AnyTimes() - api.EXPECT().ClientVersion().Return(version).AnyTimes() -} - // TestEnsureImagesDown_TaggedImageListingFailureStaysVisibleWithoutAbortingDown // guards the sibling fix to #14219 found during review: ImagesToPrune's own // listing call used to run synchronously inside ensureImagesDown and abort @@ -1278,7 +1265,6 @@ func TestDownRemovesRetainedPreStartHookContainers(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) @@ -1325,7 +1311,6 @@ func TestDownHookContainerRemovalFailureIsNonFatal(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - expectAPIVersion(api, apiVersionJobs) tested, err := NewComposeService(cli) assert.NilError(t, err) diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index c4623cdc631..f6fd4ae0602 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -624,9 +624,6 @@ func (s *composeService) ensureJobsDown(ctx context.Context, project *types.Proj // Best-effort: any error, including an engine with no jobs feature, is // treated as "no jobs" so it never blocks an otherwise-successful down. func (s *composeService) actualJobs(ctx context.Context, projectName string) types.Jobs { - if supported, err := s.jobsSupported(ctx); err != nil || !supported { - return nil - } jc, err := s.jobsClient() if err != nil { return nil diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index aedd2652910..ba0534636a1 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -370,18 +370,22 @@ func scheduledJobsProject() *types.Project { // the version negotiation fails the test. func newJobsTestService(t *testing.T, apiVersion string) *composeService { t.Helper() - mockCtrl := gomock.NewController(t) - apiClient, cli := prepareMocks(mockCtrl) - expectAPIVersion(apiClient, apiVersion) - tested, err := NewComposeService(cli) - assert.NilError(t, err) - return tested.(*composeService) + _, tested := newTestComposeService(t, gomock.NewController(t), apiVersion) + return tested } +const ( + // noJobsEngine is the newest API version below apiVersionJobs. + noJobsEngine = "1.56" + // scheduledJobsRefused is what a project declaring the scheduled jobs of + // scheduledJobsProject gets on an engine without the jobs API. + scheduledJobsRefused = "scheduled jobs are not supported in this version: backup, sync" +) + func TestJobsSupported(t *testing.T) { for version, want := range map[string]bool{ "1.44": false, - "1.56": false, + noJobsEngine: false, apiVersionJobs: true, "1.96": true, } { @@ -394,11 +398,9 @@ func TestJobsSupported(t *testing.T) { } func TestRejectUnsupportedScheduledJobs(t *testing.T) { - const noJobsEngine = "1.56" - t.Run("an engine without the jobs API refuses scheduled jobs, naming them", func(t *testing.T) { err := newJobsTestService(t, noJobsEngine).rejectUnsupportedScheduledJobs(t.Context(), scheduledJobsProject()) - assert.Error(t, err, "scheduled jobs are not supported in this version: backup, sync") + assert.Error(t, err, scheduledJobsRefused) }) t.Run("an engine with the jobs API accepts them", func(t *testing.T) { @@ -431,20 +433,19 @@ func TestRejectUnsupportedScheduledJobs(t *testing.T) { // scheduled jobs before touching the daemon: the mocks carry no expectation // besides the version negotiation. func TestScheduledJobsRefusedWithoutJobsEngine(t *testing.T) { - const want = "scheduled jobs are not supported in this version: backup, sync" - tested := newJobsTestService(t, "1.56") + tested := newJobsTestService(t, noJobsEngine) project := scheduledJobsProject() - assert.ErrorContains(t, tested.Create(t.Context(), project, api.CreateOptions{}), want) - assert.ErrorContains(t, tested.Up(t.Context(), project, api.UpOptions{}), want) - assert.ErrorContains(t, tested.Start(t.Context(), project.Name, api.StartOptions{Project: project}), want) + assert.ErrorContains(t, tested.Create(t.Context(), project, api.CreateOptions{}), scheduledJobsRefused) + assert.ErrorContains(t, tested.Up(t.Context(), project, api.UpOptions{}), scheduledJobsRefused) + assert.ErrorContains(t, tested.Start(t.Context(), project.Name, api.StartOptions{Project: project}), scheduledJobsRefused) } // RunJob reports the missing engine support with a sentinel before doing any // work, so the CLI can fall back to running the job like a one-off service. func TestRunJobWithoutJobsEngine(t *testing.T) { project := scheduledJobsProject() - _, err := newJobsTestService(t, "1.56").RunJob(t.Context(), project, "migrate", api.RunOptions{Service: "migrate"}) + _, err := newJobsTestService(t, noJobsEngine).RunJob(t.Context(), project, "migrate", api.RunOptions{Service: "migrate"}) assert.ErrorIs(t, err, api.ErrJobsNotSupported) } @@ -452,10 +453,9 @@ func TestRunJobWithoutJobsEngine(t *testing.T) { func TestDownJobsWithoutJobsEngine(t *testing.T) { project := scheduledJobsProject() - t.Run("without the jobs API there is nothing to remove or list", func(t *testing.T) { - tested := newJobsTestService(t, "1.56") + t.Run("without the jobs API there is nothing to remove", func(t *testing.T) { + tested := newJobsTestService(t, noJobsEngine) assert.Equal(t, len(tested.ensureJobsDown(t.Context(), project)), 0) - assert.Equal(t, len(tested.actualJobs(t.Context(), project.Name)), 0) }) t.Run("with the jobs API every declared job is removed", func(t *testing.T) { diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 2cc2c97e11a..16ba93d4eb8 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -29,6 +29,10 @@ import ( // jobs are refused. const jobsAPIVersion = "1.57" +// scheduledJobsRefused is the error of an engine without the jobs API refusing +// the project of TestUpRejectsScheduledJobs. +const scheduledJobsRefused = "scheduled jobs are not supported in this version: backup" + // Without the jobs API, scheduled jobs cannot run: silently not scheduling // them would break the user's expectations, so every command bringing the // project up must refuse it as a whole, before creating anything. @@ -37,15 +41,15 @@ func TestUpRejectsScheduledJobs(t *testing.T) { Requires(EngineAPIVersionBelow(jobsAPIVersion)). Step("up fails naming the scheduled job", ComposeCmd("up", "-d").MayFail(), - StderrContains("scheduled jobs are not supported in this version: backup"), + StderrContains(scheduledJobsRefused), ServiceNotCreated("web")). Step("create fails the same way", ComposeCmd("create").MayFail(), - StderrContains("scheduled jobs are not supported in this version: backup"), + StderrContains(scheduledJobsRefused), ServiceNotCreated("web")). Step("start fails the same way", ComposeCmd("start").MayFail(), - StderrContains("scheduled jobs are not supported in this version: backup"), + StderrContains(scheduledJobsRefused), ServiceNotCreated("web")) } diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go index 70cb2912ab8..87afe614050 100644 --- a/pkg/e2e/scenario.go +++ b/pkg/e2e/scenario.go @@ -28,6 +28,7 @@ import ( "time" "unicode" + "github.com/moby/moby/client" "github.com/moby/moby/client/pkg/versions" "gotest.tools/v3/icmd" ) @@ -649,8 +650,8 @@ func ContainerdImageStore(t testing.TB, c *CLI) string { return "" } -// EngineAPIVersionAtLeast requires the daemon to expose at least the given API -// version, e.g. "1.57". +// EngineAPIVersionAtLeast requires the API version Compose negotiates with the +// daemon to be at least the given one, e.g. "1.57". func EngineAPIVersionAtLeast(version string) Requirement { return func(t testing.TB, c *CLI) string { t.Helper() @@ -661,9 +662,9 @@ func EngineAPIVersionAtLeast(version string) Requirement { } } -// EngineAPIVersionBelow requires the daemon to expose an API version older -// than the given one, for scenarios pinning the behavior of Compose on an -// engine that lacks a feature. +// EngineAPIVersionBelow requires the API version Compose negotiates with the +// daemon to be older than the given one, for scenarios pinning the behavior of +// Compose on an engine that lacks a feature. func EngineAPIVersionBelow(version string) Requirement { return func(t testing.TB, c *CLI) string { t.Helper() @@ -674,9 +675,16 @@ func EngineAPIVersionBelow(version string) Requirement { } } +// engineAPIVersion returns the API version Compose negotiates with the daemon: +// the daemon's, capped at what the client library Compose is built with +// supports. func engineAPIVersion(t testing.TB, c *CLI) string { t.Helper() - return strings.TrimSpace(c.RunDockerCmd(t, "version", "-f", "{{.Server.APIVersion}}").Combined()) + v := strings.TrimSpace(c.RunDockerCmd(t, "version", "-f", "{{.Server.APIVersion}}").Combined()) + if versions.LessThan(client.MaxAPIVersion, v) { + return client.MaxAPIVersion + } + return v } // EngineVersionAtLeast requires a minimum daemon major version. From b8734e7f0757d7ebc0f3806fb3abd6eaee974108 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 16 Oct 2026 15:58:45 +0200 Subject: [PATCH 19/19] jobs: give a scheduled job's scoped service its own mutable fields scopedProjectForJob copied the Environment, CustomLabels, Volumes and Configs of every carried-over service, but not those of the job it materializes: JobAsService copies the job's ContainerSpec by value, so the maps and slices stayed shared with project.Jobs. The normalization passes run on the scoped project (useAPISocket, ensureModels, ensureImagesExists) mutate them in place, which leaked into the real project. Extract the per-service copy and apply it to the job's service too. Signed-off-by: Guillaume Lours Co-Authored-By: Claude Sonnet 5.5 --- pkg/compose/jobs.go | 40 +++++++++++++++++++++++----------------- pkg/compose/jobs_test.go | 26 +++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index f6fd4ae0602..53597e73a1d 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -296,24 +296,9 @@ func scopedProjectForJob(project *types.Project, name string, job types.JobConfi scoped := *project services := make(types.Services, len(project.Services)+1) for n, svc := range project.Services { - env := make(types.MappingWithEquals, len(svc.Environment)) - for k, v := range svc.Environment { - env[k] = v - } - svc.Environment = env - - labels := make(types.Labels, len(svc.CustomLabels)) - for k, v := range svc.CustomLabels { - labels[k] = v - } - svc.CustomLabels = labels - - svc.Volumes = append([]types.ServiceVolumeConfig{}, svc.Volumes...) - svc.Configs = append([]types.ServiceConfigObjConfig{}, svc.Configs...) - - services[n] = svc + services[n] = withOwnMutableFields(svc) } - services[name] = JobAsService(project, name, job) + services[name] = withOwnMutableFields(JobAsService(project, name, job)) scoped.Services = services configs := make(types.Configs, len(project.Configs)) @@ -324,6 +309,27 @@ func scopedProjectForJob(project *types.Project, name string, job types.JobConfi return &scoped } +// withOwnMutableFields returns svc with its own copy of the fields the +// normalization passes mutate in place, so they never write through to the +// ServiceConfig (or JobConfig) svc was copied from. +func withOwnMutableFields(svc types.ServiceConfig) types.ServiceConfig { + env := make(types.MappingWithEquals, len(svc.Environment)) + for k, v := range svc.Environment { + env[k] = v + } + svc.Environment = env + + labels := make(types.Labels, len(svc.CustomLabels)) + for k, v := range svc.CustomLabels { + labels[k] = v + } + svc.CustomLabels = labels + + svc.Volumes = append([]types.ServiceVolumeConfig{}, svc.Volumes...) + svc.Configs = append([]types.ServiceConfigObjConfig{}, svc.Configs...) + return svc +} + // registerScheduledJobs registers the project's scheduled jobs with the // engine. Create is idempotent on SpecHash: re-applying the same spec on a // later `up` is a no-op, which is what makes `up` safely re-runnable. diff --git a/pkg/compose/jobs_test.go b/pkg/compose/jobs_test.go index ba0534636a1..e06d2843ab4 100644 --- a/pkg/compose/jobs_test.go +++ b/pkg/compose/jobs_test.go @@ -148,9 +148,14 @@ func TestScopedProjectForJob(t *testing.T) { }, Jobs: types.Jobs{ "backup": { - Name: "backup", - Extensions: types.Extensions{"x-team": "platform"}, - ContainerSpec: types.ContainerSpec{Image: "backup-tool"}, + Name: "backup", + Extensions: types.Extensions{"x-team": "platform"}, + ContainerSpec: types.ContainerSpec{ + Image: "backup-tool", + Environment: types.MappingWithEquals{"BAR": strPtr("original")}, + Volumes: []types.ServiceVolumeConfig{{Type: types.VolumeTypeImage, Source: "original", Target: "/data"}}, + Configs: []types.ServiceConfigObjConfig{{Source: "original"}}, + }, }, }, Configs: types.Configs{ @@ -204,6 +209,21 @@ func TestScopedProjectForJob(t *testing.T) { _, ok := project.Services["db"].CustomLabels["new"] assert.Assert(t, !ok, "ensureImagesExists writes into a job's scoped CustomLabels map (via Labels.Add) concurrently with other jobs' registration — it must not be the shared project's map") }) + + t.Run("the job's own Environment, Volumes and Configs are not shared with project.Jobs", func(t *testing.T) { + svc, err := scoped.GetService("backup") + assert.NilError(t, err) + // useAPISocket, ensureModels and ensureImagesExists mutate these in + // place on the scoped job service: project.Jobs must stay untouched. + svc.Environment["DOCKER_CONFIG"] = strPtr("added") + svc.Volumes[0].Source = "mutated" + svc.Configs[0].Source = "mutated" + original := project.Jobs["backup"] + _, ok := original.Environment["DOCKER_CONFIG"] + assert.Assert(t, !ok, "writing to the scoped job's Environment must not be visible on project.Jobs") + assert.Equal(t, original.Volumes[0].Source, "original") + assert.Equal(t, original.Configs[0].Source, "original") + }) } // fakeJobsClient is a minimal jobsv0.Jobs double for createJobRun's routing: