From 55f46e6f59bd8b5505e83519f057140fdf717cb5 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 10 Nov 2026 10:18:57 +0100 Subject: [PATCH 1/6] internal/jobsapi: vendor the jobs extension API contract from moby 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 --- internal/jobsapi/v0/jobs.go | 558 +++++ internal/jobsapi/v0/jobs.proto | 200 ++ internal/jobsapi/v0/protogen/jobs.pb.go | 2132 +++++++++++++++++ internal/jobsapi/v0/protogen/wire.gen.go | 1265 ++++++++++ vendor.mod | 5 +- vendor.sum | 2 + .../github.com/moby/extensions/.golangci.yml | 11 + vendor/github.com/moby/extensions/README.md | 153 ++ .../extensions/clientpoint/clientpoint.go | 20 + vendor/github.com/moby/extensions/core.go | 333 +++ vendor/github.com/moby/extensions/fanout.go | 96 + .../extensions/serverpoint/serverpoint.go | 18 + vendor/modules.txt | 5 + 13 files changed, 4796 insertions(+), 2 deletions(-) create mode 100644 internal/jobsapi/v0/jobs.go create mode 100644 internal/jobsapi/v0/jobs.proto create mode 100644 internal/jobsapi/v0/protogen/jobs.pb.go create mode 100644 internal/jobsapi/v0/protogen/wire.gen.go create mode 100644 vendor/github.com/moby/extensions/.golangci.yml create mode 100644 vendor/github.com/moby/extensions/README.md create mode 100644 vendor/github.com/moby/extensions/clientpoint/clientpoint.go create mode 100644 vendor/github.com/moby/extensions/core.go create mode 100644 vendor/github.com/moby/extensions/fanout.go create mode 100644 vendor/github.com/moby/extensions/serverpoint/serverpoint.go diff --git a/internal/jobsapi/v0/jobs.go b/internal/jobsapi/v0/jobs.go new file mode 100644 index 000000000000..eae3e7da00f7 --- /dev/null +++ b/internal/jobsapi/v0/jobs.go @@ -0,0 +1,558 @@ +// Copied from moby/moby, extpoints/jobs/api/v0/jobs.go at 402d0a16f1, for +// traceability. protogen/ is generated from this file: after regenerating it, +// re-add the go1.26 build constraint below to its files, otherwise the +// gocompat check fails. + +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + +//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 (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 and discards a fire deferred + // by the queue concurrency policy. 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. + // The ID of an already removed job whose runs were kept is accepted with + // RunsRemoval remove, which purges that history. + 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. +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 Cancel or by removing + // its job. + 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. Pausing or removing + // the job discards the deferred fire. + 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, and their + // containers. + RunsKeep = "keep" + // RunsRemove drops all run records, including terminal ones, and + // removes their containers; the in-flight run's container is removed + // once it exits. + RunsRemove = "remove" + // RunsRemoveFinished drops terminal run records and their containers, + // 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, + // before the terminal run record is written, so the record reports + // ContainerGone. 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, until + // history eviction or Remove drops their run record. + RemoveOnFailure bool `pb:"6"` + // RunHistoryLimit caps retained run records, evicting the oldest + // terminal runs first, along with their containers. 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 crontab expression of numbers, lists, + // ranges, and steps. Names and 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. It is computed when the job is registered; another + // daemon version may hash the same spec differently, and Create + // compares specs with the running daemon's canonicalization. + 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, or the ID of a removed job whose runs + // were kept (see Jobs.Remove). + 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, or the ID of a removed job whose runs + // were kept. Names resolve only to live jobs. + 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, or the ID of a removed job whose runs + // were kept. Names resolve only to live jobs. + 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 a schedule job with no runs yet, "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/v0/jobs.proto b/internal/jobsapi/v0/jobs.proto new file mode 100644 index 000000000000..0d40b8152d53 --- /dev/null +++ b/internal/jobsapi/v0/jobs.proto @@ -0,0 +1,200 @@ +// Code generated by mobyextgen. DO NOT EDIT. + +syntax = "proto3"; + +package org.mobyproject.extension.jobs.api.v0; + +option go_package = "github.com/docker/cli/internal/jobsapi/v0/protogen"; + +service Jobs { + rpc Create(CreateRequest) returns (CreateReply); + rpc Run(RunRequest) returns (RunReply); + rpc CreateAndRun(CreateAndRunRequest) returns (CreateAndRunReply); + rpc Inspect(InspectRequest) returns (InspectReply); + rpc List(ListRequest) returns (ListReply); + rpc Pause(PauseRequest) returns (PauseResponse); + rpc Resume(ResumeRequest) returns (ResumeResponse); + rpc Cancel(CancelRequest) returns (CancelReply); + rpc Remove(RemoveRequest) returns (RemoveResponse); + rpc Prune(PruneRequest) returns (PruneReply); + rpc ListRuns(ListRunsRequest) returns (ListRunsReply); + rpc InspectRun(InspectRunRequest) returns (InspectRunReply); + rpc Wait(WaitRequest) returns (WaitReply); +} + +message CancelReply { + string run_id = 1; +} + +message CancelRequest { + string job_ref = 1; +} + +message CreateAndRunReply { + Job job = 1; + Run run = 2; + bool created = 3; +} + +message CreateAndRunRequest { + string name = 1; + JobSpec spec = 2; +} + +message CreateReply { + Job job = 1; + bool created = 2; +} + +message CreateRequest { + string name = 1; + JobSpec spec = 2; +} + +message ExitCode { + int64 value = 1; +} + +message InspectReply { + Job job = 1; +} + +message InspectRequest { + string job_ref = 1; +} + +message InspectRunReply { + Run run = 1; +} + +message InspectRunRequest { + string job_ref = 1; + string run_ref = 2; +} + +message Job { + string id = 1; + string name = 2; + JobSpec spec = 3; + string spec_hash = 4; + string state = 5; + bool paused = 6; + int64 next_fire_at_nano = 7; + int64 created_at_nano = 8; + int64 updated_at_nano = 9; + Run latest_run = 10; +} + +message JobSpec { + bytes container_spec = 1; + Trigger trigger = 2; + map labels = 3; + int64 timeout_seconds = 4; + bool remove_on_success = 5; + bool remove_on_failure = 6; + uint32 run_history_limit = 7; +} + +message ListReply { + repeated Job jobs = 1; +} + +message ListRequest { + repeated string names = 1; + repeated string labels = 2; + repeated string states = 3; + repeated string trigger_kinds = 4; + string paused = 5; + repeated string latest_run_states = 6; +} + +message ListRunsReply { + repeated Run runs = 1; + string next_cursor = 2; + bool cursor_stale = 3; +} + +message ListRunsRequest { + string job_ref = 1; + int32 limit = 2; + string before = 3; +} + +message PauseRequest { + string job_ref = 1; +} + +message PruneReply { + repeated string removed_job_ids = 1; +} + +message PruneRequest { + repeated string labels = 1; +} + +message RemoveRequest { + string job_ref = 1; + string runs_removal = 2; +} + +message ResumeRequest { + string job_ref = 1; +} + +message Run { + string id = 1; + string job_id = 2; + uint64 iteration = 3; + string container_id = 4; + bool container_gone = 5; + string state = 6; + int64 created_at_nano = 7; + int64 started_at_nano = 8; + int64 finished_at_nano = 9; + ExitCode exit_code = 10; + string error = 11; + TriggerEvidence trigger = 12; +} + +message RunReply { + Run run = 1; +} + +message RunRequest { + string job_ref = 1; + bool reschedule = 2; +} + +message ScheduleTrigger { + string cron = 1; + string timezone = 2; + string concurrency = 3; + string missed_fires = 4; +} + +message Trigger { + bool manual = 1; + ScheduleTrigger schedule = 2; +} + +message TriggerEvidence { + string kind = 1; + int64 scheduled_at_nano = 2; + int64 fired_at_nano = 3; +} + +message WaitReply { + Run run = 1; +} + +message WaitRequest { + string job_ref = 1; + string run_ref = 2; + string condition = 3; +} + +message PauseResponse {} + +message ResumeResponse {} + +message RemoveResponse {} diff --git a/internal/jobsapi/v0/protogen/jobs.pb.go b/internal/jobsapi/v0/protogen/jobs.pb.go new file mode 100644 index 000000000000..6cd45bae03cd --- /dev/null +++ b/internal/jobsapi/v0/protogen/jobs.pb.go @@ -0,0 +1,2132 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: internal/jobsapi/v0/jobs.proto + +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + +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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_v0_jobs_proto_rawDescGZIP(), []int{32} +} + +var File_internal_jobsapi_v0_jobs_proto protoreflect.FileDescriptor + +const file_internal_jobsapi_v0_jobs_proto_rawDesc = "" + + "\n" + + "\x1einternal/jobsapi/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.WaitReplyB4Z2github.com/docker/cli/internal/jobsapi/v0/protogenb\x06proto3" + +var ( + file_internal_jobsapi_v0_jobs_proto_rawDescOnce sync.Once + file_internal_jobsapi_v0_jobs_proto_rawDescData []byte +) + +func file_internal_jobsapi_v0_jobs_proto_rawDescGZIP() []byte { + file_internal_jobsapi_v0_jobs_proto_rawDescOnce.Do(func() { + file_internal_jobsapi_v0_jobs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_jobsapi_v0_jobs_proto_rawDesc), len(file_internal_jobsapi_v0_jobs_proto_rawDesc))) + }) + return file_internal_jobsapi_v0_jobs_proto_rawDescData +} + +var file_internal_jobsapi_v0_jobs_proto_msgTypes = make([]protoimpl.MessageInfo, 34) +var file_internal_jobsapi_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_internal_jobsapi_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_internal_jobsapi_v0_jobs_proto_init() } +func file_internal_jobsapi_v0_jobs_proto_init() { + if File_internal_jobsapi_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_internal_jobsapi_v0_jobs_proto_rawDesc), len(file_internal_jobsapi_v0_jobs_proto_rawDesc)), + NumEnums: 0, + NumMessages: 34, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_internal_jobsapi_v0_jobs_proto_goTypes, + DependencyIndexes: file_internal_jobsapi_v0_jobs_proto_depIdxs, + MessageInfos: file_internal_jobsapi_v0_jobs_proto_msgTypes, + }.Build() + File_internal_jobsapi_v0_jobs_proto = out.File + file_internal_jobsapi_v0_jobs_proto_goTypes = nil + file_internal_jobsapi_v0_jobs_proto_depIdxs = nil +} diff --git a/internal/jobsapi/v0/protogen/wire.gen.go b/internal/jobsapi/v0/protogen/wire.gen.go new file mode 100644 index 000000000000..670e0b6fde92 --- /dev/null +++ b/internal/jobsapi/v0/protogen/wire.gen.go @@ -0,0 +1,1265 @@ +// Code generated by mobyextgen. DO NOT EDIT. +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + +package protogen + +import ( + context "context" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + extensions "github.com/moby/extensions" + clientpoint "github.com/moby/extensions/clientpoint" + serverpoint "github.com/moby/extensions/serverpoint" + 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: "internal/jobsapi/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/vendor.mod b/vendor.mod index cb5e2bf7aeab..6a0a5015cf6d 100644 --- a/vendor.mod +++ b/vendor.mod @@ -32,6 +32,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/mattn/go-runewidth v0.0.31 + github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f github.com/moby/go-archive v0.3.3 github.com/moby/moby/api v1.56.2-0.20261106011722-402d0a16f131 github.com/moby/moby/client v0.6.3-0.20261106011722-402d0a16f131 @@ -65,6 +66,8 @@ require ( golang.org/x/sys v0.50.0 golang.org/x/term v0.48.0 golang.org/x/text v0.44.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 ) @@ -107,6 +110,4 @@ require ( golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/grpc v1.83.2 // indirect - google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/vendor.sum b/vendor.sum index c6d7b9e13b24..cff48ad7370a 100644 --- a/vendor.sum +++ b/vendor.sum @@ -86,6 +86,8 @@ github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5I github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= 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/moby/api v1.56.2-0.20261106011722-402d0a16f131 h1:tPz9ahj8F7AjQw62XQFP65kmoxF3rXTYSit3q06/yQc= diff --git a/vendor/github.com/moby/extensions/.golangci.yml b/vendor/github.com/moby/extensions/.golangci.yml new file mode 100644 index 000000000000..53c60e51f33e --- /dev/null +++ b/vendor/github.com/moby/extensions/.golangci.yml @@ -0,0 +1,11 @@ +version: "2" + +linters: + default: none + enable: + - copyloopvar + - errcheck + - intrange + - mirror + - perfsprint + - usestdlibvars diff --git a/vendor/github.com/moby/extensions/README.md b/vendor/github.com/moby/extensions/README.md new file mode 100644 index 000000000000..5ed29448b3ce --- /dev/null +++ b/vendor/github.com/moby/extensions/README.md @@ -0,0 +1,153 @@ +# Extensions + +> [!WARNING] +> ⚠️ This project is a work in progress. + +Moby extensions are one model for adding engine behavior. +They replace the older per-feature plugin systems for network, volume, and log drivers. + +## Mental model + +- A **point** is a versioned, namespaced Go interface and its message types. +- An **extension** implements one or more points. + It can be compiled into the daemon or run as a separate binary; the point and its callers do not change. +- The host registers extensions, resolves typed dependencies, initializes them in dependency order, and routes calls through points. +- Wire code is generated from the Go contract. + The generated proto and adapters let a separate-binary provider be written in another language. +- An ordinary point can also publish its generated gRPC service on `docker.sock`. + Publication is opt-in and uses the same point contract; it does not create a second source contract. + +## Principles + +- **Everything is a point.** + Hooks are points called during engine flows, and extension-to-extension calls use the same provider and routing model. +- **Placement is transparent.** + The same point contract works in-process or in a separate process; the runtime chooses direct Go or gRPC transport. +- **Dependencies are typed and explicit.** + The broker resolves declared point or extension dependencies before initialization; importing a package does not activate it. +- **Fan-out order is not a contract.** + A point that needs ordering defines its own phases or ordering rules. +- **Socket publication is opt-in.** + An extension publishes only the ordinary points it explicitly exposes, and those services must enforce their own access control. +- **Legacy plugins are replaced.** + Network, volume, and log drivers become extension points rather than separate plugin systems. + +## Ordinary Point publication + +The common contract is an ordinary `extensions.DefinePoint` or +`extensions.DefineSinglePoint`. The interface name determines the gRPC service +name: + +```go +package greeterv0 + +import ( + "context" + + "github.com/moby/extensions" +) + +type Greeter interface { + Greet(context.Context, *HelloRequest) (*HelloReply, error) +} + +type HelloRequest struct { + Name string `pb:"1"` +} + +type HelloReply struct { + Message string `pb:"1"` +} + +var Point = extensions.DefinePoint[Greeter]( + "org.mobyproject.extension.example.greeter.v0", +) +``` + +`mobyextgen` infers `.`, here +`org.mobyproject.extension.example.greeter.v0.Greeter`. The interface name is +part of the wire identity, so keep it stable within a Point version. + +Generated Point packages provide `ServerPoint`, `ClientPoint`, and handwritten +`NewClient(conn)`. An extension implements an ordinary Point and separately +offers it for Host-controlled publication: + +```go +var Extension = extensions.New(extensions.Declaration{ + ID: "org.example.greeter.v1", + Providers: []extensions.Provider{ + greeterv0.Point.Provide(greeter{}), + servicev0.Offer(greeterv0.Point), + }, +}) +``` + +One offer may name a subset of several implemented Points: + +```go +Providers: []extensions.Provider{ + foov1.Point.Provide(fooImpl{}), + barv1.Point.Provide(barImpl{}), + servicev0.Offer(foov1.Point), +} +``` + +An offer is not authorization. The Host defaults to deny and applies policy per +host-attested extension identity and Point: + +```go +AllowPublication: host.PublicationPolicyFunc(func(identity extensions.ExtensionIdentity, point extensions.PointID) bool { + return identity.ID == "org.example.greeter.v1" && + identity.Origin.Kind == extensions.ExtensionOriginExecutable && + point == greeterv0.Point.ID() +}), +``` + +For a separate binary, pass each implemented Point's generated adapter at the +process composition boundary: + +```go +sdk.Main(extension, greeterpb.ServerPoint) +``` + +For an in-process extension, supply the adapter through `host.Options.PointServers`. +An in-process provider has no private SDK gRPC server, so the Host needs this +generated registration logic to translate protobuf requests into calls on the +Go implementation. +Offered process Points need `ClientPoint` wiring only when the daemon also calls +them internally. +External callers use the generated handwritten client: + +```go +client := greeterpb.NewClient(hostConn) +reply, err := client.Greet(ctx, &greeterv0.HelloRequest{Name: "world"}) +``` + +The current backend is gRPC. For a launched extension, the SDK registers the +generated service, the daemon records its fully qualified name, and the proxy +forwards the raw gRPC stream, including metadata and status. Arbitrary raw gRPC +publication is not part of the extension API. + +## Glossary + +| Term | Definition | +|---|---| +| **Point** | A versioned, namespaced Go interface and message types implemented by extensions. A breaking change gets a new point version; `.v0` is experimental. | +| **Extension** | A deployable unit, built into the daemon or run as a separate binary, that declares providers, dependencies, and lifecycle. | +| **Extension identity** | The logical extension id plus the host-attested origin (`builtin` or `executable`). The extension declares only its logical id; the host validates the id and supplies the origin. | +| **Provider** | An extension's implementation of one point. It has no separate id and is identified by its extension id; an extension implements a point at most once. | +| **ClientPoint** | Generated host wiring that turns an extension connection into a provider of an ordinary point. | +| **ServerPoint** | Generated SDK or callback wiring that serves an ordinary point's gRPC service. | +| **Publication** | A Host policy allowing an extension-offered ordinary Point to become externally reachable. | +| **Consumer / dependent** | The engine or another extension that resolves and calls a point. | +| **Dependency** | A declared need resolved before initialization. A point dependency needs a provider; an extension dependency names one extension. | +| **Broker** | The host component that registers extensions, resolves dependencies, initializes them, and shuts them down. | +| **Adapter** | Generated code that makes an out-of-process gRPC provider look like the same in-process Go interface to consumers. | +| **Engine** | The host daemon, which consumes points, provides callbacks, routes calls, and publishes opted-in services on its socket. | +| **Legacy plugin** | The older Moby plugin system being replaced, or containerd plugins when discussed as prior art. The new unit is an extension. | + +## Start here + +- The principles and glossary above provide the short normative rules and definitions. +- [DESIGN.md](./docs/DESIGN.md) - current behavior, constraints, wire protocol, and discovery security. +- [AUTHORING.md](./docs/AUTHORING.md) - procedures, commands, code, and checklists. diff --git a/vendor/github.com/moby/extensions/clientpoint/clientpoint.go b/vendor/github.com/moby/extensions/clientpoint/clientpoint.go new file mode 100644 index 000000000000..30f866723fc3 --- /dev/null +++ b/vendor/github.com/moby/extensions/clientpoint/clientpoint.go @@ -0,0 +1,20 @@ +// Package clientpoint defines the host-side registration for generated +// out-of-process point providers. It has no dependency on the host runtime so +// generated code can reference it. +package clientpoint + +import ( + "github.com/moby/extensions" + "google.golang.org/grpc" +) + +// Provider builds an in-broker provider from a gRPC connection. +type Provider func(grpc.ClientConnInterface) extensions.Provider + +// Registration pairs a point id with its client provider. Single records the +// contract's one-provider cardinality. +type Registration struct { + Point extensions.PointID + Provider Provider + Single bool +} diff --git a/vendor/github.com/moby/extensions/core.go b/vendor/github.com/moby/extensions/core.go new file mode 100644 index 000000000000..271c07ea9060 --- /dev/null +++ b/vendor/github.com/moby/extensions/core.go @@ -0,0 +1,333 @@ +package extensions + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "sync" +) + +// ExtensionID identifies a deployable extension. +type ExtensionID string + +// ExtensionOriginKind identifies how the host obtained an extension. +type ExtensionOriginKind string + +const ( + // ExtensionOriginBuiltin identifies an extension compiled into the host. + ExtensionOriginBuiltin ExtensionOriginKind = "builtin" + // ExtensionOriginExecutable identifies an extension launched as an + // executable. + ExtensionOriginExecutable ExtensionOriginKind = "executable" +) + +// ExtensionOrigin identifies how the host obtained an extension and its +// kind-specific attested details. +type ExtensionOrigin struct { + Kind ExtensionOriginKind + Executable *ExecutableOrigin +} + +// ExecutableOrigin contains host-attested details for an executable origin. +type ExecutableOrigin struct { + // Path is the exact path the host executed, as discovered from the + // extension directory. It is not normalized: a relative directory or a + // symlink yields that relative or symlink path. + Path string +} + +// ExtensionIdentity is the host-attested identity of an extension. +// The extension declares ID, but the host supplies and validates the complete +// identity, including Origin. +type ExtensionIdentity struct { + ID ExtensionID + Origin ExtensionOrigin +} + +// ValidateExtensionIdentity returns an error unless identity contains a valid +// extension ID and a recognized host-attested origin. +func ValidateExtensionIdentity(identity ExtensionIdentity) error { + if err := ValidateExtensionID(identity.ID); err != nil { + return err + } + switch identity.Origin.Kind { + case "": + return errors.New("extension origin kind is required") + case ExtensionOriginBuiltin: + if identity.Origin.Executable != nil { + return errors.New("builtin extension origin must not include executable payload") + } + case ExtensionOriginExecutable: + if identity.Origin.Executable == nil { + return errors.New("executable extension origin requires executable payload") + } + if identity.Origin.Executable.Path == "" { + return errors.New("executable origin path is required") + } + default: + return fmt.Errorf("invalid extension origin kind %q: want %q or %q", identity.Origin.Kind, ExtensionOriginBuiltin, ExtensionOriginExecutable) + } + return nil +} + +// PointID identifies an extension point contract. +type PointID string + +// IsMetadataPoint reports whether point carries framework metadata rather than +// a callable capability. +// Host provider admission never consults policy for metadata points; only +// publication policy governs them. +// Currently the servicev0 offer marker is the only metadata point; its package +// tests keep this ID in sync with the point definition. +func IsMetadataPoint(point PointID) bool { + return point == "org.mobyproject.extension.service.v0" +} + +// Name returns the point's short name, the segment before its version. +func (id PointID) Name() string { + segments := strings.Split(string(id), ".") + if len(segments) < 2 { + return string(id) + } + return segments[len(segments)-2] +} + +// pointIDPattern requires a lowercase reverse-DNS-style id ending in a vN +// version. Segments may contain digits, hyphens, and underscores. +var pointIDPattern = lazyRegexp(`^[a-z][a-z0-9]*(\.[a-z0-9_-]+)+\.v[0-9]+$`) + +// ValidatePointID reports whether id is a well-formed, versioned Point ID. +func ValidatePointID(id PointID) error { + if id == "" { + return errors.New("point id is required") + } + if !pointIDPattern().MatchString(string(id)) { + return fmt.Errorf("invalid point id %q: want a versioned reverse-DNS name like org.example.api.v1", id) + } + return nil +} + +// extensionIDPattern requires a lowercase reverse-DNS name with a mandatory vN +// version. The version is a namespace element, not a semantic version: changing +// com.foo.v1 to com.foo.v2 creates a new extension, binary, and configuration. +// The restricted characters also make the id safe as a config key and filename. +var extensionIDPattern = lazyRegexp(`^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+\.v[0-9]+$`) + +// ValidateExtensionID reports whether id is well-formed before it is used as a +// configuration key or binary name. +func ValidateExtensionID(id ExtensionID) error { + if id == "" { + return errors.New("extension id is required") + } + if !extensionIDPattern().MatchString(string(id)) { + return fmt.Errorf("invalid extension id %q: want a versioned reverse-DNS name like org.example.myext.v1, lowercase, no path-hostile characters", id) + } + return nil +} + +func lazyRegexp(pattern string) func() *regexp.Regexp { + return sync.OnceValue(func() *regexp.Regexp { + re, err := regexp.Compile(pattern) + if err != nil { + panic(err) + } + return re + }) +} + +// Dependency declares one extension dependency. +type Dependency struct { + Point PointID + Extension ExtensionID + Optional bool +} + +// Provider declares an in-process implementation for a point. +type Provider struct { + Point PointID + Impl any +} + +// ResolvedProvider is a provider returned from a lookup with its host-attested +// extension identity. +type ResolvedProvider struct { + Identity ExtensionIdentity + Impl any +} + +// EffectiveProviders applies origin precedence for single-provider resolution. +// Built-ins are used only when no executable provider exists. +// Fan-out and by-id lookups do not apply this precedence. +func EffectiveProviders(providers []ResolvedProvider) []ResolvedProvider { + var executables, builtins []ResolvedProvider + for _, p := range providers { + if p.Identity.Origin.Kind == ExtensionOriginBuiltin { + builtins = append(builtins, p) + } else { + executables = append(executables, p) + } + } + if len(executables) == 0 { + return builtins + } + return executables +} + +// TypedProvider is a provider returned through a typed point handle. +type TypedProvider[T any] struct { + Identity ExtensionIdentity + Impl T +} + +// Point binds a point ID to the Go interface implemented by its providers. +type Point[T any] struct { + id PointID +} + +// DefinePoint binds id to provider interface T and panics for an invalid point +// id. Point ids are fixed in source, so invalid ids are programming errors. +func DefinePoint[T any](id PointID) Point[T] { + if ValidatePointID(id) != nil { + panic(fmt.Sprintf("extensions: invalid point id %q: want ....vN, e.g. org.mobyproject.extension.volume.driver.v1", id)) + } + return Point[T]{id: id} +} + +// DefineSinglePoint declares a point with one deciding provider instead of a +// fan-out. The wire generator carries this cardinality into ClientPoint so the +// host can reject multiple executable providers at startup. +func DefineSinglePoint[T any](id PointID) Point[T] { + return DefinePoint[T](id) +} + +// ID returns the point identifier. +func (p Point[T]) ID() PointID { + return p.id +} + +// Provide returns a provider declaration for impl. +func (p Point[T]) Provide(impl T) Provider { + return Provider{Point: p.id, Impl: impl} +} + +// Dependency returns a required dependency declaration for the point: at least +// one provider must exist before the dependent initializes. +func (p Point[T]) Dependency() Dependency { + return Dependency{Point: p.id} +} + +// OptionalDependency returns an optional dependency declaration for the point: +// the dependent still initializes, ordered after any providers, when none exist. +func (p Point[T]) OptionalDependency() Dependency { + return Dependency{Point: p.id, Optional: true} +} + +// ByExtension returns the point provider implemented by extension. +func (p Point[T]) ByExtension(r Resolver, extension ExtensionID) (T, error) { + provider, err := r.Provider(p.id, extension) + if err != nil { + var zero T + return zero, err + } + return typedProvider[T](p.id, extension, provider) +} + +// Single returns the only point provider, after origin precedence +// ([EffectiveProviders]): a built-in provider does not count against the +// one-provider limit, it stands in when no executable provider exists. +func (p Point[T]) Single(r Resolver) (T, error) { + providers := EffectiveProviders(r.Providers(p.id)) + var zero T + switch len(providers) { + case 0: + return zero, fmt.Errorf("point %q has no providers", p.id) + case 1: + return typedProvider[T](p.id, providers[0].Identity.ID, providers[0].Impl) + default: + return zero, fmt.Errorf("point %q has multiple providers", p.id) + } +} + +// Enabled reports whether any extension provides the point. +func (p Point[T]) Enabled(r Resolver) bool { + return len(r.Providers(p.id)) > 0 +} + +// All returns all point providers, including built-in and executable providers. +func (p Point[T]) All(r Resolver) ([]TypedProvider[T], error) { + providers := r.Providers(p.id) + typed := make([]TypedProvider[T], 0, len(providers)) + for _, provider := range providers { + impl, err := typedProvider[T](p.id, provider.Identity.ID, provider.Impl) + if err != nil { + return nil, err + } + typed = append(typed, TypedProvider[T]{Identity: provider.Identity, Impl: impl}) + } + return typed, nil +} + +func typedProvider[T any](point PointID, extension ExtensionID, provider any) (T, error) { + typed, ok := provider.(T) + if ok { + return typed, nil + } + var zero T + if extension == "" { + return zero, fmt.Errorf("point %q provider has type %T", point, provider) + } + return zero, fmt.Errorf("extension %q provider for point %q has type %T", extension, point, provider) +} + +// Resolver exposes provider lookup to extension initializers. Selection and +// cardinality are handled by the typed [Point] accessors. +type Resolver interface { + Provider(PointID, ExtensionID) (any, error) + Providers(PointID) []ResolvedProvider +} + +// Registrar registers extensions. +type Registrar interface { + Register(Extension) error +} + +// RegisterAll registers exts with registrar. +func RegisterAll(registrar Registrar, exts ...Extension) error { + for _, ext := range exts { + if err := registrar.Register(ext); err != nil { + return err + } + } + return nil +} + +// Config is parsed per-extension configuration delivered by id to Init or the +// out-of-process startup handshake. +type Config = map[string]any + +// Extension is a host-managed extension declaration. +type Extension interface { + // Declaration returns the extension's id, providers, dependencies, and + // conflicts, plus its optional Init and Shutdown. + Declaration() Declaration +} + +// Declaration declares an extension, its providers and dependencies, and its +// optional lifecycle callbacks. +type Declaration struct { + ID ExtensionID + Providers []Provider + Dependencies []Dependency + Conflicts []ExtensionID + Init func(context.Context, Config, Resolver) error + Shutdown func(context.Context) error +} + +// New wraps a static Declaration as an [Extension]. +func New(d Declaration) Extension { return staticExtension{d} } + +type staticExtension struct{ decl Declaration } + +func (e staticExtension) Declaration() Declaration { return e.decl } diff --git a/vendor/github.com/moby/extensions/fanout.go b/vendor/github.com/moby/extensions/fanout.go new file mode 100644 index 000000000000..3c2c15a0bf52 --- /dev/null +++ b/vendor/github.com/moby/extensions/fanout.go @@ -0,0 +1,96 @@ +package extensions + +import ( + "context" + "fmt" + "time" + + "github.com/containerd/log" +) + +// Policy controls provider deadlines and error handling for [Each] and [Fold]. +type Policy struct { + // Timeout bounds each provider call independently. Zero leaves the calls + // bounded only by the caller's context. + // + // It is enforced for out-of-process providers, which receive the deadline + // over gRPC. An in-process provider is passed the same context but only + // trusted to honor it: abandoning a direct Go call would leave the provider + // mutating shared engine state after the caller moved on. State a + // fail-closed point's guarantee accordingly. + Timeout time.Duration + + // FailOpen continues with the remaining providers when one returns an error. + // Veto points should leave it false. + FailOpen bool + + // Action names what the provider's error did to the operation, for the + // wrapped error: ` provider "" : `. Leave it empty + // for a plain failure. + Action string +} + +// wrap attributes err to the extension that produced it. +func (p Policy) wrap(point PointID, extension ExtensionID, err error) error { + if p.Action == "" { + return fmt.Errorf("%s provider %q: %w", point.Name(), extension, err) + } + return fmt.Errorf("%s provider %q %s: %w", point.Name(), extension, p.Action, err) +} + +// Each calls fn once per provider of p under policy. Use it for validation, +// veto, or notification points. +// +// Fan-out order is unspecified. A point that needs a defined order must define +// it (see the guidance in the extensions authoring guide). +func Each[T any](ctx context.Context, p Point[T], r Resolver, policy Policy, fn func(context.Context, T) error) error { + _, err := Fold(ctx, p, r, policy, struct{}{}, func(ctx context.Context, impl T, _ struct{}) (struct{}, error) { + return struct{}{}, fn(ctx, impl) + }) + return err +} + +// Fold threads acc through every provider of p and returns the final value under +// policy. Each provider sees the value produced by its predecessors. +// +// A provider that fails under a fail-closed policy aborts the fold and its +// partial value is discarded; under a fail-open one its contribution is dropped +// and the fold continues from the value it was given. +func Fold[T, A any](ctx context.Context, p Point[T], r Resolver, policy Policy, acc A, fn func(context.Context, T, A) (A, error)) (A, error) { + providers, err := p.All(r) + if err != nil { + return acc, err + } + for _, provider := range providers { + next, err := callProvider(ctx, p.id, policy, provider, acc, fn) + if err != nil { + if !policy.FailOpen { + // Discard the partial value rather than hand back a half-applied + // one: for a composing point such as the create-spec hook that + // would be an OCI spec carrying some providers' changes but not + // the rest, which is worse than no answer. + var zero A + return zero, err + } + log.G(ctx).WithError(err).Warn("extensions: skipping failed provider") + continue + } + acc = next + } + return acc, nil +} + +// callProvider gives each provider its own timeout and releases it when the +// call returns. +func callProvider[T, A any](ctx context.Context, point PointID, policy Policy, provider TypedProvider[T], acc A, fn func(context.Context, T, A) (A, error)) (A, error) { + if policy.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, policy.Timeout) + defer cancel() + } + next, err := fn(ctx, provider.Impl, acc) + if err != nil { + return acc, policy.wrap(point, provider.Identity.ID, err) + } + return next, nil +} diff --git a/vendor/github.com/moby/extensions/serverpoint/serverpoint.go b/vendor/github.com/moby/extensions/serverpoint/serverpoint.go new file mode 100644 index 000000000000..f1106d490d49 --- /dev/null +++ b/vendor/github.com/moby/extensions/serverpoint/serverpoint.go @@ -0,0 +1,18 @@ +// Package serverpoint defines the SDK-side registration for generated +// out-of-process point providers. It has no dependency on the SDK server so +// generated code can reference it. +package serverpoint + +import ( + "github.com/moby/extensions" + "google.golang.org/grpc" +) + +// Register registers a point's gRPC service on r. +type Register func(r grpc.ServiceRegistrar, impl any) + +// Registration pairs a point id with its server registration. +type Registration struct { + Point extensions.PointID + Register Register +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 708b2282a0fb..aad27bbd058a 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -161,6 +161,11 @@ github.com/mattn/go-runewidth # github.com/moby/docker-image-spec v1.3.1 ## explicit; go 1.18 github.com/moby/docker-image-spec/specs-go/v1 +# github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f +## explicit; go 1.26.0 +github.com/moby/extensions +github.com/moby/extensions/clientpoint +github.com/moby/extensions/serverpoint # github.com/moby/go-archive v0.3.3 ## explicit; go 1.25 github.com/moby/go-archive From c50bd7bc85d5e5c9c06fe5de7e614816acb09f5d Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 10 Nov 2026 10:20:29 +0100 Subject: [PATCH 2/6] cli/command/job: add `docker job ls` and `logs` commands 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 --- cli/command/commands/commands.go | 1 + cli/command/job/client.go | 84 ++++ cli/command/job/client_test.go | 103 ++++ cli/command/job/cmd.go | 106 ++++ cli/command/job/cmd_test.go | 19 + cli/command/job/list.go | 128 +++++ cli/command/job/list_test.go | 383 ++++++++++++++ cli/command/job/logs.go | 211 ++++++++ cli/command/job/logs_followswitch_test.go | 471 ++++++++++++++++++ cli/command/job/logs_resolve_test.go | 134 +++++ cli/command/job/logs_test.go | 316 ++++++++++++ .../moby/extensions/client/client.go | 226 +++++++++ vendor/modules.txt | 1 + 13 files changed, 2183 insertions(+) create mode 100644 cli/command/job/client.go create mode 100644 cli/command/job/client_test.go create mode 100644 cli/command/job/cmd.go create mode 100644 cli/command/job/cmd_test.go create mode 100644 cli/command/job/list.go create mode 100644 cli/command/job/list_test.go create mode 100644 cli/command/job/logs.go create mode 100644 cli/command/job/logs_followswitch_test.go create mode 100644 cli/command/job/logs_resolve_test.go create mode 100644 cli/command/job/logs_test.go create mode 100644 vendor/github.com/moby/extensions/client/client.go diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index 2b375938c9ec..7866aa49f838 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -8,6 +8,7 @@ import ( _ "github.com/docker/cli/cli/command/container" _ "github.com/docker/cli/cli/command/context" _ "github.com/docker/cli/cli/command/image" + _ "github.com/docker/cli/cli/command/job" _ "github.com/docker/cli/cli/command/manifest" _ "github.com/docker/cli/cli/command/network" _ "github.com/docker/cli/cli/command/node" diff --git a/cli/command/job/client.go b/cli/command/job/client.go new file mode 100644 index 000000000000..0836f7e3c539 --- /dev/null +++ b/cli/command/job/client.go @@ -0,0 +1,84 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + +package job + +import ( + "context" + "errors" + "fmt" + + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/jobsapi/v0/protogen" + extensionclient "github.com/moby/extensions/client" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// jobsClient is the subset of the jobsv0.Jobs contract consumed by the +// `docker job` commands. It is intentionally narrow: jobsv0.Jobs (the real +// implementation, obtained through github.com/moby/extensions/client against +// the daemon's jobs extension point) already satisfies it structurally, so no +// adapter type is needed in production code -- see the var _ jobsClient +// assertion in client_test.go. Do not widen it speculatively. +type jobsClient interface { + List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) + + // Inspect returns a job with its latest run summary. + Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) + + // ListRuns returns a job's runs, most recent first. Used as the + // fallback when the latest run's container is gone. + ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) +} + +// newJobsClient dials the daemon's jobs extension point and returns a +// jobsClient backed by github.com/moby/extensions/client, plus a closer for +// the underlying connection that callers must invoke once done with it. +// +// Resolve is lazy: a daemon without the feature only surfaces +// codes.Unimplemented on the first RPC call (e.g. the first List), not on +// newJobsClient itself. +func newJobsClient(dockerCli command.Cli) (jobsClient, func() error, error) { + exts, err := extensionclient.New(dockerCli.Client(), extensionclient.WithGRPCPoint(protogen.ClientPoint)) + if err != nil { + return nil, nil, err + } + client, err := extensionclient.Resolve(exts, jobsv0.Point) + if err != nil { + _ = exts.Close() + return nil, nil, err + } + return client, exts.Close, nil +} + +// enableJobsFlag is the dockerd option enabling the daemon's jobs extension. +const enableJobsFlag = "--extension-enable org.mobyproject.jobs.v0" + +// wrapJobsError turns the gRPC status errors of the Jobs API into actionable +// CLI errors instead of raw `rpc error: code = ...` dumps. Unimplemented means +// the extension is not enabled; Unavailable means the daemon is still starting +// (or the extension failed to activate), or cannot be reached at all, which is +// worth a retry. The other codes the contract defines carry their own message. +// Any other error (including nil) is returned unchanged. +func wrapJobsError(err error) error { + st, ok := status.FromError(err) + if !ok { + return err + } + switch st.Code() { //nolint:exhaustive // only the codes defined by the Jobs API contract are mapped + case codes.Unimplemented: + return errors.New("the daemon does not support jobs; start dockerd with " + enableJobsFlag + ` (or set "extensions.enable" in daemon.json)`) + case codes.Canceled: + // A cancelled context (Ctrl-C) must stay recognisable as such, so that the + // CLI exits quietly like it does for the other commands. + return context.Canceled + case codes.Unavailable: + return fmt.Errorf("the jobs API is unavailable (the daemon may be starting or unreachable), retry in a moment: %s", st.Message()) + case codes.NotFound, codes.AlreadyExists, codes.FailedPrecondition, codes.InvalidArgument: + return errors.New(st.Message()) + default: + return err + } +} diff --git a/cli/command/job/client_test.go b/cli/command/job/client_test.go new file mode 100644 index 000000000000..8d6eefcc44b9 --- /dev/null +++ b/cli/command/job/client_test.go @@ -0,0 +1,103 @@ +package job + +import ( + "context" + "errors" + "testing" + + "github.com/containerd/errdefs" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// jobsv0.Jobs must keep satisfying jobsClient structurally: this line stops +// compiling the day the real interface no longer matches what the job +// package expects from it. +var _ jobsClient = jobsv0.Jobs(nil) + +// fakeClient is a test double for jobsClient, following the fakeClient +// pattern used across cli/command/* (see cli/command/volume/client_test.go): +// one Func field per method, a nil field falling back to a harmless +// zero-value response. +type fakeClient struct { + listFunc func(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) + inspectFunc func(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) + listRunsFunc func(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) +} + +func (c *fakeClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + if c.listFunc != nil { + return c.listFunc(ctx, req) + } + return &jobsv0.ListReply{}, nil +} + +func (c *fakeClient) Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + if c.inspectFunc != nil { + return c.inspectFunc(ctx, req) + } + return &jobsv0.InspectReply{}, nil +} + +func (c *fakeClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + if c.listRunsFunc != nil { + return c.listRunsFunc(ctx, req) + } + return &jobsv0.ListRunsReply{}, nil +} + +var _ jobsClient = (*fakeClient)(nil) + +func TestWrapJobsError(t *testing.T) { + plain := errors.New("boom") + + testCases := []struct { + doc string + err error + want string // exact message; empty means the error is returned unchanged + }{ + {doc: "nil"}, + {doc: "non-status error", err: plain}, + { + doc: "unimplemented points at the enable flag", + err: status.Error(codes.Unimplemented, "unknown service"), + want: `the daemon does not support jobs; start dockerd with --extension-enable org.mobyproject.jobs.v0 (or set "extensions.enable" in daemon.json)`, + }, + { + doc: "unavailable is retryable and keeps the daemon message", + err: status.Error(codes.Unavailable, "reconciling runs"), + want: "the jobs API is unavailable (the daemon may be starting or unreachable), retry in a moment: reconciling runs", + }, + {doc: "not found", err: status.Error(codes.NotFound, `job "backup" not found`), want: `job "backup" not found`}, + {doc: "already exists", err: status.Error(codes.AlreadyExists, "name taken"), want: "name taken"}, + {doc: "failed precondition", err: status.Error(codes.FailedPrecondition, "job is already running"), want: "job is already running"}, + {doc: "invalid argument", err: status.Error(codes.InvalidArgument, "bad runs_removal"), want: "bad runs_removal"}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + got := wrapJobsError(tc.err) + + switch { + case tc.err == nil: + assert.NilError(t, got) + case tc.want == "": + assert.Check(t, is.Equal(got, tc.err)) + default: + assert.Error(t, got, tc.want) + } + }) + } +} + +// A cancelled context (Ctrl-C during a blocking call such as `docker job wait`) +// comes back from gRPC as a Canceled status, which the CLI's main only +// silences when it is recognisable as a cancellation. +func TestWrapJobsErrorCanceledIsRecognisedAsCancellation(t *testing.T) { + got := wrapJobsError(status.FromContextError(context.Canceled).Err()) + + assert.Check(t, errdefs.IsCanceled(got), "got %v", got) +} diff --git a/cli/command/job/cmd.go b/cli/command/job/cmd.go new file mode 100644 index 000000000000..0380bd4ed323 --- /dev/null +++ b/cli/command/job/cmd.go @@ -0,0 +1,106 @@ +package job + +import ( + "context" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/internal/commands" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +func init() { + commands.Register(newJobCommand) +} + +// newJobCommand returns a cobra command for `job` subcommands. +func newJobCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "job COMMAND", + Short: "Manage jobs", + Long: "Manage jobs, one-shot or scheduled containers run by the daemon.\n\n" + + "This requires the daemon's jobs extension, which is disabled by default: start\n" + + "dockerd with " + enableJobsFlag + ".", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + Annotations: map[string]string{"version": "1.57"}, + DisableFlagsInUseLine: true, + } + jobs := newLazyJobsClient(dockerCli) + cmd.PersistentPostRunE = func(*cobra.Command, []string) error { + return jobs.Close() + } + cmd.AddCommand( + newListCommand(dockerCli, jobs), + newLogsCommand(dockerCli, jobs), + ) + return cmd +} + +// lazyJobsClient defers newJobsClient(dockerCli) -- and therefore +// dockerCli.Client() -- until a jobsClient method is actually invoked from a +// subcommand's RunE. `docker job` subcommands are registered while the +// cobra command tree is built, which happens before dockerCli.Initialize(); +// dialing eagerly at that point would call dockerCli.Client() too early and +// abort the whole process (Client() calls os.Exit(1) on failure), breaking +// every command, not just `job`. +// +// The dial is memoized: a single command invocation may call more than one +// jobsClient method (e.g. `docker job logs` falling back from Inspect to +// ListRuns), and each should reuse the same connection rather than dialing +// again. newJobCommand closes it after the invoked subcommand's RunE +// succeeds (cobra skips PersistentPostRunE on error, and the process exits +// right after). +type lazyJobsClient struct { + dockerCli command.Cli + + client jobsClient + closer func() error +} + +func newLazyJobsClient(dockerCli command.Cli) *lazyJobsClient { + return &lazyJobsClient{dockerCli: dockerCli} +} + +func (c *lazyJobsClient) resolve() (jobsClient, error) { + if c.client == nil { + client, closer, err := newJobsClient(c.dockerCli) + if err != nil { + return nil, err + } + c.client, c.closer = client, closer + } + return c.client, nil +} + +func (c *lazyJobsClient) Close() error { + if c.closer == nil { + return nil + } + return c.closer() +} + +func (c *lazyJobsClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.List(ctx, req) +} + +func (c *lazyJobsClient) Inspect(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Inspect(ctx, req) +} + +func (c *lazyJobsClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.ListRuns(ctx, req) +} diff --git a/cli/command/job/cmd_test.go b/cli/command/job/cmd_test.go new file mode 100644 index 000000000000..3887c6b62968 --- /dev/null +++ b/cli/command/job/cmd_test.go @@ -0,0 +1,19 @@ +package job + +import ( + "testing" + + "github.com/docker/cli/internal/test" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// The jobs extension shipped with Engine API 1.57: the CLI hides the group and +// refuses it with a "requires API version" error on older daemons, instead of +// suggesting an --extension-enable flag they do not have. +func TestNewJobCommandRequiresAPIVersion(t *testing.T) { + cmd := newJobCommand(test.NewFakeCli(nil)) + + assert.Check(t, is.Equal(cmd.Annotations["version"], "1.57")) + assert.Check(t, is.Contains(cmd.Long, "--extension-enable org.mobyproject.jobs.v0")) +} diff --git a/cli/command/job/list.go b/cli/command/job/list.go new file mode 100644 index 000000000000..ddc1b16206e2 --- /dev/null +++ b/cli/command/job/list.go @@ -0,0 +1,128 @@ +package job + +import ( + "context" + "fmt" + "sort" + "strconv" + "time" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter/tabwriter" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + units "github.com/docker/go-units" + "github.com/spf13/cobra" +) + +// jobRow is one formatted row of `docker job ls`, matching the columns +// NAME TRIGGER STATE PAUSED LAST RUN EXIT NEXT FIRE. +type jobRow struct { + Name string + Trigger string + State string + Paused string + LastRun string + Exit string + NextFire string +} + +// newJobRow formats j into a jobRow. now is the reference time used to +// humanize durations (LAST RUN age, NEXT FIRE delay) with +// github.com/docker/go-units HumanDuration; production code passes +// time.Now(), tests inject a fixed value to stay deterministic. +func newJobRow(j jobsv0.Job, now time.Time) jobRow { + row := jobRow{ + Name: j.Name, + Trigger: jobTrigger(j), + State: j.State, + Paused: strconv.FormatBool(j.Paused), + LastRun: "-", + Exit: "-", + NextFire: "-", + } + + if j.LatestRun != nil { + row.LastRun = fmt.Sprintf("%s (%s ago)", j.LatestRun.State, units.HumanDuration(now.Sub(runAge(j.LatestRun)))) + if j.LatestRun.ExitCode != nil { + row.Exit = strconv.FormatInt(j.LatestRun.ExitCode.Value, 10) + } + } + + if !j.Paused && j.NextFireAtNano != 0 { + row.NextFire = "in " + units.HumanDuration(time.Unix(0, j.NextFireAtNano).Sub(now)) + } + + return row +} + +// jobTrigger returns the manual shorthand unless the job declares a schedule +// trigger, in which case it returns the raw cron expression. +func jobTrigger(j jobsv0.Job) string { + if isScheduled(&j) { + return j.Spec.Trigger.Schedule.Cron + } + return jobsv0.TriggerKindManual +} + +// isScheduled reports whether j declares a schedule trigger, as opposed to +// the manual shorthand. +func isScheduled(j *jobsv0.Job) bool { + return j.Spec != nil && j.Spec.Trigger != nil && j.Spec.Trigger.Schedule != nil +} + +// runAge returns the timestamp to compute a run's age from, in priority +// order: FinishedAtNano, StartedAtNano, CreatedAtNano. +func runAge(run *jobsv0.Run) time.Time { + nano := run.FinishedAtNano + if nano == 0 { + nano = run.StartedAtNano + } + if nano == 0 { + nano = run.CreatedAtNano + } + return time.Unix(0, nano) +} + +// newListCommand wires the `docker job ls` cobra command. client is injected +// explicitly (rather than dialed from dockerCli internally) so that it can be +// swapped for a fake in tests without touching gRPC; the real client is the +// lazy one built by the `docker job` parent command (see cmd.go) and threaded +// down to each subcommand constructor. +func newListCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + return &cobra.Command{ + Use: "ls [OPTIONS]", + Aliases: []string{"list"}, + Short: "List jobs", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runList(cmd.Context(), dockerCli, client) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } +} + +// 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{}) + if err != nil { + return wrapJobsError(err) + } + + jobs := reply.Jobs + sort.Slice(jobs, func(i, j int) bool { + return jobs[i].Name < jobs[j].Name + }) + + now := time.Now() + w := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tTRIGGER\tSTATE\tPAUSED\tLAST RUN\tEXIT\tNEXT FIRE") + for _, j := range jobs { + row := newJobRow(j, now) + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row.Name, row.Trigger, row.State, row.Paused, row.LastRun, row.Exit, row.NextFire) + } + return w.Flush() +} diff --git a/cli/command/job/list_test.go b/cli/command/job/list_test.go new file mode 100644 index 000000000000..866cdd7a0ef6 --- /dev/null +++ b/cli/command/job/list_test.go @@ -0,0 +1,383 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "github.com/docker/go-units" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// jobsHeaderColumns is the exact header line expected on `docker job ls`: NAME TRIGGER STATE PAUSED LAST RUN EXIT NEXT FIRE. +var jobsHeaderColumns = []string{"NAME", "TRIGGER", "STATE", "PAUSED", "LAST RUN", "EXIT", "NEXT FIRE"} + +func TestNewJobRow(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + + testCases := []struct { + doc string + job jobsv0.Job + expected jobRow + }{ + { + doc: "manual job, never run", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{Manual: true}, + }, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "nil Spec is the manual shorthand", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: nil, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "nil Trigger on a Spec is also the manual shorthand", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: nil}, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "cron job, armed, not paused", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{ + Schedule: &jobsv0.ScheduleTrigger{Cron: "*/5 * * * *"}, + }, + }, + NextFireAtNano: now.Add(5 * time.Minute).UnixNano(), + }, + expected: jobRow{ + Name: "backup", + Trigger: "*/5 * * * *", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "in 5 minutes", + }, + }, + { + doc: "paused cron job with a stale NextFireAtNano must show NEXT FIRE -, not a computed value", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Paused: true, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{ + Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}, + }, + }, + // Residual/stale value from before the job was paused: must + // be ignored, not rendered. + NextFireAtNano: now.Add(3 * time.Minute).UnixNano(), + }, + expected: jobRow{ + Name: "backup", + Trigger: "* * * * *", + State: "idle", + Paused: "true", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "unpaused cron job currently running has NextFireAtNano == 0 -> NEXT FIRE -", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateRunning, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{ + Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}, + }, + }, + NextFireAtNano: 0, + }, + expected: jobRow{ + Name: "backup", + Trigger: "* * * * *", + State: "running", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "job never ran: LAST RUN and EXIT are both -", + job: jobsv0.Job{ + Name: "migrate", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: nil, + }, + expected: jobRow{ + Name: "migrate", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "-", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "run in flight: ExitCode nil -> EXIT -, LAST RUN uses StartedAtNano since FinishedAtNano is zero", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateRunning, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateRunning, + StartedAtNano: now.Add(-30 * time.Second).UnixNano(), + ExitCode: nil, + }, + }, + expected: jobRow{ + Name: "backup", + Trigger: "manual", + State: "running", + Paused: "false", + LastRun: "running (30 seconds ago)", + Exit: "-", + NextFire: "-", + }, + }, + { + doc: "successful run with exit code zero: EXIT must be 0, NOT - (no-exit-code vs exit-code-zero trap)", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + FinishedAtNano: now.Add(-2 * time.Minute).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, + expected: jobRow{ + Name: "backup", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "succeeded (2 minutes ago)", + Exit: "0", + NextFire: "-", + }, + }, + { + doc: "failed run with a nonzero exit code", + job: jobsv0.Job{ + Name: "backup", + State: jobsv0.JobStateIdle, + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateFailed, + FinishedAtNano: now.Add(-1 * time.Hour).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 137}, + }, + }, + expected: jobRow{ + Name: "backup", + Trigger: "manual", + State: "idle", + Paused: "false", + LastRun: "failed (About an hour ago)", + Exit: "137", + NextFire: "-", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + row := newJobRow(tc.job, now) + assert.Check(t, is.DeepEqual(row, tc.expected)) + }) + } +} + +// TestNewJobRowNextFireUsesHumanDuration guards against hardcoding a +// human-readable string that only happens to match go-units' current +// implementation: it recomputes the expected string through +// units.HumanDuration itself, so the test tracks the library rather than a +// magic string. +func TestNewJobRowNextFireUsesHumanDuration(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + delay := 42 * time.Second + j := jobsv0.Job{ + Name: "backup", + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}}, + }, + NextFireAtNano: now.Add(delay).UnixNano(), + } + + row := newJobRow(j, now) + + assert.Check(t, is.Equal(row.NextFire, "in "+units.HumanDuration(delay))) +} + +func TestNewListCommandSortsByName(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return &jobsv0.ListReply{Jobs: []jobsv0.Job{ + {Name: "zeta", Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}}, + {Name: "alpha", Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}}, + {Name: "mike", Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}}, + }}, nil + }, + } + + cmd := newListCommand(cli, client) + cmd.SetOut(io.Discard) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + iAlpha := strings.Index(out, "alpha") + iMike := strings.Index(out, "mike") + iZeta := strings.Index(out, "zeta") + assert.Check(t, iAlpha >= 0 && iMike >= 0 && iZeta >= 0, "all three job names must appear in output:\n%s", out) + assert.Check(t, iAlpha < iMike, "expected alpha before mike:\n%s", out) + assert.Check(t, iMike < iZeta, "expected mike before zeta:\n%s", out) +} + +func TestNewListCommandHeader(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{} + + cmd := newListCommand(cli, client) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + for _, col := range jobsHeaderColumns { + assert.Check(t, strings.Contains(out, col), "expected header column %q in output:\n%s", col, out) + } +} + +func TestNewListCommandRendersJobFields(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return &jobsv0.ListReply{Jobs: []jobsv0.Job{ + { + Name: "backup", + State: jobsv0.JobStateIdle, + Paused: true, + Spec: &jobsv0.JobSpec{ + Trigger: &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}}, + }, + LatestRun: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, + }}, nil + }, + } + + cmd := newListCommand(cli, client) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, is.Contains(out, "backup")) + assert.Check(t, is.Contains(out, "* * * * *")) + assert.Check(t, is.Contains(out, "idle")) + assert.Check(t, is.Contains(out, "true")) // Paused column + assert.Check(t, is.Contains(out, "succeeded")) + // The exit code column must show the literal 0, not a "-" placeholder. + assert.Check(t, hasTableCell(out, "0"), "expected a table cell with the exit code 0, not '-':\n%s", out) +} + +func TestNewListCommandUnimplementedIsActionable(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + + cmd := newListCommand(cli, client) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewListCommandOtherErrorsPassThrough(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{ + listFunc: func(context.Context, *jobsv0.ListRequest) (*jobsv0.ListReply, error) { + return nil, errors.New("connection refused") + }, + } + + cmd := newListCommand(cli, client) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "connection refused") +} + +// hasTableCell reports whether out contains a whitespace-delimited cell +// equal to want. It exists because a plain strings.Contains(out, "0") would +// also match inside unrelated substrings (job names, cron fields, etc.). +func hasTableCell(out, want string) bool { + for _, field := range strings.Fields(out) { + if field == want { + return true + } + } + return false +} diff --git a/cli/command/job/logs.go b/cli/command/job/logs.go new file mode 100644 index 000000000000..86859f91e61d --- /dev/null +++ b/cli/command/job/logs.go @@ -0,0 +1,211 @@ +package job + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/moby/moby/api/pkg/stdcopy" + dockerclient "github.com/moby/moby/client" + "github.com/spf13/cobra" +) + +// pollInterval is how often waitForNextRun polls the job for a new run while +// following logs across cron occurrences. +const pollInterval = 2 * time.Second + +// logsOptions holds the parsed flags/args of `docker job logs`. +type logsOptions struct { + follow bool + job string +} + +// newLogsCommand wires the `docker job logs [-f] JOB` cobra command. client +// is injected explicitly, following the same pattern as newListCommand (see +// list.go). +func newLogsCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts logsOptions + + cmd := &cobra.Command{ + Use: "logs [OPTIONS] JOB", + Short: "Fetch the logs of a job's most recent run", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runLogs(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.BoolVarP(&opts.follow, "follow", "f", false, "Follow log output") + return cmd +} + +// runLogs resolves opts.job through client.Inspect, picks the run to read +// logs from via resolveLogsRun -- calling client.ListRuns as its listRuns +// fallback when needed -- then streams its container's logs via +// streamRunLogs. When opts.follow is set and the run's container exits +// (streamRunLogs returns after a clean EOF), runLogs polls the job via +// waitForNextRun until the next cron occurrence starts and re-attaches to +// it, so `docker job logs -f` keeps following across runs instead of +// exiting. A context cancellation (e.g. Ctrl-C) while waiting for the next +// run ends the loop cleanly, with no error surfaced to the user. +func runLogs(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *logsOptions) error { + inspectJob := func() (*jobsv0.Job, error) { + reply, err := client.Inspect(ctx, &jobsv0.InspectRequest{JobRef: opts.job}) + if err != nil { + return nil, err + } + return reply.Job, nil + } + + job, err := inspectJob() + if err != nil { + return wrapJobsError(err) + } + + run, err := resolveLogsRun(job, func() (*jobsv0.ListRunsReply, error) { + return client.ListRuns(ctx, &jobsv0.ListRunsRequest{JobRef: opts.job}) + }) + if err != nil { + return wrapJobsError(err) + } + if run.ID != job.LatestRun.ID { + _, _ = fmt.Fprintf(dockerCli.Err(), "Note: the container of the latest run %s is not available; showing the logs of run %s\n", job.LatestRun.ID, run.ID) + } + + // Only a schedule job can produce a next run on its own. + followNext := opts.follow && isScheduled(job) + for { + if err := streamRunLogs(ctx, dockerCli, run.ContainerID, opts.follow); err != nil { + return err + } + if !followNext { + return nil + } + + run, err = waitForNextRun(ctx, run.ID, inspectJob, sleepOrCancel) + if err != nil { + if ctx.Err() != nil { + return nil + } + return wrapJobsError(err) + } + } +} + +// streamRunLogs attaches to containerID's logs and demultiplexes them onto +// dockerCli.Out()/dockerCli.Err(), following the pattern of +// cli/command/container/logs.go's runLogs. It returns nil once the log +// stream ends with a clean EOF. +func streamRunLogs(ctx context.Context, dockerCli command.Cli, containerID string, follow bool) error { + c, err := dockerCli.Client().ContainerInspect(ctx, containerID, dockerclient.ContainerInspectOptions{}) + if errdefs.IsNotFound(err) { + // The container was removed (history eviction, job removal) after + // the run was looked up. + return errors.New("logs are no longer available: the run's container was removed") + } + if err != nil { + return err + } + + resp, err := dockerCli.Client().ContainerLogs(ctx, containerID, dockerclient.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + }) + if err != nil { + return err + } + defer func() { _ = resp.Close() }() + + if c.Container.Config.Tty { + _, err = io.Copy(dockerCli.Out(), resp) + } else { + _, err = stdcopy.StdCopy(dockerCli.Out(), dockerCli.Err(), resp) + } + return err +} + +// waitForNextRun polls inspect() until job.LatestRun's ID differs from +// currentRunID and its logs can be read (see hasContainer), returning that new +// run. A run is visible before its container is created (and never gets one if +// creation fails), and a fast job's container may already be gone by the time +// it is noticed: such runs are skipped rather than returned. +// +// It checks ctx.Err() before every poll so an already-cancelled (or +// cancelled-while-sleeping) context returns immediately, and tolerates +// job.LatestRun being nil (a job that hasn't run again yet) by continuing to +// poll instead of panicking. sleep is called between polls and its error +// (e.g. context cancellation) is propagated. +func waitForNextRun(ctx context.Context, currentRunID string, inspect func() (*jobsv0.Job, error), sleep func(context.Context) error) (*jobsv0.Run, error) { + for { + if err := ctx.Err(); err != nil { + return nil, err + } + + job, err := inspect() + if err != nil { + return nil, err + } + + if job.LatestRun != nil && job.LatestRun.ID != currentRunID && hasContainer(job.LatestRun) { + return job.LatestRun, nil + } + + if err := sleep(ctx); err != nil { + return nil, err + } + } +} + +// sleepOrCancel is waitForNextRun's production sleep: it waits pollInterval +// between polls, returning early with ctx.Err() if the context is cancelled +// first. +func sleepOrCancel(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + return nil + } +} + +// resolveLogsRun picks the run whose container `docker job logs` should read +// from: job.LatestRun when its logs can be read, otherwise the most recent run +// of the job's history (via listRuns, called lazily) that still has a +// container. job is the already-inspected job; this function does no I/O of +// its own. +func resolveLogsRun(job *jobsv0.Job, listRuns func() (*jobsv0.ListRunsReply, error)) (*jobsv0.Run, error) { + if job.LatestRun == nil { + return nil, fmt.Errorf("job %q has never run", job.Name) + } + if hasContainer(job.LatestRun) { + return job.LatestRun, nil + } + + reply, err := listRuns() + if err != nil { + return nil, err + } + for i := range reply.Runs { + if hasContainer(&reply.Runs[i]) { + return &reply.Runs[i], nil + } + } + return nil, fmt.Errorf("no logs available for job %q", job.Name) +} + +// hasContainer reports whether run's logs can still be read: its container +// has been created and not removed. +func hasContainer(run *jobsv0.Run) bool { + return run.ContainerID != "" && !run.ContainerGone +} diff --git a/cli/command/job/logs_followswitch_test.go b/cli/command/job/logs_followswitch_test.go new file mode 100644 index 000000000000..05080dd26566 --- /dev/null +++ b/cli/command/job/logs_followswitch_test.go @@ -0,0 +1,471 @@ +package job + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/containerd/errdefs" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + dockerclient "github.com/moby/moby/client" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// This file covers the `docker job logs -f` follow-switch behavior: once the +// container backing the followed run exits (ContainerLogs' stream ends with +// a clean EOF), `docker job logs -f` on a schedule job polls the job until a +// new run appears and re-attaches to it, instead of exiting. +// +// waitForNextRun takes an injectable sleep/inspect so its tests run in +// milliseconds with zero real time.Sleep. + +// ----------------------------------------------------------------------- +// streamRunLogs +// ----------------------------------------------------------------------- + +func TestStreamRunLogsWritesTTYOutputToStdout(t *testing.T) { + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + assert.Check(t, is.Equal(containerID, "abc123")) + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, _ dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + assert.Check(t, is.Equal(containerID, "abc123")) + return mockJobContainerLogsResult("hello\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "hello\n")) +} + +func TestStreamRunLogsForwardsFollowFlag(t *testing.T) { + var gotFollow bool + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(_ string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + gotFollow = opts.Follow + return mockJobContainerLogsResult(""), nil + }, + } + cli := test.NewFakeCli(docker) + + assert.NilError(t, streamRunLogs(context.Background(), cli, "abc123", true)) + assert.Check(t, gotFollow) +} + +func TestStreamRunLogsPropagatesContainerInspectError(t *testing.T) { + wantErr := errors.New("boom") + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{}, wantErr + }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + t.Fatal("ContainerLogs must not be called when ContainerInspect fails") + return nil, nil + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.ErrorIs(t, err, wantErr) +} + +func TestStreamRunLogsPropagatesContainerLogsError(t *testing.T) { + wantErr := errors.New("boom") + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return nil, wantErr + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.ErrorIs(t, err, wantErr) +} + +// ----------------------------------------------------------------------- +// waitForNextRun +// ----------------------------------------------------------------------- + +func TestWaitForNextRunReturnsImmediatelyWhenRunAlreadyChanged(t *testing.T) { + inspectCalls := 0 + inspect := func() (*jobsv0.Job, error) { + inspectCalls++ + return &jobsv0.Job{LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}}, nil + } + sleepCalls := 0 + sleep := func(context.Context) error { sleepCalls++; return nil } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-2")) + assert.Check(t, is.Equal(inspectCalls, 1)) + assert.Check(t, is.Equal(sleepCalls, 0), "sleep must not be called when the run already changed on the first poll") +} + +func TestWaitForNextRunPollsUntilRunChanges(t *testing.T) { + jobs := []*jobsv0.Job{ + {LatestRun: &jobsv0.Run{ID: "run-1"}}, + {LatestRun: &jobsv0.Run{ID: "run-1"}}, + {LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}}, + } + inspectCalls := 0 + inspect := func() (*jobsv0.Job, error) { + job := jobs[inspectCalls] + inspectCalls++ + return job, nil + } + sleepCalls := 0 + sleep := func(context.Context) error { sleepCalls++; return nil } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-2")) + assert.Check(t, is.Equal(inspectCalls, 3)) + assert.Check(t, is.Equal(sleepCalls, 2)) +} + +func TestWaitForNextRunReturnsImmediatelyWhenContextAlreadyCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + inspectCalled := false + inspect := func() (*jobsv0.Job, error) { + inspectCalled = true + return &jobsv0.Job{}, nil + } + sleep := func(context.Context) error { + t.Fatal("sleep must not be called when the context is already cancelled") + return nil + } + + run, err := waitForNextRun(ctx, "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, context.Canceled) + assert.Check(t, !inspectCalled, "inspect must not be called when the context is already cancelled") +} + +func TestWaitForNextRunPropagatesCancellationDuringSleep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inspect := func() (*jobsv0.Job, error) { + return &jobsv0.Job{LatestRun: &jobsv0.Run{ID: "run-1"}}, nil + } + sleep := func(ctx context.Context) error { + cancel() + return ctx.Err() + } + + run, err := waitForNextRun(ctx, "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestWaitForNextRunPropagatesInspectError(t *testing.T) { + wantErr := errors.New("connection refused") + inspect := func() (*jobsv0.Job, error) { return nil, wantErr } + sleep := func(context.Context) error { + t.Fatal("sleep must not be called when inspect fails") + return nil + } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, wantErr) +} + +func TestWaitForNextRunKeepsPollingWhenLatestRunIsNil(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inspectCalls := 0 + inspect := func() (*jobsv0.Job, error) { + inspectCalls++ + // A job that never ran again yet (e.g. a manually-triggered job) + // reports no LatestRun; polling must not panic on the nil deref + // and must keep going. + return &jobsv0.Job{LatestRun: nil}, nil + } + sleepCalls := 0 + sleep := func(ctx context.Context) error { + sleepCalls++ + if sleepCalls >= 3 { + cancel() + return ctx.Err() + } + return nil + } + + run, err := waitForNextRun(ctx, "run-1", inspect, sleep) + + assert.Check(t, is.Nil(run)) + assert.ErrorIs(t, err, context.Canceled) + assert.Check(t, inspectCalls >= 3, "expected polling to continue while LatestRun is nil, got %d inspect calls", inspectCalls) +} + +// ----------------------------------------------------------------------- +// runLogs: follow-switch integration +// ----------------------------------------------------------------------- + +func TestRunLogsWithoutFollowDoesNotPollForNextRun(t *testing.T) { + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + atomic.AddInt32(&inspectCalls, 1) + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(context.Background(), cli, jobs, &logsOptions{job: "backup", follow: false}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(1))) +} + +// TestRunLogsFollowSwitchesToNextRunOnContainerStop drives two full cron +// occurrences through runLogs(follow=true): run-1's container exits (clean +// ContainerLogs EOF), runLogs polls the job and re-attaches to run-2 as soon +// as it appears, then run-2's container also exits and the context is +// cancelled (simulating Ctrl-C) so the otherwise-infinite follow loop +// terminates. +func TestRunLogsFollowSwitchesToNextRunOnContainerStop(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + switch atomic.AddInt32(&inspectCalls, 1) { + case 1: + // Initial resolution in runLogs, before the first attach. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + case 2: + // First waitForNextRun poll, after run-1's container exits: + // run-2 already exists, switch immediately. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}, + }}, nil + default: + // Second waitForNextRun poll, after run-2's container + // exits: no new run yet. Cancel the context here to end + // the test the same way a Ctrl-C would. + cancel() + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "c2"}, + }}, nil + } + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + assert.Check(t, opts.Follow, "runLogs must request Follow on the docker API when opts.follow is set") + switch containerID { + case "c1": + return mockJobContainerLogsResult("[backup] run-1 done\n"), nil + case "c2": + return mockJobContainerLogsResult("[backup] run-2 done\n"), nil + default: + t.Fatalf("unexpected containerID %q", containerID) + return nil, nil + } + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(ctx, cli, jobs, &logsOptions{job: "backup", follow: true}) + + assert.NilError(t, err, "runLogs must exit cleanly (nil error) when the context is cancelled while waiting for the next run") + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] run-1 done\n[backup] run-2 done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(3))) +} + +// TestRunLogsFollowSkipsRunsWhoseContainerIsAlreadyGone covers the case where +// waitForNextRun picks up a new run whose container has already been removed +// (e.g. a fast job cleaned up right after a successful exit -- observed for +// real against a daemon where Compose removes successful run containers). +// runLogs must keep polling for the run after it instead of trying (and +// failing) to attach to the gone container. +func TestRunLogsFollowSkipsRunsWhoseContainerIsAlreadyGone(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + switch atomic.AddInt32(&inspectCalls, 1) { + case 1: + // Initial resolution in runLogs, before the first attach. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + case 2: + // First waitForNextRun poll: run-2 exists but its container + // is already gone -- runLogs must NOT attach to it, and + // must NOT surface an error either; it keeps polling. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-2", ContainerID: "should-not-be-attached", ContainerGone: true}, + }}, nil + case 3: + // Second poll: run-3 has a live container, switch to it. + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-3", ContainerID: "c3"}, + }}, nil + default: + // Third poll, after run-3's container exits: no new run + // yet. Cancel the context here to end the test the same + // way a Ctrl-C would. + cancel() + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: scheduledSpec(), + LatestRun: &jobsv0.Run{ID: "run-3", ContainerID: "c3"}, + }}, nil + } + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + if containerID == "should-not-be-attached" { + t.Fatalf("ContainerInspect must not be called for a run whose container is already gone") + } + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + if containerID == "should-not-be-attached" { + t.Fatalf("ContainerLogs must not be called for a run whose container is already gone") + } + switch containerID { + case "c1": + return mockJobContainerLogsResult("[backup] run-1 done\n"), nil + case "c3": + return mockJobContainerLogsResult("[backup] run-3 done\n"), nil + default: + t.Fatalf("unexpected containerID %q", containerID) + return nil, nil + } + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(ctx, cli, jobs, &logsOptions{job: "backup", follow: true}) + + assert.NilError(t, err, "runLogs must exit cleanly (nil error) when the context is cancelled while waiting for the next run") + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] run-1 done\n[backup] run-3 done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(4))) +} + +// scheduledSpec is the spec of a job with a cron schedule, the only kind of +// job `logs -f` can follow past the end of a run. +func scheduledSpec() *jobsv0.JobSpec { + return &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Schedule: &jobsv0.ScheduleTrigger{Cron: "* * * * *"}}} +} + +// A manual job only runs on an explicit `docker job run`: once its run's +// container exits, `logs -f` has nothing left to follow and must return +// instead of polling forever. +func TestRunLogsFollowReturnsAfterStreamForManualJob(t *testing.T) { + var inspectCalls int32 + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + atomic.AddInt32(&inspectCalls, 1) + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + Spec: &jobsv0.JobSpec{Trigger: &jobsv0.Trigger{Manual: true}}, + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "c1"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + err := runLogs(context.Background(), cli, jobs, &logsOptions{job: "backup", follow: true}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done\n")) + assert.Check(t, is.Equal(atomic.LoadInt32(&inspectCalls), int32(1)), "a manual job must not be polled for a next run") +} + +// A new run is visible as soon as its record is written, before its container +// exists. Attaching then would fail on an empty container ID, so the next run +// is only picked up once its container does exist. +func TestWaitForNextRunWaitsForTheRunContainer(t *testing.T) { + polls := []*jobsv0.Run{ + {ID: "run-2", State: jobsv0.RunStatePending}, + {ID: "run-2", State: jobsv0.RunStateRunning, ContainerID: "c2"}, + } + i := 0 + inspect := func() (*jobsv0.Job, error) { + run := polls[i] + i++ + return &jobsv0.Job{LatestRun: run}, nil + } + sleepCalls := 0 + sleep := func(context.Context) error { sleepCalls++; return nil } + + run, err := waitForNextRun(context.Background(), "run-1", inspect, sleep) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-2")) + assert.Check(t, is.Equal(run.ContainerID, "c2")) + assert.Check(t, is.Equal(sleepCalls, 1)) +} + +func TestStreamRunLogsReportsRemovedContainerAsUnavailableLogs(t *testing.T) { + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { + return dockerclient.ContainerInspectResult{}, errdefs.ErrNotFound.WithMessage("No such container: abc123") + }, + } + cli := test.NewFakeCli(docker) + + err := streamRunLogs(context.Background(), cli, "abc123", false) + + assert.ErrorContains(t, err, "logs are no longer available") +} diff --git a/cli/command/job/logs_resolve_test.go b/cli/command/job/logs_resolve_test.go new file mode 100644 index 000000000000..d1ab3e4cb49c --- /dev/null +++ b/cli/command/job/logs_resolve_test.go @@ -0,0 +1,134 @@ +package job + +import ( + "errors" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestResolveLogsRunNeverRun(t *testing.T) { + job := &jobsv0.Job{Name: "migrate", LatestRun: nil} + fallbackCalled := false + listRuns := func() (*jobsv0.ListRunsReply, error) { + fallbackCalled = true + return &jobsv0.ListRunsReply{}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `job "migrate" has never run`) + assert.Check(t, !fallbackCalled, "listRuns must not be called when the job never ran") +} + +func TestResolveLogsRunReturnsLatestRunWhenContainerPresent(t *testing.T) { + latest := &jobsv0.Run{ID: "run-3", ContainerID: "abc123", ContainerGone: false} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + fallbackCalled := false + listRuns := func() (*jobsv0.ListRunsReply, error) { + fallbackCalled = true + return &jobsv0.ListRunsReply{}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-3")) + assert.Check(t, is.Equal(run.ContainerID, "abc123")) + assert.Check(t, !fallbackCalled, "listRuns must not be called when LatestRun's container is not gone") +} + +func TestResolveLogsRunFallsBackToListRunsWhenContainerGone(t *testing.T) { + latest := &jobsv0.Run{ID: "run-3", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + // Most recent first, matching ListRuns's documented ordering: + // run-3 (the gone latest run) is included in history too, run-2's + // container was also removed, run-1's container is still around. + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-3", ContainerGone: true}, + {ID: "run-2", ContainerGone: true}, + {ID: "run-1", ContainerID: "def456", ContainerGone: false}, + }}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-1")) + assert.Check(t, is.Equal(run.ContainerID, "def456")) +} + +func TestResolveLogsRunNoLogsAvailableWhenNoRunHasContainer(t *testing.T) { + latest := &jobsv0.Run{ID: "run-2", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-2", ContainerGone: true}, + {ID: "run-1", ContainerGone: true}, + }}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} + +func TestResolveLogsRunNoLogsAvailableWhenHistoryEmpty(t *testing.T) { + latest := &jobsv0.Run{ID: "run-1", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} + +func TestResolveLogsRunPropagatesListRunsError(t *testing.T) { + latest := &jobsv0.Run{ID: "run-1", ContainerGone: true} + job := &jobsv0.Job{Name: "backup", LatestRun: latest} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return nil, errors.New("connection refused") + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, "connection refused") +} + +func TestResolveLogsRunFallsBackWhenLatestRunHasNoContainerYet(t *testing.T) { + // A pending run, or one that failed before its container was created, has + // neither a ContainerID nor ContainerGone set. + job := &jobsv0.Job{Name: "backup", LatestRun: &jobsv0.Run{ID: "run-2", State: jobsv0.RunStateFailed, Error: "no such image"}} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-2", State: jobsv0.RunStateFailed}, + {ID: "run-1", ContainerID: "def456"}, + }}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.NilError(t, err) + assert.Check(t, is.Equal(run.ID, "run-1")) +} + +func TestResolveLogsRunNoLogsAvailableWhenNoRunEverHadContainer(t *testing.T) { + job := &jobsv0.Job{Name: "backup", LatestRun: &jobsv0.Run{ID: "run-1", State: jobsv0.RunStatePending}} + listRuns := func() (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{{ID: "run-1", State: jobsv0.RunStatePending}}}, nil + } + + run, err := resolveLogsRun(job, listRuns) + + assert.Check(t, is.Nil(run)) + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} diff --git a/cli/command/job/logs_test.go b/cli/command/job/logs_test.go new file mode 100644 index 000000000000..596b9d703148 --- /dev/null +++ b/cli/command/job/logs_test.go @@ -0,0 +1,316 @@ +package job + +import ( + "context" + "io" + "strings" + "testing" + "time" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "github.com/moby/moby/api/types/container" + dockerclient "github.com/moby/moby/client" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// fakeDockerAPIClient is a test double for the subset of client.APIClient +// consumed by `docker job logs` (ContainerInspect, ContainerLogs), following +// the same embedding pattern as cli/command/container's fakeClient: embed +// the zero-value concrete dockerclient.Client (which satisfies +// client.APIClient) and override only what the command under test calls. +type fakeDockerAPIClient struct { + dockerclient.Client + inspectFunc func(containerID string) (dockerclient.ContainerInspectResult, error) + logFunc func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) +} + +func (f *fakeDockerAPIClient) ContainerInspect(_ context.Context, containerID string, _ dockerclient.ContainerInspectOptions) (dockerclient.ContainerInspectResult, error) { + if f.inspectFunc != nil { + return f.inspectFunc(containerID) + } + return dockerclient.ContainerInspectResult{}, nil +} + +func (f *fakeDockerAPIClient) ContainerLogs(_ context.Context, containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + if f.logFunc != nil { + return f.logFunc(containerID, opts) + } + return mockJobContainerLogsResult(""), nil +} + +func mockJobContainerLogsResult(content string) dockerclient.ContainerLogsResult { + return io.NopCloser(strings.NewReader(content)) +} + +// ttyInspectResult is a ContainerInspect result for a TTY container, the +// only demux path exercised here -- see the FIXME in +// cli/command/container/logs_test.go about mocking the non-TTY stdcopy +// stream; this test suite follows the same simplification. +func ttyInspectResult() dockerclient.ContainerInspectResult { + return dockerclient.ContainerInspectResult{ + Container: container.InspectResponse{ + Config: &container.Config{Tty: true}, + }, + } +} + +func TestNewLogsCommandFollowFlagForwarded(t *testing.T) { + testCases := []struct { + doc string + args []string + wantFollow bool + }{ + {doc: "long flag", args: []string{"backup", "--follow"}, wantFollow: true}, + {doc: "short flag", args: []string{"backup", "-f"}, wantFollow: true}, + {doc: "flag omitted defaults to false", args: []string{"backup"}, wantFollow: false}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + var gotOpts dockerclient.ContainerLogsOptions + jobs := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + assert.Check(t, is.Equal(req.JobRef, "backup")) + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "abc123"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(containerID string, opts dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + gotOpts = opts + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + // A follow=true run only returns once its context is done: after + // streaming the current run to completion, `logs -f` waits for + // the job's next run to appear (see waitForNextRun) rather than + // returning, exactly like `docker logs -f` waits on a live + // container. This test only cares that the flag reaches + // ContainerLogsOptions, which happens on the first attach, so a + // short timeout stands in for the Ctrl-C that would normally end + // a real `-f` session. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs(tc.args) + cmd.SetOut(io.Discard) + assert.NilError(t, cmd.ExecuteContext(ctx)) + + assert.Check(t, is.Equal(gotOpts.Follow, tc.wantFollow)) + }) + } +} + +func TestNewLogsCommandResolvesContainerIDFromLatestRun(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "abc123", ContainerGone: false}, + }}, nil + }, + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + t.Fatal("ListRuns must not be called when LatestRun's container is not gone") + return nil, nil + }, + } + var gotContainerID string + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + gotContainerID = containerID + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, _ dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(gotContainerID, "abc123")) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done")) +} + +func TestNewLogsCommandFallsBackWhenContainerGone(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-2", ContainerGone: true}, + }}, nil + }, + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + assert.Check(t, is.Equal(req.JobRef, "backup")) + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {ID: "run-2", ContainerGone: true}, + {ID: "run-1", ContainerID: "def456", ContainerGone: false}, + }}, nil + }, + } + var gotContainerID string + docker := &fakeDockerAPIClient{ + inspectFunc: func(containerID string) (dockerclient.ContainerInspectResult, error) { + gotContainerID = containerID + return ttyInspectResult(), nil + }, + logFunc: func(containerID string, _ dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(gotContainerID, "def456")) +} + +func TestNewLogsCommandNeverRunError(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{Name: "migrate", LatestRun: nil}}, nil + }, + } + docker := &fakeDockerAPIClient{ + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + t.Fatal("ContainerLogs must not be called when the job never ran") + return nil, nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"migrate"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `job "migrate" has never run`) +} + +func TestNewLogsCommandNoLogsAvailableError(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerGone: true}, + }}, nil + }, + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{{ID: "run-1", ContainerGone: true}}}, nil + }, + } + docker := &fakeDockerAPIClient{ + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + t.Fatal("ContainerLogs must not be called when no run has a container") + return nil, nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `no logs available for job "backup"`) +} + +func TestNewLogsCommandUnimplementedIsActionable(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(&fakeDockerAPIClient{}) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewLogsCommandStreamsContainerOutput(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + Name: "backup", + LatestRun: &jobsv0.Run{ID: "run-1", ContainerID: "abc123"}, + }}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done\n"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done\n")) + assert.Check(t, is.Equal(cli.ErrBuffer().String(), "")) +} + +func TestNewLogsCommandNotesFallbackToOlderRun(t *testing.T) { + testCases := []struct { + doc string + latest jobsv0.Run + wantNoteOn []string // substrings expected on stderr; none means stderr stays empty + }{ + {doc: "latest run has logs", latest: jobsv0.Run{ID: "run-2", ContainerID: "def456"}}, + {doc: "latest run's container is gone", latest: jobsv0.Run{ID: "run-2", ContainerGone: true}, wantNoteOn: []string{"run-1", "run-2"}}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + jobs := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{Name: "backup", LatestRun: &tc.latest}}, nil + }, + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{tc.latest, {ID: "run-1", ContainerID: "abc123"}}}, nil + }, + } + docker := &fakeDockerAPIClient{ + inspectFunc: func(string) (dockerclient.ContainerInspectResult, error) { return ttyInspectResult(), nil }, + logFunc: func(string, dockerclient.ContainerLogsOptions) (dockerclient.ContainerLogsResult, error) { + return mockJobContainerLogsResult("[backup] done"), nil + }, + } + cli := test.NewFakeCli(docker) + + cmd := newLogsCommand(cli, jobs) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "[backup] done"), "the note must not pollute stdout") + if len(tc.wantNoteOn) == 0 { + assert.Check(t, is.Equal(cli.ErrBuffer().String(), "")) + } + for _, want := range tc.wantNoteOn { + assert.Check(t, is.Contains(cli.ErrBuffer().String(), want)) + } + }) + } +} diff --git a/vendor/github.com/moby/extensions/client/client.go b/vendor/github.com/moby/extensions/client/client.go new file mode 100644 index 000000000000..e42a2302a60e --- /dev/null +++ b/vendor/github.com/moby/extensions/client/client.go @@ -0,0 +1,226 @@ +// Package client provides typed, lazy clients for extension points. +// +// Generated wire bindings expose a ClientPoint registration containing the +// runtime gRPC adapter factory. +// A handwritten extensions.Point supplies the compile-time result type used by +// Resolve. +// Each Client owns a separate lazy connection derived from an Engine dialer. +// Resolve only constructs a local proxy, so unavailable or unpublished services +// report errors when a resolved method is invoked rather than during Resolve. +// +// A generated greeter binding can be configured and resolved as follows: +// +// extensions, err := client.New( +// engine, +// client.WithGRPCPoint(greeterpb.ClientPoint), +// ) +// if err != nil { +// return err +// } +// defer extensions.Close() +// +// greeter, err := client.Resolve(extensions, greeterv0.Point) +// if err != nil { +// return err +// } +// +// reply, err := greeter.Greet( +// ctx, +// &greeterv0.HelloRequest{Name: "world"}, +// ) +package client + +import ( + "context" + "errors" + "fmt" + "net" + "reflect" + "sync" + + "github.com/moby/extensions" + "github.com/moby/extensions/clientpoint" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const engineTarget = "passthrough:///moby-engine" + +// Engine supplies the engine connection dialer used by a Client. +// +// The dialer is kept lazy by the client and is invoked by gRPC only when the +// client is used. +type Engine interface { + Dialer() func(context.Context) (net.Conn, error) +} + +// Opt configures a Client. +type Opt func(*config) error + +type config struct { + points map[extensions.PointID]clientpoint.Registration +} + +// WithGRPCPoint configures the generated ClientPoint registration for a point. +// Generated wire packages expose ClientPoint as the host-side gRPC wiring; +// Resolve receives the handwritten typed extensions.Point separately so its Go +// interface type remains available to the caller. +// Registration.Single is host cardinality metadata and is intentionally ignored +// by this client. +func WithGRPCPoint(registration clientpoint.Registration) Opt { + return func(cfg *config) error { + if cfg == nil { + return errors.New("client: nil configuration") + } + if err := extensions.ValidatePointID(registration.Point); err != nil { + return fmt.Errorf("client: invalid gRPC point registration: %w", err) + } + if registration.Provider == nil { + return fmt.Errorf("client: point %q has nil provider", registration.Point) + } + if cfg.points == nil { + cfg.points = make(map[extensions.PointID]clientpoint.Registration) + } + if _, exists := cfg.points[registration.Point]; exists { + return fmt.Errorf("client: duplicate gRPC point registration for point %q", registration.Point) + } + cfg.points[registration.Point] = registration + return nil + } +} + +type pointState struct { + registration clientpoint.Registration + provider extensions.Provider + ready bool +} + +// Client owns one lazy gRPC connection to the engine and the configured point +// adapters. The connection is private to the Client and is never borrowed from +// or closed by the Engine. +type Client struct { + mu sync.Mutex + conn *grpc.ClientConn + points map[extensions.PointID]*pointState + closed bool + closeErr error +} + +// New creates a Client without dialing the engine. +// +// The connection is separately owned by the returned Client and remains lazy; +// availability errors therefore occur when a resolved provider is invoked, not +// during construction. New performs no speculative transport connection or +// preflight. +func New(engine Engine, opts ...Opt) (*Client, error) { + if isNil(engine) { + return nil, errors.New("client: engine is nil") + } + + cfg := config{points: make(map[extensions.PointID]clientpoint.Registration)} + for _, opt := range opts { + if opt == nil { + return nil, errors.New("client: nil option") + } + if err := opt(&cfg); err != nil { + return nil, err + } + } + dialer := engine.Dialer() + if dialer == nil { + return nil, errors.New("client: engine dialer is nil") + } + + conn, err := grpc.NewClient( + engineTarget, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return dialer(ctx) + }), + ) + if err != nil { + return nil, fmt.Errorf("client: create gRPC client: %w", err) + } + + points := make(map[extensions.PointID]*pointState, len(cfg.points)) + for point, registration := range cfg.points { + points[point] = &pointState{registration: registration} + } + return &Client{conn: conn, points: points}, nil +} + +// Resolve returns the typed provider registered for point. +// +// The generated ClientPoint registration and handwritten extensions.Point are +// intentionally separate: ClientPoint supplies the gRPC adapter, while point +// supplies the compile-time provider interface type. The adapter is invoked at +// most once and only when this point is first resolved. Provider construction +// does not preflight the engine, so transport availability errors are returned +// by the provider's method invocation. +func Resolve[T any](client *Client, point extensions.Point[T]) (T, error) { + var zero T + if client == nil { + return zero, errors.New("client: client is nil") + } + + pointID := point.ID() + client.mu.Lock() + defer client.mu.Unlock() + + if client.closed { + return zero, errors.New("client: client is closed") + } + state, configured := client.points[pointID] + if !configured { + return zero, fmt.Errorf("client: point %q is not configured", pointID) + } + if !state.ready { + state.ready = true + state.provider = state.registration.Provider(client.conn) + } + + provider := state.provider + if provider.Point != pointID { + return zero, fmt.Errorf("client: provider point %q does not match requested point %q", provider.Point, pointID) + } + impl, ok := provider.Impl.(T) + if !ok { + return zero, fmt.Errorf("client: provider for point %q has implementation type %T, want %v", pointID, provider.Impl, reflect.TypeFor[T]()) + } + return impl, nil +} + +// Close closes the Client's private gRPC connection. +// +// Close is nil-safe and idempotent. It waits for an in-progress Resolve before +// closing, and later Resolve calls fail without invoking another adapter. +func (client *Client) Close() error { + if client == nil { + return nil + } + + client.mu.Lock() + defer client.mu.Unlock() + if client.closed { + return client.closeErr + } + client.closed = true + if client.conn == nil { + return nil + } + client.closeErr = client.conn.Close() + return client.closeErr +} + +func isNil(value any) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index aad27bbd058a..643f95258883 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -164,6 +164,7 @@ github.com/moby/docker-image-spec/specs-go/v1 # github.com/moby/extensions v0.0.0-20260826001921-d37867cb107f ## explicit; go 1.26.0 github.com/moby/extensions +github.com/moby/extensions/client github.com/moby/extensions/clientpoint github.com/moby/extensions/serverpoint # github.com/moby/go-archive v0.3.3 From 2d7b65c20ac8d0ffec8a77ba8befa365749a7662 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 10 Nov 2026 10:20:52 +0100 Subject: [PATCH 3/6] cli/command/job: add `docker job inspect` and `runs` commands - `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 --- cli/command/job/client.go | 3 + cli/command/job/client_test.go | 14 +- cli/command/job/cmd.go | 10 + cli/command/job/inspect.go | 96 +++++++ cli/command/job/inspect_test.go | 284 ++++++++++++++++++++ cli/command/job/runs.go | 130 +++++++++ cli/command/job/runs_test.go | 462 ++++++++++++++++++++++++++++++++ 7 files changed, 996 insertions(+), 3 deletions(-) create mode 100644 cli/command/job/inspect.go create mode 100644 cli/command/job/inspect_test.go create mode 100644 cli/command/job/runs.go create mode 100644 cli/command/job/runs_test.go diff --git a/cli/command/job/client.go b/cli/command/job/client.go index 0836f7e3c539..69a4d59aad6c 100644 --- a/cli/command/job/client.go +++ b/cli/command/job/client.go @@ -31,6 +31,9 @@ type jobsClient interface { // ListRuns returns a job's runs, most recent first. Used as the // fallback when the latest run's container is gone. ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) + + // InspectRun returns a single run. + InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) } // newJobsClient dials the daemon's jobs extension point and returns a diff --git a/cli/command/job/client_test.go b/cli/command/job/client_test.go index 8d6eefcc44b9..e818aaab65a1 100644 --- a/cli/command/job/client_test.go +++ b/cli/command/job/client_test.go @@ -23,9 +23,10 @@ var _ jobsClient = jobsv0.Jobs(nil) // one Func field per method, a nil field falling back to a harmless // zero-value response. type fakeClient struct { - listFunc func(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) - inspectFunc func(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) - listRunsFunc func(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) + listFunc func(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) + inspectFunc func(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) + listRunsFunc func(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) + inspectRunFunc func(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) } func (c *fakeClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { @@ -49,6 +50,13 @@ func (c *fakeClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) return &jobsv0.ListRunsReply{}, nil } +func (c *fakeClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + if c.inspectRunFunc != nil { + return c.inspectRunFunc(ctx, req) + } + return &jobsv0.InspectRunReply{}, nil +} + var _ jobsClient = (*fakeClient)(nil) func TestWrapJobsError(t *testing.T) { diff --git a/cli/command/job/cmd.go b/cli/command/job/cmd.go index 0380bd4ed323..3de3fe37d3d4 100644 --- a/cli/command/job/cmd.go +++ b/cli/command/job/cmd.go @@ -34,6 +34,8 @@ func newJobCommand(dockerCli command.Cli) *cobra.Command { cmd.AddCommand( newListCommand(dockerCli, jobs), newLogsCommand(dockerCli, jobs), + newRunsCommand(dockerCli, jobs), + newInspectCommand(dockerCli, jobs), ) return cmd } @@ -104,3 +106,11 @@ func (c *lazyJobsClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsReque } return client.ListRuns(ctx, req) } + +func (c *lazyJobsClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.InspectRun(ctx, req) +} diff --git a/cli/command/job/inspect.go b/cli/command/job/inspect.go new file mode 100644 index 000000000000..ac347464d470 --- /dev/null +++ b/cli/command/job/inspect.go @@ -0,0 +1,96 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + +package job + +import ( + "context" + "encoding/json" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// inspectOptions holds the parsed flags/args of `docker job inspect`. +type inspectOptions struct { + job string + run string +} + +// newInspectCommand wires the `docker job inspect [--run RUN] JOB` cobra +// command. client is injected explicitly, following the same pattern as +// newListCommand/newLogsCommand/newRunCommand (see list.go/logs.go/run.go). +func newInspectCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts inspectOptions + + cmd := &cobra.Command{ + Use: "inspect [OPTIONS] JOB", + Short: "Display detailed information on a job", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runInspect(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.StringVar(&opts.run, "run", "", "Inspect a specific run instead of the job") + return cmd +} + +// jobView is a jobsv0.Job as printed by `docker job inspect`: the container +// spec is a JSON document carried as bytes, so it is shown as that document +// rather than as the base64 string a []byte marshals to. The embedded +// structs' ContainerSpec and Spec fields are shadowed by the ones declared +// here. +type jobView struct { + jobsv0.Job + Spec *specView +} + +type specView struct { + jobsv0.JobSpec + ContainerSpec json.RawMessage +} + +func newJobView(j *jobsv0.Job) *jobView { + if j == nil { + return nil + } + v := &jobView{Job: *j} + if j.Spec != nil { + v.Spec = &specView{JobSpec: *j.Spec, ContainerSpec: j.Spec.ContainerSpec} + } + return v +} + +// runInspect prints the jobsv0.Job (or jobsv0.Run, with opts.run set) as +// indented JSON on dockerCli.Out() -- a single object, not wrapped in an +// array, unlike the generic cli/command/inspect package. +func runInspect(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *inspectOptions) error { + var v any + if opts.run != "" { + reply, err := client.InspectRun(ctx, &jobsv0.InspectRunRequest{JobRef: opts.job, RunRef: opts.run}) + if err != nil { + return wrapJobsError(err) + } + v = reply.Run + } else { + reply, err := client.Inspect(ctx, &jobsv0.InspectRequest{JobRef: opts.job}) + if err != nil { + return wrapJobsError(err) + } + v = newJobView(reply.Job) + } + + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + _, err = dockerCli.Out().Write(append(out, '\n')) + return err +} diff --git a/cli/command/job/inspect_test.go b/cli/command/job/inspect_test.go new file mode 100644 index 000000000000..f0df57cf63d9 --- /dev/null +++ b/cli/command/job/inspect_test.go @@ -0,0 +1,284 @@ +package job + +import ( + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// Output format: `docker job inspect` dumps the raw jobsv0.Job (or +// jobsv0.Run, with --run) as indented JSON on dockerCli.Out(), 2-space +// indent, one object -- not wrapped in an array, unlike the generic +// cli/command/inspect package (which serves multi-reference --format/ +// template inspection across several docker CLI resources and is not a good +// fit for this single-object, template-less case). Tests assert on decoded +// JSON fields rather than the exact byte layout, to stay robust to +// formatting-only changes. + +func TestNewInspectCommandWithoutRunCallsInspectOnly(t *testing.T) { + var gotReq *jobsv0.InspectRequest + client := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + gotReq = req + return &jobsv0.InspectReply{Job: &jobsv0.Job{ID: "job-1", Name: "backup"}}, nil + }, + inspectRunFunc: func(context.Context, *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + t.Fatal("client.InspectRun must not be called without --run") + return nil, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Inspect was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + + var got jobsv0.Job + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.ID, "job-1")) + assert.Check(t, is.Equal(got.Name, "backup")) +} + +func TestNewInspectCommandWithRunCallsInspectRunOnly(t *testing.T) { + var gotReq *jobsv0.InspectRunRequest + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + t.Fatal("client.Inspect must not be called with --run") + return nil, nil + }, + inspectRunFunc: func(_ context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + gotReq = req + return &jobsv0.InspectRunReply{Run: &jobsv0.Run{ID: "run-1", JobID: "job-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"--run", "latest", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.InspectRun was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "latest")) + + var got jobsv0.Run + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.ID, "run-1")) + assert.Check(t, is.Equal(got.JobID, "job-1")) +} + +func TestNewInspectCommandWithArbitraryRunRefForwarded(t *testing.T) { + var gotReq *jobsv0.InspectRunRequest + client := &fakeClient{ + inspectRunFunc: func(_ context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + gotReq = req + return &jobsv0.InspectRunReply{Run: &jobsv0.Run{ID: "run-42"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"--run", "run-42", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(gotReq.RunRef, "run-42")) +} + +func TestNewInspectCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewInspectCommandUnimplementedOnInspectIsActionable(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewInspectCommandUnimplementedOnInspectRunIsActionable(t *testing.T) { + client := &fakeClient{ + inspectRunFunc: func(context.Context, *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + cmd.SetArgs([]string{"--run", "latest", "backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewInspectCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + testCases := []struct { + doc string + run string + errText string + }{ + {doc: "job not found, without --run", errText: `job "backup" not found`}, + {doc: "run not found, with --run", run: "latest", errText: `run "latest" not found for job "backup"`}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return nil, errors.New(tc.errText) + }, + inspectRunFunc: func(context.Context, *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + return nil, errors.New(tc.errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newInspectCommand(cli, client) + args := []string{"backup"} + if tc.run != "" { + args = append([]string{"--run", tc.run}, args...) + } + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, tc.errText) + }) + } +} + +// TestRunInspectDirect exercises runInspect directly (bypassing cobra arg +// parsing), following the pattern set by run_test.go/logs_test.go of testing +// the RunE body as a plain function in addition to going through the cobra +// command. +func TestRunInspectDirect(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(_ context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ID: "job-1", Name: req.JobRef}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + var got jobsv0.Job + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.Name, "backup")) +} + +func TestRunInspectDirectWithRun(t *testing.T) { + client := &fakeClient{ + inspectRunFunc: func(_ context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { + return &jobsv0.InspectRunReply{Run: &jobsv0.Run{ID: "run-1", JobID: req.JobRef}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup", run: "latest"}) + + assert.NilError(t, err) + var got jobsv0.Run + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got)) + assert.Check(t, is.Equal(got.ID, "run-1")) + assert.Check(t, is.Equal(got.JobID, "backup")) +} + +// The container spec is a JSON document carried as bytes: it must be printed +// as that document, not as the base64 string a []byte marshals to. +func TestRunInspectPrintsContainerSpecAsJSON(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ + ID: "job-1", + Name: "backup", + Spec: &jobsv0.JobSpec{ + ContainerSpec: []byte(`{"Image":"busybox","Cmd":["echo","hi"]}`), + TimeoutSeconds: 30, + }, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + var got struct { + Name string + Spec struct { + ContainerSpec map[string]any + TimeoutSeconds int64 + } + } + assert.NilError(t, json.Unmarshal(cli.OutBuffer().Bytes(), &got), "output: %s", cli.OutBuffer()) + assert.Check(t, is.Equal(got.Name, "backup")) + assert.Check(t, is.Equal(got.Spec.ContainerSpec["Image"], "busybox")) + assert.Check(t, is.Equal(got.Spec.TimeoutSeconds, int64(30)), "the other spec fields must be kept") +} + +func TestRunInspectJobWithoutSpec(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{Job: &jobsv0.Job{ID: "job-1", Name: "backup"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, strings.Contains(cli.OutBuffer().String(), `"Spec": null`), "output: %s", cli.OutBuffer()) +} + +// A reply without a job used to be printed as null. Building the view of the +// job must not turn that into a panic. +func TestRunInspectReplyWithoutJob(t *testing.T) { + client := &fakeClient{ + inspectFunc: func(context.Context, *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) { + return &jobsv0.InspectReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runInspect(context.Background(), cli, client, &inspectOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "null\n")) +} diff --git a/cli/command/job/runs.go b/cli/command/job/runs.go new file mode 100644 index 000000000000..aeceb386f641 --- /dev/null +++ b/cli/command/job/runs.go @@ -0,0 +1,130 @@ +package job + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/cli/cli/command/formatter/tabwriter" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + units "github.com/docker/go-units" + "github.com/spf13/cobra" +) + +// runRow is one formatted row of `docker job runs`, matching the columns +// ITERATION STATE EXIT TRIGGER STARTED FINISHED CONTAINER. +type runRow struct { + Iteration string + State string + Exit string + Trigger string + Started string + Finished string + Container string +} + +// newRunRow formats r into a runRow. now is the reference time used to +// humanize STARTED/FINISHED with github.com/docker/go-units HumanDuration; +// production code passes time.Now(), tests inject a fixed value to stay +// deterministic. +func newRunRow(r jobsv0.Run, now time.Time) runRow { + row := runRow{ + Iteration: strconv.FormatUint(r.Iteration, 10), + State: r.State, + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "-", + Container: "-", + } + + if r.ExitCode != nil { + row.Exit = strconv.FormatInt(r.ExitCode.Value, 10) + } + if r.Trigger != nil { + row.Trigger = r.Trigger.Kind + } + if r.StartedAtNano != 0 { + row.Started = units.HumanDuration(now.Sub(time.Unix(0, r.StartedAtNano))) + " ago" + } + if r.FinishedAtNano != 0 { + row.Finished = units.HumanDuration(now.Sub(time.Unix(0, r.FinishedAtNano))) + " ago" + } + if r.ContainerID != "" { + row.Container = formatter.TruncateID(r.ContainerID) + } + + return row +} + +// runsOptions holds the parsed flags/args of `docker job runs`. +type runsOptions struct { + job string + limit int32 + before string +} + +// newRunsCommand wires the `docker job runs [OPTIONS] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newListCommand (see list.go). +func newRunsCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts runsOptions + + cmd := &cobra.Command{ + Use: "runs [OPTIONS] JOB", + Short: "List a job's runs", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runRuns(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.Int32Var(&opts.limit, "limit", 0, "Maximum number of runs to list (0 for the daemon default)") + flags.StringVar(&opts.before, "before", "", "List runs older than this cursor (from a previous page's NextCursor)") + return cmd +} + +// runRuns calls client.ListRuns and renders the returned page as a +// tabwriter table to dockerCli.Out(), in the order the daemon returned +// them (most-recent-first, per ListRuns's godoc) without re-sorting. A stale +// requested cursor is warned about on dockerCli.Err(); a non-empty +// NextCursor prints a paging hint on dockerCli.Out() after the table. +func runRuns(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *runsOptions) error { + reply, err := client.ListRuns(ctx, &jobsv0.ListRunsRequest{ + JobRef: opts.job, + Limit: opts.limit, + Before: opts.before, + }) + if err != nil { + return wrapJobsError(err) + } + + if reply.CursorStale { + _, _ = fmt.Fprintln(dockerCli.Err(), "Warning: the requested cursor is stale; showing the most recent runs instead") + } + + now := time.Now() + w := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ITERATION\tSTATE\tEXIT\tTRIGGER\tSTARTED\tFINISHED\tCONTAINER") + for _, r := range reply.Runs { + row := newRunRow(r, now) + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row.Iteration, row.State, row.Exit, row.Trigger, row.Started, row.Finished, row.Container) + } + if err := w.Flush(); err != nil { + return err + } + + if reply.NextCursor != "" { + _, _ = fmt.Fprintf(dockerCli.Out(), "\nFor the next page, run: docker job runs --before %s %s\n", reply.NextCursor, opts.job) + } + return nil +} diff --git a/cli/command/job/runs_test.go b/cli/command/job/runs_test.go new file mode 100644 index 000000000000..b78a3c03be21 --- /dev/null +++ b/cli/command/job/runs_test.go @@ -0,0 +1,462 @@ +package job + +import ( + "context" + "errors" + "io" + "strconv" + "strings" + "testing" + "time" + + "github.com/docker/cli/cli/command/formatter" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + units "github.com/docker/go-units" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// runsHeaderColumns is the exact header line expected on `docker job runs`: ITERATION STATE EXIT TRIGGER STARTED +// FINISHED CONTAINER. +var runsHeaderColumns = []string{"ITERATION", "STATE", "EXIT", "TRIGGER", "STARTED", "FINISHED", "CONTAINER"} + +func TestNewRunRow(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + + testCases := []struct { + doc string + run jobsv0.Run + expected runRow + }{ + { + doc: "pending run: never started, never finished, no container, no exit code", + run: jobsv0.Run{ + Iteration: 1, + State: jobsv0.RunStatePending, + }, + expected: runRow{ + Iteration: "1", + State: "pending", + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "-", + Container: "-", + }, + }, + { + doc: "running run: started, not finished, ExitCode nil, manual trigger", + run: jobsv0.Run{ + Iteration: 2, + State: jobsv0.RunStateRunning, + ContainerID: "abcdef0123456789abcdef", + StartedAtNano: now.Add(-30 * time.Second).UnixNano(), + Trigger: &jobsv0.TriggerEvidence{Kind: jobsv0.TriggerKindManual}, + }, + expected: runRow{ + Iteration: "2", + State: "running", + Exit: "-", + Trigger: "manual", + Started: "30 seconds ago", + Finished: "-", + Container: "abcdef012345", + }, + }, + { + doc: "succeeded run with exit code zero: EXIT must be 0, NOT - (no-exit-code vs exit-code-zero trap)", + run: jobsv0.Run{ + Iteration: 3, + State: jobsv0.RunStateSucceeded, + ContainerID: "1234567890ab1234567890ab", + StartedAtNano: now.Add(-5 * time.Minute).UnixNano(), + FinishedAtNano: now.Add(-2 * time.Minute).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 0}, + Trigger: &jobsv0.TriggerEvidence{Kind: jobsv0.TriggerKindSchedule}, + }, + expected: runRow{ + Iteration: "3", + State: "succeeded", + Exit: "0", + Trigger: "schedule", + Started: "5 minutes ago", + Finished: "2 minutes ago", + Container: "1234567890ab", + }, + }, + { + doc: "failed run with a nonzero exit code", + run: jobsv0.Run{ + Iteration: 4, + State: jobsv0.RunStateFailed, + ContainerID: "deadbeefdeadbeefdeadbeef", + StartedAtNano: now.Add(-1 * time.Hour).UnixNano(), + FinishedAtNano: now.Add(-59 * time.Minute).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 137}, + Trigger: &jobsv0.TriggerEvidence{Kind: jobsv0.TriggerKindManual}, + }, + expected: runRow{ + Iteration: "4", + State: "failed", + Exit: "137", + Trigger: "manual", + Started: "About an hour ago", + Finished: "59 minutes ago", + Container: "deadbeefdead", + }, + }, + { + doc: "run whose container was never created: failed straight from pending, no exit code, no container", + run: jobsv0.Run{ + Iteration: 5, + State: jobsv0.RunStateFailed, + FinishedAtNano: now.Add(-10 * time.Second).UnixNano(), + Error: "container create failed", + }, + expected: runRow{ + Iteration: "5", + State: "failed", + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "10 seconds ago", + Container: "-", + }, + }, + { + doc: "nil Trigger is handled defensively even though it is not expected in practice", + run: jobsv0.Run{ + Iteration: 6, + State: jobsv0.RunStateCancelled, + Trigger: nil, + }, + expected: runRow{ + Iteration: "6", + State: "cancelled", + Exit: "-", + Trigger: "-", + Started: "-", + Finished: "-", + Container: "-", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + row := newRunRow(tc.run, now) + assert.Check(t, is.DeepEqual(row, tc.expected)) + }) + } +} + +// TestNewRunRowUsesHumanDuration guards against hardcoding a human-readable +// string that only happens to match go-units' current implementation: it +// recomputes the expected string through units.HumanDuration itself, so the +// test tracks the library rather than a magic string. +func TestNewRunRowUsesHumanDuration(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + startedAgo := 42 * time.Second + finishedAgo := 7 * time.Second + + r := jobsv0.Run{ + Iteration: 1, + State: jobsv0.RunStateSucceeded, + StartedAtNano: now.Add(-startedAgo).UnixNano(), + FinishedAtNano: now.Add(-finishedAgo).UnixNano(), + ExitCode: &jobsv0.ExitCode{Value: 0}, + } + + row := newRunRow(r, now) + + assert.Check(t, is.Equal(row.Started, units.HumanDuration(startedAgo)+" ago")) + assert.Check(t, is.Equal(row.Finished, units.HumanDuration(finishedAgo)+" ago")) +} + +// TestNewRunRowContainerTruncation confirms CONTAINER reuses the repo's +// existing ID-truncation convention (formatter.TruncateID, wrapping +// github.com/moby/moby/client/pkg/stringid.TruncateID -- the same helper used +// by `docker ps` and friends) rather than a hand-rolled slice. +func TestNewRunRowContainerTruncation(t *testing.T) { + now := time.Date(2024, 3, 10, 15, 0, 0, 0, time.UTC) + containerID := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd" + + row := newRunRow(jobsv0.Run{ContainerID: containerID}, now) + + assert.Check(t, is.Equal(row.Container, formatter.TruncateID(containerID))) +} + +func TestNewRunsCommandForwardsRequest(t *testing.T) { + var gotReq *jobsv0.ListRunsRequest + client := &fakeClient{ + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + gotReq = req + return &jobsv0.ListRunsReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"--limit", "5", "--before", "cursor-123", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.ListRuns was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Limit, int32(5))) + assert.Check(t, is.Equal(gotReq.Before, "cursor-123")) +} + +func TestNewRunsCommandDefaultsLimitZeroAndBeforeEmpty(t *testing.T) { + var gotReq *jobsv0.ListRunsRequest + client := &fakeClient{ + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + gotReq = req + return &jobsv0.ListRunsReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.ListRuns was not called") + assert.Check(t, is.Equal(gotReq.Limit, int32(0))) + assert.Check(t, is.Equal(gotReq.Before, "")) +} + +func TestNewRunsCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewRunsCommandHeader(t *testing.T) { + cli := test.NewFakeCli(nil) + client := &fakeClient{} + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + for _, col := range runsHeaderColumns { + assert.Check(t, strings.Contains(out, col), "expected header column %q in output:\n%s", col, out) + } +} + +func TestNewRunsCommandRendersRunsInReceivedOrder(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {Iteration: 9, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + {Iteration: 3, State: jobsv0.RunStateFailed, ExitCode: &jobsv0.ExitCode{Value: 1}}, + {Iteration: 7, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + i9 := strings.Index(out, "9") + i3 := strings.Index(out, "3") + i7 := strings.Index(out, "7") + assert.Check(t, i9 >= 0 && i3 >= 0 && i7 >= 0, "all three iterations must appear in output:\n%s", out) + // The daemon already returns most-recent-first; the CLI must NOT re-sort. + assert.Check(t, i9 < i3, "expected iteration 9 before 3 (no CLI re-sorting):\n%s", out) + assert.Check(t, i3 < i7, "expected iteration 3 before 7 (no CLI re-sorting):\n%s", out) +} + +func TestNewRunsCommandExitCodeZeroIsNotDash(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {Iteration: 1, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, hasTableCell(out, "0"), "expected a table cell with the exit code 0, not '-':\n%s", out) +} + +func TestNewRunsCommandCursorStaleWarnsOnStderrBeforeTable(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + CursorStale: true, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"--before", "evicted-cursor", "backup"}) + assert.NilError(t, cmd.Execute()) + + errOut := cli.ErrBuffer().String() + assert.Check(t, errOut != "", "expected a stale-cursor warning on stderr, got none") + assert.Check(t, strings.Contains(strings.ToLower(errOut), "stale"), "expected the warning to mention the stale cursor:\n%s", errOut) + + // The command must still succeed and render the table on stdout. + out := cli.OutBuffer().String() + assert.Check(t, is.Contains(out, "ITERATION")) +} + +func TestNewRunsCommandNoWarningWhenCursorNotStale(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + CursorStale: false, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.ErrBuffer().String(), "")) +} + +func TestNewRunsCommandNextCursorPrintsPagingHint(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + NextCursor: "next-page-cursor", + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, strings.Contains(out, "--before"), "expected a paging hint mentioning --before:\n%s", out) + assert.Check(t, strings.Contains(out, "next-page-cursor"), "expected the paging hint to interpolate the actual cursor value:\n%s", out) +} + +func TestNewRunsCommandNoNextCursorNoPagingHint(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return &jobsv0.ListRunsReply{ + Runs: []jobsv0.Run{{Iteration: 1, State: jobsv0.RunStateSucceeded}}, + NextCursor: "", + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + out := cli.OutBuffer().String() + assert.Check(t, !strings.Contains(out, "--before"), "did not expect a paging hint when NextCursor is empty:\n%s", out) +} + +func TestNewRunsCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewRunsCommandOtherErrorsPassThrough(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + return nil, errors.New("job not found") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "job not found") +} + +// TestRunRunsDirect exercises runRuns directly (bypassing cobra arg +// parsing), following the pattern set by list.go/logs.go/run.go of testing +// the RunE body as a plain function in addition to going through the cobra +// command. +func TestRunRunsDirect(t *testing.T) { + var gotReq *jobsv0.ListRunsRequest + client := &fakeClient{ + listRunsFunc: func(_ context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + gotReq = req + return &jobsv0.ListRunsReply{Runs: []jobsv0.Run{ + {Iteration: 1, State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}, + }}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runRuns(context.Background(), cli, client, &runsOptions{job: "backup", limit: 5, before: "cursor-1"}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Limit, int32(5))) + assert.Check(t, is.Equal(gotReq.Before, "cursor-1")) + assert.Check(t, is.Contains(cli.OutBuffer().String(), "ITERATION")) + assert.Check(t, is.Contains(cli.OutBuffer().String(), strconv.FormatUint(1, 10))) +} + +// --limit is an int32 on the wire: a value that does not fit must be refused +// instead of silently wrapping around (4294967296 would become 0, i.e. the +// daemon default). +func TestNewRunsCommandRejectsLimitOutOfInt32Range(t *testing.T) { + client := &fakeClient{ + listRunsFunc: func(context.Context, *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) { + t.Fatal("client.ListRuns must not be called with an out-of-range --limit") + return nil, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunsCommand(cli, client) + cmd.SetArgs([]string{"--limit", "4294967296", "backup"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + assert.ErrorContains(t, cmd.Execute(), "--limit") +} From be88a99c989d02c00fcc881497159d14ae11039e Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 10 Nov 2026 10:21:15 +0100 Subject: [PATCH 4/6] cli/command/job: add `docker job run`, `cancel`, `pause`, `unpause`, `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 --- cli/command/job/cancel.go | 48 ++++++++ cli/command/job/cancel_test.go | 159 +++++++++++++++++++++++++ cli/command/job/client.go | 21 ++++ cli/command/job/client_test.go | 48 ++++++++ cli/command/job/cmd.go | 54 +++++++++ cli/command/job/pause.go | 41 +++++++ cli/command/job/pause_test.go | 140 ++++++++++++++++++++++ cli/command/job/prune.go | 76 ++++++++++++ cli/command/job/prune_test.go | 204 ++++++++++++++++++++++++++++++++ cli/command/job/rm.go | 55 +++++++++ cli/command/job/rm_test.go | 197 ++++++++++++++++++++++++++++++ cli/command/job/run.go | 53 +++++++++ cli/command/job/run_test.go | 153 ++++++++++++++++++++++++ cli/command/job/unpause.go | 41 +++++++ cli/command/job/unpause_test.go | 140 ++++++++++++++++++++++ 15 files changed, 1430 insertions(+) create mode 100644 cli/command/job/cancel.go create mode 100644 cli/command/job/cancel_test.go create mode 100644 cli/command/job/pause.go create mode 100644 cli/command/job/pause_test.go create mode 100644 cli/command/job/prune.go create mode 100644 cli/command/job/prune_test.go create mode 100644 cli/command/job/rm.go create mode 100644 cli/command/job/rm_test.go create mode 100644 cli/command/job/run.go create mode 100644 cli/command/job/run_test.go create mode 100644 cli/command/job/unpause.go create mode 100644 cli/command/job/unpause_test.go diff --git a/cli/command/job/cancel.go b/cli/command/job/cancel.go new file mode 100644 index 000000000000..1f3ccf52bfc0 --- /dev/null +++ b/cli/command/job/cancel.go @@ -0,0 +1,48 @@ +package job + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// newCancelCommand wires the `docker job cancel JOB` cobra command. client is +// injected explicitly, following the same pattern as newRunCommand (see +// run.go). +func newCancelCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "cancel JOB", + Short: "Cancel a job's in-flight run", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runCancel(cmd.Context(), dockerCli, client, args[0]) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + return cmd +} + +// runCancel cancels jobRef's in-flight run via client.Cancel. If a run was +// cancelled, its ID is printed on dockerCli.Out(), the same convention as +// runRun (see run.go). If no run was in flight, a message is printed instead +// and nil is returned: cancelling an idle job is a valid, idempotent +// request, not a failure. +func runCancel(ctx context.Context, dockerCli command.Cli, client jobsClient, jobRef string) error { + reply, err := client.Cancel(ctx, &jobsv0.CancelRequest{JobRef: jobRef}) + if err != nil { + return wrapJobsError(err) + } + + if reply.RunID == "" { + _, _ = fmt.Fprintf(dockerCli.Out(), "no run in flight for job %q\n", jobRef) + return nil + } + + _, _ = fmt.Fprintln(dockerCli.Out(), reply.RunID) + return nil +} diff --git a/cli/command/job/cancel_test.go b/cli/command/job/cancel_test.go new file mode 100644 index 000000000000..a6209a547724 --- /dev/null +++ b/cli/command/job/cancel_test.go @@ -0,0 +1,159 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// Output convention choice: on a successful cancellation (reply.RunID set), +// runCancel prints the cancelled run's ID on dockerCli.Out(), mirroring +// runRun's "print the created/affected resource's ID" convention (see +// run.go). When no run was in flight (reply.RunID == ""), printing an empty +// line would be confusing in a demo -- the user asked for a cancel and +// nothing happened, silently or with a blank line, which looks like a bug. +// Instead runCancel prints a clear, non-error message to dockerCli.Out() +// (`no run in flight for job ""`) and returns a nil error: asking to +// cancel an idle job is a valid, idempotent request, not a failure -- the +// same spirit as jobsv0.Jobs.Pause/Resume being documented idempotent. + +func TestNewCancelCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.CancelRequest + client := &fakeClient{ + cancelFunc: func(_ context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + gotReq = req + return &jobsv0.CancelReply{RunID: "run-1"}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newCancelCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Cancel was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) +} + +func TestNewCancelCommandPrintsCancelledRunID(t *testing.T) { + client := &fakeClient{ + cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + return &jobsv0.CancelReply{RunID: "run-42"}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newCancelCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-42\n")) +} + +func TestNewCancelCommandNoRunInFlightPrintsMessageWithoutError(t *testing.T) { + client := &fakeClient{ + cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + return &jobsv0.CancelReply{RunID: ""}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newCancelCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "no run in flight for job \"backup\"\n")) +} + +func TestNewCancelCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newCancelCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewCancelCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newCancelCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewCancelCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + return nil, errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newCancelCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunCancelDirect exercises runCancel directly (bypassing cobra arg +// parsing), following the pattern set by run_test.go/inspect_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. +func TestRunCancelDirect(t *testing.T) { + var gotReq *jobsv0.CancelRequest + client := &fakeClient{ + cancelFunc: func(_ context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + gotReq = req + return &jobsv0.CancelReply{RunID: "run-7"}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runCancel(context.Background(), cli, client, "backup") + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-7\n")) +} + +func TestRunCancelDirectNoRunInFlight(t *testing.T) { + client := &fakeClient{ + cancelFunc: func(context.Context, *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + return &jobsv0.CancelReply{RunID: ""}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runCancel(context.Background(), cli, client, "backup") + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "no run in flight for job \"backup\"\n")) +} diff --git a/cli/command/job/client.go b/cli/command/job/client.go index 69a4d59aad6c..b350a6ad763f 100644 --- a/cli/command/job/client.go +++ b/cli/command/job/client.go @@ -32,8 +32,29 @@ type jobsClient interface { // fallback when the latest run's container is gone. ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) + // Run executes an existing job, returning the created run. + Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) + // InspectRun returns a single run. InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) + + // Cancel stops the job's in-flight run. + Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) + + // Prune removes idle manual jobs. + Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) + + // Remove deletes a job. It returns only an error: jobsv0.Jobs.Remove has + // no reply message. + Remove(ctx context.Context, req *jobsv0.RemoveRequest) error + + // Pause suppresses a job's schedule trigger. Like Remove, it returns + // only an error: jobsv0.Jobs.Pause has no reply message. + Pause(ctx context.Context, req *jobsv0.PauseRequest) error + + // Resume re-arms a paused job's schedule trigger. Like Remove, it + // returns only an error: jobsv0.Jobs.Resume has no reply message. + Resume(ctx context.Context, req *jobsv0.ResumeRequest) error } // newJobsClient dials the daemon's jobs extension point and returns a diff --git a/cli/command/job/client_test.go b/cli/command/job/client_test.go index e818aaab65a1..9b08e1e08291 100644 --- a/cli/command/job/client_test.go +++ b/cli/command/job/client_test.go @@ -26,7 +26,13 @@ type fakeClient struct { listFunc func(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) inspectFunc func(ctx context.Context, req *jobsv0.InspectRequest) (*jobsv0.InspectReply, error) listRunsFunc func(ctx context.Context, req *jobsv0.ListRunsRequest) (*jobsv0.ListRunsReply, error) + runFunc func(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) inspectRunFunc func(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) + cancelFunc func(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) + pruneFunc func(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) + removeFunc func(ctx context.Context, req *jobsv0.RemoveRequest) error + pauseFunc func(ctx context.Context, req *jobsv0.PauseRequest) error + resumeFunc func(ctx context.Context, req *jobsv0.ResumeRequest) error } func (c *fakeClient) List(ctx context.Context, req *jobsv0.ListRequest) (*jobsv0.ListReply, error) { @@ -50,6 +56,13 @@ func (c *fakeClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsRequest) return &jobsv0.ListRunsReply{}, nil } +func (c *fakeClient) Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + if c.runFunc != nil { + return c.runFunc(ctx, req) + } + return &jobsv0.RunReply{}, nil +} + func (c *fakeClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { if c.inspectRunFunc != nil { return c.inspectRunFunc(ctx, req) @@ -57,6 +70,41 @@ func (c *fakeClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunReque return &jobsv0.InspectRunReply{}, nil } +func (c *fakeClient) Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + if c.cancelFunc != nil { + return c.cancelFunc(ctx, req) + } + return &jobsv0.CancelReply{}, nil +} + +func (c *fakeClient) Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + if c.pruneFunc != nil { + return c.pruneFunc(ctx, req) + } + return &jobsv0.PruneReply{}, nil +} + +func (c *fakeClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) error { + if c.removeFunc != nil { + return c.removeFunc(ctx, req) + } + return nil +} + +func (c *fakeClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { + if c.pauseFunc != nil { + return c.pauseFunc(ctx, req) + } + return nil +} + +func (c *fakeClient) Resume(ctx context.Context, req *jobsv0.ResumeRequest) error { + if c.resumeFunc != nil { + return c.resumeFunc(ctx, req) + } + return nil +} + var _ jobsClient = (*fakeClient)(nil) func TestWrapJobsError(t *testing.T) { diff --git a/cli/command/job/cmd.go b/cli/command/job/cmd.go index 3de3fe37d3d4..297f3d7bc751 100644 --- a/cli/command/job/cmd.go +++ b/cli/command/job/cmd.go @@ -34,8 +34,14 @@ func newJobCommand(dockerCli command.Cli) *cobra.Command { cmd.AddCommand( newListCommand(dockerCli, jobs), newLogsCommand(dockerCli, jobs), + newRunCommand(dockerCli, jobs), newRunsCommand(dockerCli, jobs), newInspectCommand(dockerCli, jobs), + newCancelCommand(dockerCli, jobs), + newPruneCommand(dockerCli, jobs), + newRmCommand(dockerCli, jobs), + newPauseCommand(dockerCli, jobs), + newUnpauseCommand(dockerCli, jobs), ) return cmd } @@ -107,6 +113,14 @@ func (c *lazyJobsClient) ListRuns(ctx context.Context, req *jobsv0.ListRunsReque return client.ListRuns(ctx, req) } +func (c *lazyJobsClient) Run(ctx context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Run(ctx, req) +} + func (c *lazyJobsClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunRequest) (*jobsv0.InspectRunReply, error) { client, err := c.resolve() if err != nil { @@ -114,3 +128,43 @@ func (c *lazyJobsClient) InspectRun(ctx context.Context, req *jobsv0.InspectRunR } return client.InspectRun(ctx, req) } + +func (c *lazyJobsClient) Cancel(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Cancel(ctx, req) +} + +func (c *lazyJobsClient) Prune(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Prune(ctx, req) +} + +func (c *lazyJobsClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) error { + client, err := c.resolve() + if err != nil { + return err + } + return client.Remove(ctx, req) +} + +func (c *lazyJobsClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { + client, err := c.resolve() + if err != nil { + return err + } + return client.Pause(ctx, req) +} + +func (c *lazyJobsClient) Resume(ctx context.Context, req *jobsv0.ResumeRequest) error { + client, err := c.resolve() + if err != nil { + return err + } + return client.Resume(ctx, req) +} diff --git a/cli/command/job/pause.go b/cli/command/job/pause.go new file mode 100644 index 000000000000..30a2c6e668e2 --- /dev/null +++ b/cli/command/job/pause.go @@ -0,0 +1,41 @@ +package job + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// newPauseCommand wires the `docker job pause JOB` cobra command. client is +// injected explicitly, following the same pattern as newRunCommand (see +// run.go). +func newPauseCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "pause JOB", + Short: "Suppress a job's schedule trigger", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPause(cmd.Context(), dockerCli, client, args[0]) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + return cmd +} + +// runPause pauses jobRef via client.Pause. client.Pause has no reply +// message, so on success jobRef is printed on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. +func runPause(ctx context.Context, dockerCli command.Cli, client jobsClient, jobRef string) error { + err := client.Pause(ctx, &jobsv0.PauseRequest{JobRef: jobRef}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), jobRef) + return nil +} diff --git a/cli/command/job/pause_test.go b/cli/command/job/pause_test.go new file mode 100644 index 000000000000..606a0e084b8a --- /dev/null +++ b/cli/command/job/pause_test.go @@ -0,0 +1,140 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// Output convention choice: client.Pause has no reply message (like +// client.Remove), so on success runPause prints jobRef (the job name/ID +// passed as the single positional argument) on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. + +func TestNewPauseCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.PauseRequest + client := &fakeClient{ + pauseFunc: func(_ context.Context, req *jobsv0.PauseRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Pause was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) +} + +func TestNewPauseCommandPrintsJobOnSuccess(t *testing.T) { + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestNewPauseCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewPauseCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewPauseCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunPauseDirect exercises runPause directly (bypassing cobra arg +// parsing), following the pattern set by cancel_test.go/rm_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. +func TestRunPauseDirect(t *testing.T) { + var gotReq *jobsv0.PauseRequest + client := &fakeClient{ + pauseFunc: func(_ context.Context, req *jobsv0.PauseRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + err := runPause(context.Background(), cli, client, "backup") + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestRunPauseDirectOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + pauseFunc: func(context.Context, *jobsv0.PauseRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + err := runPause(context.Background(), cli, client, "backup") + + assert.ErrorContains(t, err, errText) + // Nothing should be printed on failure. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} diff --git a/cli/command/job/prune.go b/cli/command/job/prune.go new file mode 100644 index 000000000000..f494d36eb9bf --- /dev/null +++ b/cli/command/job/prune.go @@ -0,0 +1,76 @@ +package job + +import ( + "context" + "errors" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/prompt" + "github.com/spf13/cobra" +) + +// pruneOptions holds the parsed flags of `docker job prune`. +type pruneOptions struct { + labels []string + force bool +} + +// newPruneCommand wires the `docker job prune [--label ...]` cobra command. +// client is injected explicitly, following the same pattern as +// newRunCommand (see run.go). +func newPruneCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts pruneOptions + + cmd := &cobra.Command{ + Use: "prune [OPTIONS]", + Short: "Remove idle manual jobs", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runPrune(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.BoolVarP(&opts.force, "force", "f", false, "Do not prompt for confirmation") + flags.StringArrayVar(&opts.labels, "label", nil, "Only remove jobs matching a label (\"key\" or \"key=value\")") + return cmd +} + +const pruneWarning = `WARNING! This will remove all idle manual jobs. +Are you sure you want to continue?` + +// runPrune removes idle manual jobs matching opts.labels via client.Prune, +// printing each removed job's ID on its own line on dockerCli.Out() -- +// mirroring `docker container prune`/`docker volume prune`, including the +// confirmation prompt that opts.force skips. Nothing removed prints nothing +// and returns a nil error, keeping the command scriptable. +func runPrune(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *pruneOptions) error { + if !opts.force { + r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), pruneWarning) + if err != nil { + return err + } + if !r { + return cancelledErr{errors.New("job prune has been cancelled")} + } + } + + reply, err := client.Prune(ctx, &jobsv0.PruneRequest{Labels: opts.labels}) + if err != nil { + return wrapJobsError(err) + } + + for _, id := range reply.RemovedJobIDs { + _, _ = fmt.Fprintln(dockerCli.Out(), id) + } + return nil +} + +type cancelledErr struct{ error } + +func (cancelledErr) Cancelled() {} diff --git a/cli/command/job/prune_test.go b/cli/command/job/prune_test.go new file mode 100644 index 000000000000..e10d888a2255 --- /dev/null +++ b/cli/command/job/prune_test.go @@ -0,0 +1,204 @@ +package job + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/containerd/errdefs" + "github.com/docker/cli/cli/streams" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// Flag convention: --label is repeatable (pflag StringArrayVar, not +// StringSliceVar) so a label value containing a comma is not silently split +// -- consistent with jobsv0.PruneRequest.Labels/ListRequest.Labels both being +// "key" or "key=value" filter entries, matched conjunctively (see jobs.go). +// +// Output convention choice: each removed job ID is printed on its own line +// on dockerCli.Out(), mirroring `docker container prune`/`docker volume +// prune` printing one identifier per line for what was removed. When nothing +// was pruned, runPrune prints nothing and returns a nil error -- unlike +// `docker system prune`'s reclaimed-space summary, there is no secondary +// metric worth reporting here (Prune only ever reports removed job IDs), so +// an empty, silent success keeps the command composable/scriptable (e.g. +// `docker job prune --label foo` in a script can just check the exit code). + +func TestNewPruneCommandWithoutLabelSendsEmptyRequest(t *testing.T) { + var gotReq *jobsv0.PruneRequest + client := &fakeClient{ + pruneFunc: func(_ context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + gotReq = req + return &jobsv0.PruneReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Prune was not called") + assert.Check(t, is.Len(gotReq.Labels, 0)) +} + +func TestNewPruneCommandForwardsRepeatedLabelFlag(t *testing.T) { + var gotReq *jobsv0.PruneRequest + client := &fakeClient{ + pruneFunc: func(_ context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + gotReq = req + return &jobsv0.PruneReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force", "--label", "foo", "--label", "bar=baz"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Prune was not called") + assert.Check(t, is.DeepEqual(gotReq.Labels, []string{"foo", "bar=baz"})) +} + +func TestNewPruneCommandPrintsRemovedJobIDs(t *testing.T) { + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + return &jobsv0.PruneReply{RemovedJobIDs: []string{"job-1", "job-2"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "job-1\njob-2\n")) +} + +func TestNewPruneCommandPrintsNothingWhenNoJobsRemoved(t *testing.T) { + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + return &jobsv0.PruneReply{}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} + +func TestNewPruneCommandRejectsPositionalArgs(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "accepts no arguments") +} + +func TestNewPruneCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"--force"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +// TestRunPruneDirect exercises runPrune directly (bypassing cobra arg +// parsing), following the pattern set by run_test.go/inspect_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. +func TestRunPruneDirect(t *testing.T) { + var gotReq *jobsv0.PruneRequest + client := &fakeClient{ + pruneFunc: func(_ context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + gotReq = req + return &jobsv0.PruneReply{RemovedJobIDs: []string{"job-9"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runPrune(context.Background(), cli, client, &pruneOptions{labels: []string{"foo"}, force: true}) + + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(gotReq.Labels, []string{"foo"})) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "job-9\n")) +} + +func TestNewPruneCommandAsksForConfirmation(t *testing.T) { + testCases := []struct { + doc string + input string + wantPruned bool + }{ + {doc: "confirmed", input: "y\n", wantPruned: true}, + {doc: "declined", input: "n\n", wantPruned: false}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + var pruned bool + client := &fakeClient{ + pruneFunc: func(context.Context, *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) { + pruned = true + return &jobsv0.PruneReply{RemovedJobIDs: []string{"job-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader(tc.input)))) + + cmd := newPruneCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.Check(t, is.Contains(cli.OutBuffer().String(), "WARNING! This will remove all idle manual jobs.")) + assert.Check(t, is.Equal(pruned, tc.wantPruned)) + if tc.wantPruned { + assert.NilError(t, err) + assert.Check(t, is.Contains(cli.OutBuffer().String(), "job-1\n")) + } else { + assert.Check(t, errdefs.IsCanceled(err), "got %v", err) + } + }) + } +} + +func TestNewPruneCommandForceSkipsConfirmation(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + // No input is provided: a prompt would be declined. + + cmd := newPruneCommand(cli, client) + cmd.SetArgs([]string{"-f"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + assert.NilError(t, cmd.Execute()) + assert.Check(t, !strings.Contains(cli.OutBuffer().String(), "WARNING!"), "output: %q", cli.OutBuffer()) +} diff --git a/cli/command/job/rm.go b/cli/command/job/rm.go new file mode 100644 index 000000000000..bf3ad4967fbc --- /dev/null +++ b/cli/command/job/rm.go @@ -0,0 +1,55 @@ +package job + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// rmOptions holds the parsed flags of `docker job rm`. +type rmOptions struct { + job string + runs string +} + +// newRmCommand wires the `docker job rm [--runs ...] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newRunCommand (see run.go). +func newRmCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts rmOptions + + cmd := &cobra.Command{ + Use: "rm JOB", + Aliases: []string{"remove"}, + Short: "Remove a job", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runRm(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.StringVar(&opts.runs, "runs", "", `Run history retention: "keep", "remove", or "remove-finished" (default "keep")`) + return cmd +} + +// runRm removes opts.job via client.Remove. client.Remove has no reply +// message, so on success opts.job (the identifier passed by the caller) is +// printed on dockerCli.Out(), mirroring `docker volume rm`/`docker network +// rm`. +func runRm(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *rmOptions) error { + err := client.Remove(ctx, &jobsv0.RemoveRequest{JobRef: opts.job, RunsRemoval: opts.runs}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), opts.job) + return nil +} diff --git a/cli/command/job/rm_test.go b/cli/command/job/rm_test.go new file mode 100644 index 000000000000..8e337af44e67 --- /dev/null +++ b/cli/command/job/rm_test.go @@ -0,0 +1,197 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// --runs is forwarded to jobsv0.RemoveRequest.RunsRemoval as a raw string, +// with no client-side mapping/enum: an unset flag sends an empty string, +// which the daemon defaults to "keep" (see jobsv0.RunsKeep and +// jobsv0.RemoveRequest.RunsRemoval in jobs.go). Validation of the value is +// left entirely to the daemon. +// +// Output convention choice: on success runRm prints opts.job (the job +// name/ID passed as the single positional argument) on dockerCli.Out(), +// mirroring `docker volume rm`/`docker network rm` (see +// cli/command/volume/remove.go and cli/command/network/remove.go), which +// both print the resource identifier as given by the caller via +// fmt.Fprintln(dockerCli.Out(), name) on a successful removal. + +func TestNewRmCommandForwardsJobRefWithoutRunsFlag(t *testing.T) { + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Remove was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunsRemoval, "")) +} + +func TestNewRmCommandForwardsRunsFlag(t *testing.T) { + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"--runs", "remove", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Remove was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunsRemoval, "remove")) +} + +func TestNewRmCommandForwardsRunsFlagRawValue(t *testing.T) { + // The CLI performs no validation/mapping of --runs: an arbitrary string + // is forwarded as-is, leaving the daemon to reject invalid values. + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"--runs", "bogus-value", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Remove was not called") + assert.Check(t, is.Equal(gotReq.RunsRemoval, "bogus-value")) +} + +func TestNewRmCommandPrintsJobOnSuccess(t *testing.T) { + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestNewRmCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewRmCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewRmCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRmCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunRmDirect exercises runRm directly (bypassing cobra arg parsing), +// following the pattern set by cancel_test.go/prune_test.go of testing the +// RunE body as a plain function in addition to going through the cobra +// command. +func TestRunRmDirect(t *testing.T) { + var gotReq *jobsv0.RemoveRequest + client := &fakeClient{ + removeFunc: func(_ context.Context, req *jobsv0.RemoveRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + err := runRm(context.Background(), cli, client, &rmOptions{job: "backup", runs: jobsv0.RunsRemoveFinished}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunsRemoval, jobsv0.RunsRemoveFinished)) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestRunRmDirectOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + removeFunc: func(context.Context, *jobsv0.RemoveRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + err := runRm(context.Background(), cli, client, &rmOptions{job: "backup"}) + + assert.ErrorContains(t, err, errText) + // Nothing should be printed on failure. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} + +// `docker container rm`, `volume rm` and `network rm` are all reachable as +// `remove`: keep the same spelling. +func TestNewRmCommandHasRemoveAlias(t *testing.T) { + cmd := newRmCommand(test.NewFakeCli(nil), &fakeClient{}) + + assert.Check(t, is.Contains(cmd.Aliases, "remove")) +} diff --git a/cli/command/job/run.go b/cli/command/job/run.go new file mode 100644 index 000000000000..e93f67cc9904 --- /dev/null +++ b/cli/command/job/run.go @@ -0,0 +1,53 @@ +package job + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// runOptions holds the parsed flags/args of `docker job run`. +type runOptions struct { + job string + reschedule bool +} + +// newRunCommand wires the `docker job run [--reschedule] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newListCommand/newLogsCommand (see list.go/logs.go). +func newRunCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts runOptions + + cmd := &cobra.Command{ + Use: "run [OPTIONS] JOB", + Short: "Run a job", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runRun(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.BoolVar(&opts.reschedule, "reschedule", false, "Reschedule the job's next occurrence from this run") + return cmd +} + +// runRun triggers a run of opts.job via client.Run, printing the created +// run's ID on success -- the same convention as `docker container commit` +// (see cli/command/container/commit.go). +func runRun(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *runOptions) error { + reply, err := client.Run(ctx, &jobsv0.RunRequest{JobRef: opts.job, Reschedule: opts.reschedule}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), reply.Run.ID) + return nil +} diff --git a/cli/command/job/run_test.go b/cli/command/job/run_test.go new file mode 100644 index 000000000000..e610d837d125 --- /dev/null +++ b/cli/command/job/run_test.go @@ -0,0 +1,153 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestNewRunCommandForwardsJobRefAndDefaultsRescheduleFalse(t *testing.T) { + var gotReq *jobsv0.RunRequest + client := &fakeClient{ + runFunc: func(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + gotReq = req + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Run was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Reschedule, false)) +} + +func TestNewRunCommandRescheduleFlagForwarded(t *testing.T) { + var gotReq *jobsv0.RunRequest + client := &fakeClient{ + runFunc: func(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + gotReq = req + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-1"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"--reschedule", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Run was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Reschedule, true)) +} + +func TestNewRunCommandPrintsCreatedRunID(t *testing.T) { + client := &fakeClient{ + runFunc: func(context.Context, *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-42"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + // Convention: print only the created resource's ID, one line, nothing + // else -- same as `docker container commit` (see + // cli/command/container/commit.go) and `docker run`'s container ID. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-42\n")) +} + +func TestNewRunCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewRunCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + runFunc: func(context.Context, *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewRunCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + testCases := []struct { + doc string + errText string + }{ + {doc: "job not found", errText: `job "backup" not found`}, + {doc: "reschedule rejected for a job without a schedule trigger", errText: "reschedule is not supported for jobs without a schedule trigger"}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + client := &fakeClient{ + runFunc: func(context.Context, *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + return nil, errors.New(tc.errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newRunCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, tc.errText) + }) + } +} + +// TestRunRunDirect exercises runRun directly (bypassing cobra arg parsing), +// following the pattern set by list.go/logs.go of testing the RunE body as a +// plain function in addition to going through the cobra command. +func TestRunRunDirect(t *testing.T) { + var gotReq *jobsv0.RunRequest + client := &fakeClient{ + runFunc: func(_ context.Context, req *jobsv0.RunRequest) (*jobsv0.RunReply, error) { + gotReq = req + return &jobsv0.RunReply{Run: &jobsv0.Run{ID: "run-7"}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runRun(context.Background(), cli, client, &runOptions{job: "backup", reschedule: true}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.Reschedule, true)) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-7\n")) +} diff --git a/cli/command/job/unpause.go b/cli/command/job/unpause.go new file mode 100644 index 000000000000..23f898545ce8 --- /dev/null +++ b/cli/command/job/unpause.go @@ -0,0 +1,41 @@ +package job + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// newUnpauseCommand wires the `docker job unpause JOB` cobra command. client is +// injected explicitly, following the same pattern as newRunCommand (see +// run.go). +func newUnpauseCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "unpause JOB", + Short: "Re-arm a paused job's schedule trigger", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUnpause(cmd.Context(), dockerCli, client, args[0]) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + return cmd +} + +// runUnpause unpauses jobRef via client.Resume. client.Resume has no reply +// message, so on success jobRef is printed on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. +func runUnpause(ctx context.Context, dockerCli command.Cli, client jobsClient, jobRef string) error { + err := client.Resume(ctx, &jobsv0.ResumeRequest{JobRef: jobRef}) + if err != nil { + return wrapJobsError(err) + } + + _, _ = fmt.Fprintln(dockerCli.Out(), jobRef) + return nil +} diff --git a/cli/command/job/unpause_test.go b/cli/command/job/unpause_test.go new file mode 100644 index 000000000000..f0ed9fa7ad89 --- /dev/null +++ b/cli/command/job/unpause_test.go @@ -0,0 +1,140 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// Output convention choice: client.Resume has no reply message (like +// client.Remove), so on success runUnpause prints jobRef (the job name/ID +// passed as the single positional argument) on dockerCli.Out(), mirroring +// runRm (see rm.go) and `docker volume rm`/`docker network rm`. + +func TestNewUnpauseCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.ResumeRequest + client := &fakeClient{ + resumeFunc: func(_ context.Context, req *jobsv0.ResumeRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Resume was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) +} + +func TestNewUnpauseCommandPrintsJobOnSuccess(t *testing.T) { + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestNewUnpauseCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewUnpauseCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + // The raw gRPC status dump must not leak to the user. + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewUnpauseCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newUnpauseCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunUnpauseDirect exercises runUnpause directly (bypassing cobra arg +// parsing), following the pattern set by cancel_test.go/rm_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. +func TestRunUnpauseDirect(t *testing.T) { + var gotReq *jobsv0.ResumeRequest + client := &fakeClient{ + resumeFunc: func(_ context.Context, req *jobsv0.ResumeRequest) error { + gotReq = req + return nil + }, + } + cli := test.NewFakeCli(nil) + + err := runUnpause(context.Background(), cli, client, "backup") + + assert.NilError(t, err) + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "backup\n")) +} + +func TestRunUnpauseDirectOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + resumeFunc: func(context.Context, *jobsv0.ResumeRequest) error { + return errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + err := runUnpause(context.Background(), cli, client, "backup") + + assert.ErrorContains(t, err, errText) + // Nothing should be printed on failure. + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) +} From f7e06041801498659e829fa9d05d5cf8e17fb9e3 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 10 Nov 2026 10:22:03 +0100 Subject: [PATCH 5/6] cli/command/job: add `docker job wait` command `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 or by `docker job cancel` fails with status 1 and its state on stderr, whatever exit code its container reacted with. With `--condition running` the run ID is printed once the run is up. Signed-off-by: Guillaume Lours --- cli/command/job/client.go | 4 + cli/command/job/client_test.go | 8 + cli/command/job/cmd.go | 9 + cli/command/job/wait.go | 94 ++++++++++ cli/command/job/wait_test.go | 313 +++++++++++++++++++++++++++++++++ 5 files changed, 428 insertions(+) create mode 100644 cli/command/job/wait.go create mode 100644 cli/command/job/wait_test.go diff --git a/cli/command/job/client.go b/cli/command/job/client.go index b350a6ad763f..1d7d5ab066e7 100644 --- a/cli/command/job/client.go +++ b/cli/command/job/client.go @@ -48,6 +48,10 @@ type jobsClient interface { // no reply message. Remove(ctx context.Context, req *jobsv0.RemoveRequest) error + // Wait blocks until a run reaches the requested condition and returns + // that run. + Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) + // Pause suppresses a job's schedule trigger. Like Remove, it returns // only an error: jobsv0.Jobs.Pause has no reply message. Pause(ctx context.Context, req *jobsv0.PauseRequest) error diff --git a/cli/command/job/client_test.go b/cli/command/job/client_test.go index 9b08e1e08291..9c605abb676c 100644 --- a/cli/command/job/client_test.go +++ b/cli/command/job/client_test.go @@ -31,6 +31,7 @@ type fakeClient struct { cancelFunc func(ctx context.Context, req *jobsv0.CancelRequest) (*jobsv0.CancelReply, error) pruneFunc func(ctx context.Context, req *jobsv0.PruneRequest) (*jobsv0.PruneReply, error) removeFunc func(ctx context.Context, req *jobsv0.RemoveRequest) error + waitFunc func(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) pauseFunc func(ctx context.Context, req *jobsv0.PauseRequest) error resumeFunc func(ctx context.Context, req *jobsv0.ResumeRequest) error } @@ -91,6 +92,13 @@ func (c *fakeClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) erro return nil } +func (c *fakeClient) Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + if c.waitFunc != nil { + return c.waitFunc(ctx, req) + } + return &jobsv0.WaitReply{}, nil +} + func (c *fakeClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { if c.pauseFunc != nil { return c.pauseFunc(ctx, req) diff --git a/cli/command/job/cmd.go b/cli/command/job/cmd.go index 297f3d7bc751..f1aada53ae32 100644 --- a/cli/command/job/cmd.go +++ b/cli/command/job/cmd.go @@ -40,6 +40,7 @@ func newJobCommand(dockerCli command.Cli) *cobra.Command { newCancelCommand(dockerCli, jobs), newPruneCommand(dockerCli, jobs), newRmCommand(dockerCli, jobs), + newWaitCommand(dockerCli, jobs), newPauseCommand(dockerCli, jobs), newUnpauseCommand(dockerCli, jobs), ) @@ -153,6 +154,14 @@ func (c *lazyJobsClient) Remove(ctx context.Context, req *jobsv0.RemoveRequest) return client.Remove(ctx, req) } +func (c *lazyJobsClient) Wait(ctx context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + client, err := c.resolve() + if err != nil { + return nil, err + } + return client.Wait(ctx, req) +} + func (c *lazyJobsClient) Pause(ctx context.Context, req *jobsv0.PauseRequest) error { client, err := c.resolve() if err != nil { diff --git a/cli/command/job/wait.go b/cli/command/job/wait.go new file mode 100644 index 000000000000..1384186310e0 --- /dev/null +++ b/cli/command/job/wait.go @@ -0,0 +1,94 @@ +package job + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/spf13/cobra" +) + +// waitOptions holds the parsed flags/args of `docker job wait`. +type waitOptions struct { + job string + run string + condition string +} + +// newWaitCommand wires the `docker job wait [OPTIONS] JOB` cobra command. +// client is injected explicitly, following the same pattern as +// newCancelCommand (see cancel.go). An empty --run or --condition defers to +// the daemon's defaults ("latest"/"terminal"). +func newWaitCommand(dockerCli command.Cli, client jobsClient) *cobra.Command { + var opts waitOptions + + cmd := &cobra.Command{ + Use: "wait [OPTIONS] JOB", + Short: "Block until a job's run reaches a terminal state", + Long: `Block until a run of a job reaches a terminal state, then exit with the run's exit code. + +The job must exist: names of removed jobs do not resolve. Without --run, the +latest run is awaited; for a schedule job that has not run yet, that means +blocking until its next scheduled fire. + +A run stopped by a timeout or by "docker job cancel" makes the command fail +with status 1, whatever the exit code of its container. + +With --condition running, the ID of the run is printed once it is running. A +run that already finished is reported as usual, by its exit code, so scripts +capturing the output must check which one they got.`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.job = args[0] + return runWait(cmd.Context(), dockerCli, client, &opts) + }, + ValidArgsFunction: cobra.NoFileCompletions, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.StringVar(&opts.run, "run", "", `Run to wait for: a run ID, or "latest" (default "latest")`) + flags.StringVar(&opts.condition, "condition", "", `Condition to wait for: "terminal" or "running", i.e. the run left pending (default "terminal")`) + return cmd +} + +// runWait blocks until opts.job's run reaches opts.condition via client.Wait. +// A finished run's exit code is printed on dockerCli.Out() and mirrored into +// the process exit status via cli.StatusError, like cli/command/container/ +// attach.go's getExitStatus (a zero exit code returns nil). A running run (the +// "running" condition) has no exit code yet: its ID is printed instead, like +// `docker job run`. A run stopped by a timeout or a cancellation, or whose +// container never started, fails with status 1 and the reason on +// dockerCli.Err(): an exit code, when any, is the container's reaction to +// being stopped, not the job's outcome. +func runWait(ctx context.Context, dockerCli command.Cli, client jobsClient, opts *waitOptions) error { + reply, err := client.Wait(ctx, &jobsv0.WaitRequest{JobRef: opts.job, RunRef: opts.run, Condition: opts.condition}) + if err != nil { + return wrapJobsError(err) + } + + run := reply.Run + switch { + case run.State == jobsv0.RunStateRunning: + _, _ = fmt.Fprintln(dockerCli.Out(), run.ID) + return nil + case run.State == jobsv0.RunStateTimedOut || run.State == jobsv0.RunStateCancelled: + _, _ = fmt.Fprintf(dockerCli.Err(), "run %s\n", run.State) + return cli.StatusError{StatusCode: 1} + case run.ExitCode == nil: + reason := run.Error + if reason == "" { + reason = run.State + } + _, _ = fmt.Fprintf(dockerCli.Err(), "run never started a container: %s\n", reason) + return cli.StatusError{StatusCode: 1} + } + + _, _ = fmt.Fprintln(dockerCli.Out(), run.ExitCode.Value) + if run.ExitCode.Value == 0 { + return nil + } + return cli.StatusError{StatusCode: int(run.ExitCode.Value)} +} diff --git a/cli/command/job/wait_test.go b/cli/command/job/wait_test.go new file mode 100644 index 000000000000..b079df12f489 --- /dev/null +++ b/cli/command/job/wait_test.go @@ -0,0 +1,313 @@ +package job + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + clipkg "github.com/docker/cli/cli" + jobsv0 "github.com/docker/cli/internal/jobsapi/v0" + "github.com/docker/cli/internal/test" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// Unlike `docker container wait` (cli/command/container/wait.go), which only +// ever prints the exit code on stdout and never affects the CLI process's +// own exit status, `docker job wait` is meant to be scriptable +// (`docker job wait X && do-something`): the process must exit with the +// run's own exit code. That is done by returning cli.StatusError{StatusCode: +// N} from runWait, mirroring cli/command/container/attach.go's +// getExitStatus. As in that precedent, a zero exit code returns a nil error +// (not cli.StatusError{StatusCode: 0}), since StatusCode 0 combined with a +// non-nil error does not extract correctly (see the FIXME in +// cmd/docker/docker.go). + +func TestNewWaitCommandForwardsJobRef(t *testing.T) { + var gotReq *jobsv0.WaitRequest + client := &fakeClient{ + waitFunc: func(_ context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + gotReq = req + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Wait was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "")) + assert.Check(t, is.Equal(gotReq.Condition, "")) +} + +func TestNewWaitCommandRequiresExactlyOneArg(t *testing.T) { + client := &fakeClient{} + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, "requires 1 argument") +} + +func TestNewWaitCommandUnimplementedIsActionable(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return nil, status.Error(codes.Unimplemented, "unknown service jobsv0.Jobs") + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, `--extension-enable org.mobyproject.jobs.v0`) + assert.Check(t, !strings.Contains(err.Error(), "rpc error"), "error message leaks the raw gRPC status: %v", err) + assert.Check(t, !strings.Contains(err.Error(), "Unimplemented"), "error message leaks the raw gRPC code: %v", err) +} + +func TestNewWaitCommandOtherErrorsPassThroughUnwrapped(t *testing.T) { + errText := `job "backup" not found` + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return nil, errors.New(errText) + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"backup"}) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + assert.ErrorContains(t, err, errText) +} + +// TestRunWaitDirect* exercise runWait directly (bypassing cobra arg +// parsing), following the pattern set by cancel_test.go/run_test.go of +// testing the RunE body as a plain function in addition to going through the +// cobra command. + +func TestRunWaitDirectForwardsJobRefOnly(t *testing.T) { + var gotReq *jobsv0.WaitRequest + client := &fakeClient{ + waitFunc: func(_ context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + gotReq = req + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, gotReq != nil, "client.Wait was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "")) + assert.Check(t, is.Equal(gotReq.Condition, "")) +} + +func TestRunWaitExitCodeZeroPrintsZeroAndReturnsNil(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateSucceeded, + ExitCode: &jobsv0.ExitCode{Value: 0}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "0\n")) + + var statusErr clipkg.StatusError + assert.Check(t, !errors.As(err, &statusErr), "runWait must not return cli.StatusError on a zero exit code") +} + +func TestRunWaitNonZeroExitCodePrintsCodeAndReturnsStatusError(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateFailed, + ExitCode: &jobsv0.ExitCode{Value: 3}, + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "3\n")) + + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 3)) +} + +func TestRunWaitNoExitCodePrintsNoNumberAndReturnsGenericStatusError(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateFailed, + Error: "failed to pull image", + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), ""), "no exit code to print when the run never started a container") + assert.Check(t, strings.Contains(cli.ErrBuffer().String(), "failed to pull image"), "error message should surface Run.Error: %v", cli.ErrBuffer().String()) + + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 1)) +} + +// TestRunWaitNoExitCodeAndNoErrorFallsBackToState covers a run that never +// started a container but also has no Run.Error -- e.g. cancelled while +// still pending (RunStateCancelled's godoc, unlike RunStatePending's, makes +// no promise that Error is set). The message must still be informative +// instead of ending in a bare, unexplained colon. +func TestRunWaitNoExitCodeAndNoErrorFallsBackToState(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{ + Run: &jobsv0.Run{ + State: jobsv0.RunStateCancelled, + Error: "", + }, + }, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), ""), "no exit code to print when the run never started a container") + assert.Check(t, strings.Contains(cli.ErrBuffer().String(), jobsv0.RunStateCancelled), "error message should fall back to Run.State when Run.Error is empty: %v", cli.ErrBuffer().String()) + + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 1)) +} + +func TestNewWaitCommandForwardsRunAndCondition(t *testing.T) { + var gotReq *jobsv0.WaitRequest + client := &fakeClient{ + waitFunc: func(_ context.Context, req *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + gotReq = req + return &jobsv0.WaitReply{Run: &jobsv0.Run{ID: "run-7", State: jobsv0.RunStateRunning}}, nil + }, + } + cli := test.NewFakeCli(nil) + + cmd := newWaitCommand(cli, client) + cmd.SetArgs([]string{"--run", "run-7", "--condition", "running", "backup"}) + assert.NilError(t, cmd.Execute()) + + assert.Check(t, gotReq != nil, "client.Wait was not called") + assert.Check(t, is.Equal(gotReq.JobRef, "backup")) + assert.Check(t, is.Equal(gotReq.RunRef, "run-7")) + assert.Check(t, is.Equal(gotReq.Condition, "running")) +} + +// With --condition running, a run that is up satisfies the wait: there is no +// exit code yet, so the run ID is printed (like `docker job run`) and the +// command succeeds. +func TestRunWaitConditionRunningPrintsRunID(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{Run: &jobsv0.Run{ID: "run-7", State: jobsv0.RunStateRunning}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup", condition: jobsv0.WaitConditionRunning}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "run-7\n")) +} + +// A run stopped by the daemon (timeout) or by Cancel carries the exit code of +// the container's last gasp -- 137, or even 0 when it handles SIGTERM. That +// must not be mistaken for the job's own success or failure. +func TestRunWaitTimedOutAndCancelledRunsFail(t *testing.T) { + testCases := []struct { + doc string + state string + exit int64 + }{ + {doc: "timed out, killed", state: jobsv0.RunStateTimedOut, exit: 137}, + {doc: "timed out, clean exit on SIGTERM", state: jobsv0.RunStateTimedOut, exit: 0}, + {doc: "cancelled, clean exit on SIGTERM", state: jobsv0.RunStateCancelled, exit: 0}, + } + + for _, tc := range testCases { + t.Run(tc.doc, func(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{Run: &jobsv0.Run{State: tc.state, ExitCode: &jobsv0.ExitCode{Value: tc.exit}}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup"}) + + assert.Check(t, is.Equal(cli.OutBuffer().String(), "")) + assert.Check(t, strings.Contains(cli.ErrBuffer().String(), tc.state), "stderr should name the state: %q", cli.ErrBuffer().String()) + var statusErr clipkg.StatusError + assert.Check(t, errors.As(err, &statusErr), "expected a cli.StatusError, got %v", err) + assert.Check(t, is.Equal(statusErr.StatusCode, 1)) + }) + } +} + +// With --condition running, a run that already finished still satisfies the +// wait (it jumped past the condition): it is reported like without the flag, +// with its exit code rather than its ID. +func TestRunWaitConditionRunningOnFinishedRunPrintsExitCode(t *testing.T) { + client := &fakeClient{ + waitFunc: func(context.Context, *jobsv0.WaitRequest) (*jobsv0.WaitReply, error) { + return &jobsv0.WaitReply{Run: &jobsv0.Run{ID: "run-7", State: jobsv0.RunStateSucceeded, ExitCode: &jobsv0.ExitCode{Value: 0}}}, nil + }, + } + cli := test.NewFakeCli(nil) + + err := runWait(context.Background(), cli, client, &waitOptions{job: "backup", condition: jobsv0.WaitConditionRunning}) + + assert.NilError(t, err) + assert.Check(t, is.Equal(cli.OutBuffer().String(), "0\n")) +} From 3c890a85f21df6a080e4ea8867932273585205a2 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 10 Nov 2026 10:54:35 +0100 Subject: [PATCH 6/6] docs: generate markdown for the `docker job` commands 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 --- docs/reference/commandline/docker.md | 1 + docs/reference/commandline/job.md | 28 +++++++++++++++++++++++ docs/reference/commandline/job_cancel.md | 8 +++++++ docs/reference/commandline/job_inspect.md | 14 ++++++++++++ docs/reference/commandline/job_logs.md | 14 ++++++++++++ docs/reference/commandline/job_ls.md | 12 ++++++++++ docs/reference/commandline/job_pause.md | 8 +++++++ docs/reference/commandline/job_prune.md | 15 ++++++++++++ docs/reference/commandline/job_rm.md | 18 +++++++++++++++ docs/reference/commandline/job_run.md | 14 ++++++++++++ docs/reference/commandline/job_runs.md | 15 ++++++++++++ docs/reference/commandline/job_unpause.md | 8 +++++++ docs/reference/commandline/job_wait.md | 26 +++++++++++++++++++++ 13 files changed, 181 insertions(+) create mode 100644 docs/reference/commandline/job.md create mode 100644 docs/reference/commandline/job_cancel.md create mode 100644 docs/reference/commandline/job_inspect.md create mode 100644 docs/reference/commandline/job_logs.md create mode 100644 docs/reference/commandline/job_ls.md create mode 100644 docs/reference/commandline/job_pause.md create mode 100644 docs/reference/commandline/job_prune.md create mode 100644 docs/reference/commandline/job_rm.md create mode 100644 docs/reference/commandline/job_run.md create mode 100644 docs/reference/commandline/job_runs.md create mode 100644 docs/reference/commandline/job_unpause.md create mode 100644 docs/reference/commandline/job_wait.md diff --git a/docs/reference/commandline/docker.md b/docs/reference/commandline/docker.md index 73ef948beae2..81d44974028e 100644 --- a/docs/reference/commandline/docker.md +++ b/docs/reference/commandline/docker.md @@ -28,6 +28,7 @@ The base command for the Docker CLI. | [`import`](import.md) | Import the contents from a tarball to create a filesystem image | | [`info`](info.md) | Display system-wide information | | [`inspect`](inspect.md) | Return low-level information on Docker objects | +| [`job`](job.md) | Manage jobs | | [`kill`](kill.md) | Kill one or more running containers | | [`load`](load.md) | Load an image from a tar archive or STDIN | | [`login`](login.md) | Authenticate to a registry | diff --git a/docs/reference/commandline/job.md b/docs/reference/commandline/job.md new file mode 100644 index 000000000000..8f2c21191813 --- /dev/null +++ b/docs/reference/commandline/job.md @@ -0,0 +1,28 @@ +# docker job + + +Manage jobs, one-shot or scheduled containers run by the daemon. + +This requires the daemon's jobs extension, which is disabled by default: start +dockerd with --extension-enable org.mobyproject.jobs.v0. + +### Subcommands + +| Name | Description | +|:----------------------------|:-------------------------------------------------| +| [`cancel`](job_cancel.md) | Cancel a job's in-flight run | +| [`inspect`](job_inspect.md) | Display detailed information on a job | +| [`logs`](job_logs.md) | Fetch the logs of a job's most recent run | +| [`ls`](job_ls.md) | List jobs | +| [`pause`](job_pause.md) | Suppress a job's schedule trigger | +| [`prune`](job_prune.md) | Remove idle manual jobs | +| [`rm`](job_rm.md) | Remove a job | +| [`run`](job_run.md) | Run a job | +| [`runs`](job_runs.md) | List a job's runs | +| [`unpause`](job_unpause.md) | Re-arm a paused job's schedule trigger | +| [`wait`](job_wait.md) | Block until a job's run reaches a terminal state | + + + + + diff --git a/docs/reference/commandline/job_cancel.md b/docs/reference/commandline/job_cancel.md new file mode 100644 index 000000000000..a1336c6beaa3 --- /dev/null +++ b/docs/reference/commandline/job_cancel.md @@ -0,0 +1,8 @@ +# docker job cancel + + +Cancel a job's in-flight run + + + + diff --git a/docs/reference/commandline/job_inspect.md b/docs/reference/commandline/job_inspect.md new file mode 100644 index 000000000000..ba46c80de851 --- /dev/null +++ b/docs/reference/commandline/job_inspect.md @@ -0,0 +1,14 @@ +# docker job inspect + + +Display detailed information on a job + +### Options + +| Name | Type | Default | Description | +|:--------|:---------|:--------|:------------------------------------------| +| `--run` | `string` | | Inspect a specific run instead of the job | + + + + diff --git a/docs/reference/commandline/job_logs.md b/docs/reference/commandline/job_logs.md new file mode 100644 index 000000000000..c11bb0dbda2d --- /dev/null +++ b/docs/reference/commandline/job_logs.md @@ -0,0 +1,14 @@ +# docker job logs + + +Fetch the logs of a job's most recent run + +### Options + +| Name | Type | Default | Description | +|:-----------------|:-------|:--------|:------------------| +| `-f`, `--follow` | `bool` | | Follow log output | + + + + diff --git a/docs/reference/commandline/job_ls.md b/docs/reference/commandline/job_ls.md new file mode 100644 index 000000000000..2b143f8f7e0e --- /dev/null +++ b/docs/reference/commandline/job_ls.md @@ -0,0 +1,12 @@ +# docker job ls + + +List jobs + +### Aliases + +`docker job ls`, `docker job list` + + + + diff --git a/docs/reference/commandline/job_pause.md b/docs/reference/commandline/job_pause.md new file mode 100644 index 000000000000..ede9911c9861 --- /dev/null +++ b/docs/reference/commandline/job_pause.md @@ -0,0 +1,8 @@ +# docker job pause + + +Suppress a job's schedule trigger + + + + diff --git a/docs/reference/commandline/job_prune.md b/docs/reference/commandline/job_prune.md new file mode 100644 index 000000000000..6f924a4c580c --- /dev/null +++ b/docs/reference/commandline/job_prune.md @@ -0,0 +1,15 @@ +# docker job prune + + +Remove idle manual jobs + +### Options + +| Name | Type | Default | Description | +|:----------------|:--------------|:--------|:---------------------------------------------------------| +| `-f`, `--force` | `bool` | | Do not prompt for confirmation | +| `--label` | `stringArray` | | Only remove jobs matching a label (`key` or `key=value`) | + + + + diff --git a/docs/reference/commandline/job_rm.md b/docs/reference/commandline/job_rm.md new file mode 100644 index 000000000000..832cc3e96a6e --- /dev/null +++ b/docs/reference/commandline/job_rm.md @@ -0,0 +1,18 @@ +# docker job rm + + +Remove a job + +### Aliases + +`docker job rm`, `docker job remove` + +### Options + +| Name | Type | Default | Description | +|:---------|:---------|:--------|:-------------------------------------------------------------------------------| +| `--runs` | `string` | | Run history retention: `keep`, `remove`, or `remove-finished` (default `keep`) | + + + + diff --git a/docs/reference/commandline/job_run.md b/docs/reference/commandline/job_run.md new file mode 100644 index 000000000000..65ecadf9abf0 --- /dev/null +++ b/docs/reference/commandline/job_run.md @@ -0,0 +1,14 @@ +# docker job run + + +Run a job + +### Options + +| Name | Type | Default | Description | +|:---------------|:-------|:--------|:---------------------------------------------------| +| `--reschedule` | `bool` | | Reschedule the job's next occurrence from this run | + + + + diff --git a/docs/reference/commandline/job_runs.md b/docs/reference/commandline/job_runs.md new file mode 100644 index 000000000000..a4e78c47a783 --- /dev/null +++ b/docs/reference/commandline/job_runs.md @@ -0,0 +1,15 @@ +# docker job runs + + +List a job's runs + +### Options + +| Name | Type | Default | Description | +|:-----------|:---------|:--------|:---------------------------------------------------------------------| +| `--before` | `string` | | List runs older than this cursor (from a previous page's NextCursor) | +| `--limit` | `int32` | `0` | Maximum number of runs to list (0 for the daemon default) | + + + + diff --git a/docs/reference/commandline/job_unpause.md b/docs/reference/commandline/job_unpause.md new file mode 100644 index 000000000000..14fcc3f38028 --- /dev/null +++ b/docs/reference/commandline/job_unpause.md @@ -0,0 +1,8 @@ +# docker job unpause + + +Re-arm a paused job's schedule trigger + + + + diff --git a/docs/reference/commandline/job_wait.md b/docs/reference/commandline/job_wait.md new file mode 100644 index 000000000000..474ad9a8119a --- /dev/null +++ b/docs/reference/commandline/job_wait.md @@ -0,0 +1,26 @@ +# docker job wait + + +Block until a run of a job reaches a terminal state, then exit with the run's exit code. + +The job must exist: names of removed jobs do not resolve. Without --run, the +latest run is awaited; for a schedule job that has not run yet, that means +blocking until its next scheduled fire. + +A run stopped by a timeout or by "docker job cancel" makes the command fail +with status 1, whatever the exit code of its container. + +With --condition running, the ID of the run is printed once it is running. A +run that already finished is reported as usual, by its exit code, so scripts +capturing the output must check which one they got. + +### Options + +| Name | Type | Default | Description | +|:--------------|:---------|:--------|:-----------------------------------------------------------------------------------------------| +| `--condition` | `string` | | Condition to wait for: `terminal` or `running`, i.e. the run left pending (default `terminal`) | +| `--run` | `string` | | Run to wait for: a run ID, or `latest` (default `latest`) | + + + +