diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 33321aaa4..3d89d2737 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -403,10 +403,12 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op return err } -// materializeManualJob lets run target a manual-trigger job exactly like a -// service. A job is a ContainerSpec+WorkloadSpec — the same layers a service -// is made of — so it materializes as a service for the one-off machinery: -// its profile is activated and the project narrowed to its dependencies by +// materializeManualJob lets run target a job exactly like a service: per the +// spec, any job can be triggered manually regardless of its automated +// triggers, unless it explicitly opts out with `triggers.manual: false`. +// A job is a ContainerSpec+WorkloadSpec — the same layers a service is made +// of — so it materializes as a service for the one-off machinery: its +// profile is activated and the project narrowed to its dependencies by // WithSelectedJob, then the job joins Services under its own name. func materializeManualJob(project *types.Project, name string) (*types.Project, error) { // jobs and services share the depends_on namespace but not their own: a @@ -420,8 +422,8 @@ func materializeManualJob(project *types.Project, name string) (*types.Project, if !ok { return project, nil } - if job.Triggers == nil || job.Triggers.Manual == nil || !*job.Triggers.Manual { - return nil, fmt.Errorf("job %q has no manual trigger, it cannot be run", name) + if job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual { + return nil, fmt.Errorf("job %q is declared with manual: false, it cannot be run manually", name) } project, err := project.WithSelectedJob(name) if err != nil { diff --git a/cmd/compose/run_test.go b/cmd/compose/run_test.go index 6fe87abd4..120f95f19 100644 --- a/cmd/compose/run_test.go +++ b/cmd/compose/run_test.go @@ -25,7 +25,7 @@ import ( ) func TestMaterializeManualJob(t *testing.T) { - yes := true + yes, no := true, false base := func() *types.Project { return &types.Project{ Services: types.Services{ @@ -39,8 +39,13 @@ func TestMaterializeManualJob(t *testing.T) { WorkloadSpec: types.WorkloadSpec{DependsOn: types.DependsOnConfig{"db": {Condition: types.ServiceConditionStarted, Required: true}}}, }, "backup": { - Name: "backup", - Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}, + Name: "backup", + Triggers: &types.TriggerConfig{Schedule: []types.ScheduleConfig{{Cron: "0 3 * * *"}}}, + ContainerSpec: types.ContainerSpec{Image: "backup-tool"}, + }, + "rotation": { + Name: "rotation", + Triggers: &types.TriggerConfig{Manual: &no, Schedule: []types.ScheduleConfig{{Cron: "0 3 1 * *"}}}, }, "prep": { Name: "prep", @@ -105,9 +110,17 @@ func TestMaterializeManualJob(t *testing.T) { assert.Equal(t, svc.Image, "the-service", "a same-named service must not be shadowed by the job") }) - t.Run("a schedule-only job is rejected", func(t *testing.T) { - _, err := materializeManualJob(base(), "backup") - assert.Error(t, err, `job "backup" has no manual trigger, it cannot be run`) + t.Run("a scheduled job without explicit manual opt-out can be run", func(t *testing.T) { + got, err := materializeManualJob(base(), "backup") + assert.NilError(t, err) + svc, err := got.GetService("backup") + assert.NilError(t, err) + assert.Equal(t, svc.Image, "backup-tool") + }) + + t.Run("manual: false explicitly forbids manual execution", func(t *testing.T) { + _, err := materializeManualJob(base(), "rotation") + assert.Error(t, err, `job "rotation" is declared with manual: false, it cannot be run manually`) }) } diff --git a/pkg/e2e/jobs_test.go b/pkg/e2e/jobs_test.go index 1b17264e6..a82e5b5d8 100644 --- a/pkg/e2e/jobs_test.go +++ b/pkg/e2e/jobs_test.go @@ -32,18 +32,22 @@ func TestUpRejectsScheduledJobs(t *testing.T) { ServiceNotCreated("web")) } -// A manual-trigger job runs through `compose run` exactly like a service -// would: its declared dependencies start first, its output and exit flow -// back. A schedule-only job stays out of reach. +// A job runs through `compose run` exactly like a service would: its +// declared dependencies start first, its output and exit flow back. Per the +// spec, manual execution is always available — scheduled jobs included — +// unless the job explicitly opts out with `triggers.manual: false`. func TestRunManualJob(t *testing.T) { - NewScenario(t, "run must execute a manual job like a service, starting its depends_on services first"). + NewScenario(t, "run must execute a job like a service, starting its depends_on services first"). Step("run executes the job after starting its dependency", ComposeCmd("run", "--rm", "migrate"), OutputContains("migration done"), ServiceState("db", "running")). - Step("a schedule-only job cannot be run", - ComposeCmd("run", "--rm", "backup").MayFail(), - StderrContains(`job "backup" has no manual trigger`)) + Step("a scheduled job without explicit opt-out can be run manually", + ComposeCmd("run", "--rm", "backup"), + OutputContains("backup")). + Step("manual: false explicitly forbids manual execution", + ComposeCmd("run", "--rm", "rotation").MayFail(), + StderrContains(`job "rotation" is declared with manual: false`)) } // A job's own env_file resolves exactly like a selected service's would: the diff --git a/pkg/e2e/testdata/TestRunManualJob/compose.yaml b/pkg/e2e/testdata/TestRunManualJob/compose.yaml index 675477855..5157e2bd2 100644 --- a/pkg/e2e/testdata/TestRunManualJob/compose.yaml +++ b/pkg/e2e/testdata/TestRunManualJob/compose.yaml @@ -18,3 +18,10 @@ jobs: triggers: schedule: - cron: "0 3 * * *" + rotation: + image: alpine + command: echo rotation + triggers: + manual: false + schedule: + - cron: "0 3 1 * *"