mirror of
https://github.com/dokku/dokku.git
synced 2026-08-29 10:08:53 +02:00
Cron containers were never reaped once they exceeded their active deadline, so a hung cron task ran indefinitely instead of being retired after 24 hours as documented. `cron:run` now also accepts a `--ttl-seconds` argument, matching the one `dokku run` already takes.
320 lines
11 KiB
Go
320 lines
11 KiB
Go
package scheduler_k3s
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
"helm.sh/helm/v3/pkg/chart/loader"
|
|
"helm.sh/helm/v3/pkg/chartutil"
|
|
"helm.sh/helm/v3/pkg/engine"
|
|
)
|
|
|
|
// TestCronIDLabelValue asserts the hashed cron ID fits inside Kubernetes' 63
|
|
// byte label cap, is a fixed 40-character sha1 hex digest, and is
|
|
// deterministic. Without this the dokku.com/cron-hash label can exceed the
|
|
// cap and the Kubernetes API server rejects the manifest.
|
|
func TestCronIDLabelValue(t *testing.T) {
|
|
cases := []string{
|
|
"short",
|
|
"app===echo hello-from-cron===5 5 5 5 5",
|
|
strings.Repeat("a-very-long-cron-id-that-would-far-exceed-the-label-cap-", 10),
|
|
}
|
|
for _, in := range cases {
|
|
out := cronIDLabelValue(in)
|
|
if len(out) != 40 {
|
|
t.Errorf("cronIDLabelValue(%q) = %q (len %d); want 40 hex chars", in, out, len(out))
|
|
}
|
|
if len(out) > 63 {
|
|
t.Errorf("cronIDLabelValue(%q) = %q (len %d); must be <= 63", in, out, len(out))
|
|
}
|
|
if out != cronIDLabelValue(in) {
|
|
t.Errorf("cronIDLabelValue(%q) is not deterministic", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCronJobTemplateQuotesAllDigitSuffix asserts that the rendered cron-job
|
|
// manifest produces string-typed annotation and label values even when the
|
|
// suffix and cron-id consist entirely of digits. Without `| quote` in the
|
|
// template, YAML would coerce these to numbers and the manifest would be
|
|
// rejected by the Kubernetes API server.
|
|
func TestCronJobTemplateQuotesAllDigitSuffix(t *testing.T) {
|
|
manifest := renderCronJobTemplate(t, map[string]interface{}{
|
|
"id": "1234567890",
|
|
"hash": "0123456789abcdef0123456789abcdef01234567",
|
|
"schedule": "5 5 5 5 5",
|
|
"suffix": "1234567890",
|
|
"suspend": false,
|
|
"concurrency_policy": "Allow",
|
|
})
|
|
|
|
forEachManifestMetadata(t, manifest, func(metadata map[string]interface{}) {
|
|
annotations, _ := metadata["annotations"].(map[string]interface{})
|
|
for key, value := range annotations {
|
|
if _, isString := value.(string); !isString {
|
|
t.Errorf("annotation %q has non-string value %v (type %T); helm template must apply | quote", key, value, value)
|
|
}
|
|
}
|
|
labels, _ := metadata["labels"].(map[string]interface{})
|
|
for key, value := range labels {
|
|
if _, isString := value.(string); !isString {
|
|
t.Errorf("label %q has non-string value %v (type %T); helm template must apply | quote", key, value, value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCronJobTemplateEmitsHashLabel asserts that the rendered manifest
|
|
// emits the sha1 hash as the dokku.com/cron-hash label and matching
|
|
// annotation, while keeping the original base36 cron-id in the
|
|
// dokku.com/cron-id annotation so cron:list can still surface it to users.
|
|
func TestCronJobTemplateEmitsHashLabel(t *testing.T) {
|
|
originalID := "app===echo hello-from-cron===5 5 5 5 5"
|
|
hashedID := cronIDLabelValue(originalID)
|
|
|
|
manifest := renderCronJobTemplate(t, map[string]interface{}{
|
|
"id": originalID,
|
|
"hash": hashedID,
|
|
"schedule": "5 5 5 5 5",
|
|
"suffix": "abcde",
|
|
"suspend": false,
|
|
"concurrency_policy": "Allow",
|
|
})
|
|
|
|
sawHashLabel := false
|
|
sawIDAnnotation := false
|
|
forEachManifestMetadata(t, manifest, func(metadata map[string]interface{}) {
|
|
if labels, ok := metadata["labels"].(map[string]interface{}); ok {
|
|
if value, ok := labels["dokku.com/cron-hash"]; ok {
|
|
sawHashLabel = true
|
|
if value != hashedID {
|
|
t.Errorf("dokku.com/cron-hash label = %q, want %q", value, hashedID)
|
|
}
|
|
}
|
|
}
|
|
annotations, _ := metadata["annotations"].(map[string]interface{})
|
|
if value, ok := annotations["dokku.com/cron-hash"]; ok {
|
|
if value != hashedID {
|
|
t.Errorf("dokku.com/cron-hash annotation = %q, want %q (must mirror the label)", value, hashedID)
|
|
}
|
|
}
|
|
if value, ok := annotations["dokku.com/cron-id"]; ok {
|
|
sawIDAnnotation = true
|
|
if value != originalID {
|
|
t.Errorf("dokku.com/cron-id annotation = %q, want %q", value, originalID)
|
|
}
|
|
}
|
|
})
|
|
if !sawHashLabel {
|
|
t.Errorf("rendered manifest did not include a dokku.com/cron-hash label")
|
|
}
|
|
if !sawIDAnnotation {
|
|
t.Errorf("rendered manifest did not include a dokku.com/cron-id annotation")
|
|
}
|
|
}
|
|
|
|
// TestCronJobTemplateRendersLongCronID is the direct regression test for
|
|
// dokku/dokku#8594: a cron-id well over the 63-byte Kubernetes label cap
|
|
// must render cleanly and every resulting label value must stay under the
|
|
// cap.
|
|
func TestCronJobTemplateRendersLongCronID(t *testing.T) {
|
|
longCronID := strings.Repeat("a-very-long-cron-id-that-would-far-exceed-the-label-cap-", 10)
|
|
manifest := renderCronJobTemplate(t, map[string]interface{}{
|
|
"id": longCronID,
|
|
"hash": cronIDLabelValue(longCronID),
|
|
"schedule": "5 5 5 5 5",
|
|
"suffix": "abcde",
|
|
"suspend": false,
|
|
"concurrency_policy": "Allow",
|
|
})
|
|
|
|
forEachManifestMetadata(t, manifest, func(metadata map[string]interface{}) {
|
|
labels, _ := metadata["labels"].(map[string]interface{})
|
|
for key, value := range labels {
|
|
str, ok := value.(string)
|
|
if !ok {
|
|
t.Errorf("label %q has non-string value %v (type %T)", key, value, value)
|
|
continue
|
|
}
|
|
if len(str) > 63 {
|
|
t.Errorf("label %q value %q exceeds Kubernetes' 63-byte cap (len %d)", key, str, len(str))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCronJobTemplateRendersActiveDeadlineSeconds asserts that the job's
|
|
// activeDeadlineSeconds comes from the chart values rather than a literal
|
|
// baked into the template, so the deadline cannot drift from the value the
|
|
// docker-local scheduler stamps onto its cron containers. A release rendered
|
|
// from values that predate the field must still fall back to 24 hours.
|
|
func TestCronJobTemplateRendersActiveDeadlineSeconds(t *testing.T) {
|
|
baseValues := map[string]interface{}{
|
|
"id": "abcde",
|
|
"hash": "0123456789abcdef0123456789abcdef01234567",
|
|
"schedule": "5 5 5 5 5",
|
|
"suffix": "abcde",
|
|
"suspend": false,
|
|
"concurrency_policy": "Allow",
|
|
}
|
|
|
|
t.Run("from values", func(t *testing.T) {
|
|
values := maps.Clone(baseValues)
|
|
values["active_deadline_seconds"] = 600
|
|
if got := renderedActiveDeadlineSeconds(t, renderCronJobTemplate(t, values)); got != 600 {
|
|
t.Errorf("activeDeadlineSeconds = %d, want 600", got)
|
|
}
|
|
})
|
|
|
|
t.Run("defaults when absent", func(t *testing.T) {
|
|
if got := renderedActiveDeadlineSeconds(t, renderCronJobTemplate(t, maps.Clone(baseValues))); got != 86400 {
|
|
t.Errorf("activeDeadlineSeconds = %d, want 86400", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// renderedActiveDeadlineSeconds pulls jobTemplate.spec.activeDeadlineSeconds
|
|
// out of a rendered cron-job manifest.
|
|
func renderedActiveDeadlineSeconds(t *testing.T, manifest string) int {
|
|
t.Helper()
|
|
|
|
var doc map[string]interface{}
|
|
if err := yaml.NewDecoder(strings.NewReader(manifest)).Decode(&doc); err != nil {
|
|
t.Fatalf("yaml decode failed (would also fail in Kubernetes API): %v\nrendered:\n%s", err, manifest)
|
|
}
|
|
|
|
spec, _ := doc["spec"].(map[string]interface{})
|
|
jobTemplate, _ := spec["jobTemplate"].(map[string]interface{})
|
|
jobSpec, ok := jobTemplate["spec"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("jobTemplate.spec missing from rendered manifest:\n%s", manifest)
|
|
}
|
|
|
|
deadline, ok := jobSpec["activeDeadlineSeconds"].(int)
|
|
if !ok {
|
|
t.Fatalf("activeDeadlineSeconds = %v (type %T); want an integer", jobSpec["activeDeadlineSeconds"], jobSpec["activeDeadlineSeconds"])
|
|
}
|
|
|
|
return deadline
|
|
}
|
|
|
|
// renderCronJobTemplate renders templates/chart/cron-job.yaml with the
|
|
// supplied cron-process values and returns the YAML manifest as a string.
|
|
// The chart is materialised in a tempdir alongside the shared _helpers.tpl
|
|
// so the helm engine resolves named templates the same way it does in
|
|
// production.
|
|
func renderCronJobTemplate(t *testing.T, cronValues map[string]interface{}) string {
|
|
t.Helper()
|
|
|
|
chartDir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(chartDir, "templates"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
chartYAML := []byte("apiVersion: v2\nname: test\nversion: 0.0.1\n")
|
|
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), chartYAML, 0o644); err != nil {
|
|
t.Fatalf("write Chart.yaml: %v", err)
|
|
}
|
|
|
|
cronJobTpl, err := templates.ReadFile("templates/chart/cron-job.yaml")
|
|
if err != nil {
|
|
t.Fatalf("read cron-job template: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(chartDir, "templates", "cron-job.yaml"), cronJobTpl, 0o644); err != nil {
|
|
t.Fatalf("write cron-job template: %v", err)
|
|
}
|
|
|
|
helpersTpl, err := templates.ReadFile("templates/chart/_helpers.tpl")
|
|
if err != nil {
|
|
t.Fatalf("read _helpers: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(chartDir, "templates", "_helpers.tpl"), helpersTpl, 0o644); err != nil {
|
|
t.Fatalf("write _helpers: %v", err)
|
|
}
|
|
|
|
loaded, err := loader.Load(chartDir)
|
|
if err != nil {
|
|
t.Fatalf("load chart: %v", err)
|
|
}
|
|
|
|
values := map[string]interface{}{
|
|
"global": map[string]interface{}{
|
|
"app_name": "myapp",
|
|
"deployment_id": "1",
|
|
"namespace": "myapp",
|
|
"image": map[string]interface{}{
|
|
"name": "myapp:latest",
|
|
"type": "dockerfile",
|
|
},
|
|
},
|
|
"processes": map[string]interface{}{
|
|
"cron-process": map[string]interface{}{
|
|
"args": []interface{}{"echo", "hello"},
|
|
"cron": cronValues,
|
|
},
|
|
},
|
|
}
|
|
|
|
renderValues, err := chartutil.ToRenderValues(loaded, values, chartutil.ReleaseOptions{Name: "test", Namespace: "default"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("ToRenderValues: %v", err)
|
|
}
|
|
|
|
rendered, err := engine.Render(loaded, renderValues)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
|
|
for name, content := range rendered {
|
|
if filepath.Base(name) == "cron-job.yaml" {
|
|
return content
|
|
}
|
|
}
|
|
t.Fatalf("cron-job.yaml not rendered; got: %v", rendered)
|
|
return ""
|
|
}
|
|
|
|
// forEachManifestMetadata invokes fn on every metadata block in a multi-doc
|
|
// YAML manifest, including the nested jobTemplate and pod template metadata
|
|
// blocks inside a CronJob spec.
|
|
func forEachManifestMetadata(t *testing.T, manifest string, fn func(metadata map[string]interface{})) {
|
|
t.Helper()
|
|
|
|
decoder := yaml.NewDecoder(strings.NewReader(manifest))
|
|
for {
|
|
var doc map[string]interface{}
|
|
if err := decoder.Decode(&doc); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return
|
|
}
|
|
t.Fatalf("yaml decode failed (would also fail in Kubernetes API): %v\nrendered:\n%s", err, manifest)
|
|
}
|
|
if doc == nil {
|
|
continue
|
|
}
|
|
walkMetadata(doc, fn)
|
|
}
|
|
}
|
|
|
|
func walkMetadata(node interface{}, fn func(metadata map[string]interface{})) {
|
|
switch typed := node.(type) {
|
|
case map[string]interface{}:
|
|
if metadata, ok := typed["metadata"].(map[string]interface{}); ok {
|
|
fn(metadata)
|
|
}
|
|
for _, value := range typed {
|
|
walkMetadata(value, fn)
|
|
}
|
|
case []interface{}:
|
|
for _, value := range typed {
|
|
walkMetadata(value, fn)
|
|
}
|
|
}
|
|
}
|