diff --git a/README.md b/README.md index 2fc930d..9200ba7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. | diff --git a/docs/cli/deployah_run.md b/docs/cli/deployah_run.md index 9641972..c0b2132 100644 --- a/docs/cli/deployah_run.md +++ b/docs/cli/deployah_run.md @@ -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 [flags] diff --git a/docs/spec-reference.md b/docs/spec-reference.md index 8bee77b..1811389 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -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. | @@ -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 diff --git a/docs/tasks.md b/docs/tasks.md index 2fdac24..7a38a14 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -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 @@ -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: diff --git a/flake.nix b/flake.nix index c7049a7..8136d8e 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,7 @@ buildGoModule' = pkgs.buildGoModule.override { inherit go; }; - deployahVendorHash = "sha256-18Ns++/aP6rX/8iAulEZAp0448MtW+P8W5tjAlmqkrk="; + deployahVendorHash = "sha256-8nj4lEfjnl8xnNRCM0P32zXAxATUOh4XPJql4gqxvYE="; inherit (pkgs) golangci-lint gopls; diff --git a/go.mod b/go.mod index 2c84dfc..751f17e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 265836d..4b7ea94 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/run/run.go b/internal/cmd/run/run.go index bb7f52e..c076fa0 100644 --- a/internal/cmd/run/run.go +++ b/internal/cmd/run/run.go @@ -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")), diff --git a/internal/cmd/run/run_test.go b/internal/cmd/run/run_test.go index 43e4766..f5e25f8 100644 --- a/internal/cmd/run/run_test.go +++ b/internal/cmd/run/run_test.go @@ -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"}, + }, }, } } @@ -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") diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 9c3c409..90cccef 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -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) { diff --git a/internal/e2e/testdata/task-schedule/deployah.yaml b/internal/e2e/testdata/task-schedule/deployah.yaml new file mode 100644 index 0000000..29987ac --- /dev/null +++ b/internal/e2e/testdata/task-schedule/deployah.yaml @@ -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: {} diff --git a/internal/helm/chart/charts/deployah/templates/app.yaml b/internal/helm/chart/charts/deployah/templates/app.yaml index af97334..406cde8 100644 --- a/internal/helm/chart/charts/deployah/templates/app.yaml +++ b/internal/helm/chart/charts/deployah/templates/app.yaml @@ -29,6 +29,5 @@ {{ include "deployah.serviceaccount" . }} {{ include "deployah.servicemonitor" . }} -{{ include "deployah.cronjob" . }} {{ include "deployah.podmonitor" . }} {{- end }} diff --git a/internal/helm/chart/charts/deployah/templates/cronjob.yaml b/internal/helm/chart/charts/deployah/templates/cronjob.yaml index 542fe56..207e9a6 100644 --- a/internal/helm/chart/charts/deployah/templates/cronjob.yaml +++ b/internal/helm/chart/charts/deployah/templates/cronjob.yaml @@ -1,47 +1,86 @@ {{- define "deployah.cronjob" -}} {{- if .Values.cronjob.enabled -}} +{{- if and .Values.cronjob.timeZone (ne .Values.cronjob.timeZone "Etc/UTC") }} +{{- if not (semverCompare ">=1.27-0" .Capabilities.KubeVersion.Version) }} +{{- fail (printf "task %q sets timeZone %q, which requires Kubernetes 1.27 or later (cluster reports %s): CronJob.spec.timeZone is silently dropped by older API servers" .Chart.Name .Values.cronjob.timeZone .Capabilities.KubeVersion.Version) }} +{{- end }} +{{- end }} --- -apiVersion: {{ include "common.capabilities.cronjob.apiVersion" . }} +apiVersion: batch/v1 kind: CronJob metadata: - name: {{ include "common.names.fullname" . }} + name: {{ include "deployah.cronjob.name" . }} namespace: {{ include "common.names.namespace" . | quote }} - labels: {{- include "common.labels.standard" ( dict "customLabels" .Values.commonLabels "context" $ ) | nindent 4 }} - {{- if .Values.commonAnnotations }} - annotations: {{- include "common.tplvalues.render" ( dict "value" .Values.commonAnnotations "context" $ ) | nindent 4 }} + {{- $labels := include "common.tplvalues.merge" (dict "values" (list .Values.labels .Values.commonLabels) "context" .) | fromYaml }} + labels: {{- include "common.labels.standard" ( dict "customLabels" $labels "context" $ ) | nindent 4 }} + {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.annotations .Values.commonAnnotations) "context" .) | fromYaml }} + {{- if $annotations }} + annotations: + {{- include "common.tplvalues.render" ( dict "value" $annotations "context" $ ) | nindent 4 }} {{- end }} spec: - concurrencyPolicy: {{ .Values.cronjob.concurrencyPolicy | quote }} schedule: {{ .Values.cronjob.schedule | quote }} + timeZone: {{ .Values.cronjob.timeZone | quote }} + concurrencyPolicy: {{ .Values.cronjob.concurrencyPolicy | quote }} + suspend: {{ .Values.cronjob.suspend }} successfulJobsHistoryLimit: {{ .Values.cronjob.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.cronjob.failedJobsHistoryLimit }} jobTemplate: spec: + completionMode: Indexed + completions: {{ .Values.cronjob.completions }} + parallelism: {{ .Values.cronjob.parallelism }} + backoffLimit: {{ .Values.cronjob.backoffLimit }} + {{- with .Values.cronjob.activeDeadlineSeconds }} + activeDeadlineSeconds: {{ . }} + {{- end }} + {{- if hasKey .Values.cronjob "ttlSecondsAfterFinished" }} + ttlSecondsAfterFinished: {{ .Values.cronjob.ttlSecondsAfterFinished }} + {{- end }} template: metadata: - labels: {{- include "common.labels.standard" ( dict "customLabels" .Values.commonLabels "context" $ ) | nindent 12 }} - {{- if .Values.cronjob.podAnnotations }} - annotations: {{- include "common.tplvalues.render" ( dict "value" .Values.cronjob.podAnnotations "context" $) | nindent 12 }} + {{- $podLabels := include "common.tplvalues.merge" (dict "values" (list .Values.podLabels .Values.commonLabels) "context" .) | fromYaml }} + labels: {{- include "common.labels.standard" ( dict "customLabels" $podLabels "context" $ ) | nindent 12 }} + {{- if .Values.podAnnotations }} + annotations: {{- toYaml .Values.podAnnotations | nindent 12 }} {{- end }} spec: - {{- include "common.images.pullSecrets" (dict "images" (list .Values.image) "global" .Values.global) | indent 10 }} - {{- if and .Values.cronjob.nodeSelector .Values.nodeSelector }} - nodeSelector: {{- default (toYaml .Values.nodeSelector) (toYaml .Values.cronjob.nodeSelector) | nindent 12 }} + restartPolicy: OnFailure + automountServiceAccountToken: false + {{- include "common.images.renderPullSecrets" (dict "images" (list .Values.image) "context" $) | nindent 10 }} + {{- if .Values.nodeSelector }} + nodeSelector: {{- toYaml .Values.nodeSelector | nindent 12 }} {{- end }} - {{- if and .Values.cronjob.tolerations .Values.tolerations }} - tolerations: {{- default (toYaml .Values.tolerations) (toYaml .Values.cronjob.tolerations) | nindent 12 }} + {{- if .Values.tolerations }} + tolerations: {{- toYaml .Values.tolerations | nindent 12 }} {{- end }} - restartPolicy: OnFailure {{- if .Values.podSecurityContext.enabled }} securityContext: {{- omit .Values.podSecurityContext "enabled" | toYaml | nindent 12 }} {{- end }} - {{- if .Values.cronjob.initContainers }} - initContainers: {{- include "common.tplvalues.render" (dict "value" .Values.cronjob.initContainers "context" $) | nindent 12 }} - {{- end }} - {{- if .Values.cronjob.containers }} - containers: {{- include "common.tplvalues.render" (dict "value" .Values.cronjob.containers "context" $) | nindent 12 }} - {{- end }} - {{- if .Values.cronjob.volumes }} - volumes: {{- include "common.tplvalues.render" (dict "value" .Values.cronjob.volumes "context" $) | nindent 12 }} - {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- if .Values.containerSecurityContext.enabled }} + securityContext: {{- omit .Values.containerSecurityContext "enabled" | toYaml | nindent 16 }} + {{- end }} + image: {{ include "common.images.image" (dict "imageRoot" .Values.image "global" .Values.global) }} + imagePullPolicy: {{ default (eq .Values.image.tag "latest" | ternary "Always" "IfNotPresent") .Values.image.pullPolicy }} + {{- if .Values.command }} + command: {{- toYaml .Values.command | nindent 16 }} + {{- end }} + {{- if .Values.args }} + args: {{- toYaml .Values.args | nindent 16 }} + {{- end }} + {{- if .Values.envVars }} + env: + {{- $env := .Values.envVars }} + {{- range $key := keys $env | sortAlpha }} + {{- $val := index $env $key }} + - name: {{ $key | quote }} + value: {{ $val | quote }} + {{- end }} + {{- end }} + {{- if .Values.resources }} + resources: {{- toYaml .Values.resources | nindent 16 }} + {{- end }} {{- end }} {{- end }} diff --git a/internal/helm/chart/charts/deployah/templates/helpers.tpl b/internal/helm/chart/charts/deployah/templates/helpers.tpl index 99b3586..4ec5554 100644 --- a/internal/helm/chart/charts/deployah/templates/helpers.tpl +++ b/internal/helm/chart/charts/deployah/templates/helpers.tpl @@ -30,3 +30,26 @@ Usage: {{ include "common.tplvalues.render" (dict "value" .envVars "context" $.context) }} {{- end }} {{- end -}} + +{{/* +CronJob name for a scheduled-task subchart: {release}-{task}, truncated to +52 characters with a 4-hex-char hash when needed. The API server rejects +CronJobs over 52 characters, because the controller appends an 11-character +"-$TIMESTAMP" suffix to reach the 63-character Job name limit. + +The budget is spent on the release prefix so the task name always survives, +and the hash is taken over the untruncated name so two long names sharing a +prefix cannot collide. Task names are capped at 30 by the schema and Go +validation, so the prefix budget is never below 16. +*/}} +{{- define "deployah.cronjob.name" -}} +{{- $task := .Chart.Name -}} +{{- $full := printf "%s-%s" .Release.Name $task -}} +{{- if le (len $full) 52 -}} +{{- $full -}} +{{- else -}} +{{- $hash := substr 0 4 (sha256sum $full) -}} +{{- $budget := int (sub 52 (add (len $task) 6)) -}} +{{- printf "%s-%s-%s" (trunc $budget .Release.Name | trimSuffix "-") $hash $task -}} +{{- end -}} +{{- end -}} diff --git a/internal/helm/chart/charts/deployah/values.yaml b/internal/helm/chart/charts/deployah/values.yaml index b565e42..75d2b30 100644 --- a/internal/helm/chart/charts/deployah/values.yaml +++ b/internal/helm/chart/charts/deployah/values.yaml @@ -909,83 +909,52 @@ exports: ## hookDeletePolicy: before-hook-creation,hook-succeeded - ## Cronjob: create jobs on a repeated schedule + ## CronJob: scheduled run-to-completion work (Deployah on: schedule tasks) ## Ref: https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/ ## cronjob: - ## @param cronjob.enabled + ## @param cronjob.enabled Create a CronJob for this subchart ## enabled: false - ## @param cronjob.concurrencyPolicy Allow/Forbid/Replace concurrency + ## @param cronjob.schedule Cron expression or robfig descriptor ## - concurrencyPolicy: Allow + schedule: "" - ## @param cronjob.schedule run schedule for the cronjob - ## Cron format: " " + ## @param cronjob.timeZone IANA time zone for the schedule ## - schedule: "" + timeZone: "Etc/UTC" + + ## @param cronjob.concurrencyPolicy Allow, Forbid, or Replace + ## + concurrencyPolicy: Forbid + + ## @param cronjob.suspend Pause the CronJob without deleting it + ## + suspend: false ## @param cronjob.successfulJobsHistoryLimit ## successfulJobsHistoryLimit: 3 - ## @param nodeSelector Node labels for pod assignment. - ## Ref: https://kubernetes.io/docs/user-guide/node-selection/ + ## @param cronjob.failedJobsHistoryLimit ## - nodeSelector: {} + failedJobsHistoryLimit: 3 - ## @param tolerations Tolerations for pod assignment. - ## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ + ## @param cronjob.completions Indexed Job completions (fanout count) ## - tolerations: [] + completions: 1 - ## @param restartPolicy Restart conditions - ## Ref: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#restart-policy + ## @param cronjob.parallelism How many indexed copies may run at once ## - restartPolicy: OnFailure + parallelism: 1 - ## Configure APP pods Security Context - ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-pod - ## @param podSecurityContext.enabled Enabled APP pods' Security Context - ## @param podSecurityContext.fsGroup Set APP pod's Security Context fsGroup + ## @param cronjob.backoffLimit Retries before the run is marked failed ## - podSecurityContext: - enabled: false - fsGroup: 0 + backoffLimit: 3 - ## @param initContainers Add additional init containers to the APP pods - ## Example: - ## initContainers: - ## - name: your-image-name - ## image: your-image - ## imagePullPolicy: Always - ## ports: - ## - name: portname - ## containerPort: 1234 + ## @param cronjob.activeDeadlineSeconds Seconds a single run may take ## - initContainers: [] - ## @param containers Add additional containers to the APP pods - ## Example: - ## containers: - ## - name: your-image-name - ## image: your-image - ## imagePullPolicy: Always - ## ports: - ## - name: portname - ## containerPort: 1234 + ## @param cronjob.ttlSecondsAfterFinished Seconds to keep a finished Job (0 deletes immediately). Omitted when unset. ## - containers: [] - - ## @param volumes Array to add volumes (evaluated as a template) - ## Example: - ## volumes: - ## - name: vol-conf - ## configMap: - ## name: "configmap-name" - ## - name: vol-secret - ## secret: - ## secretName: "secret-name" - ## - volumes: [] diff --git a/internal/helm/generate.go b/internal/helm/generate.go index df0d283..cf23f0b 100644 --- a/internal/helm/generate.go +++ b/internal/helm/generate.go @@ -72,7 +72,7 @@ type ChartData struct { // created for this environment. ComponentNames []string // TaskNames are the sorted names of the task sub-charts created for this - // environment. Only hook tasks get one. + // environment. Hook and scheduled tasks each get one. TaskNames []string } @@ -135,10 +135,15 @@ func PrepareChart(ctx context.Context, manifest *spec.Spec, desiredEnvironment s // Resolve the sub-chart names once, before creating anything on disk, so // Chart.yaml and the sub-chart directories below cannot disagree. componentNames := activeComponentNames(manifest, desiredEnvironment) - taskNames, err := hookTaskNames(manifest, desiredEnvironment, resolved) + hookNames, err := hookTaskNames(manifest, desiredEnvironment, resolved) if err != nil { return "", fmt.Errorf("failed to resolve task sub-chart names: %w", err) } + scheduledNames, err := scheduledTaskNames(manifest, desiredEnvironment, resolved) + if err != nil { + return "", fmt.Errorf("failed to resolve scheduled task sub-chart names: %w", err) + } + taskNames := mergeTaskNames(hookNames, scheduledNames) tmpDir, err := os.MkdirTemp("", "deployah-chart-*") if err != nil { @@ -219,7 +224,7 @@ func PrepareChart(ctx context.Context, manifest *spec.Spec, desiredEnvironment s if err = createComponentSubCharts(tmpDir, componentNames); err != nil { return "", fmt.Errorf("failed to create component sub-charts: %w", err) } - if err = createTaskSubCharts(tmpDir, taskNames); err != nil { + if err = createTaskSubCharts(tmpDir, hookNames, scheduledNames); err != nil { return "", fmt.Errorf("failed to create task sub-charts: %w", err) } diff --git a/internal/helm/generate_schedule_test.go b/internal/helm/generate_schedule_test.go new file mode 100644 index 0000000..925271e --- /dev/null +++ b/internal/helm/generate_schedule_test.go @@ -0,0 +1,444 @@ +// Copyright 2025 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helm + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/action" + "sigs.k8s.io/yaml" + + "deployah.dev/deployah/internal/k8s" + "deployah.dev/deployah/internal/render" + "deployah.dev/deployah/internal/spec" + + chartcommon "helm.sh/helm/v4/pkg/chart/common" + batchv1 "k8s.io/api/batch/v1" +) + +func scheduledRenderSpec(task spec.Task) *spec.Spec { + if task.On == "" { + task.On = spec.TaskOnSchedule + } + if task.Schedule == "" { + task.Schedule = "0 3 * * *" + } + return &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "ghcr.io/acme/shop:1.2.3", + Port: 8080, + Env: map[string]string{"DATABASE_URL": "postgres://db", "LOG": "info"}, + }, + }, + Tasks: map[string]spec.Task{ + "cleanup": task, + }, + Environments: map[string]spec.Environment{ + "dev": {}, + }, + } +} + +func TestMapScheduledTaskToChartValues(t *testing.T) { + t.Parallel() + + ttl := 0 + m := &spec.Spec{Project: "shop"} + rt := spec.ResolvedTask{ + Task: spec.Task{ + Image: "busybox:1.36", + On: spec.TaskOnSchedule, + Schedule: "0 3 * * *", + Command: []string{"cleanup"}, + TTLSecondsAfterFinished: &ttl, + }, + } + + vals, err := mapScheduledTaskToChartValues(m, "cleanup", rt, "dev") + require.NoError(t, err) + + cronjob := mustNestedMap(t, vals, "cronjob") + assert.Equal(t, true, cronjob["enabled"]) + assert.Equal(t, "0 3 * * *", cronjob["schedule"]) + assert.Equal(t, spec.DefaultScheduleTimeZone, cronjob["timeZone"]) + assert.Equal(t, spec.DefaultConcurrencyPolicy, cronjob["concurrencyPolicy"]) + assert.Equal(t, false, cronjob["suspend"]) + assert.Equal(t, spec.DefaultSuccessfulJobsHistory, cronjob["successfulJobsHistoryLimit"]) + assert.Equal(t, spec.DefaultFailedJobsHistory, cronjob["failedJobsHistoryLimit"]) + assert.Equal(t, 1, cronjob["completions"]) + assert.Equal(t, 1, cronjob["parallelism"]) + assert.Equal(t, spec.DefaultBackoffLimit, cronjob["backoffLimit"]) + assert.Equal(t, 3600, cronjob["activeDeadlineSeconds"]) + assert.Equal(t, 0, cronjob["ttlSecondsAfterFinished"]) + _, hasStart := cronjob["startingDeadlineSeconds"] + assert.False(t, hasStart) + _, hasOverride := vals["fullnameOverride"] + assert.False(t, hasOverride) + _, hasJob := vals["job"] + assert.False(t, hasJob) +} + +func TestMapScheduledTaskToChartValues_ExplicitTimeout(t *testing.T) { + t.Parallel() + + m := &spec.Spec{Project: "shop"} + rt := spec.ResolvedTask{ + Task: spec.Task{ + Image: "busybox:1.36", + On: spec.TaskOnSchedule, + Schedule: "@hourly", + Timeout: "30m", + Command: []string{"true"}, + Fanout: spec.Fanout{Count: 2, Parallelism: 1}, + Suspend: new(true), + }, + } + + vals, err := mapScheduledTaskToChartValues(m, "cleanup", rt, "dev") + require.NoError(t, err) + cronjob := mustNestedMap(t, vals, "cronjob") + assert.Equal(t, 1800, cronjob["activeDeadlineSeconds"]) + assert.Equal(t, 2, cronjob["completions"]) + assert.Equal(t, true, cronjob["suspend"]) + _, hasTTL := cronjob["ttlSecondsAfterFinished"] + assert.False(t, hasTTL) +} + +func TestHelmCronJob_InMainManifest(t *testing.T) { + t.Parallel() + + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + Command: []string{"cleanup"}, + }) + cj := renderScheduledCronJob(t, m, "dev", "cleanup") + assert.Equal(t, "batch/v1", cj.APIVersion) + assert.Equal(t, "shop-dev-cleanup", cj.Name) + assert.Equal(t, "0 3 * * *", cj.Spec.Schedule) + require.NotNil(t, cj.Spec.TimeZone) + assert.Equal(t, spec.DefaultScheduleTimeZone, *cj.Spec.TimeZone) + assert.Equal(t, batchv1.ForbidConcurrent, cj.Spec.ConcurrencyPolicy) + require.NotNil(t, cj.Spec.Suspend) + assert.False(t, *cj.Spec.Suspend) + require.NotNil(t, cj.Spec.SuccessfulJobsHistoryLimit) + assert.Equal(t, int32(spec.DefaultSuccessfulJobsHistory), *cj.Spec.SuccessfulJobsHistoryLimit) + require.NotNil(t, cj.Spec.FailedJobsHistoryLimit) + assert.Equal(t, int32(spec.DefaultFailedJobsHistory), *cj.Spec.FailedJobsHistoryLimit) + assert.Nil(t, cj.Spec.StartingDeadlineSeconds) + require.NotNil(t, cj.Spec.JobTemplate.Spec.CompletionMode) + assert.Equal(t, batchv1.IndexedCompletion, *cj.Spec.JobTemplate.Spec.CompletionMode) + assert.Equal(t, "OnFailure", string(cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy)) + require.NotNil(t, cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds) + assert.Equal(t, int64(3600), *cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds) + assert.Empty(t, cj.Annotations["helm.sh/hook"]) + assert.Equal(t, "cleanup", cj.Labels[spec.LabelComponent]) + assert.Equal(t, "shop", cj.Labels[spec.LabelProject]) +} + +func TestHelmCronJob_EveryAccepted(t *testing.T) { + t.Parallel() + + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + Schedule: "@every 1h", + Command: []string{"true"}, + }) + cj := renderScheduledCronJob(t, m, "dev", "cleanup") + assert.Equal(t, "@every 1h", cj.Spec.Schedule) +} + +func TestCronJobName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + releaseName string + task string + wantExact string + wantSuffix string + wantLen int + }{ + { + name: "under 52 passes through", + releaseName: "shop-dev", + task: "cleanup", + wantExact: "shop-dev-cleanup", + }, + { + name: "task named after the project", + releaseName: "shop-dev", + task: "shop", + wantExact: "shop-dev-shop", + }, + { + name: "over 52 truncates with hash and keeps task suffix", + releaseName: "shop-review-feature-add-new-checkout-flow-with-stripe", + task: "cleanup", + wantSuffix: "-cleanup", + wantLen: 52, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := scheduledTaskNamed(tt.task) + result := mustRenderScheduled(t, m, "dev", tt.releaseName, "") + cj := cronJobFromManifest(t, result.Manifest, tt.task) + if tt.wantExact != "" { + assert.Equal(t, tt.wantExact, cj.Name) + return + } + assert.LessOrEqual(t, len(cj.Name), 52) + if tt.wantLen > 0 { + assert.Equal(t, tt.wantLen, len(cj.Name)) + } + assert.True(t, strings.HasSuffix(cj.Name, tt.wantSuffix), "name %q suffix", cj.Name) + assert.Regexp(t, regexp.MustCompile(`-[0-9a-f]{4}`+regexp.QuoteMeta(tt.wantSuffix)+`$`), cj.Name) + }) + } +} + +func TestCronJobName_TwoTasksStayDistinguishable(t *testing.T) { + t.Parallel() + + m := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": {Role: spec.ComponentRoleService, Image: "busybox:1.36", Port: 8080}, + }, + Tasks: map[string]spec.Task{ + "cleanup": {From: "api", On: spec.TaskOnSchedule, Schedule: "0 3 * * *", Command: []string{"true"}}, + "compact": {From: "api", On: spec.TaskOnSchedule, Schedule: "0 4 * * *", Command: []string{"true"}}, + }, + Environments: map[string]spec.Environment{"dev": {}}, + } + const release = "shop-review-feature-add-new-checkout-flow-with-stripe" + result := mustRenderScheduled(t, m, "dev", release, "") + cleanup := cronJobFromManifest(t, result.Manifest, "cleanup") + compact := cronJobFromManifest(t, result.Manifest, "compact") + assert.NotEqual(t, cleanup.Name, compact.Name) + assert.True(t, strings.HasSuffix(cleanup.Name, "-cleanup")) + assert.True(t, strings.HasSuffix(compact.Name, "-compact")) +} + +func TestReleaseNamePortability(t *testing.T) { + t.Parallel() + + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + Command: []string{"true"}, + }) + first := mustRenderScheduled(t, m, "dev", "alpha-dev", "") + second := mustRenderScheduled(t, m, "dev", "bravo-dev", "") + a := cronJobFromManifest(t, first.Manifest, "cleanup") + b := cronJobFromManifest(t, second.Manifest, "cleanup") + assert.Equal(t, "alpha-dev-cleanup", a.Name) + assert.Equal(t, "bravo-dev-cleanup", b.Name) +} + +func TestTimeZoneGuard(t *testing.T) { + t.Parallel() + + t.Run("non-UTC fails on v1.26", func(t *testing.T) { + t.Parallel() + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + TimeZone: "Europe/Berlin", + Command: []string{"true"}, + }) + _, err := renderScheduled(t, m, "dev", "shop-dev", "v1.26.0") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires Kubernetes 1.27") + assert.Contains(t, err.Error(), "Europe/Berlin") + assert.NotContains(t, err.Error(), "now") + }) + + t.Run("Etc/UTC still renders on v1.26", func(t *testing.T) { + t.Parallel() + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + TimeZone: spec.DefaultScheduleTimeZone, + Command: []string{"true"}, + }) + result := mustRenderScheduled(t, m, "dev", "shop-dev", "v1.26.0") + cj := cronJobFromManifest(t, result.Manifest, "cleanup") + require.NotNil(t, cj.Spec.TimeZone) + assert.Equal(t, spec.DefaultScheduleTimeZone, *cj.Spec.TimeZone) + }) +} + +func TestHelmCronJob_RunJobHasNoDeadlineWhenTimeoutOmitted(t *testing.T) { + t.Parallel() + + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + Command: []string{"cleanup"}, + }) + require.NoError(t, spec.FillSpecWithDefaults(m, spec.CurrentManifestVersion)) + for name, task := range m.Tasks { + if p, ok := m.Components[task.From]; ok { + cp := p + m.Tasks[name] = task.MergeFrom(&cp) + } + } + merged, ok := m.MergedTask("cleanup") + require.True(t, ok) + assert.Empty(t, merged.Timeout) + + cj := renderScheduledCronJob(t, m, "dev", "cleanup") + require.NotNil(t, cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds) + assert.Equal(t, int64(3600), *cj.Spec.JobTemplate.Spec.ActiveDeadlineSeconds) + + job, err := k8s.BuildTaskJob(k8s.TaskJobOptions{ + Project: m.Project, + Environment: "dev", + Namespace: "default", + TaskName: "cleanup", + Task: merged, + }) + require.NoError(t, err) + assert.Nil(t, job.Spec.ActiveDeadlineSeconds) +} + +func scheduledTaskNamed(name string) *spec.Spec { + m := scheduledRenderSpec(spec.Task{ + From: "api", + On: spec.TaskOnSchedule, + Command: []string{"true"}, + }) + m.Tasks = map[string]spec.Task{name: m.Tasks["cleanup"]} + return m +} + +func renderScheduledCronJob(t *testing.T, manifest *spec.Spec, env, taskName string) *batchv1.CronJob { + t.Helper() + result, cleanup, err := renderOfflineScheduled(t, manifest, env) + require.NoError(t, err) + if cleanup != nil { + t.Cleanup(cleanup) + } + for _, h := range result.Hooks { + if h != nil && strings.Contains(h.Manifest, "kind: CronJob") { + t.Fatalf("scheduled CronJob must not be a Helm hook") + } + } + return cronJobFromManifest(t, result.Manifest, taskName) +} + +func renderOfflineScheduled(t *testing.T, manifest *spec.Spec, env string) (*render.RenderResult, func(), error) { + t.Helper() + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + for name, task := range manifest.Tasks { + if p, ok := manifest.Components[task.From]; ok { + cp := p + manifest.Tasks[name] = task.MergeFrom(&cp) + } + } + client, err := NewClient(WithNamespace("default")) + require.NoError(t, err) + return client.RenderOffline(t.Context(), manifest, env, nil, nil) +} + +func mustRenderScheduled(t *testing.T, manifest *spec.Spec, env, releaseName, kubeVersion string) *render.RenderResult { + t.Helper() + result, err := renderScheduled(t, manifest, env, releaseName, kubeVersion) + require.NoError(t, err) + return result +} + +func renderScheduled(t *testing.T, manifest *spec.Spec, env, releaseName, kubeVersion string) (*render.RenderResult, error) { + t.Helper() + require.NoError(t, spec.FillSpecWithDefaults(manifest, spec.CurrentManifestVersion)) + for name, task := range manifest.Tasks { + if p, ok := manifest.Components[task.From]; ok { + cp := p + manifest.Tasks[name] = task.MergeFrom(&cp) + } + } + + client, err := NewClient(WithNamespace("default")) + require.NoError(t, err) + + ch, _, cleanup, err := client.prepareAndLoadChart(t.Context(), manifest, env, nil) + if err != nil { + return nil, err + } + t.Cleanup(cleanup) + + values, labels := renderInputs(manifest, env) + defer restoreCapabilitiesForDryRun(client.config)() + + install := action.NewInstall(client.config) + install.ReleaseName = releaseName + install.Namespace = client.settings.Namespace() + install.CreateNamespace = true + install.DryRunStrategy = action.DryRunClient + install.DisableOpenAPIValidation = true + install.Labels = labels + install.APIVersions = chartcommon.VersionSet{offlineMonitorAPIVersion} + if kubeVersion != "" { + kv, parseErr := chartcommon.ParseKubeVersion(kubeVersion) + require.NoError(t, parseErr) + install.KubeVersion = kv + } + + rel, runErr := install.RunWithContext(t.Context(), ch, values) + if runErr != nil { + return nil, client.wrapHelmError("render", releaseName, runErr) + } + v1rel, convErr := releaserToV1(rel) + if convErr != nil { + return nil, convErr + } + return &render.RenderResult{ + ReleaseName: releaseName, + Namespace: install.Namespace, + Manifest: v1rel.Manifest, + Hooks: v1rel.Hooks, + IsUpgrade: false, + Revision: 1, + }, nil +} + +func cronJobFromManifest(t *testing.T, manifest, taskName string) *batchv1.CronJob { + t.Helper() + suffix := "-" + taskName + for doc := range strings.SplitSeq(manifest, "---") { + var cj batchv1.CronJob + if unmarshalErr := yaml.Unmarshal([]byte(doc), &cj); unmarshalErr != nil { + continue + } + if cj.Kind == "CronJob" && strings.HasSuffix(cj.Name, suffix) { + return &cj + } + } + t.Fatalf("no CronJob ending with %q in manifest", suffix) + return nil +} diff --git a/internal/helm/generate_task_test.go b/internal/helm/generate_task_test.go index 95c04c3..92e76f1 100644 --- a/internal/helm/generate_task_test.go +++ b/internal/helm/generate_task_test.go @@ -66,6 +66,12 @@ func taskSpec() *spec.Spec { Command: []string{"backfill"}, Fanout: spec.Fanout{Count: 4, Parallelism: 2}, }, + "cleanup": { + From: "api", + On: spec.TaskOnSchedule, + Command: []string{"cleanup"}, + Schedule: "0 3 * * *", + }, }, } } @@ -109,6 +115,16 @@ func TestMapSpecToChartValues_HookTasks(t *testing.T) { _, hasManual := vals["backfill"] assert.False(t, hasManual, "manual tasks must be absent from chart values") + cleanup := mustNestedMap(t, vals, "cleanup") + cronjob := mustNestedMap(t, cleanup, "cronjob") + assert.Equal(t, true, cronjob["enabled"]) + assert.Equal(t, "0 3 * * *", cronjob["schedule"]) + assert.Equal(t, spec.DefaultScheduleTimeZone, cronjob["timeZone"]) + assert.Equal(t, spec.DefaultConcurrencyPolicy, cronjob["concurrencyPolicy"]) + assert.Equal(t, 3600, cronjob["activeDeadlineSeconds"]) + _, hasJob := cleanup["job"] + assert.False(t, hasJob, "scheduled tasks must not render a hook Job") + deployah := mustNestedMap(t, vals, "deployah") resolved := mustNestedMap(t, deployah, "resolved") require.Contains(t, resolved, "tasks") @@ -180,9 +196,12 @@ func TestPrepareChart_ChartYAMLImportsOnlySubCharts(t *testing.T) { } // Components first, then tasks, each sorted: the order is part of what // keeps a regenerated chart byte-identical. - assert.Equal(t, []string{"api", "migrate", "smoke"}, parents, + assert.Equal(t, []string{"api", "cleanup", "migrate", "smoke"}, parents, "manual task backfill and prod-only component worker must not be imported") assert.DirExists(t, filepath.Join(chartDir, "charts", "migrate")) + assert.DirExists(t, filepath.Join(chartDir, "charts", "cleanup")) + assert.FileExists(t, filepath.Join(chartDir, "charts", "cleanup", "templates", "cronjob.yaml")) + assert.NoFileExists(t, filepath.Join(chartDir, "charts", "cleanup", "templates", "job.yaml")) assert.NoDirExists(t, filepath.Join(chartDir, "charts", "backfill")) assert.NoDirExists(t, filepath.Join(chartDir, "charts", "worker")) } diff --git a/internal/helm/task.go b/internal/helm/task.go index 640493d..2862622 100644 --- a/internal/helm/task.go +++ b/internal/helm/task.go @@ -25,39 +25,64 @@ import ( const hookDeletePolicy = "before-hook-creation,hook-succeeded" -// createTaskSubCharts creates a sub-chart directory for each name in -// taskNames, as returned by [hookTaskNames]. Manual tasks and tasks from -// other environments are absent from that list and get no subchart. -func createTaskSubCharts(chartDir string, taskNames []string) error { +// createTaskSubCharts creates a sub-chart directory for each hook and +// scheduled task name. Manual tasks and tasks from other environments are +// absent from those lists and get no subchart. +func createTaskSubCharts(chartDir string, hookNames, scheduledNames []string) error { chartsDir := filepath.Join(chartDir, "charts") if err := os.MkdirAll(chartsDir, 0o750); err != nil { return fmt.Errorf("failed to create charts directory: %w", err) } - for _, name := range taskNames { - taskChartDir := filepath.Join(chartsDir, name) - if err := os.MkdirAll(taskChartDir, 0o750); err != nil { - return fmt.Errorf("failed to create task chart directory for %s: %w", name, err) + for _, name := range hookNames { + if err := createOneTaskSubChart(chartsDir, name, createTaskJobTemplate); err != nil { + return err } - if err := createComponentChartYAML(taskChartDir, name); err != nil { - return fmt.Errorf("failed to create Chart.yaml for task %s: %w", name, err) - } - templatesDir := filepath.Join(taskChartDir, "templates") - if err := os.MkdirAll(templatesDir, 0o750); err != nil { - return fmt.Errorf("failed to create templates directory for task %s: %w", name, err) - } - if err := createTaskJobTemplate(templatesDir); err != nil { - return fmt.Errorf("failed to create job.yaml template for task %s: %w", name, err) + } + for _, name := range scheduledNames { + if err := createOneTaskSubChart(chartsDir, name, createTaskCronJobTemplate); err != nil { + return err } } return nil } +func createOneTaskSubChart(chartsDir, name string, writeTemplate func(string) error) error { + taskChartDir := filepath.Join(chartsDir, name) + if err := os.MkdirAll(taskChartDir, 0o750); err != nil { + return fmt.Errorf("failed to create task chart directory for %s: %w", name, err) + } + if err := createComponentChartYAML(taskChartDir, name); err != nil { + return fmt.Errorf("failed to create Chart.yaml for task %s: %w", name, err) + } + templatesDir := filepath.Join(taskChartDir, "templates") + if err := os.MkdirAll(templatesDir, 0o750); err != nil { + return fmt.Errorf("failed to create templates directory for task %s: %w", name, err) + } + if err := writeTemplate(templatesDir); err != nil { + return fmt.Errorf("failed to create template for task %s: %w", name, err) + } + return nil +} + func createTaskJobTemplate(templatesDir string) error { body := `{{- include "deployah.job" . -}}` return os.WriteFile(filepath.Join(templatesDir, "job.yaml"), []byte(body), 0o600) } +func createTaskCronJobTemplate(templatesDir string) error { + body := `{{- include "deployah.cronjob" . -}}` + return os.WriteFile(filepath.Join(templatesDir, "cronjob.yaml"), []byte(body), 0o600) +} + +// mergeTaskNames returns the sorted union of hook and scheduled task names +// for Chart.yaml import-values. +func mergeTaskNames(hookNames, scheduledNames []string) []string { + names := append(slices.Clone(hookNames), scheduledNames...) + slices.Sort(names) + return names +} + // hookTaskNames returns the sorted names of the hook tasks that get a // sub-chart in this environment. func hookTaskNames(m *spec.Spec, desiredEnvironment string, resolved *spec.ResolvedSpec) ([]string, error) { @@ -73,16 +98,45 @@ func hookTaskNames(m *spec.Spec, desiredEnvironment string, resolved *spec.Resol return names, nil } +// scheduledTaskNames returns the sorted names of the scheduled tasks that +// get a CronJob sub-chart in this environment. +func scheduledTaskNames(m *spec.Spec, desiredEnvironment string, resolved *spec.ResolvedSpec) ([]string, error) { + scheduled, err := scheduledTasksForChart(m, desiredEnvironment, resolved) + if err != nil { + return nil, err + } + names := make([]string, 0, len(scheduled)) + for name := range scheduled { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + // hookTasksForChart returns merged hook tasks that belong in this -// environment. Manual tasks are omitted. +// environment. Manual and scheduled tasks are omitted. func hookTasksForChart(m *spec.Spec, desiredEnvironment string, resolved *spec.ResolvedSpec) (map[string]spec.ResolvedTask, error) { + return tasksForChart(m, desiredEnvironment, resolved, func(rt spec.ResolvedTask) bool { + return rt.Task.On.IsHook() + }) +} + +// scheduledTasksForChart returns merged scheduled tasks that belong in +// this environment. +func scheduledTasksForChart(m *spec.Spec, desiredEnvironment string, resolved *spec.ResolvedSpec) (map[string]spec.ResolvedTask, error) { + return tasksForChart(m, desiredEnvironment, resolved, func(rt spec.ResolvedTask) bool { + return rt.Task.On.IsScheduled() + }) +} + +func tasksForChart(m *spec.Spec, desiredEnvironment string, resolved *spec.ResolvedSpec, keep func(spec.ResolvedTask) bool) (map[string]spec.ResolvedTask, error) { all, err := spec.EffectiveTasks(m, desiredEnvironment, resolved) if err != nil { return nil, err } out := make(map[string]spec.ResolvedTask, len(all)) for name, rt := range all { - if rt.Task.On.IsHook() { + if keep(rt) { out[name] = rt } } @@ -107,14 +161,122 @@ func applyTaskChartValues(values map[string]any, m *spec.Spec, desiredEnvironmen "timeout": rt.Task.Timeout, } } + if schedErr := applyScheduledTaskChartValues(values, resolvedTasks, m, desiredEnvironment, resolved); schedErr != nil { + return nil, schedErr + } return resolvedTasks, nil } +func applyScheduledTaskChartValues(values, resolvedTasks map[string]any, m *spec.Spec, desiredEnvironment string, resolved *spec.ResolvedSpec) error { + scheduled, err := scheduledTasksForChart(m, desiredEnvironment, resolved) + if err != nil { + return err + } + for name, rt := range scheduled { + taskValues, mapErr := mapScheduledTaskToChartValues(m, name, rt, desiredEnvironment) + if mapErr != nil { + return fmt.Errorf("task %s: %w", name, mapErr) + } + values[name] = taskValues + resolvedTasks[name] = map[string]any{ + "on": string(rt.Task.On), + "timeout": rt.Task.Timeout, + "schedule": rt.Task.Schedule, + "timeZone": rt.Task.TimeZone, + "concurrencyPolicy": rt.Task.ConcurrencyPolicy, + } + } + return nil +} + func mapTaskToChartValues(m *spec.Spec, name string, rt spec.ResolvedTask, desiredEnvironment string) (map[string]any, error) { - fields, err := spec.NewTaskJobSpec(rt.Task, 0, 0) + values, fields, err := taskBaseChartValues(m, name, rt, desiredEnvironment) if err != nil { return nil, err } + + job := map[string]any{ + "enabled": true, + "hook": rt.Task.HelmHookEvents(), + "hookWeight": rt.HookWeight, + "hookDeletePolicy": hookDeletePolicy, + "completions": int(fields.Completions), + "parallelism": int(fields.Parallelism), + "backoffLimit": int(fields.BackoffLimit), + } + if fields.ActiveDeadlineSeconds != nil { + job["activeDeadlineSeconds"] = int(*fields.ActiveDeadlineSeconds) + } + if fields.TTLSecondsAfterFinished != nil { + job["ttlSecondsAfterFinished"] = int(*fields.TTLSecondsAfterFinished) + } + values["job"] = job + return values, nil +} + +func mapScheduledTaskToChartValues(m *spec.Spec, name string, rt spec.ResolvedTask, desiredEnvironment string) (map[string]any, error) { + values, fields, err := taskBaseChartValues(m, name, rt, desiredEnvironment) + if err != nil { + return nil, err + } + + deadline, err := scheduledActiveDeadlineSeconds(rt.Task) + if err != nil { + return nil, err + } + + timeZone := rt.Task.TimeZone + if timeZone == "" { + timeZone = spec.DefaultScheduleTimeZone + } + policy := rt.Task.ConcurrencyPolicy + if policy == "" { + policy = spec.DefaultConcurrencyPolicy + } + suspend := false + if rt.Task.Suspend != nil { + suspend = *rt.Task.Suspend + } + + cronjob := map[string]any{ + "enabled": true, + "schedule": rt.Task.Schedule, + "timeZone": timeZone, + "concurrencyPolicy": policy, + "suspend": suspend, + "successfulJobsHistoryLimit": spec.DefaultSuccessfulJobsHistory, + "failedJobsHistoryLimit": spec.DefaultFailedJobsHistory, + "completions": int(fields.Completions), + "parallelism": int(fields.Parallelism), + "backoffLimit": int(fields.BackoffLimit), + "activeDeadlineSeconds": deadline, + } + if fields.TTLSecondsAfterFinished != nil { + cronjob["ttlSecondsAfterFinished"] = int(*fields.TTLSecondsAfterFinished) + } + values["cronjob"] = cronjob + return values, nil +} + +// scheduledActiveDeadlineSeconds is the CronJob-only cluster deadline. +// An empty timeout becomes [spec.DefaultScheduledTaskTimeout]. +func scheduledActiveDeadlineSeconds(task spec.Task) (int, error) { + timeout := task.Timeout + if timeout == "" { + timeout = spec.DefaultScheduledTaskTimeout + } + sec, err := spec.ParseDuration(timeout) + if err != nil { + return 0, fmt.Errorf("timeout: %w", err) + } + return int(sec), nil +} + +func taskBaseChartValues(m *spec.Spec, name string, rt spec.ResolvedTask, desiredEnvironment string) (map[string]any, spec.TaskJobSpec, error) { + fields, err := spec.NewTaskJobSpec(rt.Task, 0, 0) + if err != nil { + return nil, spec.TaskJobSpec{}, err + } image, tag := parseContainerImage(fields.Image) requests := map[string]any{} @@ -141,22 +303,6 @@ func mapTaskToChartValues(m *spec.Spec, name string, rt spec.ResolvedTask, desir } } - job := map[string]any{ - "enabled": true, - "hook": rt.Task.HelmHookEvents(), - "hookWeight": rt.HookWeight, - "hookDeletePolicy": hookDeletePolicy, - "completions": int(fields.Completions), - "parallelism": int(fields.Parallelism), - "backoffLimit": int(fields.BackoffLimit), - } - if fields.ActiveDeadlineSeconds != nil { - job["activeDeadlineSeconds"] = int(*fields.ActiveDeadlineSeconds) - } - if fields.TTLSecondsAfterFinished != nil { - job["ttlSecondsAfterFinished"] = int(*fields.TTLSecondsAfterFinished) - } - values := map[string]any{ "commonLabels": map[string]string{ spec.LabelProject: m.Project, @@ -169,7 +315,6 @@ func mapTaskToChartValues(m *spec.Spec, name string, rt spec.ResolvedTask, desir }, "image": imageValues, "resources": resources, - "job": job, "service": map[string]any{ "enabled": false, }, @@ -184,7 +329,7 @@ func mapTaskToChartValues(m *spec.Spec, name string, rt spec.ResolvedTask, desir values["envVars"] = fields.Env } if applyErr := applyMergedProfile(values, rt.MergedProfile); applyErr != nil { - return nil, applyErr + return nil, spec.TaskJobSpec{}, applyErr } - return values, nil + return values, fields, nil } diff --git a/internal/plan/build.go b/internal/plan/build.go index c307324..c4fb64f 100644 --- a/internal/plan/build.go +++ b/internal/plan/build.go @@ -128,6 +128,8 @@ func plannedTaskOn(on spec.TaskOn) string { return TaskOnPostDeploy case spec.TaskOnManual: return TaskOnManual + case spec.TaskOnSchedule: + return TaskOnSchedule default: return string(on) } diff --git a/internal/plan/format_text.go b/internal/plan/format_text.go index 351888c..5227c42 100644 --- a/internal/plan/format_text.go +++ b/internal/plan/format_text.go @@ -392,6 +392,7 @@ func writeTasks(w io.Writer, p *Plan, opts TextOptions) error { }{ {TaskOnPreDeploy, TaskOnPreDeploy}, {TaskOnPostDeploy, TaskOnPostDeploy}, + {"schedule (CronJob)", TaskOnSchedule}, {"manual (CLI only)", TaskOnManual}, } for _, g := range groups { @@ -401,7 +402,7 @@ func writeTasks(w io.Writer, p *Plan, opts TextOptions) error { items = append(items, task) } } - if g.on != TaskOnManual { + if g.on == TaskOnPreDeploy || g.on == TaskOnPostDeploy { slices.SortFunc(items, func(a, b PlannedTask) int { if a.HookWeight != b.HookWeight { return a.HookWeight - b.HookWeight @@ -420,7 +421,7 @@ func writeTasks(w io.Writer, p *Plan, opts TextOptions) error { if task.Timeout != "" { line += " (timeout " + task.Timeout + ")" } - if !task.Manual { + if task.On == TaskOnPreDeploy || task.On == TaskOnPostDeploy { line += fmt.Sprintf(" weight %d", task.HookWeight) } if _, err := fmt.Fprintln(w, line); err != nil { diff --git a/internal/plan/format_text_test.go b/internal/plan/format_text_test.go index 5f195ff..7f27f93 100644 --- a/internal/plan/format_text_test.go +++ b/internal/plan/format_text_test.go @@ -349,6 +349,7 @@ func TestRenderText_TasksSection(t *testing.T) { {Name: "migrate", On: TaskOnPreDeploy, Timeout: "5m", HookWeight: 0}, {Name: "seed", On: TaskOnPreDeploy, Timeout: "5m", HookWeight: 1}, {Name: "smoke", On: TaskOnPostDeploy, Timeout: "5m", HookWeight: 0}, + {Name: "cleanup", On: TaskOnSchedule}, {Name: "backfill", On: TaskOnManual, Manual: true}, } tests := []struct { @@ -374,6 +375,8 @@ func TestRenderText_TasksSection(t *testing.T) { "migrate (timeout 5m) weight 0", "seed (timeout 5m) weight 1", "postDeploy", + "schedule (CronJob)", + "cleanup", "manual (CLI only)", "backfill", "database must already be reachable", @@ -394,7 +397,7 @@ func TestRenderText_TasksSection(t *testing.T) { Tasks: []PlannedTask{{Name: "smoke", On: TaskOnPostDeploy, Timeout: "5m"}}, }, contains: []string{"Tasks:", "postDeploy", "smoke (timeout 5m) weight 0"}, - omits: []string{"preDeploy", "manual"}, + omits: []string{"preDeploy", "manual", "schedule"}, }, { name: "task without timeout", diff --git a/internal/plan/types.go b/internal/plan/types.go index 6f863f0..cd72def 100644 --- a/internal/plan/types.go +++ b/internal/plan/types.go @@ -161,7 +161,7 @@ type Plan struct { DriftIncomplete []string // Tasks lists spec tasks active in this environment, grouped by the - // renderer into preDeploy, postDeploy, and manual. + // renderer into preDeploy, postDeploy, schedule, and manual. Tasks []PlannedTask } @@ -172,6 +172,8 @@ const ( TaskOnPostDeploy = "postDeploy" // TaskOnManual is a task that runs only via the CLI. TaskOnManual = "manual" + // TaskOnSchedule is a task that runs as a Kubernetes CronJob. + TaskOnSchedule = "schedule" ) // PlannedTask is one spec task shown in the plan Tasks section. diff --git a/internal/spec/constants.go b/internal/spec/constants.go index 549fce3..6db9ac6 100644 --- a/internal/spec/constants.go +++ b/internal/spec/constants.go @@ -82,6 +82,12 @@ const ( // MaxComponentNameLength is the maximum allowed length for component names MaxComponentNameLength = 63 + // MaxTaskNameLength is the maximum allowed length for task names. + // The CronJob helper keeps the task suffix intact inside the 52-character + // Kubernetes CronJob name limit, so the schema and Go validation cap + // names at 30. + MaxTaskNameLength = 30 + // MaxProjectNameLength is the maximum allowed length for project names MaxProjectNameLength = 63 @@ -200,6 +206,27 @@ const ( // postDeploy tasks when timeout is omitted. DefaultHookTaskTimeout = "5m" + // DefaultScheduledTaskTimeout is the CronJob activeDeadlineSeconds + // used when a scheduled task omits timeout. It is not applied to + // [Task.Timeout] or to deployah run Jobs. + DefaultScheduledTaskTimeout = "1h" + + // DefaultConcurrencyPolicy is the CronJob concurrencyPolicy when + // omitted on a scheduled task. + DefaultConcurrencyPolicy = "Forbid" + + // DefaultScheduleTimeZone is the CronJob timeZone when omitted on a + // scheduled task. + DefaultScheduleTimeZone = "Etc/UTC" + + // DefaultSuccessfulJobsHistory is successfulJobsHistoryLimit on a + // scheduled-task CronJob. + DefaultSuccessfulJobsHistory = 3 + + // DefaultFailedJobsHistory is failedJobsHistoryLimit on a + // scheduled-task CronJob. + DefaultFailedJobsHistory = 3 + // DefaultDeployTimeout is the default CLI --timeout. Hook task // timeouts must be strictly less than the session --timeout at // deploy or run time. diff --git a/internal/spec/defaults.go b/internal/spec/defaults.go index 7ef2fec..eebf962 100644 --- a/internal/spec/defaults.go +++ b/internal/spec/defaults.go @@ -915,6 +915,14 @@ func applyTaskDefaults(t *Task) { if t.On.IsHook() && t.Timeout == "" { t.Timeout = DefaultHookTaskTimeout } + if t.On.IsScheduled() { + if t.ConcurrencyPolicy == "" { + t.ConcurrencyPolicy = DefaultConcurrencyPolicy + } + if t.TimeZone == "" { + t.TimeZone = DefaultScheduleTimeZone + } + } } // CreateSpecWithDefaults creates a minimal [Spec] for projectName and fills diff --git a/internal/spec/doc.go b/internal/spec/doc.go index bac3767..9796a33 100644 --- a/internal/spec/doc.go +++ b/internal/spec/doc.go @@ -28,7 +28,7 @@ // - [ValidateSpec]: validate spec data against a schema version // - [ValidateEnvironments]: validate environment definitions // - [ValidateSpecComponents]: check component resources and autoscaling -// - [ValidateSpecTasks]: check task names, from, on, after, and fanout +// - [ValidateSpecTasks]: check task names, from, on, after, schedule, and fanout // // # Tasks // diff --git a/internal/spec/schema/v1-alpha.5/manifest.json b/internal/spec/schema/v1-alpha.5/manifest.json index 230f4f3..79da282 100644 --- a/internal/spec/schema/v1-alpha.5/manifest.json +++ b/internal/spec/schema/v1-alpha.5/manifest.json @@ -61,7 +61,7 @@ "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "minLength": 2, - "maxLength": 63 + "maxLength": 30 }, "additionalProperties": { "$ref": "#/$defs/Task" @@ -866,7 +866,7 @@ "Task": { "type": "object", "title": "Task", - "description": "Run-to-completion work triggered on deploy or via the CLI.", + "description": "Run-to-completion work triggered on deploy, on a schedule, or via the CLI.", "additionalProperties": false, "required": [ "on" @@ -918,17 +918,18 @@ "on": { "type": "string", "title": "Trigger", - "description": "When the task runs. preDeploy and postDeploy run on every install and upgrade. manual runs only via the CLI.", + "description": "When the task runs. preDeploy and postDeploy run on every install and upgrade. schedule creates a CronJob. manual runs only via the CLI.", "enum": [ "preDeploy", "postDeploy", - "manual" + "manual", + "schedule" ] }, "after": { "type": "array", "title": "After", - "description": "Task names that must finish first in the same on phase. Not allowed on manual tasks.", + "description": "Task names that must finish first in the same on phase. Not allowed on manual or schedule tasks.", "items": { "type": "string", "minLength": 1 @@ -991,7 +992,7 @@ }, "fanout": { "title": "Fanout", - "description": "How many indexed copies to run. Integer shorthand (count, parallelism 1) or an object. Applies to preDeploy, postDeploy, and manual.", + "description": "How many indexed copies to run. Integer shorthand (count, parallelism 1) or an object. Applies to preDeploy, postDeploy, manual, and schedule.", "oneOf": [ { "type": "integer", @@ -1005,9 +1006,36 @@ "timeout": { "type": "string", "title": "Timeout", - "description": "How long a single run may take. Defaults to 5m for preDeploy and postDeploy. Hook timeout must be less than the CLI --timeout used at deploy or run time (default 10m).", + "description": "How long a single run may take. Defaults to 5m for preDeploy and postDeploy. Omitted schedule tasks get 1h on the CronJob only; deployah run does not apply that default. Hook timeout must be less than the CLI --timeout used at deploy or run time (default 10m).", "pattern": "^[1-9][0-9]*(s|m|h)$" }, + "schedule": { + "type": "string", + "title": "Schedule", + "description": "Cron expression or robfig descriptor (@hourly, @daily, @every 1h). Required when on is schedule. TZ and CRON_TZ prefixes are not allowed; use timeZone.", + "minLength": 1 + }, + "timeZone": { + "type": "string", + "title": "Time Zone", + "description": "IANA time zone for the schedule. Defaults to Etc/UTC. Requires Kubernetes 1.27 or newer when set to a value other than Etc/UTC.", + "minLength": 1 + }, + "concurrencyPolicy": { + "type": "string", + "title": "Concurrency Policy", + "description": "What to do when a run is still active at the next tick. Defaults to Forbid.", + "enum": [ + "Allow", + "Forbid", + "Replace" + ] + }, + "suspend": { + "type": "boolean", + "title": "Suspend", + "description": "When true, the CronJob does not create Jobs until set back to false." + }, "backoffLimit": { "type": "integer", "title": "Backoff Limit", diff --git a/internal/spec/task.go b/internal/spec/task.go index f765854..c2f357b 100644 --- a/internal/spec/task.go +++ b/internal/spec/task.go @@ -32,6 +32,9 @@ const ( TaskOnPostDeploy TaskOn = "postDeploy" // TaskOnManual runs the task only via the CLI. TaskOnManual TaskOn = "manual" + // TaskOnSchedule runs the task on a cron schedule as a Kubernetes + // CronJob. + TaskOnSchedule TaskOn = "schedule" ) // IsHook reports whether o is a deploy hook (preDeploy or postDeploy). @@ -39,6 +42,11 @@ func (o TaskOn) IsHook() bool { return o == TaskOnPreDeploy || o == TaskOnPostDeploy } +// IsScheduled reports whether o is a CronJob schedule trigger. +func (o TaskOn) IsScheduled() bool { + return o == TaskOnSchedule +} + // Task is run-to-completion work in a spec. type Task struct { // From names a component whose env, envFile, configFile, environments, @@ -56,7 +64,7 @@ type Task struct { // On selects when the task runs. Required. On TaskOn `json:"on" yaml:"on"` // After lists task names that must finish first in the same On phase. - // Not allowed when On is manual. + // Not allowed when On is manual or schedule. After []string `json:"after,omitempty" yaml:"after,omitempty"` // Env overlays inherited environment variables. Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` @@ -81,6 +89,20 @@ type Task struct { Fanout Fanout `json:"fanout,omitzero" yaml:"fanout,omitempty"` // Timeout is how long a single run may take (for example "5m"). Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` + // Schedule is a 5-field cron expression or a robfig descriptor such as + // @daily or @every 1h. Required when On is [TaskOnSchedule]. + Schedule string `json:"schedule,omitempty" yaml:"schedule,omitempty"` + // TimeZone is an IANA time zone for Schedule. Empty means + // [DefaultScheduleTimeZone] after defaults. Only valid when On is + // [TaskOnSchedule]. + TimeZone string `json:"timeZone,omitempty" yaml:"timeZone,omitempty"` + // ConcurrencyPolicy is Allow, Forbid, or Replace. Empty means + // [DefaultConcurrencyPolicy] after defaults. Only valid when On is + // [TaskOnSchedule]. + ConcurrencyPolicy string `json:"concurrencyPolicy,omitempty" yaml:"concurrencyPolicy,omitempty"` + // Suspend, when true, pauses the CronJob. Nil means false. Only valid + // when On is [TaskOnSchedule]. + Suspend *bool `json:"suspend,omitempty" yaml:"suspend,omitempty"` // BackoffLimit is how many retries are allowed before the run fails. // Nil means [DefaultBackoffLimit]. BackoffLimit *int `json:"backoffLimit,omitempty" yaml:"backoffLimit,omitempty"` @@ -215,7 +237,7 @@ func (t Task) MergeFrom(parent *Component) Task { } // HelmHookEvents returns the Helm hook event list for t.On, or empty for -// manual tasks. +// manual and scheduled tasks. func (t Task) HelmHookEvents() string { switch t.On { case TaskOnPreDeploy: @@ -316,6 +338,3 @@ func (m *Spec) componentRef(name string) *Component { cp := parent return &cp } - -// scheduleOnToken is rejected with a pointer at issue #35. -const scheduleOnToken = "schedule" diff --git a/internal/spec/task_job_test.go b/internal/spec/task_job_test.go index 3b68ab4..4aeffde 100644 --- a/internal/spec/task_job_test.go +++ b/internal/spec/task_job_test.go @@ -77,6 +77,20 @@ func TestNewTaskJobSpec(t *testing.T) { Image: "busybox:1.36", }, }, + { + name: "scheduled empty timeout has no deadline", + task: Task{ + Image: "busybox:1.36", + On: TaskOnSchedule, + Schedule: "0 3 * * *", + }, + want: TaskJobSpec{ + Completions: 1, + Parallelism: 1, + BackoffLimit: int32(DefaultBackoffLimit), + Image: "busybox:1.36", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/spec/task_test.go b/internal/spec/task_test.go index 65434bc..96b090e 100644 --- a/internal/spec/task_test.go +++ b/internal/spec/task_test.go @@ -493,6 +493,7 @@ func TestTask_HelmHookEvents(t *testing.T) { {name: "preDeploy", on: TaskOnPreDeploy, want: "pre-install,pre-upgrade"}, {name: "postDeploy", on: TaskOnPostDeploy, want: "post-install,post-upgrade"}, {name: "manual", on: TaskOnManual, want: ""}, + {name: "schedule", on: TaskOnSchedule, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -542,11 +543,114 @@ func TestValidateSpecTasks(t *testing.T) { wantErr: "on is required", }, { - name: "on schedule points at issue 35", + name: "valid scheduled task", spec: shopSpec(map[string]Task{ - "nightly": {From: "api", On: TaskOn("schedule"), Command: []string{"true"}}, + "cleanup": { + From: "api", + On: TaskOnSchedule, + Schedule: "0 3 * * *", + Command: []string{"cleanup"}, + }, + }), + }, + { + name: "valid scheduled task with all fields", + spec: shopSpec(map[string]Task{ + "cleanup": { + From: "api", + On: TaskOnSchedule, + Schedule: "0 3 * * *", + TimeZone: "Etc/UTC", + ConcurrencyPolicy: "Allow", + Suspend: new(true), + Timeout: "30m", + Command: []string{"cleanup"}, + }, + }), + }, + { + name: "scheduled missing schedule", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Command: []string{"true"}}, + }), + wantErr: "schedule is required", + }, + { + name: "scheduled invalid cron", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "0 0", Command: []string{"true"}}, + }), + wantErr: "expected exactly 5 fields", + }, + { + name: "scheduled CRON_TZ rejected", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "CRON_TZ=UTC 0 3 * * *", Command: []string{"true"}}, + }), + wantErr: "timeZone field", + }, + { + name: "scheduled TZ equals zero does not panic", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "TZ=0", Command: []string{"true"}}, + }), + wantErr: "timeZone field", + }, + { + name: "after on scheduled is rejected", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "0 3 * * *", After: []string{"migrate"}, Command: []string{"true"}}, + }), + wantErr: "after is not allowed on scheduled tasks", + }, + { + name: "invalid concurrencyPolicy", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "0 3 * * *", ConcurrencyPolicy: "forbid", Command: []string{"true"}}, + }), + wantErr: "concurrencyPolicy", + }, + { + name: "invalid timeZone", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "0 3 * * *", TimeZone: "Not/AZone", Command: []string{"true"}}, + }), + wantErr: "timeZone", + }, + { + name: "Local timeZone rejected", + spec: shopSpec(map[string]Task{ + "cleanup": {From: "api", On: TaskOnSchedule, Schedule: "0 3 * * *", TimeZone: "Local", Command: []string{"true"}}, + }), + wantErr: "timeZone", + }, + { + name: "schedule on hook is rejected", + spec: shopSpec(map[string]Task{ + "migrate": {From: "api", On: TaskOnPreDeploy, Schedule: "0 3 * * *", Command: []string{"true"}}, + }), + wantErr: "schedule is only valid when on is schedule", + }, + { + name: "timeZone on hook is rejected", + spec: shopSpec(map[string]Task{ + "migrate": {From: "api", On: TaskOnPreDeploy, TimeZone: "Etc/UTC", Command: []string{"true"}}, + }), + wantErr: "timeZone is only valid when on is schedule", + }, + { + name: "concurrencyPolicy on hook is rejected", + spec: shopSpec(map[string]Task{ + "migrate": {From: "api", On: TaskOnPreDeploy, ConcurrencyPolicy: "Forbid", Command: []string{"true"}}, + }), + wantErr: "concurrencyPolicy is only valid when on is schedule", + }, + { + name: "suspend on hook is rejected", + spec: shopSpec(map[string]Task{ + "migrate": {From: "api", On: TaskOnPreDeploy, Suspend: new(true), Command: []string{"true"}}, }), - wantErr: "issues/35", + wantErr: "suspend is only valid when on is schedule", }, { name: "on invalid value", @@ -760,6 +864,19 @@ func TestValidateSpecTasks(t *testing.T) { }), wantErr: "is invalid", }, + { + name: "task name at max length", + spec: shopSpec(map[string]Task{ + "abcdefghijklmnopqrstuvwxyz1234": {From: "api", On: TaskOnManual, Command: []string{"true"}}, + }), + }, + { + name: "task name too long", + spec: shopSpec(map[string]Task{ + "task-name-that-is-too-long-here": {From: "api", On: TaskOnManual, Command: []string{"true"}}, + }), + wantErr: "at most 30 characters", + }, { name: "after contains an empty name", spec: shopSpec(map[string]Task{ @@ -853,6 +970,52 @@ func TestValidateSpecTasks_Nil(t *testing.T) { assert.Contains(t, err.Error(), "spec cannot be nil") } +func Test_validateCronSchedule(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schedule string + wantErr string + }{ + {name: "five field every minute", schedule: "* * * * *"}, + {name: "five field daily", schedule: "0 3 * * *"}, + {name: "vixie step minutes", schedule: "*/5 * * * *"}, + {name: "vixie range step hours", schedule: "0 0-23/2 * * *"}, + {name: "macro hourly", schedule: "@hourly"}, + {name: "macro daily", schedule: "@daily"}, + {name: "macro midnight", schedule: "@midnight"}, + {name: "macro weekly", schedule: "@weekly"}, + {name: "macro monthly", schedule: "@monthly"}, + {name: "macro yearly", schedule: "@yearly"}, + {name: "macro annually", schedule: "@annually"}, + {name: "every duration", schedule: "@every 1h"}, + {name: "question mark day of month", schedule: "0 0 ? * *"}, + {name: "question mark day of week", schedule: "0 0 * * ?"}, + {name: "named weekday", schedule: "0 0 * * sun"}, + {name: "named weekday saturday", schedule: "0 0 * * sat"}, + {name: "empty", schedule: "", wantErr: "schedule is required"}, + {name: "CRON_TZ prefix", schedule: "CRON_TZ=UTC 0 3 * * *", wantErr: "timeZone field"}, + {name: "TZ prefix", schedule: "TZ=UTC 0 3 * * *", wantErr: "timeZone field"}, + {name: "TZ equals zero does not panic", schedule: "TZ=0", wantErr: "timeZone field"}, + {name: "too few fields", schedule: "0 0", wantErr: "expected exactly 5 fields"}, + {name: "not a cron expression", schedule: "every night", wantErr: "expected exactly 5 fields"}, + {name: "reboot descriptor", schedule: "@reboot", wantErr: "unrecognized descriptor"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateCronSchedule(tt.schedule) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + func TestCheckHookTaskTimeouts(t *testing.T) { t.Parallel() @@ -924,6 +1087,17 @@ func TestCheckTaskTimeout(t *testing.T) { limit: 5 * time.Minute, wantErr: DefaultHookTaskTimeout, }, + { + name: "scheduled empty timeout skipped", + task: Task{On: TaskOnSchedule}, + limit: 10 * time.Minute, + }, + { + name: "scheduled explicit timeout over session", + task: Task{On: TaskOnSchedule, Timeout: "1h"}, + limit: 10 * time.Minute, + wantErr: "use --detach or raise --timeout", + }, { name: "non-positive limit", task: Task{On: TaskOnManual, Timeout: "1m"}, @@ -983,3 +1157,22 @@ func TestValidateAPIVersion_SupportedVersions(t *testing.T) { assert.Contains(t, err.Error(), "unsupported spec schema version") assert.Contains(t, err.Error(), "v1-alpha.4") } + +func TestFillSpecWithDefaults_ScheduledTask(t *testing.T) { + t.Parallel() + + m := shopSpec(map[string]Task{ + "cleanup": { + From: "api", + On: TaskOnSchedule, + Schedule: "0 3 * * *", + Command: []string{"cleanup"}, + }, + }) + require.NoError(t, FillSpecWithDefaults(m, CurrentManifestVersion)) + task := m.Tasks["cleanup"] + assert.Equal(t, DefaultConcurrencyPolicy, task.ConcurrencyPolicy) + assert.Equal(t, DefaultScheduleTimeZone, task.TimeZone) + assert.Empty(t, task.Timeout) + assert.Equal(t, DefaultBackoffLimit, *task.BackoffLimit) +} diff --git a/internal/spec/task_validate.go b/internal/spec/task_validate.go index f254c55..7152efc 100644 --- a/internal/spec/task_validate.go +++ b/internal/spec/task_validate.go @@ -20,10 +20,13 @@ import ( "slices" "strings" "time" + + "github.com/robfig/cron/v3" ) -// ValidateSpecTasks validates all tasks in spec: name pool, from, on, -// after, fanout, command, timeout, and environment filter. +// ValidateSpecTasks validates all tasks in spec: name pool and length, from, +// on, after, schedule fields, fanout, command, timeout, and environment +// filter. func ValidateSpecTasks(spec *Spec) error { if spec == nil { return fmt.Errorf("spec cannot be nil") @@ -38,6 +41,9 @@ func ValidateSpecTasks(spec *Spec) error { if err := ValidateComponentName(name); err != nil { errs = append(errs, fmt.Errorf("task %s: %w", name, err)) } + if len(name) > MaxTaskNameLength { + errs = append(errs, fmt.Errorf("task %s: name must be at most %d characters", name, MaxTaskNameLength)) + } if _, exists := spec.Components[name]; exists { errs = append(errs, fmt.Errorf("task %s: name collides with a component", name)) } @@ -68,17 +74,44 @@ func validateTask(name string, task Task, spec *Spec) error { } switch task.On { - case TaskOnPreDeploy, TaskOnPostDeploy, TaskOnManual: - case TaskOn(scheduleOnToken): - errs = append(errs, fmt.Errorf("%s: on: schedule is not supported yet (see https://github.com/deployah-dev/deployah/issues/35)", prefix)) + case TaskOnPreDeploy, TaskOnPostDeploy, TaskOnManual, TaskOnSchedule: case "": - errs = append(errs, fmt.Errorf("%s: on is required (preDeploy, postDeploy, or manual)", prefix)) + errs = append(errs, fmt.Errorf("%s: on is required (preDeploy, postDeploy, manual, or schedule)", prefix)) default: - errs = append(errs, fmt.Errorf("%s: on %q is invalid (preDeploy, postDeploy, or manual)", prefix, task.On)) + errs = append(errs, fmt.Errorf("%s: on %q is invalid (preDeploy, postDeploy, manual, or schedule)", prefix, task.On)) } - if len(task.After) > 0 && task.On == TaskOnManual { - errs = append(errs, fmt.Errorf("%s: after is not allowed on manual tasks", prefix)) + if task.On.IsScheduled() { + if err := validateCronSchedule(task.Schedule); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", prefix, err)) + } + if err := validateTaskTimeZone(task.TimeZone); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", prefix, err)) + } + if err := validateConcurrencyPolicy(task.ConcurrencyPolicy); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", prefix, err)) + } + } else { + if task.Schedule != "" { + errs = append(errs, fmt.Errorf("%s: schedule is only valid when on is schedule", prefix)) + } + if task.TimeZone != "" { + errs = append(errs, fmt.Errorf("%s: timeZone is only valid when on is schedule", prefix)) + } + if task.ConcurrencyPolicy != "" { + errs = append(errs, fmt.Errorf("%s: concurrencyPolicy is only valid when on is schedule", prefix)) + } + if task.Suspend != nil { + errs = append(errs, fmt.Errorf("%s: suspend is only valid when on is schedule", prefix)) + } + } + + if len(task.After) > 0 && (task.On == TaskOnManual || task.On.IsScheduled()) { + if task.On.IsScheduled() { + errs = append(errs, fmt.Errorf("%s: after is not allowed on scheduled tasks", prefix)) + } else { + errs = append(errs, fmt.Errorf("%s: after is not allowed on manual tasks", prefix)) + } } for _, dep := range task.After { if strings.TrimSpace(dep) == "" { @@ -144,6 +177,52 @@ func validateTask(name string, task Task, spec *Spec) error { return nil } +// validateCronSchedule mirrors Kubernetes CronJob schedule validation: a +// "TZ" substring check, then [cron.ParseStandard]. The parse is wrapped +// because cron.ParseStandard panics on malformed input such as "TZ=0". +func validateCronSchedule(schedule string) (err error) { + if schedule == "" { + return errors.New("schedule is required when on is schedule") + } + if strings.Contains(schedule, "TZ") { + return fmt.Errorf("schedule %q: TZ and CRON_TZ are not supported; use the timeZone field", schedule) + } + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("schedule %q: invalid format: %v", schedule, r) + } + }() + if _, perr := cron.ParseStandard(schedule); perr != nil { + return fmt.Errorf("schedule %q: %w", schedule, perr) + } + return nil +} + +func validateTaskTimeZone(tz string) error { + if tz == "" { + return nil + } + if tz == "Local" { + return fmt.Errorf("timeZone %q is not a valid IANA name", tz) + } + if _, err := time.LoadLocation(tz); err != nil { + return fmt.Errorf("timeZone %q is not a valid IANA name: %w", tz, err) + } + return nil +} + +func validateConcurrencyPolicy(policy string) error { + if policy == "" { + return nil + } + switch policy { + case "Allow", "Forbid", "Replace": + return nil + default: + return fmt.Errorf("concurrencyPolicy %q is invalid (Allow, Forbid, or Replace)", policy) + } +} + func validateTaskAfterGraph(spec *Spec) error { var errs []error tasks := spec.Tasks @@ -220,10 +299,10 @@ func CheckHookTaskTimeouts(tasks map[string]Task, limit time.Duration) error { // CheckTaskTimeout reports when task's timeout is not strictly less than // limit (the CLI --timeout). An empty timeout on a hook is treated as -// [DefaultHookTaskTimeout]. An empty timeout on a manual task is skipped -// because the Job has no deadline. When the timeout exceeds limit, the -// error names --detach and --timeout so the caller can wait in the -// background or raise the session limit. +// [DefaultHookTaskTimeout]. An empty timeout on a manual or scheduled +// task is skipped because the Job has no deadline. When the timeout +// exceeds limit, the error names --detach and --timeout so the caller +// can wait in the background or raise the session limit. func CheckTaskTimeout(name string, task Task, limit time.Duration) error { if limit <= 0 { return fmt.Errorf("session timeout must be a positive duration") diff --git a/main.go b/main.go index ff5cb89..44ccc43 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,11 @@ // Command deployah is the Deployah CLI entry point. package main -import "deployah.dev/deployah/internal/cmd" +import ( + "deployah.dev/deployah/internal/cmd" + + _ "time/tzdata" // embedded IANA database; time prefers system data when present +) //go:generate go run ./internal/tools/gendocs diff --git a/scenarios/error-task-bad-on/deployah.yaml b/scenarios/error-task-bad-on/deployah.yaml index 206c856..25de6c4 100644 --- a/scenarios/error-task-bad-on/deployah.yaml +++ b/scenarios/error-task-bad-on/deployah.yaml @@ -9,7 +9,7 @@ components: tasks: nightly: from: api - "on": schedule + "on": whenever command: ["true"] environments: dev: {} diff --git a/scenarios/error-task-name-too-long/deployah.yaml b/scenarios/error-task-name-too-long/deployah.yaml new file mode 100644 index 0000000..8447042 --- /dev/null +++ b/scenarios/error-task-name-too-long/deployah.yaml @@ -0,0 +1,16 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: error-task-name-too-long +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] +tasks: + task-name-that-is-too-long-here: + from: api + "on": schedule + schedule: "0 3 * * *" + command: ["true"] +environments: + dev: {} diff --git a/scenarios/error-task-name-too-long/error-config.yaml b/scenarios/error-task-name-too-long/error-config.yaml new file mode 100644 index 0000000..bbb9b7a --- /dev/null +++ b/scenarios/error-task-name-too-long/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "maxLength" diff --git a/scenarios/error-task-schedule-after/deployah.yaml b/scenarios/error-task-schedule-after/deployah.yaml new file mode 100644 index 0000000..07cf39c --- /dev/null +++ b/scenarios/error-task-schedule-after/deployah.yaml @@ -0,0 +1,17 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: error-task-schedule-after +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] +tasks: + cleanup: + from: api + "on": schedule + schedule: "0 3 * * *" + after: [migrate] + command: ["true"] +environments: + dev: {} diff --git a/scenarios/error-task-schedule-after/error-config.yaml b/scenarios/error-task-schedule-after/error-config.yaml new file mode 100644 index 0000000..3f0b894 --- /dev/null +++ b/scenarios/error-task-schedule-after/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "after is not allowed on scheduled tasks" diff --git a/scenarios/error-task-schedule-cron/deployah.yaml b/scenarios/error-task-schedule-cron/deployah.yaml new file mode 100644 index 0000000..3431204 --- /dev/null +++ b/scenarios/error-task-schedule-cron/deployah.yaml @@ -0,0 +1,16 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: error-task-schedule-cron +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] +tasks: + cleanup: + from: api + "on": schedule + schedule: "0 0" + command: ["true"] +environments: + dev: {} diff --git a/scenarios/error-task-schedule-cron/error-config.yaml b/scenarios/error-task-schedule-cron/error-config.yaml new file mode 100644 index 0000000..f958a60 --- /dev/null +++ b/scenarios/error-task-schedule-cron/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "expected exactly 5 fields" diff --git a/scenarios/error-task-schedule-crontz/deployah.yaml b/scenarios/error-task-schedule-crontz/deployah.yaml new file mode 100644 index 0000000..c007ea5 --- /dev/null +++ b/scenarios/error-task-schedule-crontz/deployah.yaml @@ -0,0 +1,16 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: error-task-schedule-crontz +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] +tasks: + cleanup: + from: api + "on": schedule + schedule: "CRON_TZ=UTC 0 3 * * *" + command: ["true"] +environments: + dev: {} diff --git a/scenarios/error-task-schedule-crontz/error-config.yaml b/scenarios/error-task-schedule-crontz/error-config.yaml new file mode 100644 index 0000000..36cc9ae --- /dev/null +++ b/scenarios/error-task-schedule-crontz/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "timeZone field" diff --git a/scenarios/error-task-schedule-missing/deployah.yaml b/scenarios/error-task-schedule-missing/deployah.yaml new file mode 100644 index 0000000..be49b47 --- /dev/null +++ b/scenarios/error-task-schedule-missing/deployah.yaml @@ -0,0 +1,15 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: error-task-schedule-missing +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] +tasks: + cleanup: + from: api + "on": schedule + command: ["true"] +environments: + dev: {} diff --git a/scenarios/error-task-schedule-missing/error-config.yaml b/scenarios/error-task-schedule-missing/error-config.yaml new file mode 100644 index 0000000..dcbe263 --- /dev/null +++ b/scenarios/error-task-schedule-missing/error-config.yaml @@ -0,0 +1,2 @@ +expectedErrors: + - "schedule is required" diff --git a/scenarios/task-schedule-basic/deployah.yaml b/scenarios/task-schedule-basic/deployah.yaml new file mode 100644 index 0000000..92eada2 --- /dev/null +++ b/scenarios/task-schedule-basic/deployah.yaml @@ -0,0 +1,17 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: task-schedule-basic +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] + resourcePreset: small +tasks: + cleanup: + from: api + "on": schedule + schedule: "0 3 * * *" + command: ["cleanup"] +environments: + dev: {} diff --git a/scenarios/task-schedule-basic/expected/cronjob-task-schedule-basic-dev-cleanup.yaml b/scenarios/task-schedule-basic/expected/cronjob-task-schedule-basic-dev-cleanup.yaml new file mode 100644 index 0000000..2f30b5a --- /dev/null +++ b/scenarios/task-schedule-basic/expected/cronjob-task-schedule-basic-dev-cleanup.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + deployah.dev/project: task-schedule-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cleanup + deployah.dev/component: cleanup + deployah.dev/environment: dev + deployah.dev/project: task-schedule-basic + helm.sh/chart: cleanup-0.1.0 + name: task-schedule-basic-dev-cleanup + namespace: default +spec: + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 3600 + backoffLimit: 3 + completionMode: Indexed + completions: 1 + parallelism: 1 + template: + metadata: + labels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cleanup + deployah.dev/component: cleanup + deployah.dev/environment: dev + deployah.dev/project: task-schedule-basic + helm.sh/chart: cleanup-0.1.0 + spec: + automountServiceAccountToken: false + containers: + - command: + - cleanup + image: docker.io/library/nginx:latest + imagePullPolicy: Always + name: cleanup + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: OnFailure + schedule: 0 3 * * * + successfulJobsHistoryLimit: 3 + suspend: false + timeZone: Etc/UTC diff --git a/scenarios/task-schedule-basic/expected/deployment-task-schedule-basic-dev-api.yaml b/scenarios/task-schedule-basic/expected/deployment-task-schedule-basic-dev-api.yaml new file mode 100644 index 0000000..9846ec3 --- /dev/null +++ b/scenarios/task-schedule-basic/expected/deployment-task-schedule-basic-dev-api.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: task-schedule-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-basic + helm.sh/chart: api-0.1.0 + name: task-schedule-basic-dev-api + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/name: api + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-basic + helm.sh/chart: api-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/name: api + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/nginx:latest + imagePullPolicy: Always + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: api + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/task-schedule-basic/expected/service-task-schedule-basic-dev-api.yaml b/scenarios/task-schedule-basic/expected/service-task-schedule-basic-dev-api.yaml new file mode 100644 index 0000000..1aebdb0 --- /dev/null +++ b/scenarios/task-schedule-basic/expected/service-task-schedule-basic-dev-api.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: task-schedule-basic + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-basic + helm.sh/chart: api-0.1.0 + name: task-schedule-basic-dev-api + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: task-schedule-basic-dev + app.kubernetes.io/name: api + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/task-schedule-full/deployah.yaml b/scenarios/task-schedule-full/deployah.yaml new file mode 100644 index 0000000..b5d3d07 --- /dev/null +++ b/scenarios/task-schedule-full/deployah.yaml @@ -0,0 +1,24 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: task-schedule-full +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] + resourcePreset: small + env: + DATABASE_URL: postgres://db +tasks: + cleanup: + from: api + "on": schedule + schedule: "0 3 * * *" + timeZone: Etc/UTC + concurrencyPolicy: Allow + timeout: 30m + suspend: true + fanout: 2 + command: ["cleanup"] +environments: + dev: {} diff --git a/scenarios/task-schedule-full/expected/cronjob-task-schedule-full-dev-cleanup.yaml b/scenarios/task-schedule-full/expected/cronjob-task-schedule-full-dev-cleanup.yaml new file mode 100644 index 0000000..9118291 --- /dev/null +++ b/scenarios/task-schedule-full/expected/cronjob-task-schedule-full-dev-cleanup.yaml @@ -0,0 +1,58 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + deployah.dev/project: task-schedule-full + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cleanup + deployah.dev/component: cleanup + deployah.dev/environment: dev + deployah.dev/project: task-schedule-full + helm.sh/chart: cleanup-0.1.0 + name: task-schedule-full-dev-cleanup + namespace: default +spec: + concurrencyPolicy: Allow + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 1800 + backoffLimit: 3 + completionMode: Indexed + completions: 2 + parallelism: 1 + template: + metadata: + labels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cleanup + deployah.dev/component: cleanup + deployah.dev/environment: dev + deployah.dev/project: task-schedule-full + helm.sh/chart: cleanup-0.1.0 + spec: + automountServiceAccountToken: false + containers: + - command: + - cleanup + env: + - name: DATABASE_URL + value: postgres://db + image: docker.io/library/nginx:latest + imagePullPolicy: Always + name: cleanup + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: OnFailure + schedule: 0 3 * * * + successfulJobsHistoryLimit: 3 + suspend: true + timeZone: Etc/UTC diff --git a/scenarios/task-schedule-full/expected/deployment-task-schedule-full-dev-api.yaml b/scenarios/task-schedule-full/expected/deployment-task-schedule-full-dev-api.yaml new file mode 100644 index 0000000..8a08ab2 --- /dev/null +++ b/scenarios/task-schedule-full/expected/deployment-task-schedule-full-dev-api.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: task-schedule-full + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-full + helm.sh/chart: api-0.1.0 + name: task-schedule-full-dev-api + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/name: api + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-full + helm.sh/chart: api-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/name: api + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/nginx:latest + imagePullPolicy: Always + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: api + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/task-schedule-full/expected/service-task-schedule-full-dev-api.yaml b/scenarios/task-schedule-full/expected/service-task-schedule-full-dev-api.yaml new file mode 100644 index 0000000..ed332ac --- /dev/null +++ b/scenarios/task-schedule-full/expected/service-task-schedule-full-dev-api.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: task-schedule-full + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-full + helm.sh/chart: api-0.1.0 + name: task-schedule-full-dev-api + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: task-schedule-full-dev + app.kubernetes.io/name: api + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/task-schedule-longname/deployah.yaml b/scenarios/task-schedule-longname/deployah.yaml new file mode 100644 index 0000000..00e2a14 --- /dev/null +++ b/scenarios/task-schedule-longname/deployah.yaml @@ -0,0 +1,17 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: longnameproj +components: + api: + image: nginx:latest + port: 8080 + environments: [review-feature-add-new-checkout-flow] + resourcePreset: small +tasks: + cleanup: + from: api + "on": schedule + schedule: "0 3 * * *" + command: ["cleanup"] +environments: + review-feature-add-new-checkout-flow: {} diff --git a/scenarios/task-schedule-longname/expected/cronjob-longnameproj-review-feature-add-new-che-c287-cleanup.yaml b/scenarios/task-schedule-longname/expected/cronjob-longnameproj-review-feature-add-new-che-c287-cleanup.yaml new file mode 100644 index 0000000..3640b2b --- /dev/null +++ b/scenarios/task-schedule-longname/expected/cronjob-longnameproj-review-feature-add-new-che-c287-cleanup.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + deployah.dev/project: longnameproj + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cleanup + deployah.dev/component: cleanup + deployah.dev/environment: review-feature-add-new-checkout-flow + deployah.dev/project: longnameproj + helm.sh/chart: cleanup-0.1.0 + name: longnameproj-review-feature-add-new-che-c287-cleanup + namespace: default +spec: + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 3600 + backoffLimit: 3 + completionMode: Indexed + completions: 1 + parallelism: 1 + template: + metadata: + labels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: cleanup + deployah.dev/component: cleanup + deployah.dev/environment: review-feature-add-new-checkout-flow + deployah.dev/project: longnameproj + helm.sh/chart: cleanup-0.1.0 + spec: + automountServiceAccountToken: false + containers: + - command: + - cleanup + image: docker.io/library/nginx:latest + imagePullPolicy: Always + name: cleanup + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: OnFailure + schedule: 0 3 * * * + successfulJobsHistoryLimit: 3 + suspend: false + timeZone: Etc/UTC diff --git a/scenarios/task-schedule-longname/expected/deployment-longnameproj-review-feature-add-new-checkout-flow-api.yaml b/scenarios/task-schedule-longname/expected/deployment-longnameproj-review-feature-add-new-checkout-flow-api.yaml new file mode 100644 index 0000000..e437743 --- /dev/null +++ b/scenarios/task-schedule-longname/expected/deployment-longnameproj-review-feature-add-new-checkout-flow-api.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: longnameproj + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: review-feature-add-new-checkout-flow + deployah.dev/project: longnameproj + helm.sh/chart: api-0.1.0 + name: longnameproj-review-feature-add-new-checkout-flow-api + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/name: api + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: review-feature-add-new-checkout-flow + deployah.dev/project: longnameproj + helm.sh/chart: api-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/name: api + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/nginx:latest + imagePullPolicy: Always + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: api + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/task-schedule-longname/expected/service-longnameproj-review-feature-add-new-checkout-flow-api.yaml b/scenarios/task-schedule-longname/expected/service-longnameproj-review-feature-add-new-checkout-flow-api.yaml new file mode 100644 index 0000000..051372b --- /dev/null +++ b/scenarios/task-schedule-longname/expected/service-longnameproj-review-feature-add-new-checkout-flow-api.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: longnameproj + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: review-feature-add-new-checkout-flow + deployah.dev/project: longnameproj + helm.sh/chart: api-0.1.0 + name: longnameproj-review-feature-add-new-checkout-flow-api + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: longnameproj-review-feature-add-new-checkout-flow + app.kubernetes.io/name: api + sessionAffinity: None + type: ClusterIP diff --git a/scenarios/task-schedule-macros/deployah.yaml b/scenarios/task-schedule-macros/deployah.yaml new file mode 100644 index 0000000..0dd90a3 --- /dev/null +++ b/scenarios/task-schedule-macros/deployah.yaml @@ -0,0 +1,27 @@ +# $schema: ../../internal/spec/schema/v1-alpha.5/manifest.json +apiVersion: v1-alpha.5 +project: task-schedule-macros +components: + api: + image: nginx:latest + port: 8080 + environments: [dev] + resourcePreset: small +tasks: + daily: + from: api + "on": schedule + schedule: "@daily" + command: ["true"] + hourly: + from: api + "on": schedule + schedule: "@hourly" + command: ["true"] + every: + from: api + "on": schedule + schedule: "@every 1h" + command: ["true"] +environments: + dev: {} diff --git a/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-daily.yaml b/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-daily.yaml new file mode 100644 index 0000000..cb98d65 --- /dev/null +++ b/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-daily.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + deployah.dev/project: task-schedule-macros + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: daily + deployah.dev/component: daily + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: daily-0.1.0 + name: task-schedule-macros-dev-daily + namespace: default +spec: + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 3600 + backoffLimit: 3 + completionMode: Indexed + completions: 1 + parallelism: 1 + template: + metadata: + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: daily + deployah.dev/component: daily + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: daily-0.1.0 + spec: + automountServiceAccountToken: false + containers: + - command: + - "true" + image: docker.io/library/nginx:latest + imagePullPolicy: Always + name: daily + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: OnFailure + schedule: '@daily' + successfulJobsHistoryLimit: 3 + suspend: false + timeZone: Etc/UTC diff --git a/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-every.yaml b/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-every.yaml new file mode 100644 index 0000000..2f4e76e --- /dev/null +++ b/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-every.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + deployah.dev/project: task-schedule-macros + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: every + deployah.dev/component: every + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: every-0.1.0 + name: task-schedule-macros-dev-every + namespace: default +spec: + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 3600 + backoffLimit: 3 + completionMode: Indexed + completions: 1 + parallelism: 1 + template: + metadata: + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: every + deployah.dev/component: every + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: every-0.1.0 + spec: + automountServiceAccountToken: false + containers: + - command: + - "true" + image: docker.io/library/nginx:latest + imagePullPolicy: Always + name: every + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: OnFailure + schedule: '@every 1h' + successfulJobsHistoryLimit: 3 + suspend: false + timeZone: Etc/UTC diff --git a/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-hourly.yaml b/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-hourly.yaml new file mode 100644 index 0000000..8aa64a6 --- /dev/null +++ b/scenarios/task-schedule-macros/expected/cronjob-task-schedule-macros-dev-hourly.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + deployah.dev/project: task-schedule-macros + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: hourly + deployah.dev/component: hourly + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: hourly-0.1.0 + name: task-schedule-macros-dev-hourly + namespace: default +spec: + concurrencyPolicy: Forbid + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 3600 + backoffLimit: 3 + completionMode: Indexed + completions: 1 + parallelism: 1 + template: + metadata: + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: hourly + deployah.dev/component: hourly + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: hourly-0.1.0 + spec: + automountServiceAccountToken: false + containers: + - command: + - "true" + image: docker.io/library/nginx:latest + imagePullPolicy: Always + name: hourly + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + restartPolicy: OnFailure + schedule: '@hourly' + successfulJobsHistoryLimit: 3 + suspend: false + timeZone: Etc/UTC diff --git a/scenarios/task-schedule-macros/expected/deployment-task-schedule-macros-dev-api.yaml b/scenarios/task-schedule-macros/expected/deployment-task-schedule-macros-dev-api.yaml new file mode 100644 index 0000000..046ae56 --- /dev/null +++ b/scenarios/task-schedule-macros/expected/deployment-task-schedule-macros-dev-api.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployah.dev/project: task-schedule-macros + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: api-0.1.0 + name: task-schedule-macros-dev-api + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/name: api + strategy: + type: RollingUpdate + template: + metadata: + annotations: null + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: api-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/name: api + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - image: docker.io/library/nginx:latest + imagePullPolicy: Always + livenessProbe: + failureThreshold: 6 + periodSeconds: 10 + tcpSocket: + port: http + timeoutSeconds: 3 + name: api + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + resources: + limits: {} + requests: + cpu: 500m + ephemeral-storage: 50Mi + memory: 512Mi + startupProbe: + failureThreshold: 36 + periodSeconds: 5 + tcpSocket: + port: http + timeoutSeconds: 3 + restartPolicy: Always + serviceAccountName: default + terminationGracePeriodSeconds: 30 diff --git a/scenarios/task-schedule-macros/expected/service-task-schedule-macros-dev-api.yaml b/scenarios/task-schedule-macros/expected/service-task-schedule-macros-dev-api.yaml new file mode 100644 index 0000000..0860621 --- /dev/null +++ b/scenarios/task-schedule-macros/expected/service-task-schedule-macros-dev-api.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + deployah.dev/project: task-schedule-macros + deployah.dev/source: spec + labels: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: api + deployah.dev/component: api + deployah.dev/environment: dev + deployah.dev/project: task-schedule-macros + helm.sh/chart: api-0.1.0 + name: task-schedule-macros-dev-api + namespace: default +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/instance: task-schedule-macros-dev + app.kubernetes.io/name: api + sessionAffinity: None + type: ClusterIP