Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ outside that set goes in as plain Kubernetes YAML under `.deployah/`; see
- **Role.** What a component is for:
- `service`: it serves traffic and can be exposed (the default).
- `worker`: a long-running background task, not exposed.
- **Task.** Run-to-completion work (`preDeploy`, `postDeploy`, or `manual`).
See [Tasks](docs/tasks.md).
- **Task.** Run-to-completion work (`preDeploy`, `postDeploy`, `schedule`, or
`manual`). See [Tasks](docs/tasks.md).
- **Kind.** The component's `kind` field: `stateless` (the default) or
`stateful` (StatefulSet with stable identity; optional per-pod volumes). This
field has nothing to do with Kind, the tool that runs the optional local
Expand Down Expand Up @@ -385,7 +385,7 @@ Field-level detail lives in `docs/`:
| [Spec reference](docs/spec-reference.md) | Every `deployah.yaml` field, value rules, resource presets, and full examples. |
| [Platform file](docs/platform.md) | Contexts, domains, TLS modes, storage classes, and profiles. |
| [Workloads](docs/workloads.md) | Stateful components and volumes, workers, health checks, metrics. |
| [Tasks](docs/tasks.md) | Migrations, smoke checks, `deployah run`, and fanout. |
| [Tasks](docs/tasks.md) | Migrations, smoke checks, scheduled CronJobs, `deployah run`, and fanout. |
| [Configuration](docs/configuration.md) | Environment selection, variables, `.env` files, precedence rules. |
| [Networking](docs/networking.md) | Reaching your app, and how the local cluster resolves hostnames. |
| [Custom manifests and CRDs](docs/custom-manifests-and-crds.md) | Ship plain Kubernetes YAML alongside the release. |
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/deployah_run.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Run a spec task as a one-off Job

### Synopsis

Create a Kubernetes Job for a task from the spec. Works for preDeploy, postDeploy, and manual tasks. Runs only the named task; tasks listed in its after field are not run. Waits for completion unless --detach is set.
Create a Kubernetes Job for a task from the spec. Works for preDeploy, postDeploy, manual, and schedule tasks. Runs only the named task; tasks listed in its after field are not run. Waits for completion unless --detach is set.

```text
deployah run <task> <environment> [flags]
Expand Down
12 changes: 8 additions & 4 deletions docs/spec-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,19 @@ single value, not a list. See [Tasks](tasks.md) for how-to examples.
| `from` | none | Component to inherit env, environments, profiles, and resources from. Also copies envFile and configFile paths. |
| `image` | from `from` | Replaces the parent image when set. `from` and/or `image` is required. |
| `command` / `args` | none | `command` is required when using the parent image. |
| `"on"` | none (required) | `preDeploy`, `postDeploy`, or `manual`. |
| `after` | none | Task names in the **same** `on` that must finish first. The dependency must be active in every environment the dependent is. Not allowed on `manual`. |
| `"on"` | none (required) | `preDeploy`, `postDeploy`, `manual`, or `schedule`. |
| `after` | none | Task names in the **same** `on` that must finish first. The dependency must be active in every environment the dependent is. Not allowed on `manual` or `schedule`. |
| `schedule` | none | Cron expression or descriptor (`@daily`, `@every 1h`). Required when `"on"` is `schedule`. Do not use `TZ=` or `CRON_TZ=`; use `timeZone`. |
| `timeZone` | `Etc/UTC` | IANA time zone. Values other than `Etc/UTC` need Kubernetes 1.27 or later. |
| `concurrencyPolicy` | `Forbid` | `Allow`, `Forbid`, or `Replace`. Only valid when `"on"` is `schedule`. |
| `suspend` | `false` | Pause the CronJob. Only valid when `"on"` is `schedule`. |
| `env` | inherited | Overlay on the parent map. Inlined onto the Job. |
| `envFile` / `configFile` | inherited | Inherited as fields; not mounted in this release. |
| `environments` | inherited | Replaces the parent filter when set. |
| `profiles` | inherited | Replaces the parent list when set. Applied to the Job pod (node selector, tolerations, security context). |
| `resourcePreset` / `resources` | inherited | Same rules as components. |
| `fanout` | count 1, parallelism 1 | Integer (`fanout: 4`) or `{count, parallelism}`. Applies to every `on`. `parallelism` must be `<= count` and at most 100000 (Kubernetes Indexed Job limit). |
| `timeout` | `5m` for hooks | Duration such as `5m`. Hook timeout must be less than the session `--timeout` at deploy or run time (default `10m`). Raise `--timeout` for a longer hook. No default for `manual`. |
| `timeout` | `5m` for hooks | Duration such as `5m`. Hook timeout must be less than the session `--timeout` at deploy or run time (default `10m`). Raise `--timeout` for a longer hook. Omitted `schedule` tasks get 1h `activeDeadlineSeconds` on the CronJob only. `deployah run` does not apply that default. An explicit `timeout:` applies to both. |
| `backoffLimit` | `3` | Retries before the run is marked failed. |
| `ttlSecondsAfterFinished` | none (CLI runs: 7 days) | Seconds to keep a finished run. |

Expand Down Expand Up @@ -187,7 +191,7 @@ A few fields have specific formats:
- **Names** (`project`, component names, environment names): lowercase
letters, digits, and dashes (`-`), and cannot start or end with a dash.
`project` must be at least 3 characters; component and environment names
must be at least 2.
must be at least 2. Task names are 2 to 30 characters.

## Resource presets

Expand Down
56 changes: 53 additions & 3 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ tasks:
command: ["curl", "-f", "http://api/health"]
```

