Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3fc5fff
build(deps): pin compose-go to the jobs branch (compose-spec/compose-…
ndeloof Aug 19, 2026
60f0244
jobs: warn and ignore, reject active scheduled jobs on up
ndeloof Aug 19, 2026
90967c0
run: execute manual-trigger jobs exactly like services
ndeloof Aug 19, 2026
52b697a
jobs: wire up/run/down to the engine's jobs gRPC API
glours Aug 27, 2026
05531cb
build(deps): replace the compose-go jobs fork with upstream main
glours Sep 17, 2026
76b099d
jobs: extract shared manual/schedule predicates
glours Sep 22, 2026
9fc5582
run: drop a reload in runProject that bypassed materializeManualJob
glours Sep 22, 2026
f8e0bf9
cmd: centralize the job-target error translation in WithServices
glours Sep 22, 2026
1ec3c14
jobs: reject a job declaring both manual:true and a schedule
glours Sep 22, 2026
117d6a2
jobs: resolve sibling service references before running a job manually
glours Sep 22, 2026
0d412de
jobs: run scheduled-job registration through the same build/socket/mo…
glours Sep 22, 2026
38bf997
up: give Up a NoStart switch instead of --no-start bypassing it
glours Sep 22, 2026
11f047c
e2e: add the missing testdata fixture for TestUpRefusesJob
glours Sep 22, 2026
120c77e
cmd: give build/pull/push the same job-target error as other commands
glours Sep 22, 2026
2560c4d
jobs: route run's engine call on the job's declared trigger
glours Sep 22, 2026
c622f99
jobs: fix scheduled-job registration races and run's tty handling
glours Sep 23, 2026
79718cc
jobs: guard against nil runs and a not-yet-created container
glours Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/compose/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
8 changes: 8 additions & 0 deletions cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
21 changes: 3 additions & 18 deletions cmd/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand All @@ -108,11 +98,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
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/compose/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 31 additions & 31 deletions cmd/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -429,6 +440,21 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op
Index: 0,
}

if _, ok := project.AllJobs()[options.Service]; ok {
if options.name != "" {
logrus.Warnf("--name has no effect on a job: the engine names its run containers itself")
}
exitCode, err := backend.RunJob(ctx, project, options.Service, runOpts)
if exitCode != 0 {
errMsg := ""
if err != nil {
errMsg = err.Error()
}
return cli.StatusError{StatusCode: exitCode, Status: errMsg, Cause: err}
}
return err
}

for name, service := range project.Services {
if name == options.Service {
service.StdinOpen = options.interactive
Expand Down Expand Up @@ -466,8 +492,8 @@ func materializeManualJob(project *types.Project, name string) (*types.Project,
if !ok {
return project, nil
}
if job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual {
return nil, fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name)
if compose.ManualTriggerDisabled(job) {
return nil, compose.ManualTriggerDisabledErr(name)
}
project, err := project.WithSelectedJob(name)
if err != nil {
Expand All @@ -482,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
}

Expand All @@ -503,39 +529,13 @@ 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 {
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
}
6 changes: 0 additions & 6 deletions cmd/compose/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 21 additions & 23 deletions cmd/compose/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,6 @@ func runUp(
return err
}

if err := rejectScheduledJobs(project); err != nil {
return err
}
warnIgnoredJobs(project)

err := createOptions.Apply(project)
Expand Down Expand Up @@ -297,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
Expand Down Expand Up @@ -356,34 +356,32 @@ func runUp(
})
}

// warnIgnoredJobs names the declared jobs up will not act on: manual jobs
// wait for an explicit `compose run <job>` trigger.
// warnIgnoredJobs names the declared manual jobs up will not act on: they
// wait for an explicit `compose run <job>` trigger. Scheduled jobs are not
// ignored — they are registered with the engine (see pkg/compose Up).
func warnIgnoredJobs(project *types.Project) {
jobs := project.Jobs
if len(jobs) == 0 {
names := manualJobNames(project)
if len(names) == 0 {
return
}
names := make([]string, 0, len(jobs))
for name := range jobs {
names = append(names, name)
}
sort.Strings(names)
logrus.Warnf("jobs are not started by up; trigger them with `docker compose run`: %s", strings.Join(names, ", "))
}

// rejectScheduledJobs refuses to bring a project up when it declares active
// scheduled jobs: silently not scheduling them would break the user's
// expectations, unlike manual jobs which simply wait for an explicit trigger.
func rejectScheduledJobs(project *types.Project) error {
// 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 <job>` trigger — unless they opt out with `manual: false`.
func manualJobNames(project *types.Project) []string {
names := make([]string, 0, len(project.Jobs))
for name, job := range project.Jobs {
if job.Triggers != nil && len(job.Triggers.Schedule) > 0 {
names = append(names, name)
if compose.HasSchedule(job) {
continue
}
}
if len(names) == 0 {
return nil
if compose.ManualTriggerDisabled(job) {
continue
}
names = append(names, name)
}
sort.Strings(names)
return fmt.Errorf("scheduled jobs are not supported in this version: %s", strings.Join(names, ", "))
return names
}
19 changes: 9 additions & 10 deletions cmd/compose/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,20 +180,19 @@ 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) {
manualTrigger := true
manual := types.JobConfig{Triggers: &types.TriggerConfig{Manual: &manualTrigger}}
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
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
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
Expand Down Expand Up @@ -54,6 +55,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
)
Expand Down Expand Up @@ -123,7 +125,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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -139,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=
Expand Down
53 changes: 53 additions & 0 deletions internal/jobsapi/errors.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading