From 3fc5fff06db47a14b80856d9d4a22f186d9f2bd7 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:42:24 +0200 Subject: [PATCH 01/17] build(deps): pin compose-go to the jobs branch (compose-spec/compose-go#866) Signed-off-by: Nicolas De Loof --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 96fb967f96..f5bad31771 100644 --- a/go.mod +++ b/go.mod @@ -135,3 +135,5 @@ exclude ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 ) + +replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.1-0.20260819150359-59f9907c1949 diff --git a/go.sum b/go.sum index 1d9c2d48b7..909ac9952c 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,6 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf h1:ot3ZuEYubwVMNT5sO8/5MONyTkGi+GNxezgUT2Wwf9E= -github.com/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ= github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= @@ -175,6 +173,8 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ndeloof/compose-go/v2 v2.0.1-0.20260819150359-59f9907c1949 h1:4v++96bXaWqscXzJS20N1zT3YBf1Xf+aOj0iPE4bRh0= +github.com/ndeloof/compose-go/v2 v2.0.1-0.20260819150359-59f9907c1949/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= From 60f0244c16b733a38fa5e298a22d4918fc7cf775 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:44:10 +0200 Subject: [PATCH 02/17] 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 5b39a33593..c14ec70979 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 90967c00d5440a7df3c551e6162f9e52025fdd06 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 15:54:52 +0200 Subject: [PATCH 03/17] 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 +++++ cmd/compose/up.go | 2 +- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index c14ec70979..5b39a33593 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 852b48658b..1ff62e05bc 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -277,6 +277,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 diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 3716b5c8be..0c3325dde2 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -359,7 +359,7 @@ func runUp( // warnIgnoredJobs names the declared jobs up will not act on: manual jobs // wait for an explicit `compose run ` trigger. func warnIgnoredJobs(project *types.Project) { - jobs := project.Jobs + jobs := project.AllJobs() if len(jobs) == 0 { return } From 52b697a473dee75b2d5b75e8967370861a91b94e Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 27 Aug 2026 14:05:08 +0200 Subject: [PATCH 04/17] 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/run.go | 15 + cmd/compose/up.go | 35 +- cmd/compose/up_test.go | 18 +- go.mod | 4 +- go.sum | 4 + 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 | 11 + pkg/compose/create.go | 16 +- pkg/compose/down.go | 5 + pkg/compose/down_test.go | 14 + pkg/compose/jobs.go | 398 +++ pkg/compose/observed_state.go | 14 +- pkg/compose/up.go | 3 + pkg/e2e/jobs_test.go | 21 +- .../TestUpRegistersScheduledJobs/compose.yaml | 15 + .../TestUpRejectsScheduledJobs/compose.yaml | 12 - pkg/mocks/mock_docker_compose_api.go | 15 + 22 files changed, 4540 insertions(+), 66 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/run.go b/cmd/compose/run.go index 1ff62e05bc..933606ed3d 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -434,6 +434,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/up.go b/cmd/compose/up.go index 0c3325dde2..e21cb58f08 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.AllJobs() - 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.AllJobs() + names := make([]string, 0, len(jobs)) + for name, job := range jobs { + if job.Triggers != 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 98ac8949db..1f8f6449ea 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -180,20 +180,18 @@ services: assert.Assert(t, !strings.Contains(fmt.Sprint(err), "invalid ip address"), fmt.Sprint(err)) } -func TestRejectScheduledJobs(t *testing.T) { - yes := true - manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: &yes}} +func TestManualJobNames(t *testing.T) { + manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: true}} 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 diff --git a/go.mod b/go.mod index f5bad31771..5f20fa20b1 100644 --- a/go.mod +++ b/go.mod @@ -23,10 +23,12 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.9.0 + github.com/jonboulle/clockwork v0.5.0 github.com/mattn/go-runewidth v0.0.30 github.com/mattn/go-shellwords v1.0.15 github.com/mitchellh/go-ps v1.0.0 github.com/moby/buildkit v0.33.0 + github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f github.com/moby/go-archive v0.3.3 github.com/moby/moby/api v1.56.0 github.com/moby/moby/client v0.6.0 @@ -54,6 +56,7 @@ require ( golang.org/x/sync v0.23.0 golang.org/x/sys v0.48.0 google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.1 ) @@ -123,7 +126,6 @@ require ( golang.org/x/time v0.15.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 909ac9952c..bba3df48c2 100644 --- a/go.sum +++ b/go.sum @@ -125,6 +125,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= @@ -137,6 +139,8 @@ github.com/moby/buildkit v0.33.0 h1:zBbt1FiMcTB/oFg1iCNcKa83k5Rn8MGcVjXFIcfYhuQ= github.com/moby/buildkit v0.33.0/go.mod h1:uNKSZnfMk1aSa18JiCR5BT68M/3jIDGo6XuAByUK3ek= 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 0000000000..4069abeee7 --- /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 0000000000..eb23fc1790 --- /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 0000000000..46b4129a3c --- /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 0000000000..1336439855 --- /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 9966736b3a..81970d26e1 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -128,6 +128,14 @@ type Compose interface { Kill(ctx context.Context, projectName string, options KillOptions) error // 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) // Remove executes the equivalent to a `compose rm` Remove(ctx context.Context, projectName string, options RemoveOptions) error // Exec executes a command in a running service container diff --git a/pkg/api/labels.go b/pkg/api/labels.go index 19b4e1bd3c..201a181721 100644 --- a/pkg/api/labels.go +++ b/pkg/api/labels.go @@ -73,6 +73,8 @@ const ( // runPreStartHook so orphan hook containers from a previous failed run can // be found and removed by project+service+hook label filters. HookLabel = "com.docker.compose.hook" + // 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 7f39461ed2..d884856bcc 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -30,11 +30,14 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/flags" "github.com/docker/cli/cli/streams" + "github.com/jonboulle/clockwork" + 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" "github.com/sirupsen/logrus" + jobsv0 "github.com/docker/compose/v5/internal/jobsapi" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/dryrun" ) @@ -213,6 +216,11 @@ type composeService struct { dryRun bool runtimeAPIVersion runtimeVersionCache + + jobsGRPCOnce sync.Once + jobsExtClient *extensionclient.Client + jobsAPI jobsv0.Jobs + jobsAPIErr error } // Close releases any connections/resources held by the underlying clients. @@ -224,6 +232,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 b5496a68e9..8520202eb2 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -117,11 +117,22 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt 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, ", ")) } plan, err := reconcile(ctx, project, observed, toReconcileOptions(options), s.prompt) @@ -275,6 +286,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 b4bbc6e3cf..1d6903e0e0 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -68,6 +68,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 @@ -115,6 +119,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a } ops := s.ensureNetworksDown(ctx, project) + ops = append(ops, s.ensureJobsDown(ctx, project)...) if options.Images != "" { imgOps, err := s.ensureImagesDown(ctx, project, options) diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index 06817b8da1..14204dd14a 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -17,8 +17,10 @@ package compose import ( + "context" "errors" "fmt" + "net" "os" "strings" "testing" @@ -38,6 +40,14 @@ import ( "github.com/docker/compose/v5/pkg/mocks" ) +// 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() @@ -465,6 +475,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 0000000000..e743b44997 --- /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: + 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 { + 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); 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 25d0e3da6c..a4df46a72e 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 @@ -394,15 +397,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 8aed083603..2e052ca671 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -48,6 +48,9 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options if err != nil { return err } + if err := s.registerScheduledJobs(ctx, project); err != nil { + return err + } if options.Start.Attach == nil { return s.start(ctx, project.Name, options.Start, nil) } diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index b64f1700bd..91eaa217c2 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 0000000000..48e5d07b69 --- /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 5efd658bb6..0000000000 --- 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 664de10609..799334a167 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() From 05531cbbc2aa463277cb0b990e8378d0c9fd81fe Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Thu, 17 Sep 2026 19:52:07 +0200 Subject: [PATCH 05/17] build(deps): replace the compose-go jobs fork with upstream main The pinned ndeloof/compose-go fork fell behind upstream/main and broke the build after rebasing: it predates the unsupported-attributes warning and WithoutUnresolvedOptionalDependencies added there since. compose-go's main has since merged jobs support too, unreleased, so depend on that commit directly instead of a divergent fork. Pinned to 11feead, just before a later, unrelated commit changes type=image volume source resolution. Adapts to type changes bundled with jobs support: TriggerConfig.Manual is now *bool, and DependsOn/Networks/Image moved into ContainerSpec/WorkloadSpec. Signed-off-by: Guillaume Lours --- cmd/compose/create.go | 5 ----- cmd/compose/start.go | 6 ------ cmd/compose/up.go | 2 +- cmd/compose/up_test.go | 3 ++- go.mod | 3 --- go.sum | 6 ++---- pkg/compose/compose.go | 1 - pkg/compose/jobs.go | 4 ++-- 8 files changed, 7 insertions(+), 23 deletions(-) diff --git a/cmd/compose/create.go b/cmd/compose/create.go index 211ec2baec..f839ec92d8 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/start.go b/cmd/compose/start.go index f3f69cec34..062efb680d 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 e21cb58f08..337e5127da 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -369,7 +369,7 @@ func manualJobNames(project *types.Project) []string { jobs := project.AllJobs() names := make([]string, 0, len(jobs)) for name, job := range jobs { - if job.Triggers != nil && job.Triggers.Manual { + if job.Triggers != nil && job.Triggers.Manual != nil && *job.Triggers.Manual { names = append(names, name) } } diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go index 1f8f6449ea..f869a85f4d 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -181,7 +181,8 @@ services: } func TestManualJobNames(t *testing.T) { - manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: true}} + manualTrigger := true + manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: &manualTrigger}} scheduled := types.JobConfig{Triggers: &types.TriggerConfig{ Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}, }} diff --git a/go.mod b/go.mod index 5f20fa20b1..a48097e5f4 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,6 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.9.0 - github.com/jonboulle/clockwork v0.5.0 github.com/mattn/go-runewidth v0.0.30 github.com/mattn/go-shellwords v1.0.15 github.com/mitchellh/go-ps v1.0.0 @@ -137,5 +136,3 @@ exclude ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 ) - -replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.1-0.20260819150359-59f9907c1949 diff --git a/go.sum b/go.sum index bba3df48c2..644869896a 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf h1:ot3ZuEYubwVMNT5sO8/5MONyTkGi+GNxezgUT2Wwf9E= +github.com/compose-spec/compose-go/v2 v2.15.1-0.20260918184426-f18e211cbeaf/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ= github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= @@ -125,8 +127,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= @@ -177,8 +177,6 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ndeloof/compose-go/v2 v2.0.1-0.20260819150359-59f9907c1949 h1:4v++96bXaWqscXzJS20N1zT3YBf1Xf+aOj0iPE4bRh0= -github.com/ndeloof/compose-go/v2 v2.0.1-0.20260819150359-59f9907c1949/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index d884856bcc..2c1d718b6a 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -30,7 +30,6 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/flags" "github.com/docker/cli/cli/streams" - "github.com/jonboulle/clockwork" extensionclient "github.com/moby/extensions/client" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/swarm" diff --git a/pkg/compose/jobs.go b/pkg/compose/jobs.go index e743b44997..f96ea689b2 100644 --- a/pkg/compose/jobs.go +++ b/pkg/compose/jobs.go @@ -83,7 +83,7 @@ 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: + 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] @@ -223,7 +223,7 @@ 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 { + 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) } From 76b099d92342fbf6972f119d49fd3201f629b207 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:07:40 +0200 Subject: [PATCH 06/17] 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 933606ed3d..bbc648958b 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -486,8 +486,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 { @@ -523,7 +523,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 337e5127da..1c29c605b0 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.AllJobs() - 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 f96ea689b2..7fee7fdf30 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); 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 0000000000..b9ffe41923 --- /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 9fc55821a1e41be8f3fbc5675cdf2d20bf7f6966 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:08:15 +0200 Subject: [PATCH 07/17] 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 bbc648958b..6a92a5939f 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -277,11 +277,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 f8e0bf9b4d6d95e56704e2d813a6289037f71253 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:09:35 +0200 Subject: [PATCH 08/17] 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 5b39a33593..cf91b42243 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 f839ec92d8..d8dc01b817 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 91eaa217c2..11ff790998 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 1ec3c14a5f7fd537711385f3413e505db8088858 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:10:36 +0200 Subject: [PATCH 09/17] 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 7fee7fdf30..ea8b6d9c7b 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 b9ffe41923..265a1bae64 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 117d6a26cb08f29829f06d59a4aa190c3f3d403d Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:11:15 +0200 Subject: [PATCH 10/17] 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 ea8b6d9c7b..eec37f5820 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 0d412deabbfd01dc01f8cad3ab3577ec23cff764 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:48:33 +0200 Subject: [PATCH 11/17] 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 eec37f5820..b555c0d92e 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); 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 265a1bae64..d396fe61ab 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 38bf99741192cdc94976c51db53956fc07e27539 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:48:47 +0200 Subject: [PATCH 12/17] 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 | 3 +++ pkg/e2e/jobs_test.go | 11 +++++++++++ .../compose.yaml | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 pkg/e2e/testdata/TestUpNoStartRegistersScheduledJobs/compose.yaml diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 1c29c605b0..7bc0bd0370 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 81970d26e1..a70574d403 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -373,6 +373,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 2e052ca671..03b9a1c691 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -51,6 +51,9 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options if err := s.registerScheduledJobs(ctx, project); err != nil { return err } + if options.Start.NoStart { + return nil + } if options.Start.Attach == nil { return s.start(ctx, project.Name, options.Start, nil) } diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 11ff790998..afd1e9905a 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 0000000000..48e5d07b69 --- /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 11f047c8aa2b4926aae910fdced3efd225058f0e Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:48:56 +0200 Subject: [PATCH 13/17] 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 0000000000..7cbebdadeb --- /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 120c77e08ff5948a3a0387032a96e19082072584 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 14:50:33 +0200 Subject: [PATCH 14/17] 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 3dc9536775..2a83fbb904 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 4710d94f1c..a279912a9b 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 178dbab584..7ddb755ea3 100644 --- a/cmd/compose/push.go +++ b/cmd/compose/push.go @@ -62,7 +62,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 6a92a5939f..913057c581 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -364,6 +364,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 2560c4da95e57fd9db5d04a48c7de2de2e157f70 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 22 Sep 2026 15:13:30 +0200 Subject: [PATCH 15/17] 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 b555c0d92e..f34b888411 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 d396fe61ab..409c22d018 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 afd1e9905a..52fe8d921c 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 0000000000..1e0ae18287 --- /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 c622f990fc48dc722a36722ca82060690d1e02f0 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 23 Sep 2026 10:52:25 +0200 Subject: [PATCH 16/17] 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 913057c581..330bfa6705 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -508,7 +508,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 } @@ -535,33 +535,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 f34b888411..6ca4e2cf1d 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 409c22d018..e2b716937d 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 1f233fe32f..d780ba30df 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -138,6 +138,9 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, 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 { @@ -223,11 +226,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 1e0ae18287..0b5c34e3b5 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 79718cc25c656ad955574588153127980f8140ae Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 23 Sep 2026 16:06:54 +0200 Subject: [PATCH 17/17] 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 330bfa6705..98351ff657 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -450,7 +450,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 6ca4e2cf1d..51f96b8a34 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 }