Files
dokku/plugins/cron/cron.go
Jose Diaz-Gonzalez 96d726c43c fix: retire cron containers past their active deadline
Cron containers were never reaped once they exceeded their active deadline, so a hung cron task ran indefinitely instead of being retired after 24 hours as documented. `cron:run` now also accepts a `--ttl-seconds` argument, matching the one `dokku run` already takes.
2026-08-12 13:11:34 -04:00

252 lines
7.6 KiB
Go

package cron
import (
"fmt"
"strconv"
"strings"
appjson "github.com/dokku/dokku/plugins/app-json"
"github.com/dokku/dokku/plugins/common"
"github.com/multiformats/go-base36"
cronparser "github.com/robfig/cron/v3"
"mvdan.cc/sh/v3/shell"
)
// ValidateCronCommand returns an error if the command cannot be tokenised
// as a sequence of shell words. cron:run dispatches with the same parser,
// so a command that fails here will also fail to execute - validating at
// deploy time surfaces the error before the schedule fires.
func ValidateCronCommand(command string) error {
_, err := shell.Fields(command, func(name string) string {
return ""
})
return err
}
var (
// DefaultProperties is a map of all valid cron properties with corresponding default property values
DefaultProperties = map[string]string{
"mailfrom": "",
"mailto": "",
"maintenance": "false",
}
// GlobalProperties is a map of all valid global cron properties
GlobalProperties = map[string]bool{
"mailfrom": true,
"mailto": true,
"maintenance": true,
}
)
const MaintenancePropertyPrefix = "maintenance."
// DefaultTTLSeconds is how long a cron task may run before it is reaped. The
// docker-local scheduler stamps this onto the container as the
// com.dokku.active-deadline-seconds label, while the k3s scheduler renders it
// as the CronJob's activeDeadlineSeconds.
const DefaultTTLSeconds int64 = 86400
// validateTTLSeconds returns an error if the requested task lifetime is not a
// positive number of seconds. A zero or negative deadline would either expire
// the task the instant it starts or never expire it at all.
func validateTTLSeconds(ttlSeconds int64) error {
if ttlSeconds <= 0 {
return fmt.Errorf("--ttl-seconds must be a positive integer")
}
return nil
}
// CronTask is a struct that represents a cron task
type CronTask struct {
// ID is a unique identifier for the cron task
ID string `json:"id"`
// App is the app the cron task belongs to
App string `json:"app,omitempty"`
// Command is the command to run
Command string `json:"command"`
// Global is whether the cron task is global
Global bool `json:"global,omitempty"`
// Schedule is the cron schedule
Schedule string `json:"schedule"`
// ConcurrencyPolicy is the concurrency policy for the cron command
ConcurrencyPolicy string `json:"concurrency_policy"`
// AltCommand is an alternate command to run
AltCommand string `json:"-"`
// LogFile is the log file to write to
LogFile string `json:"-"`
// AppInMaintenance is whether the app's cron is in maintenance mode
AppInMaintenance bool `json:"app-in-maintenance"`
// Maintenance is whether the cron task is in maintenance mode
TaskInMaintenance bool `json:"task-in-maintenance"`
// Maintenance is whether the cron task is in maintenance mode
Maintenance bool `json:"maintenance"`
}
// DokkuRunCommand returns the dokku run command to execute for a given cron task
func (t CronTask) DokkuRunCommand() string {
if t.AltCommand != "" {
if t.LogFile != "" {
return fmt.Sprintf("%s &>> %s", t.AltCommand, t.LogFile)
}
return t.AltCommand
}
return fmt.Sprintf("dokku cron:run %s %s", t.App, t.ID)
}
// FetchCronTasksInput is the input for the FetchCronTasks function
type FetchCronTasksInput struct {
AppName string
AppJSON *appjson.AppJSON
WarnToFailure bool
}
// FetchCronTasks returns a list of cron tasks for a given app
func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) {
appName := input.AppName
tasks := []CronTask{}
isAppCronInMaintenance := reportComputedMaintenance(appName) == "true"
if input.AppJSON == nil && input.AppName == "" {
return tasks, fmt.Errorf("Missing app name or app.json")
}
if input.AppJSON == nil {
appJSON, err := appjson.GetAppJSON(appName)
if err != nil {
return tasks, fmt.Errorf("Unable to fetch app.json for app %s: %s", appName, err.Error())
}
input.AppJSON = &appJSON
}
if input.AppJSON.Cron == nil {
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 {
return tasks, fmt.Errorf("Missing cron task command for app %s (index %d)", appName, i)
}
common.LogWarn(fmt.Sprintf("Missing cron task command for app %s (index %d)", appName, i))
continue
}
if c.Schedule == "" {
if input.WarnToFailure {
return tasks, fmt.Errorf("Missing cron schedule for app %s (index %d)", appName, i)
}
common.LogWarn(fmt.Sprintf("Missing cron schedule for app %s (index %d)", appName, i))
continue
}
parser := cronparser.NewParser(cronparser.Minute | cronparser.Hour | cronparser.Dom | cronparser.Month | cronparser.Dow | cronparser.Descriptor)
_, err := parser.Parse(c.Schedule)
if err != nil {
return tasks, fmt.Errorf("Invalid cron schedule for app %s (schedule %s): %s", appName, c.Schedule, err.Error())
}
if err := ValidateCronCommand(c.Command); err != nil {
return tasks, fmt.Errorf("Invalid cron command for app %s (command %q): %s", appName, c.Command, err.Error())
}
cronID := GenerateCommandID(appName, c)
maintenance := c.Maintenance
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())
}
// only override the maintenance value if the property is set to true
if boolValue {
maintenance = boolValue
}
}
if c.ConcurrencyPolicy == "" {
c.ConcurrencyPolicy = "allow"
}
if c.ConcurrencyPolicy != "allow" && c.ConcurrencyPolicy != "forbid" && c.ConcurrencyPolicy != "replace" {
return tasks, fmt.Errorf("Invalid cron concurrency policy for app %s (schedule %s): %s", appName, c.Schedule, c.ConcurrencyPolicy)
}
tasks = append(tasks, CronTask{
App: appName,
Command: c.Command,
Schedule: c.Schedule,
ID: cronID,
ConcurrencyPolicy: c.ConcurrencyPolicy,
Maintenance: isAppCronInMaintenance || maintenance,
AppInMaintenance: isAppCronInMaintenance,
TaskInMaintenance: maintenance,
})
}
return tasks, nil
}
// FetchGlobalCronTasks returns a list of global cron tasks
// This function should only be used for the cron:list --global command
// and not internally by the cron plugin
func FetchGlobalCronTasks() ([]CronTask, error) {
tasks := []CronTask{}
response, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "cron-entries",
Args: []string{"docker-local"},
})
for _, line := range strings.Split(response.StdoutContents(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Split(line, ";")
if len(parts) != 2 && len(parts) != 3 {
common.LogWarn(fmt.Sprintf("Invalid injected cron task: %v", line))
continue
}
id := base36.EncodeToStringLc([]byte(strings.Join(parts, ";;;")))
task := CronTask{
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]
}
tasks = append(tasks, task)
}
return tasks, nil
}
// GenerateCommandID creates a unique ID for a given app/command/schedule combination
func GenerateCommandID(appName string, c appjson.CronTask) string {
return base36.EncodeToStringLc([]byte(appName + "===" + c.Command + "===" + c.Schedule))
}