`on` is one value: `preDeploy`, `postDeploy`, or `manual`. To run the same
`on` is one value: `preDeploy`, `postDeploy`, `manual`, or `schedule`. To run the same
command before and after deploy, define two tasks that share `from`.

`after` orders tasks **inside the same `on`**. The named task must also run in
every environment the dependent runs in. Cross-phase `after` is an error.
`after` is not allowed on `manual` tasks.
`after` is not allowed on `manual` or `schedule` tasks.

## Run a task yourself

Expand All @@ -75,11 +75,61 @@ tasks:
Wait is the default. `--detach` returns after the Job is created. Concurrent
runs are allowed; each run gets a unique Job name.

## Scheduled tasks

Set `"on": schedule` so Deployah creates a Kubernetes CronJob in the release.
`deployah deploy` applies the CronJob and does not start a Job on that deploy.
Quote `"on"` in YAML 1.1.

```yaml
tasks:
cleanup:
from: api
"on": schedule
schedule: "0 3 * * *"
command: ["cleanup"]
```

Fields:

- `schedule`: a 5-field cron expression, a Vixie step such as `*/5`, a
named weekday (`sun`-`sat`), `?` (same as `*`), or a descriptor
(`@hourly`, `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly`,
`@annually`, `@every 1h`). Do not put `TZ=` or `CRON_TZ=` in the string;
use `timeZone`.
- `timeZone`: IANA name. Defaults to `Etc/UTC`. Values other than `Etc/UTC`
need Kubernetes 1.27 or later; older API servers drop the field with no
error.
- `concurrencyPolicy`: `Allow`, `Forbid`, or `Replace`. Defaults to
`Forbid`.
- `timeout`: how long one run may take. When omitted, the CronJob uses a 1h
cluster deadline. `deployah run` does not apply that default; the CLI Job
has no cluster deadline unless you set `timeout`.
- `suspend`: when `true`, the CronJob creates no Jobs until you set it back
to `false`.

`deployah run cleanup dev` still creates a one-shot Job. That Job and the
CronJob can overlap. Fanout is an Indexed Job inside the CronJob template.

`Forbid` with no starting deadline defers the next tick instead of dropping
it: one catch-up run starts when the active run finishes. If a task overruns
its interval, lengthen the interval or split the work.

Setting `suspend` back to `false` schedules the missed run at once, not on
the next tick.

`@every` is a delay from CronJob creation time, so a redeploy shifts the
schedule. Use a cron expression for a fixed wall-clock time.

`ttlSecondsAfterFinished` deletes finished Jobs before
`successfulJobsHistoryLimit` can keep them, so `kubectl get jobs` can be
empty. Leave TTL unset if you want the history limits to apply.

## Fanout

Fanout runs several indexed copies of a task. Use a number as a shortcut
(count, one at a time) or an object. It works on `preDeploy`, `postDeploy`,
and `manual`.
`manual`, and `schedule`.

