Repository navigation
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
This PR adds a full suite of docker job commands (cancel, inspect, list, logs, pause, prune, resume, rm, run, runs, wait) backed by a new gRPC jobs API and a vendored github.com/moby/extensions framework. The structure follows Docker CLI conventions well. Three medium-severity nil-pointer risks were found in the new code — all straightforward to fix — plus two lower-confidence edge cases in the vendored extensions client.
667d581 to
9c85a81
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
Lower-confidence findings (not posted inline)
-
[low] cli/command/job/list.go:52 — NextFire column can display a negative duration when NextFireAtNano is in the past (unverified; low severity)
The guard
!j.Paused && j.NextFireAtNano != 0does not check whether the timestamp is in the future. If the daemon hasn't refreshedNextFireAtNanoafter the last cron fire,time.Unix(0, j.NextFireAtNano).Sub(now)is negative andunits.HumanDurationrenders it as e.g.in -2 seconds. Adding.After(now)to the condition would prevent confusing output.
9c85a81 to
4fda975
Compare
Copy extpoints/jobs/api/v0/jobs.go from moby/moby at 402d0a16f1 and regenerate protogen/ with mobyextgen at the vendored github.com/moby/extensions version, so it compiles standalone against github.com/docker/cli/internal/jobsapi/v0 instead of moby's internal import path. Vendor github.com/moby/extensions, which the generated code builds on, and make google.golang.org/grpc and google.golang.org/protobuf direct dependencies since they are now imported directly. Signed-off-by: Guillaume Lours <[email protected]>
Add the `docker job` command group. It reaches the daemon's jobs extension point through github.com/moby/extensions/client, dialed lazily on first use because the command tree is built before the CLI is initialized. - `ls` lists jobs: trigger, state, paused flag, latest run, exit code and next fire. - `logs [-f] JOB` reads the logs of a job's most recent run from the container logs API with the run's container ID (the Jobs API does not proxy logs). When the latest run has no readable container (removed, still pending, or failed before its container was created) it falls back to the most recent run that has one and says so on stderr. With -f on a schedule job it keeps following across runs; on a manual job it returns once the logs end. Jobs API status codes become actionable errors instead of raw gRPC dumps: Unimplemented points at `--extension-enable org.mobyproject.jobs.v0`, Unavailable is reported as retryable, the other codes show the daemon's message, and a cancelled context (Ctrl-C) stays recognisable so the CLI exits quietly. Signed-off-by: Guillaume Lours <[email protected]>
- `inspect [--run RUN] JOB` prints a job or one of its runs as JSON. - `runs [--limit N] [--before CURSOR] JOB` lists a job's runs, most recent first, with paging; a stale cursor is reported on stderr. Signed-off-by: Guillaume Lours <[email protected]>
…`rm` and `prune` commands - `run [--reschedule] JOB` triggers a run and prints its ID. - `cancel JOB` stops the in-flight run; cancelling an idle job is a no-op. - `pause JOB` and `unpause JOB` suppress and re-arm a schedule trigger. - `rm [--runs keep|remove|remove-finished] JOB` removes a job; run history is kept unless asked otherwise, and the ID of a removed job stays usable to read or purge it. - `prune [--label LABEL]` removes idle manual jobs. Signed-off-by: Guillaume Lours <[email protected]>
4fda975 to
3c890a8
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The docker job command group is well-structured, follows Docker CLI conventions, and uses a sound lazy-connection pattern. One minor defensive-coding gap is noted inline; a few additional low-severity observations are listed below for the author's awareness.
Lower-confidence findings (not posted inline — unverified low-severity)
- [low]
cli/command/job/logs.go:171—time.Aftertimer goroutine leak insleepOrCancel: thetime.Afterchannel is not stopped on context cancellation, leaving a 2-second timer goroutine per cancelled poll cycle. Usetime.NewTimer+defer t.Stop()to fix. Benign in practice given the short interval. - [low]
cli/command/job/cmd.go:33— gRPC connection not closed when a subcommand'sRunEreturns an error (cobra skipsPersistentPostRunEon error). The in-code comment acknowledges this; the OS reclaims the socket on exit, so it only matters for multi-command-in-process usage. - [low]
cli/command/job/runs.go:127— Paging hint prints job name and cursor without shell-quoting:docker job runs --before $cursor $job. A job name containing spaces produces a broken suggested command. Using%qfor the job name would make the hint copy-pasteable. - [low]
cli/command/job/inspect.go:81—v = reply.Runon the--runbranch assigns a*Runpointer without a nil check; if the daemon returns nil Run with no error,json.MarshalIndentoutputsnullsilently. A nil guard matching thereply.Jobpattern would be more consistent.
ndeloof
left a comment
There was a problem hiding this comment.
Thanks for this, the command group is clean and the series is easy to follow. I read the hand-written code of cli/command/job (not the vendored moby/extensions, nor the generated protogen), and I compared the behavior with what docker compose does with the same engine API in docker/compose#14155, since Compose will be the main producer of the jobs these commands show. I did not run anything against a daemon with the extension, and the tests I did not read line by line. The nil-guard threads are settled, I am not going back to them.
Compose registers a scheduled job under <project>.<job> with the project label, with RemoveOnSuccess: true (a successful run of a scheduled job drops its container), runs a manual job through CreateAndRun and then Removes it, keeping the run records and containers, and exits 124 / 130 for a run that timed out / was cancelled. Three points come out of that.
To settle
docker job logsfor a job whose last run succeeded (threads onlogs.go). With Compose, that is every healthy scheduled job.- The exit status of
docker job waitfor a timed out or cancelled run is1, whiledocker compose runexits124/130for the same run (thread onwait.go).
For a follow-up, or a word in the description
3. ls has no --filter, and the API has the filters (thread on list.go).
4. The run history of a manual job removed by Compose is kept by the engine, and logs cannot reach it (thread on logs.go).
5. github.com/moby/extensions is vendored at d37867cb107f (2026-08-26) while Compose moved to d2819d88bcbe (2026-10-20), with the contract copy taken at the same moby commit 402d0a16f1. If the contract did not move between them it is only a version to keep in step, but it would be worth checking that both clients speak the same wire.
What I liked: wrapJobsError, which turns the status codes into advice and keeps Canceled recognizable so Ctrl-C stays quiet; the lazy and memoized dial, which keeps the command tree buildable before the CLI is initialized; and run printing the ID of the run only, which composes well with wait and logs (--reschedule defaulting to false is also what Compose sends).
| return &reply.Runs[i], nil | ||
| } | ||
| } | ||
| return nil, fmt.Errorf("no logs available for job %q", job.Name) |
There was a problem hiding this comment.
Two cases where this gives the wrong answer, both seen from Compose's side.
-
The run was just created. After
docker job run J, the latest run is pending and has no container yet, soresolveLogsRunfalls back to an older run. For a job that never ran before, the user getsno logs available for job "J"right after having started it. Compose meets the same window and waits for it:runContainerIDdoes aWaitwith therunningcondition on the run it just created. Would it make sense, when the latest run ispending, to wait for it (theWaitcall is already in the contract) rather than to look at the history? -
The latest run succeeded and its container was removed. Compose registers its scheduled jobs with
RemoveOnSuccess: true, so for a healthy job the latest run hasContainerGone, and the fallback shows the most recent run that still has a container: an older failed run. The note on stderr says the container of the latest run is not available, but not that the run succeeded, and a user reading stdout only sees the output of a failure and may conclude that the job is failing. Could the note carry the state and the age of the run it shows, and could the case 'latest run succeeded, container removed' say that the run succeeded and that its output is gone, before showing anything else?
There was a problem hiding this comment.
Done in the wait commit, as that is where Wait lands in the client interface: when the latest run is pending, logs now waits for it to leave pending (by run ID, with the running condition, as runContainerID does), instead of looking at the history. The fallback note now gives the state and the age of both runs, and when the latest run succeeded and its container was removed it says so first ("the latest run R succeeded 2 hours ago and its container was removed, so its output is gone"), before "Showing the logs of run R2 (failed, 3 hours ago)".
| if err != nil { | ||
| return wrapJobsError(err) | ||
| } | ||
| if run.ID != job.LatestRun.ID { |
There was a problem hiding this comment.
A smaller point on logs and Inspect: the contract accepts the ID of a removed job whose runs were kept for ListRuns and InspectRun, and docker job runs / inspect --run pass the argument through, so they work. logs starts with Inspect, which only resolves live jobs. Compose removes a manual job after each run (the run records and the run containers stay, for a postmortem of a failed run). So the logs of that failure are one ListRuns away but out of reach of docker job logs JOB_ID. If Inspect fails with NotFound, falling back to ListRuns (no follow across runs then, there is no schedule to follow) would make logs work for them. It also needs a way to learn the ID, which Compose does not print today, so this can wait for a follow-up.
There was a problem hiding this comment.
Agreed that this can wait. Added to the "Not in this PR" list of the description: logs would fall back to ListRuns when Inspect returns NotFound, once there is a way to learn the ID of a removed job.
| case run.State == jobsv0.RunStateRunning: | ||
| _, _ = fmt.Fprintln(dockerCli.Out(), run.ID) | ||
| return nil | ||
| case run.State == jobsv0.RunStateTimedOut || run.State == jobsv0.RunStateCancelled: |
There was a problem hiding this comment.
This is where the CLI and Compose disagree on the same run. For a run that timed out, docker job wait exits 1, and docker compose run of the same job exits 124 (and 130 for a cancelled run, following timeout(1) and the SIGINT convention), each with a message. The reasoning in the help is sound, an exit code of a stopped container is not the outcome of the job, but 1 is the code of a job that failed with exit 1, and a script cannot tell the two apart without parsing stderr, while with Compose it can.
Would you align on 124 / 130, or keep 1 and say in the description that this is a choice and why? Either is fine; the point is that the same engine run should not give two statuses depending on the client, and I would rather settle it now than after scripts depend on it.
There was a problem hiding this comment.
Aligned: wait now exits 124 for a timed out run and 130 for a cancelled one, as docker compose run does. A run whose container never started still exits 1 with the reason on stderr. Help text and docs updated.
| // runList calls client.List, sorts the result by Name, and renders it as a | ||
| // tabwriter table to dockerCli.Out(). | ||
| func runList(ctx context.Context, dockerCli command.Cli, client jobsClient) error { | ||
| reply, err := client.List(ctx, &jobsv0.ListRequest{}) |
There was a problem hiding this comment.
ListRequest takes Names, Labels, States, TriggerKinds, Paused and LatestRunStates, and ls sends none of them. It is in the 'not in this PR' list, so this is only to say why it will matter: the jobs registered by Compose are named <project>.<job> and carry the Compose project label, and the registry is daemon-wide, so with several projects docker job ls becomes a long flat list with no way to narrow it to a project. --filter label=com.docker.compose.project=app, and --format for scripts, would be the first things asked for. Not for this PR, a follow-up issue would do.
There was a problem hiding this comment.
Agreed, and noted in the description with the Compose use case (jobs named <project>.<job> carrying the project label). --filter and --format fit a follow-up PR.
`wait [--run RUN] [--condition terminal|running] JOB` blocks until a run reaches the condition and exits with the run's exit code, so it can be used in scripts. Without --run the latest run is awaited, which for a schedule job that has not run yet means blocking until its next fire. A run stopped by a timeout fails with status 124, and one stopped by `docker job cancel` with status 130, whatever exit code its container reacted with, and its state is printed on stderr. These are the statuses `docker compose run` exits with for the same run (timeout(1) and the SIGINT convention), so that a script can tell them from a job that failed with `exit 1`. With `--condition running` the run ID is printed once the run is up. `logs` uses Wait for the window between the creation of a run and the start of its container: when the latest run is pending, it waits for the run to leave pending instead of showing an older run, or failing for a job that never ran before. The note it prints when it shows another run than the latest one now gives the state and the age of both runs, and says when the latest run succeeded and its container was removed (the normal case of a job that removes its container on success), so that the output of an older failed run is not taken for the state of the job. Signed-off-by: Guillaume Lours <[email protected]>
Add the reference page of each `docker job` command and list the group in docker.md. Generated with `make mddocs`. Signed-off-by: Guillaume Lours <[email protected]>
3c890a8 to
7b4ce3a
Compare
|
Thanks for the review. On the vendored |
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
All medium-severity findings overlapped with prior resolved review threads (logs.go:175, client.go:178/186) and were suppressed per the Prior Review Thread History policy. No high-severity issues found. The low-severity findings below are surfaced for awareness but do not block merging.
Lower-confidence findings (not posted inline)
-
[low]
cli/command/job/logs.go:222—time.AfterinsleepOrCancelcreates a timer goroutine that leaks until it fires when the context is cancelled. Impact is minor (at most one 2-second stale goroutine per cancelled poll cycle). Prefert := time.NewTimer(pollInterval); defer t.Stop()to release the timer immediately on cancellation. -
[low]
cli/command/job/cmd.go:31—PersistentPostRunE(which callsjobs.Close()) is skipped by cobra when a subcommand'sRunEreturns an error. The gRPC connection is not explicitly closed in the error path. The comment at line 62 acknowledges this ("cobra skips PersistentPostRunE on error, and the process exits right after"), so this is by design for a short-lived CLI, but worth noting for future embedding or test harnesses. -
[low]
cli/command/job/wait.go:106—cli.StatusError{StatusCode: int(run.ExitCode.Value)}castsint64toint. On 32-bit platforms, exit codes greater thanmath.MaxInt32would silently truncate. Container exit codes are conventionally 0–255, so in practice this is benign, but a defensive range clamp orint32cast would be safer. -
[low]
cli/command/job/runs.go:127— The paging hint usesfmt.Fprintf(..., "docker job runs --before %s %s\n", reply.NextCursor, opts.job)without quoting. If the daemon returns a cursor containing spaces or shell metacharacters, the displayed hint will be un-runnable or misleading. Consider wrapping the cursor with%qor single quotes in the format string. -
[low]
vendor.mod:35—github.com/moby/extensionsis pinned to a pseudo-version (v0.0.0-20260826001921-d37867cb107f) on an unreleased commit.vendor.sumprovides content integrity at fetch time, but if the commit is ever deleted or rebased before the module proxy caches it,go mod downloadin a fresh environment would fail. Consider requesting a tagged release from the upstream repository when available. -
[low]
vendor/github.com/moby/extensions/fanout.go:72— InFold, whenFailOpenis true and a provider'sfnreturns an error, the accumulator is reset to its pre-call value. This correctly drops value changes but cannot roll back in-process side effects (e.g., mutations to shared state pointed to byacc). The existing code comments acknowledge this limitation. Worth documenting thatfnmust be side-effect-free onaccforFailOpento work correctly.
Related to: moby/moby#53485
Summary
Add the
docker jobcommand group, a client for the jobs extension of the daemon (org.mobyproject.jobs.v0, served as a gRPC service on the daemon socket). Jobs are one-shot or cron-scheduled containers managed by the daemon; every execution is a run with a durable identity that survives container removal.ls,inspect [--run RUN]andruns [--limit N] [--before CURSOR]read jobs and runs.logs [-f] JOBreads the logs of the most recent run from the container logs API with the run's container ID (the Jobs API does not proxy logs). A latest run that is still pending is waited for until its container starts. When the latest run has no readable container (removed, or failed before its container was created) it falls back to the most recent run that has one and says so on stderr, with the state and the age of both runs; when the latest run succeeded and its container was removed (a job that removes its container on success), the note says that its output is gone. With-fit keeps following across runs for schedule jobs.run [--reschedule],cancel,pause,unpause,rm [--runs keep|remove|remove-finished]andprune [--label LABEL] [-f]act on jobs.wait [--run RUN] [--condition terminal|running] JOBblocks until a run reaches the condition and exits with the run's exit code; a run stopped by a timeout exits with status 124 and one stopped bycancelwith status 130, asdocker compose rundoes for the same run, so that a script can tell them from a job that failed withexit 1.Requirements
The daemon must serve Engine API 1.57 or newer and have the extension enabled, which is not the default:
dockerd --extension-enable org.mobyproject.jobs.v0, orextensions.enableindaemon.json. The group carries theversion: 1.57annotation, so older daemons hide it and refuse it with the usual "requires API version" error. On a recent daemon where the extension is off, commands fail with a message pointing at that flag, andUnavailable(daemon starting or unreachable) is reported as retryable instead of dumping the gRPC status.The CLI reaches the extension through
github.com/moby/extensions/client, dialed lazily on first use because the command tree is built before the CLI is initialized.Notes for review
internal/jobsapi/v0is a copy ofextpoints/jobs/api/v0/jobs.gofrom moby/moby at402d0a16f1(it is not published as a standalone Go module).protogen/andjobs.protoare generated from it withmobyextgenat the vendoredgithub.com/moby/extensionsversion.github.com/moby/extensionsis vendored at the pseudo-versionv0.0.0-20260826001921-d37867cb107f;google.golang.org/grpcandgoogle.golang.org/protobufbecome direct dependencies.vendor:commit, so thatgo mod tidy && go mod vendorleaves no diff at any commit. The series is: contract and vendoring,ls/logs,inspect/runs, the action commands,wait, then the generated markdown docs.github.com/moby/extensionsruntime (client,clientpoint,core,serverpoint) and the generatedprotogenmatch the onesdocker composeuses (d2819d88bcbe, 2026-10-20) on the wire: the service and method names and the point ID are the same, and only comments, SPDX headers and source paths differ.go test -race ./cli/command/job/...andgolangci-lint.Not in this PR
lsandrunshave no--format,--quietor--filter, andinspectprints the raw JSON of the API types (field names as in the Go contract).lshas no--filter(the API hasNames,Labels,States,TriggerKinds,PausedandLatestRunStates), which would help to narrow the daemon-wide list to the jobs of a Compose project (named<project>.<job>, with the project label).logsdoes not read the runs kept for a job that was removed (the contract accepts the ID of such a job forrunsandinspect --run); it would fall back toListRunswhenInspectfails withNotFound.Release notes (optional)
A picture of a cute animal (not mandatory but encouraged)