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:
Jose Diaz-Gonzalez
2026-07-22 07:56:33 -04:00
parent e3687a62ed
commit c6b4ea75c1
24 changed files with 441 additions and 259 deletions

View File

@@ -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

View File

@@ -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 <uid>` (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

View File

@@ -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'"`.

View File

@@ -7,4 +7,5 @@
/install
/post-*
/report
/scheduler-cron-write
/scheduler-stop

View File

@@ -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
View 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)
}

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -3,6 +3,5 @@
/cron-*
/report
/report-subcommand
/scheduler-cron-write
/scheduler-storage-exec
/subcommands/report

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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 "$@"

View File

@@ -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 "$@"

View File

@@ -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 "$@"

View File

@@ -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 {

View File

@@ -1,6 +0,0 @@
package schedulerdockerlocal
// TriggerSchedulerCronWrite force updates the cron file for all apps
func TriggerSchedulerCronWrite(scheduler string) error {
return writeCronTab(scheduler)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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" {

View File

@@ -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 "$@"

View File

@@ -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"