run: honor the explicit manual: false opt-out on jobs

Per the spec, any job can be triggered manually by an explicit run
command, its automated triggers notwithstanding — so run now accepts
scheduled jobs too. The exception is a job explicitly declared with
triggers.manual: false, which run rejects: meant for scheduled jobs
whose out-of-schedule execution would be harmful.

compose-go is bumped to the jobs-branch commit making Manual tri-state
(*bool) and allowing manual and schedule to be combined.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-21 16:35:20 +02:00
committed by Nicolas De loof
parent 135fb0ed3a
commit 2804f9d4ae
4 changed files with 45 additions and 19 deletions
+8 -6
View File
@@ -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 {
+19 -6
View File
@@ -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`)
})
}
+11 -7
View File
@@ -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
+7
View File
@@ -18,3 +18,10 @@ jobs:
triggers:
schedule:
- cron: "0 3 * * *"
rotation:
image: alpine
command: echo rotation
triggers:
manual: false
schedule:
- cron: "0 3 1 * *"