From c885205b913f54fcef0e15709591845fc203d26c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 19:48:11 -0400 Subject: [PATCH 01/18] refactor: respect the maintenance value of the cron task instead of skipping them The former method would omit the CronJob objects entirely, meaning a redeploy would be necessary to re-enable them. --- plugins/scheduler-k3s/template.go | 1 + plugins/scheduler-k3s/templates/chart/cron-job.yaml | 2 +- plugins/scheduler-k3s/triggers.go | 10 ++-------- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/plugins/scheduler-k3s/template.go b/plugins/scheduler-k3s/template.go index 835e5f35d..17eafdde5 100644 --- a/plugins/scheduler-k3s/template.go +++ b/plugins/scheduler-k3s/template.go @@ -302,6 +302,7 @@ type ProcessCron struct { ID string `yaml:"id"` Schedule string `yaml:"schedule"` Suffix string `yaml:"suffix"` + Suspend bool `yaml:"suspend"` } type ProcessPortMap struct { diff --git a/plugins/scheduler-k3s/templates/chart/cron-job.yaml b/plugins/scheduler-k3s/templates/chart/cron-job.yaml index f44d40696..b5b4e8b88 100644 --- a/plugins/scheduler-k3s/templates/chart/cron-job.yaml +++ b/plugins/scheduler-k3s/templates/chart/cron-job.yaml @@ -135,6 +135,6 @@ spec: schedule: {{ $config.cron.schedule }} startingDeadlineSeconds: 60 successfulJobsHistoryLimit: 10 - suspend: false + suspend: {{ $config.cron.suspend }} timeZone: Etc/UTC {{- end }} diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index def82453f..efaba74b0 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -312,17 +312,10 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e workingDir := common.GetWorkingDir(appName, image) - allCronTasks, err := cron.FetchCronTasks(cron.FetchCronTasksInput{AppName: appName}) + cronTasks, err := cron.FetchCronTasks(cron.FetchCronTasksInput{AppName: appName}) if err != nil { return fmt.Errorf("Error fetching cron tasks: %w", err) } - // remove maintenance cron tasks - cronTasks := []cron.CronTask{} - for _, cronTask := range allCronTasks { - if !cronTask.Maintenance { - cronTasks = append(cronTasks, cronTask) - } - } domains := []string{} if _, ok := processes["web"]; ok { @@ -655,6 +648,7 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e ID: cronTask.ID, Schedule: cronTask.Schedule, Suffix: suffix, + Suspend: cronEntry.Maintenance, }, Labels: labels, ProcessType: ProcessType_Cron, From 4a357e9896f24740d5f3f3eeb7c0ada5c52ea8ec Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 20:08:22 -0400 Subject: [PATCH 02/18] fix: properly suspend and resume k8s CronJob objects when executing scheduler-cron-write --- plugins/scheduler-k3s/Makefile | 2 +- plugins/scheduler-k3s/k8s.go | 29 ++++++++++ .../scheduler-k3s/src/triggers/triggers.go | 4 ++ plugins/scheduler-k3s/triggers.go | 58 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/plugins/scheduler-k3s/Makefile b/plugins/scheduler-k3s/Makefile index e894570d5..b873f3701 100644 --- a/plugins/scheduler-k3s/Makefile +++ b/plugins/scheduler-k3s/Makefile @@ -1,5 +1,5 @@ SUBCOMMANDS = subcommands/annotations:set subcommands/autoscaling-auth:set subcommands/autoscaling-auth:report subcommands/cluster-add subcommands/cluster-list subcommands/cluster-remove subcommands/ensure-charts subcommands/initialize subcommands/labels:set subcommands/report subcommands/set subcommands/show-kubeconfig subcommands/uninstall -TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop +TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write BUILD = commands subcommands triggers PLUGIN_NAME = scheduler-k3s diff --git a/plugins/scheduler-k3s/k8s.go b/plugins/scheduler-k3s/k8s.go index a078f8ead..aa5a07543 100644 --- a/plugins/scheduler-k3s/k8s.go +++ b/plugins/scheduler-k3s/k8s.go @@ -805,6 +805,35 @@ func (k KubernetesClient) ListTriggerAuthentications(ctx context.Context, input return triggerAuthentications, nil } +// ResumeCronJobsInput contains all the information needed to resume a Kubernetes cron job +type ResumeCronJobsInput struct { + // LabelSelector is the Kubernetes label selector + LabelSelector string + + // Namespace is the Kubernetes namespace + Namespace string +} + +// ResumeCronJobs resumes a Kubernetes cron job +func (k KubernetesClient) ResumeCronJobs(ctx context.Context, input ResumeCronJobsInput) error { + cronJobs, err := k.Client.BatchV1().CronJobs(input.Namespace).List(ctx, metav1.ListOptions{ + LabelSelector: input.LabelSelector, + }) + if err != nil { + return err + } + + for _, cronJob := range cronJobs.Items { + cronJob.Spec.Suspend = ptr.To(false) + _, err := k.Client.BatchV1().CronJobs(input.Namespace).Update(ctx, &cronJob, metav1.UpdateOptions{}) + if err != nil { + return err + } + } + + return nil +} + // ScaleDeploymentInput contains all the information needed to scale a Kubernetes deployment type ScaleDeploymentInput struct { // Name is the Kubernetes deployment name diff --git a/plugins/scheduler-k3s/src/triggers/triggers.go b/plugins/scheduler-k3s/src/triggers/triggers.go index dd48a6b71..15caf5146 100644 --- a/plugins/scheduler-k3s/src/triggers/triggers.go +++ b/plugins/scheduler-k3s/src/triggers/triggers.go @@ -123,6 +123,10 @@ func main() { scheduler := flag.Arg(0) appName := flag.Arg(1) err = scheduler_k3s.TriggerSchedulerStop(scheduler, appName) + case "scheduler-cron-write": + scheduler := flag.Arg(0) + appName := flag.Arg(1) + err = scheduler_k3s.TriggerSchedulerCronWrite(scheduler, appName) case "scheduler-post-delete": scheduler := flag.Arg(0) appName := flag.Arg(1) diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index efaba74b0..4ca07841f 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -163,6 +163,64 @@ func TriggerSchedulerAppStatus(scheduler string, appName string) error { return nil } +// TriggerSchedulerCronWrite writes out cron entries for a given application +func TriggerSchedulerCronWrite(scheduler string, appName string) error { + if scheduler != "k3s" { + return nil + } + + allCronEntries, err := cron.FetchCronEntries(cron.FetchCronEntriesInput{AppName: appName}) + if err != nil { + return fmt.Errorf("Error fetching cron entries: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGQUIT, + syscall.SIGTERM) + go func() { + <-signals + common.LogWarn(fmt.Sprintf("Deployment of %s has been cancelled", appName)) + cancel() + }() + + namespace := getComputedNamespace(appName) + + clientset, err := NewKubernetesClient() + if err != nil { + return fmt.Errorf("Error creating kubernetes client: %w", err) + } + + for _, cronEntry := range allCronEntries { + labelSelector := []string{ + fmt.Sprintf("app.kubernetes.io/part-of=%s", appName), + fmt.Sprintf("dokku.com/cron-id=%s", cronEntry.ID), + } + + if cronEntry.Maintenance { + err = clientset.SuspendCronJobs(ctx, SuspendCronJobsInput{ + Namespace: namespace, + LabelSelector: strings.Join(labelSelector, ","), + }) + if err != nil { + return fmt.Errorf("Error suspending cron jobs: %w", err) + } + } else { + err = clientset.ResumeCronJobs(ctx, ResumeCronJobsInput{ + Namespace: namespace, + LabelSelector: strings.Join(labelSelector, ","), + }) + if err != nil { + return fmt.Errorf("Error resuming cron jobs: %w", err) + } + } + } + + return nil +} + // TriggerSchedulerDeploy deploys an image tag for a given application func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) error { if scheduler != "k3s" { From e42df29a790ab9711d66afa7bc3b2725f7a4f5d4 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 20:14:50 -0400 Subject: [PATCH 03/18] feat: allow specifying maintenance mode in the file or not If the app's cron tasks are set to maintenance mode, then the task is set to maintenance mode regardless. --- docs/appendices/file-formats/app-json.md | 1 + docs/processes/scheduled-cron-tasks.md | 1 + plugins/app-json/appjson.go | 3 +++ plugins/cron/cron.go | 5 +++-- plugins/scheduler-k3s/triggers.go | 10 +++++----- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/appendices/file-formats/app-json.md b/docs/appendices/file-formats/app-json.md index 189a892b8..c7500e19f 100644 --- a/docs/appendices/file-formats/app-json.md +++ b/docs/appendices/file-formats/app-json.md @@ -21,6 +21,7 @@ (list, optional) A list of cron resources. Keys are the names of the process types. The values are an object containing one or more of the following properties: - `command`: (string, required) +- `maintenance`: (boolean, optional) - `schedule`: (string, required) ## Formation diff --git a/docs/processes/scheduled-cron-tasks.md b/docs/processes/scheduled-cron-tasks.md index f647b6edb..a8d39fdfc 100644 --- a/docs/processes/scheduled-cron-tasks.md +++ b/docs/processes/scheduled-cron-tasks.md @@ -34,6 +34,7 @@ The `app.json` file for a given app can define a special `cron` key that contain A cron task takes the following properties: - `command`: A command to be run within the built app image. Specified commands can also be `Procfile` entries. +- `maintenance`: A boolean value that decides whether the cron task is in maintenance and therefore executable or not. - `schedule`: A [cron-compatible](https://en.wikipedia.org/wiki/Cron#Overview) scheduling definition upon which to run the command. Seconds are generally not supported. Zero or more cron tasks can be specified per app. Cron tasks are validated after the build artifact is created but before the app is deployed, and the cron schedule is updated during the post-deploy phase. diff --git a/plugins/app-json/appjson.go b/plugins/app-json/appjson.go index d251da4ea..ab80fb21f 100644 --- a/plugins/app-json/appjson.go +++ b/plugins/app-json/appjson.go @@ -55,6 +55,9 @@ type CronTask struct { // Command is the command to execute Command string `json:"command"` + // Maintenance is whether or not the cron command is in maintenance mode + Maintenance bool `json:"maintenance"` + // Schedule is the cron schedule to execute the command on Schedule string `json:"schedule"` } diff --git a/plugins/cron/cron.go b/plugins/cron/cron.go index 9fb96860c..90cbdced7 100644 --- a/plugins/cron/cron.go +++ b/plugins/cron/cron.go @@ -77,7 +77,7 @@ type FetchCronTasksInput struct { func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { appName := input.AppName tasks := []CronTask{} - isMaintenance := reportComputedMaintenance(appName) == "true" + isAppCronInMaintenance := reportComputedMaintenance(appName) == "true" if input.AppJSON == nil && input.AppName == "" { return tasks, fmt.Errorf("Missing app name or app.json") @@ -121,12 +121,13 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { return tasks, fmt.Errorf("Invalid cron schedule for app %s (schedule %s): %s", appName, c.Schedule, err.Error()) } + maintenance := isAppCronInMaintenance || c.Maintenance tasks = append(tasks, CronTask{ App: appName, Command: c.Command, Schedule: c.Schedule, ID: GenerateCommandID(appName, c), - Maintenance: isMaintenance, + Maintenance: maintenance, }) } diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index 4ca07841f..7a2e0e030 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -169,7 +169,7 @@ func TriggerSchedulerCronWrite(scheduler string, appName string) error { return nil } - allCronEntries, err := cron.FetchCronEntries(cron.FetchCronEntriesInput{AppName: appName}) + cronTasks, err := cron.FetchCronTasks(cron.FetchCronTasksInput{AppName: appName}) if err != nil { return fmt.Errorf("Error fetching cron entries: %w", err) } @@ -193,13 +193,13 @@ func TriggerSchedulerCronWrite(scheduler string, appName string) error { return fmt.Errorf("Error creating kubernetes client: %w", err) } - for _, cronEntry := range allCronEntries { + for _, cronTask := range cronTasks { labelSelector := []string{ fmt.Sprintf("app.kubernetes.io/part-of=%s", appName), - fmt.Sprintf("dokku.com/cron-id=%s", cronEntry.ID), + fmt.Sprintf("dokku.com/cron-id=%s", cronTask.ID), } - if cronEntry.Maintenance { + if cronTask.Maintenance { err = clientset.SuspendCronJobs(ctx, SuspendCronJobsInput{ Namespace: namespace, LabelSelector: strings.Join(labelSelector, ","), @@ -706,7 +706,7 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e ID: cronTask.ID, Schedule: cronTask.Schedule, Suffix: suffix, - Suspend: cronEntry.Maintenance, + Suspend: cronTask.Maintenance, }, Labels: labels, ProcessType: ProcessType_Cron, From 3c7985cd82675f5389947c4379cd83cd5858a662 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 20:30:49 -0400 Subject: [PATCH 04/18] feat: track task vs app maintenance separately We should know _where_ the maintenance value is applied for the end-user's usage --- plugins/cron/cron.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/plugins/cron/cron.go b/plugins/cron/cron.go index 90cbdced7..dbca27826 100644 --- a/plugins/cron/cron.go +++ b/plugins/cron/cron.go @@ -50,7 +50,13 @@ type CronTask struct { // LogFile is the log file to write to LogFile string `json:"-"` - // Maintenance is whether the cron task is in maintenance mode + // AppInMaintenance is whether the app's cron is in maintenance mode + AppInMaintenance bool `json:"app-in-maintenance"` + + // Maintenance is whether the cron command is in maintenance mode + TaskInMaintenance bool `json:"task-in-maintenance"` + + // Maintenance is whether the cron command is in maintenance mode Maintenance bool `json:"maintenance"` } @@ -123,11 +129,13 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { maintenance := isAppCronInMaintenance || c.Maintenance tasks = append(tasks, CronTask{ - App: appName, - Command: c.Command, - Schedule: c.Schedule, - ID: GenerateCommandID(appName, c), - Maintenance: maintenance, + App: appName, + Command: c.Command, + Schedule: c.Schedule, + ID: GenerateCommandID(appName, c), + Maintenance: isAppCronInMaintenance || maintenance, + AppInMaintenance: isAppCronInMaintenance, + TaskInMaintenance: maintenance, }) } @@ -156,12 +164,14 @@ func FetchGlobalCronTasks() ([]CronTask, error) { id := base36.EncodeToStringLc([]byte(strings.Join(parts, ";;;"))) task := CronTask{ - ID: id, - Schedule: parts[0], - Command: parts[1], - AltCommand: parts[1], - Maintenance: false, - Global: true, + ID: id, + Schedule: parts[0], + Command: parts[1], + AltCommand: parts[1], + Global: true, + Maintenance: false, + TaskInMaintenance: false, + AppInMaintenance: false, } if len(parts) == 3 { task.LogFile = parts[2] From f99e3a6c9c3ffd6460a6bf2352ec7d4cc55ce406 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 20:52:57 -0400 Subject: [PATCH 05/18] feat: add ability to suspend and resume a cron task by ID This takes advantage of the properties system and allows users to suspend/resume the task at will. --- plugins/common/properties.go | 14 ++--- plugins/cron/Makefile | 2 +- plugins/cron/cron.go | 22 +++++++- plugins/cron/src/subcommands/subcommands.go | 12 +++++ plugins/cron/subcommands.go | 58 ++++++++++++++++++++- plugins/scheduler-k3s/triggers.go | 4 +- 6 files changed, 99 insertions(+), 13 deletions(-) diff --git a/plugins/common/properties.go b/plugins/common/properties.go index 637991d77..b472c24b9 100644 --- a/plugins/common/properties.go +++ b/plugins/common/properties.go @@ -12,27 +12,27 @@ import ( ) // CommandPropertySet is a generic function that will set a property for a given plugin/app combination -func CommandPropertySet(pluginName, appName, property, value string, properties map[string]string, globalProperties map[string]bool) { +func CommandPropertySet(pluginName, appName, property, value string, validProperties map[string]string, validGlobalProperties map[string]bool) { if appName != "--global" { if err := VerifyAppName(appName); err != nil { LogFailWithError(err) } } - if appName == "--global" && !globalProperties[property] { + if appName == "--global" && !validGlobalProperties[property] { LogFail("Property cannot be specified globally") } if property == "" { LogFail("No property specified") } - for k := range globalProperties { - if _, ok := properties[k]; !ok { - properties[k] = "" + for k := range validGlobalProperties { + if _, ok := validProperties[k]; !ok { + validProperties[k] = "" } } - if _, ok := properties[property]; !ok { - properties := reflect.ValueOf(properties).MapKeys() + if _, ok := validProperties[property]; !ok { + properties := reflect.ValueOf(validProperties).MapKeys() validPropertyList := make([]string, len(properties)) for i := 0; i < len(properties); i++ { validPropertyList[i] = properties[i].String() diff --git a/plugins/cron/Makefile b/plugins/cron/Makefile index 98eeb5fd2..46513788c 100644 --- a/plugins/cron/Makefile +++ b/plugins/cron/Makefile @@ -1,4 +1,4 @@ -SUBCOMMANDS = subcommands/list subcommands/report subcommands/run subcommands/set +SUBCOMMANDS = subcommands/list subcommands/report cron/resume subcommands/run subcommands/set subcommands/suspend TRIGGERS = triggers/app-json-is-valid triggers/cron-get-property triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/scheduler-stop BUILD = commands subcommands triggers PLUGIN_NAME = cron diff --git a/plugins/cron/cron.go b/plugins/cron/cron.go index dbca27826..de19d07b3 100644 --- a/plugins/cron/cron.go +++ b/plugins/cron/cron.go @@ -2,6 +2,7 @@ package cron import ( "fmt" + "strconv" "strings" appjson "github.com/dokku/dokku/plugins/app-json" @@ -27,6 +28,8 @@ var ( } ) +const MaintenancePropertyPrefix = "maintenance." + // CronTask is a struct that represents a cron task type CronTask struct { // ID is a unique identifier for the cron task @@ -102,6 +105,11 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { return tasks, nil } + properties, err := common.PropertyGetAllByPrefix("cron", appName, MaintenancePropertyPrefix) + if err != nil { + return tasks, fmt.Errorf("Error getting maintenance properties: %w", err) + } + for i, c := range input.AppJSON.Cron { if c.Command == "" { if input.WarnToFailure { @@ -127,7 +135,19 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { return tasks, fmt.Errorf("Invalid cron schedule for app %s (schedule %s): %s", appName, c.Schedule, err.Error()) } - maintenance := isAppCronInMaintenance || c.Maintenance + maintenance := c.Maintenance + if value, ok := properties[MaintenancePropertyPrefix+c.Schedule]; ok { + boolValue, err := strconv.ParseBool(value) + if err != nil { + return tasks, fmt.Errorf("Invalid maintenance property for app %s (schedule %s): %s", appName, c.Schedule, err.Error()) + } + + // only override the maintenance value if the property is set to true + if boolValue { + maintenance = boolValue + } + } + tasks = append(tasks, CronTask{ App: appName, Command: c.Command, diff --git a/plugins/cron/src/subcommands/subcommands.go b/plugins/cron/src/subcommands/subcommands.go index 329b240e8..c9452ee05 100644 --- a/plugins/cron/src/subcommands/subcommands.go +++ b/plugins/cron/src/subcommands/subcommands.go @@ -37,6 +37,12 @@ func main() { appName := args.Arg(0) err = cron.CommandReport(appName, *format, infoFlag) } + case "resume": + args := flag.NewFlagSet("cron:resume", flag.ExitOnError) + args.Parse(os.Args[2:]) + appName := args.Arg(0) + cronID := args.Arg(1) + err = cron.CommandResume(appName, cronID) case "run": args := flag.NewFlagSet("cron:run", flag.ExitOnError) detached := args.Bool("detach", false, "--detach: run the container in a detached mode") @@ -57,6 +63,12 @@ func main() { value = args.Arg(1) } err = cron.CommandSet(appName, property, value) + case "suspend": + args := flag.NewFlagSet("cron:suspend", flag.ExitOnError) + args.Parse(os.Args[2:]) + appName := args.Arg(0) + cronID := args.Arg(1) + err = cron.CommandSuspend(appName, cronID) default: err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) } diff --git a/plugins/cron/subcommands.go b/plugins/cron/subcommands.go index 1cc89342f..aef1b833e 100644 --- a/plugins/cron/subcommands.go +++ b/plugins/cron/subcommands.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/dokku/dokku/plugins/common" @@ -43,7 +44,15 @@ func CommandList(appName string, format string) error { if format == "stdout" { output := []string{"ID | Schedule | Maintenance | Command"} for _, task := range tasks { - output = append(output, fmt.Sprintf("%s | %s | %t | %s", task.ID, task.Schedule, task.Maintenance, task.Command)) + maintenance := "false" + if task.Maintenance { + if task.TaskInMaintenance { + maintenance = "true (task)" + } else if task.AppInMaintenance { + maintenance = "true (app)" + } + } + output = append(output, fmt.Sprintf("%s | %s | %s | %s", task.ID, task.Schedule, maintenance, task.Command)) } result := columnize.SimpleFormat(output) @@ -82,6 +91,11 @@ func CommandReport(appName string, format string, infoFlag string) error { return ReportSingleApp(appName, format, infoFlag) } +// CommandResume resumes a cron task +func CommandResume(appName string, cronID string) error { + return CommandSet(appName, fmt.Sprintf("%s%s", MaintenancePropertyPrefix, cronID), "") +} + // CommandRun executes a cron task on the fly func CommandRun(appName string, cronID string, detached bool) error { if err := common.VerifyAppName(appName); err != nil { @@ -138,7 +152,42 @@ func CommandSet(appName string, property string, value string) error { return err } - common.CommandPropertySet("cron", appName, property, value, DefaultProperties, GlobalProperties) + validProperties := DefaultProperties + globalProperties := GlobalProperties + if strings.HasPrefix(property, MaintenancePropertyPrefix) { + if appName == "--global" { + return fmt.Errorf("Task maintenance properties cannot be set globally") + } + + maintenanceParts := strings.SplitN(property, ".", 2) + if len(maintenanceParts) != 3 { + return fmt.Errorf("Invalid task maintenance property, expected format: %s$ID: %s", MaintenancePropertyPrefix, property) + } + + if maintenanceParts[1] == "" { + return fmt.Errorf("Invalid task maintenance property, missing ID") + } + + tasks, err := FetchCronTasks(FetchCronTasksInput{AppName: appName}) + if err != nil { + return err + } + + maintenanceID := maintenanceParts[1] + for _, task := range tasks { + if task.ID == maintenanceID { + validProperties[property] = "" + globalProperties[property] = false + break + } + } + + if _, ok := validProperties[property]; !ok { + return fmt.Errorf("Invalid task maintenance property, no matching task ID found: %s", property) + } + } + + common.CommandPropertySet("cron", appName, property, value, validProperties, globalProperties) scheduler := common.GetAppScheduler(appName) _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ Trigger: "scheduler-cron-write", @@ -147,3 +196,8 @@ func CommandSet(appName string, property string, value string) error { }) return err } + +// CommandSuspend suspends a cron task +func CommandSuspend(appName string, cronID string) error { + return CommandSet(appName, fmt.Sprintf("%s%s", MaintenancePropertyPrefix, cronID), "true") +} diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index 7a2e0e030..626f11a5d 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -163,7 +163,7 @@ func TriggerSchedulerAppStatus(scheduler string, appName string) error { return nil } -// TriggerSchedulerCronWrite writes out cron entries for a given application +// TriggerSchedulerCronWrite writes out cron tasks for a given application func TriggerSchedulerCronWrite(scheduler string, appName string) error { if scheduler != "k3s" { return nil @@ -171,7 +171,7 @@ func TriggerSchedulerCronWrite(scheduler string, appName string) error { cronTasks, err := cron.FetchCronTasks(cron.FetchCronTasksInput{AppName: appName}) if err != nil { - return fmt.Errorf("Error fetching cron entries: %w", err) + return fmt.Errorf("Error fetching cron tasks: %w", err) } ctx, cancel := context.WithCancel(context.Background()) From 66a30b0547338880f6c21d45cde9fbe1e85e307e Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 20:58:56 -0400 Subject: [PATCH 06/18] refactor: rename cron entry to cron task This naming better suits what it is we are fetching. --- plugins/scheduler-docker-local/functions.go | 4 ++-- plugins/scheduler-docker-local/triggers.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scheduler-docker-local/functions.go b/plugins/scheduler-docker-local/functions.go index 977a8904c..4fc6a9a9c 100644 --- a/plugins/scheduler-docker-local/functions.go +++ b/plugins/scheduler-docker-local/functions.go @@ -116,7 +116,7 @@ func generateCronTasks() ([]cron.CronTask, error) { return tasks, nil } -func writeCronTasks(scheduler string) error { +func writeCronTab(scheduler string) error { // allow empty scheduler, which means all apps (used by letsencrypt) if scheduler != "docker-local" && scheduler != "" { return nil @@ -154,7 +154,7 @@ func writeCronTasks(scheduler string) error { return err } - tmpFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("dokku-%s-%s", common.MustGetEnv("DOKKU_PID"), "WriteCronTasks")) + tmpFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("dokku-%s-%s", common.MustGetEnv("DOKKU_PID"), "WriteCronTab")) if err != nil { return fmt.Errorf("Cannot create temporary schedule file: %v", err) } diff --git a/plugins/scheduler-docker-local/triggers.go b/plugins/scheduler-docker-local/triggers.go index 3bd78229d..321d2dc43 100644 --- a/plugins/scheduler-docker-local/triggers.go +++ b/plugins/scheduler-docker-local/triggers.go @@ -2,5 +2,5 @@ package schedulerdockerlocal // TriggerSchedulerCronWrite force updates the cron file for all apps func TriggerSchedulerCronWrite(scheduler string) error { - return writeCronTasks(scheduler) + return writeCronTab(scheduler) } From 426e6b746a207e978a89394d04ee35d1ba6bf19b Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Sep 2025 21:05:24 -0400 Subject: [PATCH 07/18] chore: standardize on cron task vs any other naming --- plugins/app-json/appjson.go | 2 +- plugins/cron/cron.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/app-json/appjson.go b/plugins/app-json/appjson.go index ab80fb21f..99c856bc5 100644 --- a/plugins/app-json/appjson.go +++ b/plugins/app-json/appjson.go @@ -55,7 +55,7 @@ type CronTask struct { // Command is the command to execute Command string `json:"command"` - // Maintenance is whether or not the cron command is in maintenance mode + // Maintenance is whether or not the cron task is in maintenance mode Maintenance bool `json:"maintenance"` // Schedule is the cron schedule to execute the command on diff --git a/plugins/cron/cron.go b/plugins/cron/cron.go index de19d07b3..f21a058fa 100644 --- a/plugins/cron/cron.go +++ b/plugins/cron/cron.go @@ -56,10 +56,10 @@ type CronTask struct { // AppInMaintenance is whether the app's cron is in maintenance mode AppInMaintenance bool `json:"app-in-maintenance"` - // Maintenance is whether the cron command is in maintenance mode + // Maintenance is whether the cron task is in maintenance mode TaskInMaintenance bool `json:"task-in-maintenance"` - // Maintenance is whether the cron command is in maintenance mode + // Maintenance is whether the cron task is in maintenance mode Maintenance bool `json:"maintenance"` } From 7461b839729ef294cb849e2dafb686c61234a38e Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 7 Nov 2025 22:09:51 -0500 Subject: [PATCH 08/18] fix: use correct makefile target --- plugins/cron/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cron/Makefile b/plugins/cron/Makefile index 46513788c..93d8c9a0a 100644 --- a/plugins/cron/Makefile +++ b/plugins/cron/Makefile @@ -1,4 +1,4 @@ -SUBCOMMANDS = subcommands/list subcommands/report cron/resume subcommands/run subcommands/set subcommands/suspend +SUBCOMMANDS = subcommands/list subcommands/report subcommands/resume subcommands/run subcommands/set subcommands/suspend TRIGGERS = triggers/app-json-is-valid triggers/cron-get-property triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/scheduler-stop BUILD = commands subcommands triggers PLUGIN_NAME = cron From 00ce3932d060367462059f351cb4dc27a7c2771d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 8 Nov 2025 03:23:39 -0500 Subject: [PATCH 09/18] fix: update the json output --- tests/unit/cron.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index 77a22c2ea..09bb3b6b4 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -129,7 +129,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success - assert_output '[{"id":"5cruaotm4yzzpnjlsdunblj8qyjp","command":"/bin/true","global":true,"schedule":"@daily","maintenance":false}]' + assert_output '[{"id":"5cruaotm4yzzpnjlsdunblj8qyjp","command":"/bin/true","global":true,"schedule":"@daily","app-in-maintenance":false,"task-in-maintenance":false,"maintenance":false}]' run /bin/bash -c "cat /var/spool/cron/crontabs/dokku" echo "output: $output" From 2aea9993261f5ed9bdf444bfcef9ccd3f925350e Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 9 Nov 2025 01:45:07 -0500 Subject: [PATCH 10/18] test: check if uninstalling after deleting apps will cause tests to work again Not clear why they are suddenly failing, but lets switch this around for now. --- tests/unit/scheduler-k3s-2.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/scheduler-k3s-2.bats b/tests/unit/scheduler-k3s-2.bats index 978bfa531..325c0c286 100644 --- a/tests/unit/scheduler-k3s-2.bats +++ b/tests/unit/scheduler-k3s-2.bats @@ -5,8 +5,8 @@ load test_helper TEST_APP="rdmtestapp" setup() { - uninstall_k3s || true global_setup + uninstall_k3s || true dokku nginx:stop export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" } From e30b579de5119654117cba8e09b2b58a10cfeafa Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 9 Nov 2025 01:51:57 -0500 Subject: [PATCH 11/18] test: fix issue where teardown was not being called --- tests/unit/scheduler-k3s-2.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/scheduler-k3s-2.bats b/tests/unit/scheduler-k3s-2.bats index 325c0c286..978bfa531 100644 --- a/tests/unit/scheduler-k3s-2.bats +++ b/tests/unit/scheduler-k3s-2.bats @@ -5,8 +5,8 @@ load test_helper TEST_APP="rdmtestapp" setup() { - global_setup uninstall_k3s || true + global_setup dokku nginx:stop export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" } From d9603af08ad4b2e1fbe8483d3d02f4a5850acb51 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 01:55:44 -0500 Subject: [PATCH 12/18] docs: document cron task suspension --- docs/processes/scheduled-cron-tasks.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/processes/scheduled-cron-tasks.md b/docs/processes/scheduled-cron-tasks.md index a8d39fdfc..39f659586 100644 --- a/docs/processes/scheduled-cron-tasks.md +++ b/docs/processes/scheduled-cron-tasks.md @@ -6,8 +6,10 @@ ``` cron:list [--format json|stdout] # List scheduled cron tasks for an app cron:report [] [] # Display report about an app +cron:resume # Resume a cron task cron:run [--detach] # Run a cron task on the fly cron:set [--global|] # Set or clear a cron property for an app +cron:suspend # Suspend a cron task ``` ## Usage @@ -124,6 +126,26 @@ ID Schedule Command 5cruaotm4yzzpnjlsdunblj8qyjp @daily /bin/true ``` +#### Suspending and resuming a specific cron task + +Cron tasks can be suspended to temporarily prevent them from running, and later resumed to re-enable them. This is useful for maintenance or debugging purposes. + +To suspend a specific cron task, use the `cron:suspend` command with the app name and cron ID: + +```shell +dokku cron:suspend node-js-app cGhwPT09cGhwIHRlc3QucGhwPT09QGRhaWx5 +``` + +A suspended task will not execute according to its schedule. You can verify a task is suspended by checking the `Maintenance` column in the `cron:list` output, which will show `true (task)` for suspended tasks. + +To resume a suspended cron task, use the `cron:resume` command: + +```shell +dokku cron:resume node-js-app cGhwPT09cGhwIHRlc3QucGhwPT09QGRhaWx5 +``` + +Once resumed, the task will execute according to its schedule again. The cron ID can be retrieved from the `cron:list` output. + #### Executing a cron task on the fly Cron tasks can be invoked via the `cron:run` command. This command takes an `app` argument and a `cron id` (retrievable from `cron:list` output). From 4929272c870a1dd17c40ebf0be777a6bee068407 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 01:56:07 -0500 Subject: [PATCH 13/18] fix: calculate maintenance property correctly --- plugins/cron/cron.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/cron/cron.go b/plugins/cron/cron.go index f21a058fa..1d02ade85 100644 --- a/plugins/cron/cron.go +++ b/plugins/cron/cron.go @@ -135,8 +135,9 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { return tasks, fmt.Errorf("Invalid cron schedule for app %s (schedule %s): %s", appName, c.Schedule, err.Error()) } + cronID := GenerateCommandID(appName, c) maintenance := c.Maintenance - if value, ok := properties[MaintenancePropertyPrefix+c.Schedule]; ok { + if value, ok := properties[MaintenancePropertyPrefix+cronID]; ok { boolValue, err := strconv.ParseBool(value) if err != nil { return tasks, fmt.Errorf("Invalid maintenance property for app %s (schedule %s): %s", appName, c.Schedule, err.Error()) @@ -152,7 +153,7 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) { App: appName, Command: c.Command, Schedule: c.Schedule, - ID: GenerateCommandID(appName, c), + ID: cronID, Maintenance: isAppCronInMaintenance || maintenance, AppInMaintenance: isAppCronInMaintenance, TaskInMaintenance: maintenance, From 3638399726d1a30bbe2929d044de761c2a7b5666 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 01:56:59 -0500 Subject: [PATCH 14/18] fix: parse cronID correctly --- plugins/cron/subcommands.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/plugins/cron/subcommands.go b/plugins/cron/subcommands.go index aef1b833e..233361953 100644 --- a/plugins/cron/subcommands.go +++ b/plugins/cron/subcommands.go @@ -159,12 +159,8 @@ func CommandSet(appName string, property string, value string) error { return fmt.Errorf("Task maintenance properties cannot be set globally") } - maintenanceParts := strings.SplitN(property, ".", 2) - if len(maintenanceParts) != 3 { - return fmt.Errorf("Invalid task maintenance property, expected format: %s$ID: %s", MaintenancePropertyPrefix, property) - } - - if maintenanceParts[1] == "" { + cronTaskID := strings.TrimPrefix(property, MaintenancePropertyPrefix) + if cronTaskID == "" { return fmt.Errorf("Invalid task maintenance property, missing ID") } @@ -173,9 +169,8 @@ func CommandSet(appName string, property string, value string) error { return err } - maintenanceID := maintenanceParts[1] for _, task := range tasks { - if task.ID == maintenanceID { + if task.ID == cronTaskID { validProperties[property] = "" globalProperties[property] = false break From d49f9b89c28a601e20e3c5b8ad64fab835dd04f4 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 01:57:12 -0500 Subject: [PATCH 15/18] chore: add commands to cron:help output --- plugins/cron/src/commands/commands.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/cron/src/commands/commands.go b/plugins/cron/src/commands/commands.go index 36761c778..88ae2d1ee 100644 --- a/plugins/cron/src/commands/commands.go +++ b/plugins/cron/src/commands/commands.go @@ -20,8 +20,10 @@ Additional commands:` helpContent = ` cron:list [--format json|stdout], List scheduled cron tasks for an app cron:report [] [], Display report about an app + cron:resume , Resume a cron task cron:run [--detach], Run a cron task on the fly - cron:set [--global|] , Set or clear a cron property for an app` + cron:set [--global|] , Set or clear a cron property for an app + cron:suspend , Suspend a cron task` ) func main() { From 379ada07d9b5a6cbc37a8a9ab71333dd7b04cdf1 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 01:57:46 -0500 Subject: [PATCH 16/18] fix: properly fetch non-maintenance tasks when building crontab file --- plugins/scheduler-docker-local/functions.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/scheduler-docker-local/functions.go b/plugins/scheduler-docker-local/functions.go index 4fc6a9a9c..b5ac3eecc 100644 --- a/plugins/scheduler-docker-local/functions.go +++ b/plugins/scheduler-docker-local/functions.go @@ -107,9 +107,10 @@ func generateCronTasks() ([]cron.CronTask, error) { } for result := range results { - c := result - if len(c) > 0 && !c[0].Maintenance { - tasks = append(tasks, c...) + for _, task := range result { + if !task.Maintenance { + tasks = append(tasks, task) + } } } From 4cc2dc7844d770605c2547357917d37dc185d11f Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 01:58:02 -0500 Subject: [PATCH 17/18] feat: add cron maintenance info to cron:report output --- plugins/cron/report.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/plugins/cron/report.go b/plugins/cron/report.go index acf9760cb..6577b0a9a 100644 --- a/plugins/cron/report.go +++ b/plugins/cron/report.go @@ -1,7 +1,9 @@ package cron import ( + "fmt" "strconv" + "strings" "github.com/dokku/dokku/plugins/common" ) @@ -21,6 +23,11 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { "--cron-maintenance": reportMaintenance, } + extraFlags := addCronMaintenanceFlags(appName, infoFlag) + for flag, fn := range extraFlags { + flags[flag] = fn + } + flagKeys := []string{} for flagKey := range flags { flagKeys = append(flagKeys, flagKey) @@ -32,6 +39,24 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { return common.ReportSingleApp("cron", appName, infoFlag, infoFlags, flagKeys, format, trimPrefix, uppercaseFirstCharacter) } +func addCronMaintenanceFlags(appName string, infoFlag string) map[string]common.ReportFunc { + flags := map[string]common.ReportFunc{} + + properties, err := common.PropertyGetAllByPrefix("cron", appName, MaintenancePropertyPrefix) + if err != nil { + return flags + } + + for property, value := range properties { + key := strings.Replace(property, MaintenancePropertyPrefix, "", 1) + flags[fmt.Sprintf("--cron-maintenance-%s", key)] = func(appName string) string { + return value + } + } + + return flags +} + func reportMailfrom(_ string) string { return common.PropertyGet("cron", "--global", "mailfrom") } From 8ba6ce94fce678f97b7ee6868d5cfb00fd62c642 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 10 Nov 2025 02:00:14 -0500 Subject: [PATCH 18/18] test: add tests for cron suspend and resume commands --- tests/unit/cron.bats | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index 09bb3b6b4..5c0b1345d 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -235,6 +235,74 @@ teardown() { assert_output "['task.py', 'schedule', 'now']" } +@test "(cron) cron:suspend cron:resume" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_valid_multiple + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "cat /var/spool/cron/crontabs/dokku" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "python3 task.py first" + assert_output_contains "python3 task.py second" + + cron_id="$(dokku cron:list $TEST_APP --format json | jq -r '.[0].id')" + run /bin/bash -c "echo $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + first_command="$(dokku cron:list $TEST_APP --format json | jq -r '.[0].command')" + run /bin/bash -c "echo $first_command" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku cron:report $TEST_APP --cron-maintenance-$cron_id" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku cron:suspend $TEST_APP $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku cron:report $TEST_APP --cron-maintenance-$cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "true" + + run /bin/bash -c "cat /var/spool/cron/crontabs/dokku" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "python3 task.py first" 0 + assert_output_contains "python3 task.py second" + + run /bin/bash -c "dokku cron:resume $TEST_APP $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku cron:report $TEST_APP --cron-maintenance-$cron_id" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "cat /var/spool/cron/crontabs/dokku" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "python3 task.py first" + assert_output_contains "python3 task.py second" +} + template_cron_file_invalid() { local APP="$1" local APP_REPO_DIR="$2"