Merge pull request #9104 from dokku/cron-entries-mailto
Allow cron-entries to set a per-entry MAILTO
This commit is contained in:
@@ -688,8 +688,10 @@ APP="$1"; PROPERTY="$2"
|
|||||||
### `cron-entries`
|
### `cron-entries`
|
||||||
|
|
||||||
- Description: Allows injecting cron tasks into the written out scheduled cron task list. Each entry is newline delimited, and individual tasks come in the form `$SCHEDULE;$FULL_COMMAND;$ARBITRARY_DATA`. Individual implementations of cron writing can decide whether and how to include these cron tasks. The `ARBITRARY_DATA` includes the log file path for the basic `docker-local` cron implementation.
|
- Description: Allows injecting cron tasks into the written out scheduled cron task list. Each entry is newline delimited, and individual tasks come in the form `$SCHEDULE;$FULL_COMMAND;$ARBITRARY_DATA`. Individual implementations of cron writing can decide whether and how to include these cron tasks. The `ARBITRARY_DATA` includes the log file path for the basic `docker-local` cron implementation.
|
||||||
- Invoked by:
|
|
||||||
- Arguments: `$DOKKU_SCHEDULER`
|
When `$CRON_ENTRY_FORMAT` is `json`, each entry may instead be printed as a json object on its own line, with the keys `schedule`, `command`, `log-file` and `mailto`. Only `schedule` and `command` are required. A task with a `mailto` value is written to the crontab after a `MAILTO` line of its own and followed by one resetting `MAILTO` to the global value, or to the `dokku` user when no global value is set, so the value only applies to that task. Its output is mailed to that address in addition to being appended to its `log-file`. Json entries and `;` delimited entries may be printed together. Older versions of Dokku do not pass `$CRON_ENTRY_FORMAT`, and fail to write the crontab if a json entry is printed, so a json entry should only be printed when the argument is `json`.
|
||||||
|
- Invoked by: `dokku cron:list --global`, and when the `dokku` user crontab is written
|
||||||
|
- Arguments: `$DOKKU_SCHEDULER $CRON_ENTRY_FORMAT`
|
||||||
- Example:
|
- Example:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -699,8 +701,13 @@ set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
|
|||||||
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
DOKKU_SCHEDULER="$1"
|
DOKKU_SCHEDULER="$1"
|
||||||
|
CRON_ENTRY_FORMAT="$2"
|
||||||
|
|
||||||
# TODO
|
if [[ "$CRON_ENTRY_FORMAT" == "json" ]]; then
|
||||||
|
echo '{"schedule":"@daily","command":"/usr/local/bin/some-task","log-file":"/var/log/dokku/some-task.log","mailto":"ops@example.com"}'
|
||||||
|
else
|
||||||
|
echo "@daily;/usr/local/bin/some-task;/var/log/dokku/some-task.log"
|
||||||
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
### `dependencies`
|
### `dependencies`
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ When running scheduled cron tasks, there are a few items to be aware of:
|
|||||||
- At this time, only the `PATH` and `SHELL` environment variables are specified in the cron template.
|
- At this time, only the `PATH` and `SHELL` environment variables are specified in the cron template.
|
||||||
- A `MAILTO` value can be set via the `cron:set` command.
|
- A `MAILTO` value can be set via the `cron:set` command.
|
||||||
- A `MAILFROM` value can be set via the `cron:set` command.
|
- A `MAILFROM` value can be set via the `cron:set` command.
|
||||||
|
- Tasks injected by plugins via the [`cron-entries` trigger](/docs/development/plugin-triggers.md#cron-entries) may set a `MAILTO` value of their own, which is used for that task instead of the global value. `MAILTO` is reset after the task to the global value, or to the `dokku` user when no global value is set, so no other task is mailed to that address. The output of such a task is mailed to that address as well as appended to the task's log file.
|
||||||
- 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.
|
- 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. 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package cron
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
appjson "github.com/dokku/dokku/plugins/app-json"
|
appjson "github.com/dokku/dokku/plugins/app-json"
|
||||||
"github.com/dokku/dokku/plugins/common"
|
"github.com/dokku/dokku/plugins/common"
|
||||||
@@ -85,6 +84,9 @@ type CronTask struct {
|
|||||||
// LogFile is the log file to write to
|
// LogFile is the log file to write to
|
||||||
LogFile string `json:"-"`
|
LogFile string `json:"-"`
|
||||||
|
|
||||||
|
// Mailto is the MAILTO value cron uses for this task instead of the global one
|
||||||
|
Mailto string `json:"mailto,omitempty"`
|
||||||
|
|
||||||
// AppInMaintenance is whether the app's cron is in maintenance mode
|
// AppInMaintenance is whether the app's cron is in maintenance mode
|
||||||
AppInMaintenance bool `json:"app-in-maintenance"`
|
AppInMaintenance bool `json:"app-in-maintenance"`
|
||||||
|
|
||||||
@@ -98,6 +100,10 @@ type CronTask struct {
|
|||||||
// DokkuRunCommand returns the dokku run command to execute for a given cron task
|
// DokkuRunCommand returns the dokku run command to execute for a given cron task
|
||||||
func (t CronTask) DokkuRunCommand() string {
|
func (t CronTask) DokkuRunCommand() string {
|
||||||
if t.AltCommand != "" {
|
if t.AltCommand != "" {
|
||||||
|
if t.LogFile != "" && t.Mailto != "" {
|
||||||
|
// keep the output on stdout so cron mails it to the task's MAILTO
|
||||||
|
return fmt.Sprintf("%s 2>&1 | tee -a %s", t.AltCommand, t.LogFile)
|
||||||
|
}
|
||||||
if t.LogFile != "" {
|
if t.LogFile != "" {
|
||||||
return fmt.Sprintf("%s &>> %s", t.AltCommand, t.LogFile)
|
return fmt.Sprintf("%s &>> %s", t.AltCommand, t.LogFile)
|
||||||
}
|
}
|
||||||
@@ -210,37 +216,17 @@ func FetchCronTasks(input FetchCronTasksInput) ([]CronTask, error) {
|
|||||||
// This function should only be used for the cron:list --global command
|
// This function should only be used for the cron:list --global command
|
||||||
// and not internally by the cron plugin
|
// and not internally by the cron plugin
|
||||||
func FetchGlobalCronTasks() ([]CronTask, error) {
|
func FetchGlobalCronTasks() ([]CronTask, error) {
|
||||||
tasks := []CronTask{}
|
|
||||||
response, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
response, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||||
Trigger: "cron-entries",
|
Trigger: "cron-entries",
|
||||||
Args: []string{"docker-local"},
|
Args: []string{"docker-local", CronEntryFormat},
|
||||||
})
|
})
|
||||||
for _, line := range strings.Split(response.StdoutContents(), "\n") {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(line, ";")
|
tasks, invalid := parseInjectedCronEntries(response.StdoutContents())
|
||||||
if len(parts) != 2 && len(parts) != 3 {
|
for _, line := range invalid {
|
||||||
common.LogWarn(fmt.Sprintf("Invalid injected cron task: %v", line))
|
common.LogWarn(fmt.Sprintf("Invalid injected cron task: %v", line))
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
for i := range tasks {
|
||||||
id := base36.EncodeToStringLc([]byte(strings.Join(parts, ";;;")))
|
tasks[i].Global = true
|
||||||
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
|
return tasks, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,38 @@ func TestDokkuRunCommandAltCommandWithLogFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDokkuRunCommandAltCommandWithMailto pins that an injected task with its
|
||||||
|
// own MAILTO keeps its output on stdout, where cron mails it from, while still
|
||||||
|
// appending it to the log file.
|
||||||
|
func TestDokkuRunCommandAltCommandWithMailto(t *testing.T) {
|
||||||
|
task := CronTask{
|
||||||
|
ID: "abc123",
|
||||||
|
AltCommand: "/usr/bin/some-internal-task",
|
||||||
|
LogFile: "/var/log/dokku/internal-task.log",
|
||||||
|
Mailto: "ops@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := task.DokkuRunCommand()
|
||||||
|
want := "/usr/bin/some-internal-task 2>&1 | tee -a /var/log/dokku/internal-task.log"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DokkuRunCommand() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDokkuRunCommandAltCommandWithMailtoAndNoLogFile(t *testing.T) {
|
||||||
|
task := CronTask{
|
||||||
|
ID: "abc123",
|
||||||
|
AltCommand: "/usr/bin/some-internal-task",
|
||||||
|
Mailto: "ops@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := task.DokkuRunCommand()
|
||||||
|
want := "/usr/bin/some-internal-task"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DokkuRunCommand() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDokkuRunCommandAppTaskIgnoresLogFile pins that LogFile is honored only
|
// TestDokkuRunCommandAppTaskIgnoresLogFile pins that LogFile is honored only
|
||||||
// for internally injected tasks from the cron-entries trigger. App tasks never
|
// for internally injected tasks from the cron-entries trigger. App tasks never
|
||||||
// interpolate a path into the crontab line - their output is shipped by the
|
// interpolate a path into the crontab line - their output is shipped by the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cron
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,6 +17,9 @@ import (
|
|||||||
//go:embed templates/cron.tmpl
|
//go:embed templates/cron.tmpl
|
||||||
var cronTemplate string
|
var cronTemplate string
|
||||||
|
|
||||||
|
// crontabUser is the user whose crontab the host-cron tasks are written to
|
||||||
|
const crontabUser = "dokku"
|
||||||
|
|
||||||
// usesHostCron reports whether the given scheduler writes its cron tasks to the
|
// 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
|
// 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.
|
// or an unimplemented scheduler-uses-host-cron trigger is treated as false.
|
||||||
@@ -50,36 +54,114 @@ func hostCronSchedulers(appSchedulers []string) map[string]bool {
|
|||||||
return schedulers
|
return schedulers
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectedCronTasks parses the tasks injected via the cron-entries trigger for a
|
// CronEntryFormat is passed to the cron-entries trigger to signal that each
|
||||||
// given scheduler. Each entry is newline delimited in the form
|
// entry may be printed as a json object on its own line. Implementations that
|
||||||
// $SCHEDULE;$COMMAND[;$LOGFILE].
|
// do not know it keep printing $SCHEDULE;$COMMAND[;$LOGFILE] lines, which are
|
||||||
func injectedCronTasks(scheduler string) ([]CronTask, error) {
|
// still read.
|
||||||
tasks := []CronTask{}
|
const CronEntryFormat = "json"
|
||||||
response, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
|
||||||
Trigger: "cron-entries",
|
// injectedCronEntry is a task printed as a json line by the cron-entries trigger
|
||||||
Args: []string{scheduler},
|
type injectedCronEntry struct {
|
||||||
})
|
// Schedule is the cron schedule
|
||||||
for _, line := range strings.Split(response.StdoutContents(), "\n") {
|
Schedule string `json:"schedule"`
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
return []CronTask{}, nil
|
// Command is the command to run
|
||||||
|
Command string `json:"command"`
|
||||||
|
|
||||||
|
// LogFile is the log file the command's output is appended to
|
||||||
|
LogFile string `json:"log-file"`
|
||||||
|
|
||||||
|
// Mailto is the MAILTO value cron uses for the task instead of the global one
|
||||||
|
Mailto string `json:"mailto"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInjectedCronEntry parses a single line printed by the cron-entries
|
||||||
|
// trigger, either a json object or in the form $SCHEDULE;$COMMAND[;$LOGFILE].
|
||||||
|
// The fields the task id is generated from are returned alongside the task.
|
||||||
|
func parseInjectedCronEntry(line string) (injectedCronEntry, []string, bool) {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "{") {
|
||||||
|
entry := injectedCronEntry{}
|
||||||
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||||
|
return injectedCronEntry{}, nil, false
|
||||||
|
}
|
||||||
|
if entry.Schedule == "" || entry.Command == "" {
|
||||||
|
return injectedCronEntry{}, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// json can encode a newline, which would add a line to the crontab
|
||||||
|
fields := []string{entry.Schedule, entry.Command, entry.LogFile, entry.Mailto}
|
||||||
|
for _, field := range fields {
|
||||||
|
if strings.ContainsAny(field, "\r\n") {
|
||||||
|
return injectedCronEntry{}, nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = []string{entry.Schedule, entry.Command}
|
||||||
|
if entry.LogFile != "" {
|
||||||
|
fields = append(fields, entry.LogFile)
|
||||||
|
}
|
||||||
|
if entry.Mailto != "" {
|
||||||
|
fields = append(fields, entry.Mailto)
|
||||||
|
}
|
||||||
|
return entry, fields, true
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(line, ";")
|
parts := strings.Split(line, ";")
|
||||||
if len(parts) != 2 && len(parts) != 3 {
|
if len(parts) != 2 && len(parts) != 3 {
|
||||||
return []CronTask{}, fmt.Errorf("Invalid injected cron task: %v", line)
|
return injectedCronEntry{}, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
id := base36.EncodeToStringLc([]byte(strings.Join(parts, ";;;")))
|
entry := injectedCronEntry{
|
||||||
task := CronTask{
|
|
||||||
ID: id,
|
|
||||||
Schedule: parts[0],
|
Schedule: parts[0],
|
||||||
AltCommand: parts[1],
|
Command: parts[1],
|
||||||
Maintenance: false,
|
|
||||||
}
|
}
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
task.LogFile = parts[2]
|
entry.LogFile = parts[2]
|
||||||
}
|
}
|
||||||
tasks = append(tasks, task)
|
return entry, parts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInjectedCronEntries parses the newline delimited output of the
|
||||||
|
// cron-entries trigger, returning the tasks and any lines that are not valid
|
||||||
|
// entries
|
||||||
|
func parseInjectedCronEntries(output string) ([]CronTask, []string) {
|
||||||
|
tasks := []CronTask{}
|
||||||
|
invalid := []string{}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, fields, ok := parseInjectedCronEntry(line)
|
||||||
|
if !ok {
|
||||||
|
invalid = append(invalid, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, CronTask{
|
||||||
|
ID: base36.EncodeToStringLc([]byte(strings.Join(fields, ";;;"))),
|
||||||
|
Schedule: entry.Schedule,
|
||||||
|
Command: entry.Command,
|
||||||
|
AltCommand: entry.Command,
|
||||||
|
LogFile: entry.LogFile,
|
||||||
|
Mailto: entry.Mailto,
|
||||||
|
Maintenance: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tasks, invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectedCronTasks parses the tasks injected via the cron-entries trigger for a
|
||||||
|
// given scheduler
|
||||||
|
func injectedCronTasks(scheduler string) ([]CronTask, error) {
|
||||||
|
response, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||||
|
Trigger: "cron-entries",
|
||||||
|
Args: []string{scheduler, CronEntryFormat},
|
||||||
|
})
|
||||||
|
|
||||||
|
tasks, invalid := parseInjectedCronEntries(response.StdoutContents())
|
||||||
|
if len(invalid) > 0 {
|
||||||
|
return []CronTask{}, fmt.Errorf("Invalid injected cron task: %v", invalid[0])
|
||||||
}
|
}
|
||||||
return tasks, nil
|
return tasks, nil
|
||||||
}
|
}
|
||||||
@@ -179,12 +261,7 @@ func writeCronTab() error {
|
|||||||
|
|
||||||
mailfrom := common.PropertyGetDefault("cron", "--global", "mailfrom", DefaultProperties["mailfrom"])
|
mailfrom := common.PropertyGetDefault("cron", "--global", "mailfrom", DefaultProperties["mailfrom"])
|
||||||
mailto := common.PropertyGetDefault("cron", "--global", "mailto", DefaultProperties["mailto"])
|
mailto := common.PropertyGetDefault("cron", "--global", "mailto", DefaultProperties["mailto"])
|
||||||
|
data := cronTemplateData(tasks, mailfrom, mailto)
|
||||||
data := map[string]interface{}{
|
|
||||||
"Tasks": tasks,
|
|
||||||
"Mailfrom": mailfrom,
|
|
||||||
"Mailto": mailto,
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := getCronTemplate()
|
t, err := getCronTemplate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,7 +282,7 @@ func writeCronTab() error {
|
|||||||
|
|
||||||
result, err := common.CallExecCommand(common.ExecCommandInput{
|
result, err := common.CallExecCommand(common.ExecCommandInput{
|
||||||
Command: "crontab",
|
Command: "crontab",
|
||||||
Args: []string{"-u", "dokku", tmpFile.Name()},
|
Args: []string{"-u", crontabUser, tmpFile.Name()},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to update schedule file: %w", err)
|
return fmt.Errorf("Unable to update schedule file: %w", err)
|
||||||
@@ -219,11 +296,30 @@ func writeCronTab() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cronTemplateData builds the data the cron template is executed with. A task
|
||||||
|
// that sets its own MAILTO is written between a MAILTO line of its own and one
|
||||||
|
// resetting it, so every task after it goes back to the global MAILTO. Without
|
||||||
|
// a global MAILTO it is reset to the crontab's owner, which is who cron mails
|
||||||
|
// when MAILTO is unset - an empty MAILTO would stop cron mailing anyone.
|
||||||
|
func cronTemplateData(tasks []CronTask, mailfrom string, mailto string) map[string]interface{} {
|
||||||
|
resetMailto := mailto
|
||||||
|
if resetMailto == "" {
|
||||||
|
resetMailto = crontabUser
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"Tasks": tasks,
|
||||||
|
"Mailfrom": mailfrom,
|
||||||
|
"Mailto": mailto,
|
||||||
|
"ResetMailto": resetMailto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// deleteCrontab removes the dokku user crontab
|
// deleteCrontab removes the dokku user crontab
|
||||||
func deleteCrontab() error {
|
func deleteCrontab() error {
|
||||||
result, err := common.CallExecCommand(common.ExecCommandInput{
|
result, err := common.CallExecCommand(common.ExecCommandInput{
|
||||||
Command: "crontab",
|
Command: "crontab",
|
||||||
Args: []string{"-l", "-u", "dokku"},
|
Args: []string{"-l", "-u", crontabUser},
|
||||||
})
|
})
|
||||||
if err != nil || result.ExitCode != 0 {
|
if err != nil || result.ExitCode != 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -231,7 +327,7 @@ func deleteCrontab() error {
|
|||||||
|
|
||||||
result, err = common.CallExecCommand(common.ExecCommandInput{
|
result, err = common.CallExecCommand(common.ExecCommandInput{
|
||||||
Command: "crontab",
|
Command: "crontab",
|
||||||
Args: []string{"-r", "-u", "dokku"},
|
Args: []string{"-r", "-u", crontabUser},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to remove schedule file: %w", err)
|
return fmt.Errorf("Unable to remove schedule file: %w", err)
|
||||||
|
|||||||
253
plugins/cron/crontab_test.go
Normal file
253
plugins/cron/crontab_test.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
base36 "github.com/multiformats/go-base36"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseInjectedCronEntries(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
output string
|
||||||
|
wantTasks []CronTask
|
||||||
|
wantInvalid []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "text entry without a log file",
|
||||||
|
output: "@daily;/bin/true",
|
||||||
|
wantTasks: []CronTask{
|
||||||
|
{Schedule: "@daily", Command: "/bin/true", AltCommand: "/bin/true"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text entry with a log file",
|
||||||
|
output: "@daily;/bin/true;/var/log/dokku/log.log",
|
||||||
|
wantTasks: []CronTask{
|
||||||
|
{Schedule: "@daily", Command: "/bin/true", AltCommand: "/bin/true", LogFile: "/var/log/dokku/log.log"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry without a log file or mailto",
|
||||||
|
output: `{"schedule":"@daily","command":"/bin/true"}`,
|
||||||
|
wantTasks: []CronTask{
|
||||||
|
{Schedule: "@daily", Command: "/bin/true", AltCommand: "/bin/true"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry with a log file and mailto",
|
||||||
|
output: `{"schedule":"@daily","command":"/bin/true","log-file":"/var/log/dokku/log.log","mailto":"ops@example.com"}`,
|
||||||
|
wantTasks: []CronTask{
|
||||||
|
{Schedule: "@daily", Command: "/bin/true", AltCommand: "/bin/true", LogFile: "/var/log/dokku/log.log", Mailto: "ops@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry whose command holds a semicolon",
|
||||||
|
output: `{"schedule":"@daily","command":"/bin/true; /bin/false"}`,
|
||||||
|
wantTasks: []CronTask{
|
||||||
|
{Schedule: "@daily", Command: "/bin/true; /bin/false", AltCommand: "/bin/true; /bin/false"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json and text entries printed together, with blank lines",
|
||||||
|
output: "@daily;/bin/true\n\n" + `{"schedule":"@hourly","command":"/bin/false","mailto":"ops@example.com"}` + "\n",
|
||||||
|
wantTasks: []CronTask{
|
||||||
|
{Schedule: "@daily", Command: "/bin/true", AltCommand: "/bin/true"},
|
||||||
|
{Schedule: "@hourly", Command: "/bin/false", AltCommand: "/bin/false", Mailto: "ops@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text entry with too many fields",
|
||||||
|
output: "@daily;/bin/true;/var/log/dokku/log.log;ops@example.com",
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{"@daily;/bin/true;/var/log/dokku/log.log;ops@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text entry with too few fields",
|
||||||
|
output: "@daily",
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{"@daily"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry that does not decode",
|
||||||
|
output: `{"schedule":"@daily",`,
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{`{"schedule":"@daily",`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry without a schedule",
|
||||||
|
output: `{"command":"/bin/true"}`,
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{`{"command":"/bin/true"}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry without a command",
|
||||||
|
output: `{"schedule":"@daily"}`,
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{`{"schedule":"@daily"}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry whose mailto holds a newline",
|
||||||
|
output: `{"schedule":"@daily","command":"/bin/true","mailto":"ops@example.com\n* * * * * /bin/evil"}`,
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{`{"schedule":"@daily","command":"/bin/true","mailto":"ops@example.com\n* * * * * /bin/evil"}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json entry whose command holds a carriage return",
|
||||||
|
output: `{"schedule":"@daily","command":"/bin/true\r/bin/evil"}`,
|
||||||
|
wantTasks: []CronTask{},
|
||||||
|
wantInvalid: []string{`{"schedule":"@daily","command":"/bin/true\r/bin/evil"}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tasks, invalid := parseInjectedCronEntries(tc.output)
|
||||||
|
for i := range tasks {
|
||||||
|
if tasks[i].ID == "" {
|
||||||
|
t.Errorf("task %d has no id", i)
|
||||||
|
}
|
||||||
|
tasks[i].ID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(tasks, tc.wantTasks) {
|
||||||
|
t.Errorf("parseInjectedCronEntries() tasks = %#v, want %#v", tasks, tc.wantTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantInvalid := tc.wantInvalid
|
||||||
|
if wantInvalid == nil {
|
||||||
|
wantInvalid = []string{}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(invalid, wantInvalid) {
|
||||||
|
t.Errorf("parseInjectedCronEntries() invalid = %#v, want %#v", invalid, wantInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseInjectedCronEntriesKeepsTextIDs pins that the id of a text entry is
|
||||||
|
// generated as it was before json entries were read, and that a json entry
|
||||||
|
// without a mailto gets the same id as the text entry it replaces.
|
||||||
|
func TestParseInjectedCronEntriesKeepsTextIDs(t *testing.T) {
|
||||||
|
want := base36.EncodeToStringLc([]byte("@daily;;;/bin/true;;;/var/log/dokku/log.log"))
|
||||||
|
|
||||||
|
for _, output := range []string{
|
||||||
|
"@daily;/bin/true;/var/log/dokku/log.log",
|
||||||
|
`{"schedule":"@daily","command":"/bin/true","log-file":"/var/log/dokku/log.log"}`,
|
||||||
|
} {
|
||||||
|
tasks, _ := parseInjectedCronEntries(output)
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("parseInjectedCronEntries(%q) returned %d tasks, want 1", output, len(tasks))
|
||||||
|
}
|
||||||
|
if tasks[0].ID != want {
|
||||||
|
t.Errorf("parseInjectedCronEntries(%q) id = %q, want %q", output, tasks[0].ID, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withMailto, _ := parseInjectedCronEntries(`{"schedule":"@daily","command":"/bin/true","log-file":"/var/log/dokku/log.log","mailto":"ops@example.com"}`)
|
||||||
|
if len(withMailto) != 1 || withMailto[0].ID == want {
|
||||||
|
t.Errorf("a json entry with a mailto should not share the id of the entry without one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCronTemplate renders the cron template for the given tasks and global
|
||||||
|
// MAILTO
|
||||||
|
func renderCronTemplate(t *testing.T, tasks []CronTask, mailto string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpl, err := getCronTemplate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCronTemplate() returned an error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&got, cronTemplateData(tasks, "", mailto)); err != nil {
|
||||||
|
t.Fatalf("Execute() returned an error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return got.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTemplateResetsMailtoAfterATaskWithItsOwn pins that a task with its own
|
||||||
|
// MAILTO is written in place between a MAILTO line of its own and one resetting
|
||||||
|
// it to the global MAILTO, so no task after it is mailed to its recipient.
|
||||||
|
func TestCronTemplateResetsMailtoAfterATaskWithItsOwn(t *testing.T) {
|
||||||
|
tasks := []CronTask{
|
||||||
|
{Schedule: "@daily", AltCommand: "/bin/true", LogFile: "/var/log/dokku/log.log"},
|
||||||
|
{Schedule: "@hourly", AltCommand: "/bin/alpha", LogFile: "/var/log/dokku/alpha.log", Mailto: "alpha@example.com"},
|
||||||
|
{Schedule: "@daily", AltCommand: "/bin/false"},
|
||||||
|
{Schedule: "@daily", AltCommand: "/bin/zeta", LogFile: "/var/log/dokku/zeta.log", Mailto: "zeta@example.com"},
|
||||||
|
{Schedule: "@weekly", AltCommand: "/bin/zeta-weekly", Mailto: "zeta@example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"MAILTO=global@example.com",
|
||||||
|
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||||
|
"SHELL=/bin/bash",
|
||||||
|
"",
|
||||||
|
"@daily /bin/true &>> /var/log/dokku/log.log",
|
||||||
|
"MAILTO=alpha@example.com",
|
||||||
|
"@hourly /bin/alpha 2>&1 | tee -a /var/log/dokku/alpha.log",
|
||||||
|
"MAILTO=global@example.com",
|
||||||
|
"@daily /bin/false",
|
||||||
|
"MAILTO=zeta@example.com",
|
||||||
|
"@daily /bin/zeta 2>&1 | tee -a /var/log/dokku/zeta.log",
|
||||||
|
"MAILTO=global@example.com",
|
||||||
|
"MAILTO=zeta@example.com",
|
||||||
|
"@weekly /bin/zeta-weekly",
|
||||||
|
"MAILTO=global@example.com",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if got := renderCronTemplate(t, tasks, "global@example.com"); got != want {
|
||||||
|
t.Errorf("rendered crontab =\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTemplateResetsMailtoToTheCrontabOwner pins that without a global
|
||||||
|
// MAILTO, a task with its own is followed by a MAILTO naming the crontab's
|
||||||
|
// owner, who cron mails when MAILTO is unset. An empty MAILTO would stop cron
|
||||||
|
// mailing anyone about the tasks after it.
|
||||||
|
func TestCronTemplateResetsMailtoToTheCrontabOwner(t *testing.T) {
|
||||||
|
tasks := []CronTask{
|
||||||
|
{Schedule: "@hourly", AltCommand: "/bin/alpha", LogFile: "/var/log/dokku/alpha.log", Mailto: "alpha@example.com"},
|
||||||
|
{Schedule: "@daily", AltCommand: "/bin/true", LogFile: "/var/log/dokku/log.log"},
|
||||||
|
}
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||||
|
"SHELL=/bin/bash",
|
||||||
|
"",
|
||||||
|
"MAILTO=alpha@example.com",
|
||||||
|
"@hourly /bin/alpha 2>&1 | tee -a /var/log/dokku/alpha.log",
|
||||||
|
"MAILTO=dokku",
|
||||||
|
"@daily /bin/true &>> /var/log/dokku/log.log",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if got := renderCronTemplate(t, tasks, ""); got != want {
|
||||||
|
t.Errorf("rendered crontab =\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTemplateWithoutTaskMailto pins that a crontab without tasks that set
|
||||||
|
// their own MAILTO renders as it did before per-task MAILTO values existed.
|
||||||
|
func TestCronTemplateWithoutTaskMailto(t *testing.T) {
|
||||||
|
tasks := []CronTask{
|
||||||
|
{Schedule: "@daily", AltCommand: "/bin/true", LogFile: "/var/log/dokku/log.log"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := renderCronTemplate(t, tasks, "")
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||||
|
"SHELL=/bin/bash",
|
||||||
|
"",
|
||||||
|
"@daily /bin/true &>> /var/log/dokku/log.log",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("rendered crontab =\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,11 @@ PATH=/usr/local/bin:/usr/bin:/bin
|
|||||||
SHELL=/bin/bash
|
SHELL=/bin/bash
|
||||||
|
|
||||||
{{ range $task := .Tasks -}}
|
{{ range $task := .Tasks -}}
|
||||||
{{ $task.Schedule }} {{ $task.DokkuRunCommand }}
|
{{ if $task.Mailto -}}
|
||||||
|
MAILTO={{ $task.Mailto }}
|
||||||
|
{{ end -}}
|
||||||
|
{{ $task.Schedule }} {{ $task.DokkuRunCommand }}
|
||||||
|
{{ if $task.Mailto -}}
|
||||||
|
MAILTO={{ $.ResetMailto }}
|
||||||
|
{{ end -}}
|
||||||
{{ end -}}
|
{{ end -}}
|
||||||
|
|||||||
@@ -104,6 +104,54 @@ teardown() {
|
|||||||
assert_failure
|
assert_failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "(cron) injected json entries" {
|
||||||
|
cat >/var/lib/dokku/plugins/enabled/cron-entries/cron-entries <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
[[ "$2" == "json" ]] || exit 0
|
||||||
|
echo '@daily;/bin/true'
|
||||||
|
echo '{"schedule":"@hourly","command":"/bin/false","log-file":"/var/log/dokku/log.log","mailto":"ops@example.com"}'
|
||||||
|
EOF
|
||||||
|
chmod +x /var/lib/dokku/plugins/enabled/cron-entries/cron-entries
|
||||||
|
|
||||||
|
run /bin/bash -c "dokku cron:set --global mailto global@example.com"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_success
|
||||||
|
|
||||||
|
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_success
|
||||||
|
# once in the header, and once to reset it after the task with its own
|
||||||
|
assert_output_contains "MAILTO=global@example.com" 2
|
||||||
|
assert_output_contains "@daily /bin/true"
|
||||||
|
assert_output_contains "MAILTO=ops@example.com"
|
||||||
|
assert_output_contains "@hourly /bin/false 2>&1 | tee -a /var/log/dokku/log.log"
|
||||||
|
|
||||||
|
# the global MAILTO is restored for every task after the one with its own
|
||||||
|
run /bin/bash -c "grep -A1 -F 'tee -a /var/log/dokku/log.log' /var/spool/cron/crontabs/dokku | tail -n 1"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_success
|
||||||
|
assert_output "MAILTO=global@example.com"
|
||||||
|
|
||||||
|
run /bin/bash -c "dokku cron:list --global --format json | jq -r '.[] | select(.schedule == \"@hourly\") | .mailto'"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_success
|
||||||
|
assert_output "ops@example.com"
|
||||||
|
|
||||||
|
run /bin/bash -c "dokku cron:set --global mailto"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_success
|
||||||
|
}
|
||||||
|
|
||||||
@test "(cron) invalid [command]" {
|
@test "(cron) invalid [command]" {
|
||||||
run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_injection
|
run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_injection
|
||||||
echo "output: $output"
|
echo "output: $output"
|
||||||
|
|||||||
Reference in New Issue
Block a user