```yaml
tasks:
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

buildGoModule' = pkgs.buildGoModule.override { inherit go; };

deployahVendorHash = "sha256-18Ns++/aP6rX/8iAulEZAp0448MtW+P8W5tjAlmqkrk=";
deployahVendorHash = "sha256-8nj4lEfjnl8xnNRCM0P32zXAxATUOh4XPJql4gqxvYE=";

inherit (pkgs) golangci-lint gopls;

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/google/renameio/v2 v2.0.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/homeport/dyff v1.12.0
github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0=
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type Options struct {
func Register(app *nabat.App) {
app.MustCommand("run",
nabat.WithDescription("Run a spec task as a one-off Job"),
nabat.WithLongDescription("Create a Kubernetes Job for a task from the spec. Works for preDeploy, postDeploy, and manual tasks. Runs only the named task; tasks listed in its after field are not run. Waits for completion unless --detach is set."),
nabat.WithLongDescription("Create a Kubernetes Job for a task from the spec. Works for preDeploy, postDeploy, manual, and schedule tasks. Runs only the named task; tasks listed in its after field are not run. Waits for completion unless --detach is set."),
nabat.WithArg("task", "", nabat.WithRequired(), nabat.WithUsage("Task name to run"), nabat.WithPrompt("Task", "", nabat.WithHint("e.g. migrate, backfill"))),
nabat.WithArg("environment", "", nabat.WithRequired(), nabat.WithUsage("Environment to run in"), nabat.WithPrompt("Environment", "", nabat.WithHint("e.g. prod, staging"))),
nabat.WithFlag("detach", false, nabat.WithUsage("Return after creating the Job without waiting for completion")),
Expand Down
15 changes: 15 additions & 0 deletions internal/cmd/run/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ func testManifest() *spec.Spec {
Command: []string{"backfill"},
Environments: []string{"prod"},
},
"cleanup": {
From: "api",
On: spec.TaskOnSchedule,
Schedule: "0 3 * * *",
Command: []string{"cleanup"},
},
},
}
}
Expand All @@ -71,6 +77,15 @@ func TestResolveRunTask(t *testing.T) {
assert.Equal(t, []string{"migrate", "up"}, rt.Task.Command)
})

t.Run("scheduled task is runnable", func(t *testing.T) {
t.Parallel()
rt, err := resolveRunTask(m, nil, "dev", "cleanup")
require.NoError(t, err)
assert.Equal(t, spec.TaskOnSchedule, rt.Task.On)
assert.Equal(t, "0 3 * * *", rt.Task.Schedule)
assert.Empty(t, rt.Task.Timeout)
})

t.Run("unknown task", func(t *testing.T) {
t.Parallel()
_, err := resolveRunTask(m, nil, "dev", "missing")
Expand Down
57 changes: 57 additions & 0 deletions internal/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,63 @@ func (s *E2ESuite) TestDeleteCleansCLIJobs() {
assert.Empty(t, jobs.Items)
}

func (s *E2ESuite) TestTaskSchedule() {
t := s.T()
src := filepath.Join(s.testdataDir, "task-schedule")
dir := t.TempDir()
copyTree(t, src, dir)
t.Chdir(dir)
t.Cleanup(func() {
if err := runErr(t, "delete", "taskcron", "dev",
"--yes", "--wait", "--allow-missing-platform",
"--context", "kind-deployah"); err != nil {
t.Logf("cleanup delete failed (non-fatal): %v", err)
}
})

run(t, "deploy", "dev", "--context", "kind-deployah", "--yes")

res := s.client.Resources("default")
var cronjobs batchv1.CronJobList
require.NoError(t, res.List(t.Context(), &cronjobs,
resources.WithLabelSelector("deployah.dev/project=taskcron,deployah.dev/component=cleanup")))
require.Len(t, cronjobs.Items, 1)
cj := cronjobs.Items[0]
assert.Empty(t, cj.Annotations["helm.sh/hook"])
assert.Equal(t, "@every 1h", cj.Spec.Schedule)
require.NotNil(t, cj.Spec.TimeZone)
assert.Equal(t, "Etc/UTC", *cj.Spec.TimeZone)
assert.Equal(t, batchv1.ForbidConcurrent, cj.Spec.ConcurrencyPolicy)
require.NotNil(t, cj.Spec.SuccessfulJobsHistoryLimit)
assert.Equal(t, int32(3), *cj.Spec.SuccessfulJobsHistoryLimit)
require.NotNil(t, cj.Spec.FailedJobsHistoryLimit)
assert.Equal(t, int32(3), *cj.Spec.FailedJobsHistoryLimit)
assert.Equal(t, corev1.RestartPolicyOnFailure, cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy)
assert.Nil(t, cj.Spec.StartingDeadlineSeconds)
require.NotNil(t, cj.Spec.JobTemplate.Spec.CompletionMode)
assert.Equal(t, batchv1.IndexedCompletion, *cj.Spec.JobTemplate.Spec.CompletionMode)
require.Len(t, cj.Spec.JobTemplate.Spec.Template.Spec.Containers, 1)
assert.Equal(t, []string{"echo", "cleanup-ok"}, cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command)
require.NotNil(t, cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds)
assert.Equal(t, int64(3600), *cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds)

run(t, "run", "cleanup", "dev", "--context", "kind-deployah", "--yes")
var jobs batchv1.JobList
require.NoError(t, res.List(t.Context(), &jobs,
resources.WithLabelSelector("deployah.dev/project=taskcron,deployah.dev/component=cleanup")))
require.NotEmpty(t, jobs.Items)
var cliJob *batchv1.Job
for i := range jobs.Items {
job := &jobs.Items[i]
if job.Labels["deployah.dev/managed-by"] == "deployah" {
cliJob = job
break
}
}
require.NotNil(t, cliJob, "deployah run must create a standalone Job")
assert.Nil(t, cliJob.Spec.ActiveDeadlineSeconds)
}

// prepareTaskdemo copies the task-migrate-smoke scenario into a temp dir,
// makes it the working directory, and registers a best-effort delete.
func (s *E2ESuite) prepareTaskdemo(t *testing.T) {
Expand Down
17 changes: 17 additions & 0 deletions internal/e2e/testdata/task-schedule/deployah.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apiVersion: v1-alpha.5
project: taskcron
components:
api:
image: nginx:latest
port: 80
environments: [dev]
resourcePreset: nano
tasks:
cleanup:
from: api
image: busybox:1.36
"on": schedule
schedule: "@every 1h"
command: ["echo", "cleanup-ok"]
environments:
dev: {}
1 change: 0 additions & 1 deletion internal/helm/chart/charts/deployah/templates/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,5 @@

{{ include "deployah.serviceaccount" . }}
{{ include "deployah.servicemonitor" . }}
{{ include "deployah.cronjob" . }}
{{ include "deployah.podmonitor" . }}
{{- end }}
Loading
Loading