diff --git a/docs/deployment/schedulers/scheduler-management.md b/docs/deployment/schedulers/scheduler-management.md index fa838a732..ca20b4539 100644 --- a/docs/deployment/schedulers/scheduler-management.md +++ b/docs/deployment/schedulers/scheduler-management.md @@ -122,7 +122,7 @@ At this time, the following dokku commands are used to implement a complete sche - `apps:clone`: handles app cloning - triggers: post-app-clone-setup - `cron`: generates cron tasks for the app - - triggers: scheduler-cron-write + - triggers: scheduler-uses-host-cron, scheduler-cron-write - `deploy`: deploys app proceses and checks the status of a deploy - triggers: scheduler-app-status, scheduler-deploy, scheduler-is-deployed, scheduler-logs-failed - `enter`: enters a running container diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index cb9444be2..4c043d47c 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -2527,8 +2527,8 @@ DOKKU_SCHEDULER="$1"; APP="$2"; > The scheduler plugin trigger apis are under development and may change > between minor releases until the 1.0 release. -- Description: Force triggers writing out cron tasks. Arguments are optional. -- Invoked by: `ps:start`, `ps:stop`, `cron:set` +- Description: Force triggers writing out cron tasks. Arguments are optional. The `cron` plugin implements this for host-crontab schedulers (regenerating the whole `dokku` user crontab when the scheduler uses host cron or when no scheduler is given); self-managed schedulers such as `k3s` implement it to update their own cron backend. +- Invoked by: `ps:start`, `ps:stop`, `cron:set`, `apps:destroy` - Arguments: `$DOKKU_SCHEDULER $APP` - Example: @@ -2989,6 +2989,30 @@ DOKKU_SCHEDULER="$1"; APP="$2"; REMOVE_CONTAINERS="$3"; - Arguments: `$SCHEDULER $ENTRY_NAME $IMAGE [-- $cmd...]` - Flags: `--interactive` (stdin is open), `--tty` (stdin is a terminal), `--as-user ` (override `entry.Chown`). +### `scheduler-uses-host-cron` + +> [!WARNING] +> The scheduler plugin trigger apis are under development and may change +> between minor releases until the 1.0 release. + +- Description: Reports whether the scheduler writes `app.json` cron tasks to the host `dokku` user crontab. Schedulers that use the host crontab (`docker-local`) echo `true`; schedulers that manage their own cron backend (`k3s`, which creates in-cluster CronJobs) echo `false`. The cron plugin reads this to decide which apps to include when regenerating the host crontab; a scheduler that does not implement the trigger is treated as `false`. +- Invoked by: `cron` +- Arguments: `$DOKKU_SCHEDULER` +- Example: + +```shell +#!/usr/bin/env bash + +set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x +DOKKU_SCHEDULER="$1"; + +if [[ "$DOKKU_SCHEDULER" != "custom-scheduler" ]]; then + return +fi + +echo "true" +``` + ### `traefik-template-source` - Description: Retrieves an alternative template for the traefik compose config diff --git a/docs/processes/scheduled-cron-tasks.md b/docs/processes/scheduled-cron-tasks.md index 12c21f507..9a68ffad5 100644 --- a/docs/processes/scheduled-cron-tasks.md +++ b/docs/processes/scheduled-cron-tasks.md @@ -57,8 +57,8 @@ When running scheduled cron tasks, there are a few items to be aware of: - A `MAILTO` value can be set via the `cron:set` command. - A `MAILFROM` value can be set via the `cron:set` command. - Each scheduled task is executed within a one-off `run` container, and thus inherit any docker-options specified for `run` containers. Resources are never shared between scheduled tasks. -- Scheduled cron tasks are supported on a per-scheduler basis, and are currently only implemented by the `docker-local` scheduler. -- Tasks for _all_ apps managed by the `docker-local` scheduler are written to a single crontab file owned by the `dokku` user. The `dokku` user's crontab should be considered reserved for this purpose. +- Scheduled cron tasks are supported on a per-scheduler basis. Schedulers that use the host crontab - such as `docker-local` - have their `app.json` cron tasks written to the `dokku` user crontab, while schedulers that manage their own cron backend - such as `k3s` - schedule them natively. +- Tasks for _all_ apps managed by a host-crontab scheduler such as `docker-local` are written to a single crontab file owned by the `dokku` user. The `dokku` user's crontab should be considered reserved for this purpose. - The `command` is tokenized and exec'd directly inside the container. Shell features such as `;`, `&&`, `|`, and `>` are _not_ interpreted. Commands that contain a bare shell operator are rejected when `app.json` is validated at deploy time, so a malformed cron command will fail the deploy rather than silently fail to run. If shell semantics are required, wrap the command explicitly, for example `"sh -c 'do-thing > /var/log/x.log'"`. diff --git a/plugins/cron/.gitignore b/plugins/cron/.gitignore index ee795a9c9..d56a44bf9 100644 --- a/plugins/cron/.gitignore +++ b/plugins/cron/.gitignore @@ -7,4 +7,5 @@ /install /post-* /report +/scheduler-cron-write /scheduler-stop diff --git a/plugins/cron/Makefile b/plugins/cron/Makefile index 93d8c9a0a..dec0dd43a 100644 --- a/plugins/cron/Makefile +++ b/plugins/cron/Makefile @@ -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 diff --git a/plugins/cron/crontab.go b/plugins/cron/crontab.go new file mode 100644 index 000000000..a2b229f32 --- /dev/null +++ b/plugins/cron/crontab.go @@ -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) +} diff --git a/plugins/cron/go.mod b/plugins/cron/go.mod index 58e437477..5b3fa5947 100644 --- a/plugins/cron/go.mod +++ b/plugins/cron/go.mod @@ -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 ) diff --git a/plugins/cron/src/triggers/triggers.go b/plugins/cron/src/triggers/triggers.go index 1a65cec73..85321c66c 100644 --- a/plugins/cron/src/triggers/triggers.go +++ b/plugins/cron/src/triggers/triggers.go @@ -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) diff --git a/plugins/cron/triggers.go b/plugins/cron/triggers.go index e19097bbd..16be1aa2a 100644 --- a/plugins/cron/triggers.go +++ b/plugins/cron/triggers.go @@ -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 } diff --git a/plugins/scheduler-docker-local/.gitignore b/plugins/scheduler-docker-local/.gitignore index 4c57fe4bf..4e82d7ccf 100644 --- a/plugins/scheduler-docker-local/.gitignore +++ b/plugins/scheduler-docker-local/.gitignore @@ -3,6 +3,5 @@ /cron-* /report /report-subcommand -/scheduler-cron-write /scheduler-storage-exec /subcommands/report diff --git a/plugins/scheduler-docker-local/Makefile b/plugins/scheduler-docker-local/Makefile index 841d9ae0c..4c50aa653 100644 --- a/plugins/scheduler-docker-local/Makefile +++ b/plugins/scheduler-docker-local/Makefile @@ -1,4 +1,4 @@ -TRIGGERS = triggers/report triggers/scheduler-cron-write triggers/scheduler-storage-exec +TRIGGERS = triggers/report triggers/scheduler-storage-exec BUILD = report-subcommand triggers PLUGIN_NAME = scheduler-docker-local diff --git a/plugins/scheduler-docker-local/functions.go b/plugins/scheduler-docker-local/functions.go deleted file mode 100644 index b5ac3eecc..000000000 --- a/plugins/scheduler-docker-local/functions.go +++ /dev/null @@ -1,197 +0,0 @@ -package schedulerdockerlocal - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "text/template" - - "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/cron" - "golang.org/x/sync/errgroup" - - base36 "github.com/multiformats/go-base36" -) - -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 -} - -func generateCronTasks() ([]cron.CronTask, error) { - apps, _ := common.UnfilteredDokkuApps() - - g := new(errgroup.Group) - results := make(chan []cron.CronTask, len(apps)+1) - for _, appName := range apps { - appName := appName - g.Go(func() error { - scheduler := common.GetAppScheduler(appName) - if scheduler != "docker-local" { - results <- []cron.CronTask{} - return nil - } - - c, err := cron.FetchCronTasks(cron.FetchCronTasksInput{AppName: appName}) - if err != nil { - results <- []cron.CronTask{} - common.LogWarn(err.Error()) - return nil - } - - results <- c - return nil - }) - } - - g.Go(func() error { - tasks := []cron.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) == "" { - results <- []cron.CronTask{} - return nil - } - - parts := strings.Split(line, ";") - if len(parts) != 2 && len(parts) != 3 { - results <- []cron.CronTask{} - return fmt.Errorf("Invalid injected cron task: %v", line) - } - - id := base36.EncodeToStringLc([]byte(strings.Join(parts, ";;;"))) - task := cron.CronTask{ - ID: id, - Schedule: parts[0], - AltCommand: parts[1], - Maintenance: false, - } - if len(parts) == 3 { - task.LogFile = parts[2] - } - tasks = append(tasks, task) - } - results <- tasks - return nil - }) - - err := g.Wait() - close(results) - - tasks := []cron.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 -} - -func writeCronTab(scheduler string) error { - // allow empty scheduler, which means all apps (used by letsencrypt) - if scheduler != "docker-local" && scheduler != "" { - return nil - } - - tasks, err := generateCronTasks() - if err != nil { - return err - } - - if len(tasks) == 0 { - return deleteCrontab() - } - - resultfromResults, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "cron-get-property", - Args: []string{"--global", "mailfrom"}, - }) - mailfrom := resultfromResults.StdoutContents() - - mailtoResults, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "cron-get-property", - Args: []string{"--global", "mailto"}, - }) - mailto := mailtoResults.StdoutContents() - - 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 -} - -func getCronTemplate() (*template.Template, error) { - t := template.New("cron") - - templatePath := filepath.Join(common.MustGetEnv("PLUGIN_ENABLED_PATH"), "cron", "templates", "cron.tmpl") - b, err := os.ReadFile(templatePath) - if err != nil { - return t, fmt.Errorf("Cannot read template file: %v", err) - } - - s := strings.TrimSpace(string(b)) - return t.Parse(s) -} diff --git a/plugins/scheduler-docker-local/go.mod b/plugins/scheduler-docker-local/go.mod index c9a05f71e..e65e2af3b 100644 --- a/plugins/scheduler-docker-local/go.mod +++ b/plugins/scheduler-docker-local/go.mod @@ -4,21 +4,16 @@ go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 - github.com/dokku/dokku/plugins/cron v0.0.0-00010101000000-000000000000 github.com/dokku/dokku/plugins/storage v0.0.0-00010101000000-000000000000 - github.com/multiformats/go-base36 v0.2.0 github.com/spf13/pflag v1.0.10 - golang.org/x/sync v0.22.0 ) require ( github.com/alexellis/go-execute/v2 v2.2.1 // indirect - github.com/dokku/dokku/plugins/app-json v0.0.0-00010101000000-000000000000 // indirect github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 // indirect github.com/fatih/color v1.19.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.23 // indirect @@ -27,21 +22,15 @@ require ( github.com/otiai10/mint v1.6.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.11 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/ryanuber/columnize v2.1.2+incompatible // 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 mvdan.cc/sh/v3 v3.13.1 // indirect ) -replace github.com/dokku/dokku/plugins/app-json => ../app-json - replace github.com/dokku/dokku/plugins/common => ../common -replace github.com/dokku/dokku/plugins/cron => ../cron - replace github.com/dokku/dokku/plugins/storage => ../storage replace github.com/dokku/dokku/plugins/docker-options => ../docker-options diff --git a/plugins/scheduler-docker-local/go.sum b/plugins/scheduler-docker-local/go.sum index 595d096de..724b73f1b 100644 --- a/plugins/scheduler-docker-local/go.sum +++ b/plugins/scheduler-docker-local/go.sum @@ -12,8 +12,6 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -26,8 +24,6 @@ github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyi github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/melbahja/goph v1.5.2 h1:2eoR45SLF3LyM6tnIhnpjakvXTjtQMJqOK/mp1PYojM= github.com/melbahja/goph v1.5.2/go.mod h1:T+5uoB1PDP6EeK2qXerf5gRh7b6IF8u37GK2ckEi9FU= -github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= -github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= @@ -40,8 +36,6 @@ github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= -github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= @@ -50,8 +44,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= -github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= @@ -68,7 +60,5 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ= -k8s.io/utils v0.0.0-20240102154912-e7106e64919e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/plugins/scheduler-docker-local/post-deploy b/plugins/scheduler-docker-local/post-deploy deleted file mode 100755 index 0b56c6d24..000000000 --- a/plugins/scheduler-docker-local/post-deploy +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" -[[ $DOKKU_TRACE ]] && set -x - -trigger-scheduler-docker-local-post-deploy() { - declare desc="scheduler-docker-local post-deploy plugin trigger" - declare trigger="post-deploy" - declare APP="$1" - - local DOKKU_SCHEDULER="$(get_app_scheduler "$APP")" - plugn trigger scheduler-cron-write "$DOKKU_SCHEDULER" "$APP" -} - -trigger-scheduler-docker-local-post-deploy "$@" diff --git a/plugins/scheduler-docker-local/scheduler-stop b/plugins/scheduler-docker-local/scheduler-stop index 51beef518..b3900b585 100755 --- a/plugins/scheduler-docker-local/scheduler-stop +++ b/plugins/scheduler-docker-local/scheduler-stop @@ -50,8 +50,6 @@ trigger-scheduler-docker-local-scheduler-stop() { "$DOCKER_BIN" container rm --force $DOKKU_APP_CIDS &>/dev/null || true fi fi - - plugn trigger scheduler-cron-write "$DOKKU_SCHEDULER" "$APP" } trigger-scheduler-docker-local-scheduler-stop "$@" diff --git a/plugins/scheduler-docker-local/scheduler-uses-host-cron b/plugins/scheduler-docker-local/scheduler-uses-host-cron new file mode 100755 index 000000000..e09c828be --- /dev/null +++ b/plugins/scheduler-docker-local/scheduler-uses-host-cron @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +trigger-scheduler-docker-local-scheduler-uses-host-cron() { + declare desc="reports whether the scheduler writes cron tasks to the host crontab" + declare trigger="scheduler-uses-host-cron" + declare DOKKU_SCHEDULER="$1" + + if [[ "$DOKKU_SCHEDULER" != "docker-local" ]]; then + return + fi + + echo "true" +} + +trigger-scheduler-docker-local-scheduler-uses-host-cron "$@" diff --git a/plugins/scheduler-docker-local/src/triggers/triggers.go b/plugins/scheduler-docker-local/src/triggers/triggers.go index 598c8e023..b0faeefd8 100644 --- a/plugins/scheduler-docker-local/src/triggers/triggers.go +++ b/plugins/scheduler-docker-local/src/triggers/triggers.go @@ -23,9 +23,6 @@ func main() { var err error switch trigger { - case "scheduler-cron-write": - scheduler := flag.Arg(0) - err = schedulerdockerlocal.TriggerSchedulerCronWrite(scheduler) case "scheduler-storage-exec": args := flag.Args() if len(args) < 3 { diff --git a/plugins/scheduler-docker-local/triggers.go b/plugins/scheduler-docker-local/triggers.go deleted file mode 100644 index 321d2dc43..000000000 --- a/plugins/scheduler-docker-local/triggers.go +++ /dev/null @@ -1,6 +0,0 @@ -package schedulerdockerlocal - -// TriggerSchedulerCronWrite force updates the cron file for all apps -func TriggerSchedulerCronWrite(scheduler string) error { - return writeCronTab(scheduler) -} diff --git a/plugins/scheduler-k3s/Makefile b/plugins/scheduler-k3s/Makefile index f0e959413..9dfb4a6b5 100644 --- a/plugins/scheduler-k3s/Makefile +++ b/plugins/scheduler-k3s/Makefile @@ -1,5 +1,5 @@ SUBCOMMANDS = subcommands/annotations:set subcommands/annotations:report subcommands/autoscaling-auth:set subcommands/autoscaling-auth:report subcommands/charts:report subcommands/charts:set subcommands/cluster:add subcommands/cluster:list subcommands/cluster:remove subcommands/ensure-charts subcommands/initialize subcommands/labels:set subcommands/labels:report subcommands/preview subcommands/profiles:add subcommands/profiles:list subcommands/profiles:remove 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-certs-update triggers/post-certs-remove 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 triggers/storage-create triggers/storage-destroy triggers/storage-status triggers/scheduler-storage-exec +TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-certs-update triggers/post-certs-remove 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 triggers/scheduler-uses-host-cron triggers/storage-create triggers/storage-destroy triggers/storage-status triggers/scheduler-storage-exec BUILD = commands subcommands triggers PLUGIN_NAME = scheduler-k3s diff --git a/plugins/scheduler-k3s/src/triggers/triggers.go b/plugins/scheduler-k3s/src/triggers/triggers.go index 6998f9b1d..11bf74ef7 100644 --- a/plugins/scheduler-k3s/src/triggers/triggers.go +++ b/plugins/scheduler-k3s/src/triggers/triggers.go @@ -86,6 +86,9 @@ func main() { scheduler := flag.Arg(0) appName := flag.Arg(1) err = scheduler_k3s.TriggerSchedulerIsDeployed(scheduler, appName) + case "scheduler-uses-host-cron": + scheduler := flag.Arg(0) + err = scheduler_k3s.TriggerSchedulerUsesHostCron(scheduler) case "scheduler-logs": var tail bool var quiet bool diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index 5aa93409b..ad235a21b 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -637,6 +637,17 @@ func TriggerSchedulerIsDeployed(scheduler string, appName string) error { return fmt.Errorf("App %s is not deployed", appName) } +// TriggerSchedulerUsesHostCron reports that the k3s scheduler does not use the +// host crontab; it manages its own in-cluster CronJobs +func TriggerSchedulerUsesHostCron(scheduler string) error { + if scheduler != "k3s" { + return nil + } + + fmt.Println("false") + return nil +} + // TriggerSchedulerEnter enters a container for a given application func TriggerSchedulerEnter(scheduler string, appName string, processType string, podName string, args []string) error { if scheduler != "k3s" { diff --git a/plugins/scheduler-null/scheduler-uses-host-cron b/plugins/scheduler-null/scheduler-uses-host-cron new file mode 100755 index 000000000..95528f119 --- /dev/null +++ b/plugins/scheduler-null/scheduler-uses-host-cron @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +trigger-scheduler-null-scheduler-uses-host-cron() { + declare desc="reports whether the scheduler writes cron tasks to the host crontab" + declare trigger="scheduler-uses-host-cron" + declare DOKKU_SCHEDULER="$1" + + if [[ "$DOKKU_SCHEDULER" != "null" ]]; then + return + fi + + echo "false" +} + +trigger-scheduler-null-scheduler-uses-host-cron "$@" diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index b52a16e71..a7c1d457d 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -18,6 +18,10 @@ EOF teardown() { rm -rf /var/lib/dokku/plugins/available/cron-entries /var/lib/dokku/plugins/enabled/cron-entries + # restore the default scheduler before destroy: a k3s-scheduled app cannot be + # torn down cleanly without a cluster, and would otherwise leak into the next + # test's cleanup_apps + dokku scheduler:set "$TEST_APP" selected docker-local 2>/dev/null || true destroy_app global_teardown } @@ -43,6 +47,32 @@ teardown() { assert_failure } +@test "(cron) scheduler-uses-host-cron" { + run /bin/bash -c "dokku plugin:trigger scheduler-uses-host-cron docker-local" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "true" + + run /bin/bash -c "dokku plugin:trigger scheduler-uses-host-cron k3s" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku plugin:trigger scheduler-uses-host-cron null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku plugin:trigger scheduler-uses-host-cron bogus-scheduler" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" +} + @test "(cron) invalid [missing-keys]" { run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_invalid echo "output: $output" @@ -406,6 +436,39 @@ teardown() { assert_failure } +@test "(cron) k3s-scheduled app skips host crontab" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_valid_single + echo "output: $output" + echo "status: $status" + assert_success + + cron_id="$(dokku cron:list $TEST_APP --format json | jq -r '.[0].id')" + + run /bin/bash -c "cat /var/spool/cron/crontabs/dokku" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "dokku cron:run $TEST_APP $cron_id" + + # switch the app to a scheduler that manages its own cron backend + run /bin/bash -c "dokku scheduler:set $TEST_APP selected k3s" + echo "output: $output" + echo "status: $status" + assert_success + + # regenerating the host crontab must now skip the k3s app's tasks, leaving + # no host-cron tasks and therefore no crontab file + run /bin/bash -c "dokku plugin:trigger scheduler-cron-write" + 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_failure +} + @test "(cron) cron:list --format json" { run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_valid echo "output: $output"