diff --git a/docs/processes/scheduled-cron-tasks.md b/docs/processes/scheduled-cron-tasks.md index 2ea0ae4a0b9..c53a97d8b8f 100644 --- a/docs/processes/scheduled-cron-tasks.md +++ b/docs/processes/scheduled-cron-tasks.md @@ -5,6 +5,7 @@ ``` cron:list [--format json|stdout] # List scheduled cron tasks for an app cron:report [] [] # Display report about an app +cron:run [--detach] # Run a cron task on the fly ``` ## Usage @@ -44,7 +45,7 @@ When running scheduled cron tasks, there are a few items to be aware of: - Scheduled cron tasks are performed within the app environment available at runtime. If the app image does not exist, the command may fail to execute. - Schedules are performed on the hosting server's timezone, which is typically UTC. - At this time, only the `PATH` and `SHELL` environment variables are specified in the cron template. -- 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, 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. @@ -72,6 +73,22 @@ dokku cron:list node-js-app --format json [{"id":"cGhwPT09cGhwIHRlc3QucGhwPT09QGRhaWx5","app":"node-js-app","command":"node index.js","schedule":"@daily"}] ``` +#### Executing a cron task on the fly + +Cron tasks can be invoked via the `cron:run` command. This command takes an `app` argument and a `cron id` (retrievable from `cron:list` output). + +```shell +dokku cron:run node-js-app cGhwPT09cGhwIHRlc3QucGhwPT09QGRhaWx5 +``` + +By default, the task is run in an attached container - as supported by the scheduler. To run in a background detached container, specify the `--detach` flag: + +```shell +dokku cron:run node-js-app cGhwPT09cGhwIHRlc3QucGhwPT09QGRhaWx5 --detach +``` + +All one-off cron executions have their containers terminated after invocation. + #### Displaying reports You can get a report about the cron configuration for apps using the `cron:report` command: diff --git a/plugins/cron/Makefile b/plugins/cron/Makefile index e2196f4ddfc..85c72cfea1f 100644 --- a/plugins/cron/Makefile +++ b/plugins/cron/Makefile @@ -1,4 +1,4 @@ -SUBCOMMANDS = subcommands/list subcommands/report +SUBCOMMANDS = subcommands/list subcommands/report subcommands/run BUILD = commands subcommands PLUGIN_NAME = cron diff --git a/plugins/cron/go.mod b/plugins/cron/go.mod index a0589bc7ba9..7eb5c6d2bed 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.5 + mvdan.cc/sh/v3 v3.7.0 ) require ( diff --git a/plugins/cron/go.sum b/plugins/cron/go.sum index 772d219859e..f164725fe50 100644 --- a/plugins/cron/go.sum +++ b/plugins/cron/go.sum @@ -2,9 +2,12 @@ github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 h1:sDMmm+q/3+Bu github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27 h1:HHUr4P/aKh4quafGxDT9LDasjGdlGkzLbfmmrlng3kA= github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27/go.mod h1:VQx0hjo2oUeQkQUET7wRwradO6f+fN5jzXgB/zROxxE= +github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 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/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc= @@ -13,6 +16,7 @@ github.com/otiai10/copy v1.12.0/go.mod h1:rSaLseMUsZFFbsFGc7wCJnnkTAvdc5L6VWxPE4 github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= 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.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY= github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -24,3 +28,5 @@ golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= +mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= diff --git a/plugins/cron/src/commands/commands.go b/plugins/cron/src/commands/commands.go index 90f825fc2b6..466d428bb72 100644 --- a/plugins/cron/src/commands/commands.go +++ b/plugins/cron/src/commands/commands.go @@ -20,6 +20,7 @@ Additional commands:` helpContent = ` cron:list [--format json|stdout], List scheduled cron tasks for an app cron:report [] [], Display report about an app + cron:run [--detach], Run a cron task on the fly ` ) diff --git a/plugins/cron/src/subcommands/subcommands.go b/plugins/cron/src/subcommands/subcommands.go index 302fdef6e07..94ff0c1916f 100644 --- a/plugins/cron/src/subcommands/subcommands.go +++ b/plugins/cron/src/subcommands/subcommands.go @@ -33,6 +33,13 @@ func main() { appName := args.Arg(0) err = cron.CommandReport(appName, *format, infoFlag) } + case "run": + args := flag.NewFlagSet("cron:run", flag.ExitOnError) + detached := args.Bool("detach", false, "--detach: run the container in a detached mode") + args.Parse(os.Args[2:]) + appName := args.Arg(0) + cronID := args.Arg(1) + err = cron.CommandRun(appName, cronID, *detached) default: err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) } diff --git a/plugins/cron/subcommands.go b/plugins/cron/subcommands.go index b34bf758b2f..aa6af1c0e76 100644 --- a/plugins/cron/subcommands.go +++ b/plugins/cron/subcommands.go @@ -3,9 +3,12 @@ package cron import ( "encoding/json" "fmt" + "os" "github.com/dokku/dokku/plugins/common" + "github.com/ryanuber/columnize" + "mvdan.cc/sh/v3/shell" ) // CommandList lists all scheduled cron tasks for a given app @@ -64,3 +67,48 @@ func CommandReport(appName string, format string, infoFlag string) error { return ReportSingleApp(appName, format, infoFlag) } + +// CommandRun executes a cron command on the fly +func CommandRun(appName string, cronID string, detached bool) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + entries, err := FetchCronEntries(appName) + if err != nil { + return err + } + + if cronID == "" { + return fmt.Errorf("Please specify a Cron ID from the output of 'dokku cron:list %s'", appName) + } + + command := "" + for _, entry := range entries { + if entry.ID == cronID { + command = entry.Command + } + } + + if command == "" { + return fmt.Errorf("No matching Cron ID found. Please specify a Cron ID from the output of 'dokku cron:list %s'", appName) + } + + fields, err := shell.Fields(command, func(name string) string { + return "" + }) + if err != nil { + return fmt.Errorf("Could not parse command: %s", err) + } + + if detached { + os.Setenv("DOKKU_DETACH_CONTAINER", "1") + os.Setenv("DOKKU_DISABLE_TTY", "true") + } + + os.Setenv("DOKKU_CRON_ID", cronID) + os.Setenv("DOKKU_RM_CONTAINER", "1") + scheduler := common.GetAppScheduler(appName) + args := append([]string{scheduler, appName, "0", ""}, fields...) + return common.PlugnTrigger("scheduler-run", args...) +} diff --git a/plugins/repo/go.mod b/plugins/repo/go.mod index b8490692f09..e2b67dbc3f4 100644 --- a/plugins/repo/go.mod +++ b/plugins/repo/go.mod @@ -4,14 +4,12 @@ go 1.19 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 - github.com/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000 github.com/spf13/pflag v1.0.5 ) require ( github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 // indirect github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27 // indirect - github.com/joho/godotenv v1.2.0 // indirect github.com/otiai10/copy v1.12.0 // indirect github.com/ryanuber/columnize v2.1.2+incompatible // indirect golang.org/x/sync v0.3.0 // indirect diff --git a/plugins/repo/go.sum b/plugins/repo/go.sum index 1a7ae7db219..a7b423b1ca5 100644 --- a/plugins/repo/go.sum +++ b/plugins/repo/go.sum @@ -3,8 +3,6 @@ github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcju github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27 h1:HHUr4P/aKh4quafGxDT9LDasjGdlGkzLbfmmrlng3kA= github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27/go.mod h1:VQx0hjo2oUeQkQUET7wRwradO6f+fN5jzXgB/zROxxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/joho/godotenv v1.2.0 h1:vGTvz69FzUFp+X4/bAkb0j5BoLC+9bpqTWY8mjhA9pc= -github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/onsi/gomega v1.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc= github.com/otiai10/copy v1.12.0 h1:cLMgSQnXBs1eehF0Wy/FAGsgDTDmAqFR7rQylBb1nDY= github.com/otiai10/copy v1.12.0/go.mod h1:rSaLseMUsZFFbsFGc7wCJnnkTAvdc5L6VWxPE4308Ww= diff --git a/plugins/scheduler-docker-local/go.mod b/plugins/scheduler-docker-local/go.mod index 8e565bfe1b3..b1f47636a57 100644 --- a/plugins/scheduler-docker-local/go.mod +++ b/plugins/scheduler-docker-local/go.mod @@ -18,6 +18,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/ryanuber/columnize v2.1.2+incompatible // indirect golang.org/x/sys v0.8.0 // indirect + mvdan.cc/sh/v3 v3.7.0 // indirect ) replace github.com/dokku/dokku/plugins/app-json => ../app-json diff --git a/plugins/scheduler-docker-local/go.sum b/plugins/scheduler-docker-local/go.sum index 085ca0d9e56..3a0b55f7b94 100644 --- a/plugins/scheduler-docker-local/go.sum +++ b/plugins/scheduler-docker-local/go.sum @@ -2,9 +2,12 @@ github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 h1:sDMmm+q/3+Bu github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27 h1:HHUr4P/aKh4quafGxDT9LDasjGdlGkzLbfmmrlng3kA= github.com/codeskyblue/go-sh v0.0.0-20190412065543-76bd3d59ff27/go.mod h1:VQx0hjo2oUeQkQUET7wRwradO6f+fN5jzXgB/zROxxE= +github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 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/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc= @@ -13,6 +16,7 @@ github.com/otiai10/copy v1.12.0/go.mod h1:rSaLseMUsZFFbsFGc7wCJnnkTAvdc5L6VWxPE4 github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= 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.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY= github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= @@ -22,3 +26,5 @@ golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= +mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= diff --git a/tests/apps/python/task.py b/tests/apps/python/task.py index 6d543660048..01b998d122c 100644 --- a/tests/apps/python/task.py +++ b/tests/apps/python/task.py @@ -1,3 +1,5 @@ +import sys + def main(args): print(args) diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index 4349027bc36..a7a2e409c4d 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -78,7 +78,7 @@ teardown() { } @test "(cron) create [single-verbose]" { - run deploy_app python dokku@dokku.me:$TEST_APP template_cron_file_valid + run deploy_app python dokku@dokku.me:$TEST_APP template_cron_file_valid_single echo "output: $output" echo "status: $status" assert_success @@ -197,6 +197,39 @@ teardown() { assert_output_exists } +@test "(cron) cron:run" { + run deploy_app python dokku@dokku.me:$TEST_APP template_cron_file_valid + 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 "echo $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku cron:run $TEST_APP $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "['task.py', 'schedule']" + + cron_id="$(dokku cron:list $TEST_APP --format json | jq -r '.[1].id')" + run /bin/bash -c "echo $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku cron:run $TEST_APP $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "['task.py', 'schedule', 'now']" +} + template_cron_file_invalid() { local APP="$1" local APP_REPO_DIR="$2" @@ -258,12 +291,32 @@ template_cron_file_valid() { { "command": "python task.py schedule", "schedule": "5 5 5 5 5" + }, + { + "command": "python task.py schedule now", + "schedule": "6 5 5 5 5" } ] } EOF } +template_cron_file_valid_single() { + local APP="$1" + local APP_REPO_DIR="$2" + [[ -z "$APP" ]] && local APP="$TEST_APP" + echo "injecting valid cron app.json -> $APP_REPO_DIR/app.json" + cat <"$APP_REPO_DIR/app.json" +{ + "cron": [ + { + "command": "python task.py schedule", + "schedule": "5 5 5 5 5" + } + ] +} +EOF +} template_cron_file_valid_short() { local APP="$1" local APP_REPO_DIR="$2"