refactor: move host-crontab generation into cron plugin
Host-crontab generation for `app.json` cron tasks now lives in the `cron` plugin, gated by a new `scheduler-uses-host-cron` trigger that the `docker-local` scheduler answers true while self-managed schedulers such as `k3s` answer false. This lets any host-cron scheduler participate in normal `app.json` cron without coupling to `scheduler-docker-local` or duplicating the crontab writer, while the `k3s` scheduler continues to manage its own in-cluster cron jobs. Closes #8862.
This commit is contained in:
1
plugins/cron/.gitignore
vendored
1
plugins/cron/.gitignore
vendored
@@ -7,4 +7,5 @@
|
||||
/install
|
||||
/post-*
|
||||
/report
|
||||
/scheduler-cron-write
|
||||
/scheduler-stop
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
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/post-deploy triggers/scheduler-cron-write triggers/scheduler-stop
|
||||
BUILD = commands subcommands triggers
|
||||
PLUGIN_NAME = cron
|
||||
|
||||
|
||||
252
plugins/cron/crontab.go
Normal file
252
plugins/cron/crontab.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
base36 "github.com/multiformats/go-base36"
|
||||
)
|
||||
|
||||
//go:embed templates/cron.tmpl
|
||||
var cronTemplate string
|
||||
|
||||
// usesHostCron reports whether the given scheduler writes its cron tasks to the
|
||||
// host crontab (as opposed to managing its own cron backend). An empty scheduler
|
||||
// or an unimplemented scheduler-uses-host-cron trigger is treated as false.
|
||||
func usesHostCron(scheduler string) bool {
|
||||
if scheduler == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||
Trigger: "scheduler-uses-host-cron",
|
||||
Args: []string{scheduler},
|
||||
})
|
||||
return results.StdoutContents() == "true"
|
||||
}
|
||||
|
||||
// hostCronSchedulers returns a map keyed by every distinct scheduler seen across
|
||||
// the given apps plus the global scheduler, with a boolean value indicating
|
||||
// whether that scheduler uses the host crontab. Deduplicating up front avoids
|
||||
// redundant scheduler-uses-host-cron dispatches and any shared-cache race across
|
||||
// concurrent task collection.
|
||||
func hostCronSchedulers(appSchedulers []string) map[string]bool {
|
||||
schedulers := map[string]bool{}
|
||||
for _, scheduler := range append(appSchedulers, common.GetGlobalScheduler()) {
|
||||
if scheduler == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := schedulers[scheduler]; ok {
|
||||
continue
|
||||
}
|
||||
schedulers[scheduler] = usesHostCron(scheduler)
|
||||
}
|
||||
return schedulers
|
||||
}
|
||||
|
||||
// injectedCronTasks parses the tasks injected via the cron-entries trigger for a
|
||||
// given scheduler. Each entry is newline delimited in the form
|
||||
// $SCHEDULE;$COMMAND[;$LOGFILE].
|
||||
func injectedCronTasks(scheduler string) ([]CronTask, error) {
|
||||
tasks := []CronTask{}
|
||||
response, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||
Trigger: "cron-entries",
|
||||
Args: []string{scheduler},
|
||||
})
|
||||
for _, line := range strings.Split(response.StdoutContents(), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return []CronTask{}, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(line, ";")
|
||||
if len(parts) != 2 && len(parts) != 3 {
|
||||
return []CronTask{}, fmt.Errorf("Invalid injected cron task: %v", line)
|
||||
}
|
||||
|
||||
id := base36.EncodeToStringLc([]byte(strings.Join(parts, ";;;")))
|
||||
task := CronTask{
|
||||
ID: id,
|
||||
Schedule: parts[0],
|
||||
AltCommand: parts[1],
|
||||
Maintenance: false,
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
task.LogFile = parts[2]
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// generateCronTasks returns all cron tasks that should be written to the host
|
||||
// crontab: the app.json cron tasks for every app whose scheduler uses the host
|
||||
// crontab, plus any tasks injected via the cron-entries trigger for each such
|
||||
// scheduler. Tasks in maintenance are omitted.
|
||||
func generateCronTasks() ([]CronTask, error) {
|
||||
apps, _ := common.UnfilteredDokkuApps()
|
||||
|
||||
appSchedulers := make([]string, len(apps))
|
||||
sg := new(errgroup.Group)
|
||||
for i, appName := range apps {
|
||||
i := i
|
||||
appName := appName
|
||||
sg.Go(func() error {
|
||||
appSchedulers[i] = common.GetAppScheduler(appName)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := sg.Wait(); err != nil {
|
||||
return []CronTask{}, err
|
||||
}
|
||||
|
||||
hostCron := hostCronSchedulers(appSchedulers)
|
||||
|
||||
g := new(errgroup.Group)
|
||||
results := make(chan []CronTask, len(apps)+len(hostCron))
|
||||
|
||||
for i, appName := range apps {
|
||||
if !hostCron[appSchedulers[i]] {
|
||||
continue
|
||||
}
|
||||
appName := appName
|
||||
g.Go(func() error {
|
||||
c, err := FetchCronTasks(FetchCronTasksInput{AppName: appName})
|
||||
if err != nil {
|
||||
results <- []CronTask{}
|
||||
common.LogWarn(err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
results <- c
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
for scheduler, isHostCron := range hostCron {
|
||||
if !isHostCron {
|
||||
continue
|
||||
}
|
||||
scheduler := scheduler
|
||||
g.Go(func() error {
|
||||
tasks, err := injectedCronTasks(scheduler)
|
||||
if err != nil {
|
||||
results <- []CronTask{}
|
||||
return err
|
||||
}
|
||||
|
||||
results <- tasks
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err := g.Wait()
|
||||
close(results)
|
||||
|
||||
tasks := []CronTask{}
|
||||
if err != nil {
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
for result := range results {
|
||||
for _, task := range result {
|
||||
if !task.Maintenance {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// writeCronTab regenerates the dokku user crontab from every host-cron app. It
|
||||
// is always a full regeneration, so there is no clobbering when multiple
|
||||
// schedulers use the host crontab.
|
||||
func writeCronTab() error {
|
||||
tasks, err := generateCronTasks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return deleteCrontab()
|
||||
}
|
||||
|
||||
mailfrom := common.PropertyGetDefault("cron", "--global", "mailfrom", DefaultProperties["mailfrom"])
|
||||
mailto := common.PropertyGetDefault("cron", "--global", "mailto", DefaultProperties["mailto"])
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Tasks": tasks,
|
||||
"Mailfrom": mailfrom,
|
||||
"Mailto": mailto,
|
||||
}
|
||||
|
||||
t, err := getCronTemplate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
defer tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if err := t.Execute(tmpFile, data); err != nil {
|
||||
return fmt.Errorf("Unable to template out schedule file: %v", err)
|
||||
}
|
||||
|
||||
result, err := common.CallExecCommand(common.ExecCommandInput{
|
||||
Command: "crontab",
|
||||
Args: []string{"-u", "dokku", tmpFile.Name()},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to update schedule file: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("Unable to update schedule file: %s", result.StderrContents())
|
||||
}
|
||||
|
||||
common.LogInfo1("Updated schedule file")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteCrontab removes the dokku user crontab
|
||||
func deleteCrontab() error {
|
||||
result, err := common.CallExecCommand(common.ExecCommandInput{
|
||||
Command: "crontab",
|
||||
Args: []string{"-l", "-u", "dokku"},
|
||||
})
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err = common.CallExecCommand(common.ExecCommandInput{
|
||||
Command: "crontab",
|
||||
Args: []string{"-r", "-u", "dokku"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to remove schedule file: %w", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("Unable to remove schedule file: %s", result.StderrContents())
|
||||
}
|
||||
|
||||
common.LogInfo1("Removed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCronTemplate parses the embedded cron template
|
||||
func getCronTemplate() (*template.Template, error) {
|
||||
t := template.New("cron")
|
||||
s := strings.TrimSpace(cronTemplate)
|
||||
return t.Parse(s)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/ryanuber/columnize v2.1.2+incompatible
|
||||
github.com/spf13/pflag v1.0.10
|
||||
golang.org/x/sync v0.22.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
@@ -28,7 +29,6 @@ require (
|
||||
github.com/pkg/sftp v1.13.11 // indirect
|
||||
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
k8s.io/utils v0.0.0-20240102154912-e7106e64919e // indirect
|
||||
)
|
||||
|
||||
@@ -44,9 +44,16 @@ func main() {
|
||||
case "post-delete":
|
||||
appName := flag.Arg(0)
|
||||
err = cron.TriggerPostDelete(appName)
|
||||
case "post-deploy":
|
||||
appName := flag.Arg(0)
|
||||
err = cron.TriggerPostDeploy(appName)
|
||||
case "report":
|
||||
appName := flag.Arg(0)
|
||||
err = cron.ReportSingleApp(appName, "", "")
|
||||
case "scheduler-cron-write":
|
||||
scheduler := flag.Arg(0)
|
||||
appName := flag.Arg(1)
|
||||
err = cron.TriggerSchedulerCronWrite(scheduler, appName)
|
||||
case "scheduler-stop":
|
||||
scheduler := flag.Arg(0)
|
||||
appName := flag.Arg(1)
|
||||
|
||||
@@ -98,11 +98,43 @@ func TriggerPostDelete(appName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerSchedulerStop stops the scheduler for a given app container
|
||||
func TriggerSchedulerStop(scheduler string, appName string, removeContainers string) error {
|
||||
if scheduler != "docker-local" {
|
||||
// TriggerPostDeploy regenerates the cron schedule for a given app after it is
|
||||
// deployed. Dispatching scheduler-cron-write lets the cron plugin regenerate the
|
||||
// host crontab for host-cron schedulers while self-managed schedulers update
|
||||
// their own backends.
|
||||
func TriggerPostDeploy(appName string) error {
|
||||
scheduler := common.GetAppScheduler(appName)
|
||||
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||
Trigger: "scheduler-cron-write",
|
||||
Args: []string{scheduler, appName},
|
||||
StreamStdio: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// TriggerSchedulerCronWrite regenerates the host crontab when no scheduler is
|
||||
// given (the letsencrypt "all apps" path) or when the given scheduler uses the
|
||||
// host crontab. Self-managed schedulers implement their own scheduler-cron-write
|
||||
// trigger and are no-ops here.
|
||||
func TriggerSchedulerCronWrite(scheduler string, appName string) error {
|
||||
if scheduler != "" && !usesHostCron(scheduler) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return writeCronTab()
|
||||
}
|
||||
|
||||
// TriggerSchedulerStop regenerates the host crontab for a host-cron app after
|
||||
// its processes are stopped
|
||||
func TriggerSchedulerStop(scheduler string, appName string, removeContainers string) error {
|
||||
if !usesHostCron(scheduler) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||
Trigger: "scheduler-cron-write",
|
||||
Args: []string{scheduler, appName},
|
||||
StreamStdio: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user