Conversation
docker-agent
left a comment
There was a problem hiding this comment.
🟢 No issues found — LGTM! View logs.
e433256 to
ec9fe61
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Four medium-severity findings in the new jobs orchestration code: two nil pointer dereference risks (unguarded proto pointer fields after Wait and in createJobRun return paths), one log-streaming race where the container may not yet exist when the goroutine starts, and one typed-error loss on the RunJob CLI exit path.
| logsDone := make(chan struct{}) | ||
| go func() { | ||
| defer close(logsDone) | ||
| if err := s.streamJobLogs(ctx, created.ContainerID, svc.Tty); err != nil && ctx.Err() == nil { |
There was a problem hiding this comment.
[medium] Log-streaming goroutine starts immediately with a potentially empty ContainerID
The goroutine at line 440 calls s.streamJobLogs(ctx, created.ContainerID, ...) before the container is guaranteed to exist. The RunReply and RunRequest docs state:
ContainerID is set once the container is created.
This means the engine may return a Run record (via CreateAndRun or Run) before the container is created, leaving ContainerID as an empty string at the time streamJobLogs is called. ContainerLogs("") will immediately fail with a "no such container" error. The error is swallowed at logrus.Debugf level and logsDone is closed — the user sees no output from the job even though it runs to completion.
To fix, either:
- Poll
InspectRununtilContainerIDis non-empty before starting the goroutine, or - Issue a
Waitcall withWaitConditionRunningfirst so the container is guaranteed to exist before streaming.
| Confidence | Score |
|---|---|
| 🟡 moderate | 57/100 |
| return 0, err | ||
| } | ||
|
|
||
| run := waited.Run |
There was a problem hiding this comment.
[medium] Nil pointer dereference: waited.Run is not guarded after jc.Wait() succeeds
After jc.Wait() returns with a nil error (post MapError), run := waited.Run is assigned and immediately dereferenced in switch run.State. The proto-generated WaitReply.Run is a *Run pointer with omitempty — if the engine returns a successful reply with no run field set (e.g., a partial or malformed response), waited.Run is nil, and run.State panics.
MapError only maps gRPC status codes; it does not validate that the response payload is well-formed.
Suggested fix — add a nil guard before the switch:
run := waited.Run
if run == nil {
return 1, fmt.Errorf("job %q: Wait returned no run", name)
}
switch run.State {| Confidence | Score |
|---|---|
| 🟡 moderate | 67/100 |
| if err := mapAlreadyExists(err, name, "run"); err != nil { | ||
| return nil, err | ||
| } | ||
| return reply.Run, nil |
There was a problem hiding this comment.
[medium] createJobRun can return a nil *Run without an error, causing a nil dereference in the caller
Both return paths in createJobRun return reply.Run directly:
// manual job (line 492)
return reply.Run, nil
// scheduled job (line 503)
return reply.Run, nilCreateAndRunReply.Run and RunReply.Run are both proto *Run pointer fields with omitempty, so they can be nil in a non-error reply. If createJobRun returns (nil, nil), the caller (RunJob) immediately dereferences created.ContainerID, created.JobID, and created.ID — all three are nil pointer dereferences.
Suggested fix — add a nil guard in createJobRun before returning:
if reply.Run == nil {
return nil, fmt.Errorf("job %q: engine returned no run", name)
}
return reply.Run, nilApply the same guard to the RunRequest path as well.
| Confidence | Score |
|---|---|
| 🟡 moderate | 67/100 |
| if err != nil { | ||
| errMsg = err.Error() | ||
| } | ||
| return cli.StatusError{StatusCode: exitCode, Status: errMsg} |
There was a problem hiding this comment.
[medium] Non-zero exit code from RunJob with a non-nil error silently discards the typed error
When backend.RunJob returns (exitCode != 0, err != nil), the typed err is flattened to a plain string in cli.StatusError.Status:
errMsg = err.Error()
return cli.StatusError{StatusCode: exitCode, Status: errMsg}Any errors.Is/errors.As inspection downstream is broken — callers cannot detect errdefs.ErrNotFound, errdefs.ErrNotImplemented, or other typed errors that RunJob can return (via jobsv0.MapError). For example, if the engine is unreachable, the typed "not implemented" error is lost and the caller sees only a string.
The RunOneOffContainer path below uses the same pattern (pre-existing code, out of scope), but the RunJob path is new in this PR.
Consider adding the typed error to the struct or returning it directly when no meaningful exit code is available:
return cli.StatusError{StatusCode: exitCode, Status: err.Error(), Err: err}(if cli.StatusError has an Err field — otherwise return the typed error directly when exitCode == 0 and wrap with the exit code otherwise).
| Confidence | Score |
|---|---|
| 🟡 moderate | 70/100 |
…go#866) Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
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 <nicolas.deloof@gmail.com>
A job is a ContainerSpec plus a WorkloadSpec — the same layers a service is made of — so `compose run <job>` 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 <nicolas.deloof@gmail.com>
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 <job>` 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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
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 <job>` 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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
…del prep as a manual run 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 <glours@users.noreply.github.com>
`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 <verb>`; 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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
Verified empirically: `docker compose build/pull/push <job>` 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 <glours@users.noreply.github.com>
`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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
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 <glours@users.noreply.github.com>
ec9fe61 to
79718cc
Compare
What I did
Added support for the
jobs:top-level element from the Compose Specification: a job is a one-shot unit of work, distinct from a long-running servic, that fires either manually or on a schedule.docker compose upregisters a project's scheduled jobs with theengine, so they fire on the daemon's own clock from then on,
independently of the Compose CLI staying attached. Manual jobs are
registered but never fired by
up.docker compose run <job>(the same command already used forservices) triggers a manual job: it starts the job's declared
dependencies first, streams its output, and exits with the job's
own exit code, exactly like a one-off service run.
docker compose downderegisters the project's jobs from theengine.
upon an unchanged project is a safe no-op. If a job'sdefinition changed since it was registered,
up(orrun) reportsa clear error asking the user to
downthe project first ratherthan silently applying the change.
This is an early, working implementation: it targets an experimental engine capability (gated behind a feature flag) that is not yet part of a released Docker Engine, so jobs are not usable against a standard engine today.
Related issue
Based on #14093
(not mandatory) A picture of a cute animal, if possible in relation to what you did
