From 12e154bb4e28eb8069aac87b7b6760e0f7a610c1 Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Thu, 15 Feb 2024 12:39:14 -0500 Subject: [PATCH] feat: add support for jobs that are both cron and event based Before this change, a cronjob that also specifies events would produce a cronjob that is only running after one of the events occurs and is deleted after a non-listed event. Additionally, a cronjob is not marked as "ready" until it runs at least once. After this change, a cronjob is marked as ready unless it is running or failed. Additionally, a job that specifies both events and schedule, a cronjob is created for the schedule and a "regular" job for each event. Once the event-based job succeeds, the job's status is read from the cronjob. That means, if the "regular" job fails, then the job's status will be "failed" until the "regular" job passed, regardless of what is happening with the cronjob. Signed-off-by: Donnie Adams --- go.mod | 2 +- go.sum | 4 +- integration/run/job_test.go | 116 ++ integration/run/run_test.go | 32 - .../testdata/jobs/cron-with-create/Acornfile | 7 + .../jobs/cron-with-create/scripts/run.sh | 3 + .../testdata/jobs/cron-with-update/Acornfile | 11 + .../jobs/cron-with-update/scripts/run.sh | 3 + .../{jobfinalize => jobs/finalize}/Acornfile | 0 .../finalize}/scripts/run.sh | 0 pkg/controller/appdefinition/jobs.go | 94 +- .../testdata/computeclass/job/expected.golden | 1 + .../filter-user-labels/expected.golden | 1 + .../deployspec/labels/expected.golden | 1 + .../deployspec/no-user-labels/expected.golden | 1 + .../deployspec/pre-stop/job/expected.golden | 1 + .../testdata/job/basic/expected.golden | 1 + .../expected.golden | 584 ++++++++ .../input.yaml | 31 + .../testdata/job/labels/expected.golden | 1 + .../testdata/memory/job/expected.golden | 1 + .../testdata/permissions/both/expected.golden | 1 + .../bothwithnopermissions/expected.golden | 1 + .../testdata/permissions/job/expected.golden | 1 + .../permissions/multiplejobs/expected.golden | 2 + pkg/controller/appstatus/jobs.go | 29 +- pkg/controller/appstatus/jobs_test.go | 1280 +++++++++++++++++ pkg/jobs/jobs.go | 3 +- 28 files changed, 2113 insertions(+), 99 deletions(-) create mode 100644 integration/run/job_test.go create mode 100644 integration/run/testdata/jobs/cron-with-create/Acornfile create mode 100755 integration/run/testdata/jobs/cron-with-create/scripts/run.sh create mode 100644 integration/run/testdata/jobs/cron-with-update/Acornfile create mode 100755 integration/run/testdata/jobs/cron-with-update/scripts/run.sh rename integration/run/testdata/{jobfinalize => jobs/finalize}/Acornfile (100%) rename integration/run/testdata/{jobfinalize => jobs/finalize}/scripts/run.sh (100%) create mode 100644 pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/expected.golden create mode 100644 pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/input.yaml create mode 100644 pkg/controller/appstatus/jobs_test.go diff --git a/go.mod b/go.mod index 22b57931c..81e9003fb 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/acorn-io/mink v0.0.0-20240105015834-b1f7af4fadea github.com/acorn-io/namegenerator v0.0.0-20220915160418-9e3d5a0ffe78 github.com/acorn-io/schemer v0.0.0-20240105014212-9739d5485208 - github.com/acorn-io/z v0.0.0-20230714155009-a770ecbbdc45 + github.com/acorn-io/z v0.0.0-20231104012607-4cab1b3ec5e5 github.com/adrg/xdg v0.4.0 github.com/aws/aws-sdk-go-v2 v1.20.0 github.com/aws/aws-sdk-go-v2/config v1.18.32 diff --git a/go.sum b/go.sum index 0ce5451e0..373bfcc82 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/acorn-io/namegenerator v0.0.0-20220915160418-9e3d5a0ffe78 h1:5zs9L/CX github.com/acorn-io/namegenerator v0.0.0-20220915160418-9e3d5a0ffe78/go.mod h1:/5647+1/L7m7Aq7upTLtfLznTLYttURzH7Y23LKrW0M= github.com/acorn-io/schemer v0.0.0-20240105014212-9739d5485208 h1:GpTdbiOxq3tybaMlUkCwfBco34Zi9P5/7ikbAZ+2BdM= github.com/acorn-io/schemer v0.0.0-20240105014212-9739d5485208/go.mod h1:oQ4BjkYtNmfKPUMvi+/tBC7xaeHmBWM59lfBjsS4Gkg= -github.com/acorn-io/z v0.0.0-20230714155009-a770ecbbdc45 h1:+od+ijNLAt+tc33uZa71qkE4eyvubts45thVXz8J7DU= -github.com/acorn-io/z v0.0.0-20230714155009-a770ecbbdc45/go.mod h1:5UO0+eOne2Zhvn7Ox5IiK4u+4dlCSLmHfTQWORRdEyo= +github.com/acorn-io/z v0.0.0-20231104012607-4cab1b3ec5e5 h1:oQnpRt5KoANqwwUNzWFu+5I12Unfu/WZ330QHefxNc8= +github.com/acorn-io/z v0.0.0-20231104012607-4cab1b3ec5e5/go.mod h1:5UO0+eOne2Zhvn7Ox5IiK4u+4dlCSLmHfTQWORRdEyo= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= diff --git a/integration/run/job_test.go b/integration/run/job_test.go new file mode 100644 index 000000000..d15dc8fae --- /dev/null +++ b/integration/run/job_test.go @@ -0,0 +1,116 @@ +package run + +import ( + "testing" + + "github.com/acorn-io/runtime/integration/helper" + apiv1 "github.com/acorn-io/runtime/pkg/apis/api.acorn.io/v1" + "github.com/acorn-io/runtime/pkg/client" + crClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestJobDelete(t *testing.T) { + helper.StartController(t) + + ctx := helper.GetCTX(t) + c, _ := helper.ClientAndProject(t) + + image, err := c.AcornImageBuild(ctx, "./testdata/jobfinalize/Acornfile", &client.AcornImageBuildOptions{ + Cwd: "./testdata/jobfinalize", + }) + if err != nil { + t.Fatal(err) + } + + app, err := c.AppRun(ctx, image.ID, nil) + if err != nil { + t.Fatal(err) + } + + app = helper.WaitForObject(t, helper.Watcher(t, c), new(apiv1.AppList), app, func(app *apiv1.App) bool { + return len(app.Finalizers) > 0 + }) + + app, err = c.AppDelete(ctx, app.Name) + if err != nil { + t.Fatal(err) + } + + _ = helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { + return c.AppGet(ctx, app.Name) + }) +} + +func TestCronJobWithCreate(t *testing.T) { + helper.StartController(t) + + ctx := helper.GetCTX(t) + c, _ := helper.ClientAndProject(t) + + image, err := c.AcornImageBuild(ctx, "./testdata/jobs/cron-with-create/Acornfile", &client.AcornImageBuildOptions{ + Cwd: "./testdata/jobs/cron-with-create", + }) + if err != nil { + t.Fatal(err) + } + + app, err := c.AppRun(ctx, image.ID, nil) + if err != nil { + t.Fatal(err) + } + + app = helper.WaitForObject(t, helper.Watcher(t, c), new(apiv1.AppList), app, func(app *apiv1.App) bool { + return app.Status.Ready + }) + + app, err = c.AppDelete(ctx, app.Name) + if err != nil { + t.Fatal(err) + } + + _ = helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { + return c.AppGet(ctx, app.Name) + }) +} + +func TestCronJobWithUpdate(t *testing.T) { + helper.StartController(t) + + ctx := helper.GetCTX(t) + c, _ := helper.ClientAndProject(t) + + image, err := c.AcornImageBuild(ctx, "./testdata/jobs/cron-with-update/Acornfile", &client.AcornImageBuildOptions{ + Cwd: "./testdata/jobs/cron-with-update", + }) + if err != nil { + t.Fatal(err) + } + + app, err := c.AppRun(ctx, image.ID, nil) + if err != nil { + t.Fatal(err) + } + + app = helper.WaitForObject(t, helper.Watcher(t, c), new(apiv1.AppList), app, func(app *apiv1.App) bool { + return app.Status.Ready && app.Status.AppStatus.Jobs["update"].Skipped + }) + + app, err = c.AppUpdate(ctx, app.Name, &client.AppUpdateOptions{ + DeployArgs: map[string]any{ + "forceUpdateGen": 2, + }, + }) + + app = helper.WaitForObject(t, helper.Watcher(t, c), new(apiv1.AppList), app, func(app *apiv1.App) bool { + return app.Status.Ready && !app.Status.AppStatus.Jobs["update"].Skipped + }) + + app, err = c.AppDelete(ctx, app.Name) + if err != nil { + t.Fatal(err) + } + + _ = helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { + return c.AppGet(ctx, app.Name) + }) +} diff --git a/integration/run/run_test.go b/integration/run/run_test.go index d16602c90..a4622fa32 100644 --- a/integration/run/run_test.go +++ b/integration/run/run_test.go @@ -1439,38 +1439,6 @@ func TestUsingComputeClasses(t *testing.T) { } } -func TestJobDelete(t *testing.T) { - helper.StartController(t) - - ctx := helper.GetCTX(t) - c, _ := helper.ClientAndProject(t) - - image, err := c.AcornImageBuild(ctx, "./testdata/jobfinalize/Acornfile", &client.AcornImageBuildOptions{ - Cwd: "./testdata/jobfinalize", - }) - if err != nil { - t.Fatal(err) - } - - app, err := c.AppRun(ctx, image.ID, nil) - if err != nil { - t.Fatal(err) - } - - app = helper.WaitForObject(t, helper.Watcher(t, c), new(apiv1.AppList), app, func(app *apiv1.App) bool { - return len(app.Finalizers) > 0 - }) - - app, err = c.AppDelete(ctx, app.Name) - if err != nil { - t.Fatal(err) - } - - _ = helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { - return c.AppGet(ctx, app.Name) - }) -} - func TestAppWithBadRegion(t *testing.T) { helper.StartController(t) diff --git a/integration/run/testdata/jobs/cron-with-create/Acornfile b/integration/run/testdata/jobs/cron-with-create/Acornfile new file mode 100644 index 000000000..4740e72b1 --- /dev/null +++ b/integration/run/testdata/jobs/cron-with-create/Acornfile @@ -0,0 +1,7 @@ +jobs: create: { + image: "ghcr.io/acorn-io/images-mirror/busybox:latest" + schedule: "0 0 31 2 *" // February 31st, never runs + events: ["create"] + dirs: "/app": "./scripts" + command: "/app/run.sh" +} diff --git a/integration/run/testdata/jobs/cron-with-create/scripts/run.sh b/integration/run/testdata/jobs/cron-with-create/scripts/run.sh new file mode 100755 index 000000000..552629d4c --- /dev/null +++ b/integration/run/testdata/jobs/cron-with-create/scripts/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -x -e +[ "$ACORN_EVENT" = "create" ] \ No newline at end of file diff --git a/integration/run/testdata/jobs/cron-with-update/Acornfile b/integration/run/testdata/jobs/cron-with-update/Acornfile new file mode 100644 index 000000000..19e4a16b0 --- /dev/null +++ b/integration/run/testdata/jobs/cron-with-update/Acornfile @@ -0,0 +1,11 @@ +args: { + forceUpdateGen: 1 +} + +jobs: update: { + image: "ghcr.io/acorn-io/images-mirror/busybox:latest" + schedule: "0 0 31 2 *" // February 31st, never runs + events: ["update"] + dirs: "/app": "./scripts" + command: "/app/run.sh" +} diff --git a/integration/run/testdata/jobs/cron-with-update/scripts/run.sh b/integration/run/testdata/jobs/cron-with-update/scripts/run.sh new file mode 100755 index 000000000..00b9cf40f --- /dev/null +++ b/integration/run/testdata/jobs/cron-with-update/scripts/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -x -e +[ "$ACORN_EVENT" = "update" ] \ No newline at end of file diff --git a/integration/run/testdata/jobfinalize/Acornfile b/integration/run/testdata/jobs/finalize/Acornfile similarity index 100% rename from integration/run/testdata/jobfinalize/Acornfile rename to integration/run/testdata/jobs/finalize/Acornfile diff --git a/integration/run/testdata/jobfinalize/scripts/run.sh b/integration/run/testdata/jobs/finalize/scripts/run.sh similarity index 100% rename from integration/run/testdata/jobfinalize/scripts/run.sh rename to integration/run/testdata/jobs/finalize/scripts/run.sh diff --git a/pkg/controller/appdefinition/jobs.go b/pkg/controller/appdefinition/jobs.go index effb3749e..39ef35f7b 100644 --- a/pkg/controller/appdefinition/jobs.go +++ b/pkg/controller/appdefinition/jobs.go @@ -46,13 +46,15 @@ func toJobs(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSe addBusybox = true } } - job, err := toJob(req, appInstance, pullSecrets, tag, jobName, jobDef, interpolator, addBusybox) + jbs, err := toJobAndCronJob(req, appInstance, pullSecrets, tag, jobName, jobDef, interpolator, addBusybox) if err != nil { return nil, err } - if job == nil { + if len(jbs) == 0 { continue } + + job := jbs[0] perms := v1.FindPermission(jobName, appInstance.Status.Permissions) sa, err := toServiceAccount(req, job.GetName(), job.GetLabels(), stripPruneAndUpdate(job.GetAnnotations()), appInstance, perms) if err != nil { @@ -65,8 +67,10 @@ func toJobs(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSe } result = append(result, perms...) } - result = append(result, sa, job) + result = append(result, sa) + result = append(result, jbs...) } + return result, nil } @@ -91,18 +95,16 @@ func setSecretOutputVolume(containers []corev1.Container) (result []corev1.Conta return } -func toJob(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSecrets, tag name.Reference, name string, container v1.Container, interpolator *secrets.Interpolator, addBusybox bool) (kclient.Object, error) { +func toJobAndCronJob(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSecrets, tag name.Reference, name string, container v1.Container, interpolator *secrets.Interpolator, addBusybox bool) ([]kclient.Object, error) { + var result []kclient.Object interpolator = interpolator.ForJob(name) jobEventName := jobs.GetEvent(name, appInstance) jobStatus := appInstance.Status.AppStatus.Jobs[name] jobStatus.Skipped = !jobs.ShouldRunForEvent(jobEventName, container) - if appInstance.Status.AppStatus.Jobs == nil { - appInstance.Status.AppStatus.Jobs = make(map[string]v1.JobStatus, len(appInstance.Status.AppSpec.Jobs)) - } - appInstance.Status.AppStatus.Jobs[name] = jobStatus + appInstance.Status.AppStatus.Jobs = z.AddToMap(appInstance.Status.AppStatus.Jobs, name, jobStatus) - if jobStatus.Skipped { + if jobStatus.Skipped && container.Schedule == "" { return nil, nil } @@ -125,20 +127,20 @@ func toJob(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSec return nil, err } - baseAnnotations := labels.Merge(secretAnnotations, labels.GatherScoped(name, v1.LabelTypeJob, - appInstance.Status.AppSpec.Annotations, container.Annotations, appInstance.Spec.Annotations)) + baseAnnotations := labels.Merge( + secretAnnotations, + labels.GatherScoped(name, v1.LabelTypeJob, appInstance.Status.AppSpec.Annotations, container.Annotations, appInstance.Spec.Annotations), + ) baseAnnotations[labels.AcornConfigHashAnnotation] = appInstance.Status.AppStatus.Jobs[name].ConfigHash - - if appInstance.Generation > 0 { - baseAnnotations[labels.AcornAppGeneration] = strconv.FormatInt(appInstance.Generation, 10) - } + baseAnnotations[labels.AcornAppGeneration] = strconv.FormatInt(appInstance.Generation, 10) podLabels, err := jobLabels(appInstance, container, name, interpolator, labels.AcornManaged, "true", labels.AcornAppPublicName, publicname.Get(appInstance), labels.AcornJobName, name, - labels.AcornContainerName, "") + labels.AcornContainerName, "", + ) if err != nil { return nil, err } @@ -173,19 +175,22 @@ func toJob(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSec }, } + objectMeta := metav1.ObjectMeta{ + Name: name, + Namespace: appInstance.Status.Namespace, + Labels: jobSpec.Template.Labels, + Annotations: labels.Merge(getDependencyAnnotations(appInstance, name, container.Dependencies), baseAnnotations), + } + interpolator.AddMissingAnnotations(appInstance.GetStopped(), baseAnnotations) - if container.Schedule == "" { - jobSpec.BackoffLimit = z.Pointer[int32](1000) + if container.Schedule == "" || !jobStatus.Skipped { job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: appInstance.Status.Namespace, - Labels: jobSpec.Template.Labels, - Annotations: labels.Merge(getDependencyAnnotations(appInstance, name, container.Dependencies), baseAnnotations), - }, - Spec: jobSpec, + ObjectMeta: *objectMeta.DeepCopy(), + Spec: *jobSpec.DeepCopy(), } + + job.Spec.BackoffLimit = z.Pointer[int32](1000) job.Spec.Template.Spec.Containers = setJobEventName(setSecretOutputVolume(containers), jobEventName) job.Spec.Template.Spec.InitContainers = setJobEventName(setSecretOutputVolume(initContainers), jobEventName) job.Annotations[apply.AnnotationPrune] = "false" @@ -193,32 +198,29 @@ func toJob(req router.Request, appInstance *v1.AppInstance, pullSecrets *PullSec // getDependencyAnnotations may set this annotation, so don't override here job.Annotations[apply.AnnotationUpdate] = "true" } - job.Annotations[labels.AcornAppGeneration] = strconv.FormatInt(appInstance.Generation, 10) - return job, nil + + result = append(result, job) } - cronJob := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: appInstance.Status.Namespace, - Labels: jobSpec.Template.Labels, - Annotations: labels.Merge(getDependencyAnnotations(appInstance, name, container.Dependencies), baseAnnotations), - }, - Spec: batchv1.CronJobSpec{ - FailedJobsHistoryLimit: z.Pointer[int32](3), - SuccessfulJobsHistoryLimit: z.Pointer[int32](1), - ConcurrencyPolicy: batchv1.ReplaceConcurrent, - Schedule: toCronJobSchedule(container.Schedule), - JobTemplate: batchv1.JobTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: jobSpec.Template.Labels, + if container.Schedule != "" { + result = append(result, &batchv1.CronJob{ + ObjectMeta: objectMeta, + Spec: batchv1.CronJobSpec{ + FailedJobsHistoryLimit: z.Pointer[int32](3), + SuccessfulJobsHistoryLimit: z.Pointer[int32](1), + ConcurrencyPolicy: batchv1.ReplaceConcurrent, + Schedule: toCronJobSchedule(container.Schedule), + JobTemplate: batchv1.JobTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: jobSpec.Template.Labels, + }, + Spec: jobSpec, }, - Spec: jobSpec, }, - }, + }) } - cronJob.Annotations[labels.AcornAppGeneration] = strconv.FormatInt(appInstance.Generation, 10) - return cronJob, nil + + return result, nil } func toCronJobSchedule(schedule string) string { diff --git a/pkg/controller/appdefinition/testdata/computeclass/job/expected.golden b/pkg/controller/appdefinition/testdata/computeclass/job/expected.golden index b21a4ca1b..2c29a8067 100644 --- a/pkg/controller/appdefinition/testdata/computeclass/job/expected.golden +++ b/pkg/controller/appdefinition/testdata/computeclass/job/expected.golden @@ -53,6 +53,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/deployspec/filter-user-labels/expected.golden b/pkg/controller/appdefinition/testdata/deployspec/filter-user-labels/expected.golden index 85b02dceb..ea948f702 100644 --- a/pkg/controller/appdefinition/testdata/deployspec/filter-user-labels/expected.golden +++ b/pkg/controller/appdefinition/testdata/deployspec/filter-user-labels/expected.golden @@ -229,6 +229,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"annotations":{"admit-job.io":"test-admit-job-ann"},"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","labels":{"allowed-job.io":"test-allowed-job-label"},"metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null}' admit-job.io: test-admit-job-ann diff --git a/pkg/controller/appdefinition/testdata/deployspec/labels/expected.golden b/pkg/controller/appdefinition/testdata/deployspec/labels/expected.golden index e547f2bf0..ae7248cc0 100644 --- a/pkg/controller/appdefinition/testdata/deployspec/labels/expected.golden +++ b/pkg/controller/appdefinition/testdata/deployspec/labels/expected.golden @@ -225,6 +225,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"annotations":{"jobAnn":"test-job-ann"},"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","labels":{"jobLabel":"test-job-label"},"metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null}' appSpecAnn: test-app-spec-ann diff --git a/pkg/controller/appdefinition/testdata/deployspec/no-user-labels/expected.golden b/pkg/controller/appdefinition/testdata/deployspec/no-user-labels/expected.golden index 954974543..9c321d48b 100644 --- a/pkg/controller/appdefinition/testdata/deployspec/no-user-labels/expected.golden +++ b/pkg/controller/appdefinition/testdata/deployspec/no-user-labels/expected.golden @@ -173,6 +173,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/deployspec/pre-stop/job/expected.golden b/pkg/controller/appdefinition/testdata/deployspec/pre-stop/job/expected.golden index f84d10ab5..89d9ee97d 100644 --- a/pkg/controller/appdefinition/testdata/deployspec/pre-stop/job/expected.golden +++ b/pkg/controller/appdefinition/testdata/deployspec/pre-stop/job/expected.golden @@ -53,6 +53,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/job/basic/expected.golden b/pkg/controller/appdefinition/testdata/job/basic/expected.golden index f84d10ab5..89d9ee97d 100644 --- a/pkg/controller/appdefinition/testdata/job/basic/expected.golden +++ b/pkg/controller/appdefinition/testdata/job/basic/expected.golden @@ -53,6 +53,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/expected.golden b/pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/expected.golden new file mode 100644 index 000000000..92978b31b --- /dev/null +++ b/pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/expected.golden @@ -0,0 +1,584 @@ +`apiVersion: v1 +data: + .dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7ImF1dGgiOiJPZz09In0sImluZGV4LmRvY2tlci5pbyI6eyJhdXRoIjoiT2c9PSJ9fX0= +kind: Secret +metadata: + creationTimestamp: null + labels: + acorn.io/managed: "true" + acorn.io/pull-secret: "true" + name: create-only-pull-1234567890ab + namespace: app-created-namespace +type: kubernetes.io/dockerconfigjson + +--- +apiVersion: v1 +data: + .dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7ImF1dGgiOiJPZz09In0sImluZGV4LmRvY2tlci5pbyI6eyJhdXRoIjoiT2c9PSJ9fX0= +kind: Secret +metadata: + creationTimestamp: null + labels: + acorn.io/managed: "true" + acorn.io/pull-secret: "true" + name: delete-only-pull-1234567890ab + namespace: app-created-namespace +type: kubernetes.io/dockerconfigjson + +--- +apiVersion: v1 +data: + .dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7ImF1dGgiOiJPZz09In0sImluZGV4LmRvY2tlci5pbyI6eyJhdXRoIjoiT2c9PSJ9fX0= +kind: Secret +metadata: + creationTimestamp: null + labels: + acorn.io/managed: "true" + acorn.io/pull-secret: "true" + name: stop-only-pull-1234567890ab + namespace: app-created-namespace +type: kubernetes.io/dockerconfigjson + +--- +apiVersion: v1 +data: + .dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7ImF1dGgiOiJPZz09In0sImluZGV4LmRvY2tlci5pbyI6eyJhdXRoIjoiT2c9PSJ9fX0= +kind: Secret +metadata: + creationTimestamp: null + labels: + acorn.io/managed: "true" + acorn.io/pull-secret: "true" + name: update-only-pull-1234567890ab + namespace: app-created-namespace +type: kubernetes.io/dockerconfigjson + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: create-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: create-only + namespace: app-created-namespace + +--- +apiVersion: batch/v1 +kind: Job +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + apply.acorn.io/prune: "false" + apply.acorn.io/update: "true" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: create-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: create-only + namespace: app-created-namespace +spec: + backoffLimit: 1000 + template: + metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + acorn.io/container-spec: '{"events":["create"],"image":"create-only-image","metrics":{},"probes":null,"schedule":"*/5 + * * * * *"}' + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: create-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + containers: + - env: + - name: ACORN_EVENT + value: create + image: create-only-image + name: create-only + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + - command: + - /usr/local/bin/acorn-job-helper-init + env: + - name: ACORN_EVENT + value: create + image: ghcr.io/acorn-io/runtime:main + imagePullPolicy: IfNotPresent + name: acorn-job-output-helper + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + enableServiceLinks: false + imagePullSecrets: + - name: create-only-pull-1234567890ab + restartPolicy: Never + serviceAccountName: create-only + terminationGracePeriodSeconds: 5 + volumes: + - emptyDir: + medium: Memory + sizeLimit: 1M + name: acorn-job-output-helper +status: {} + +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: create-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: create-only + namespace: app-created-namespace +spec: + concurrencyPolicy: Replace + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: create-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + template: + metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + acorn.io/container-spec: '{"events":["create"],"image":"create-only-image","metrics":{},"probes":null,"schedule":"*/5 + * * * * *"}' + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: create-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + containers: + - image: create-only-image + name: create-only + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + - command: + - /usr/local/bin/acorn-job-helper-init + image: ghcr.io/acorn-io/runtime:main + imagePullPolicy: IfNotPresent + name: acorn-job-output-helper + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + enableServiceLinks: false + imagePullSecrets: + - name: create-only-pull-1234567890ab + restartPolicy: Never + serviceAccountName: create-only + terminationGracePeriodSeconds: 5 + volumes: + - emptyDir: + medium: Memory + sizeLimit: 1M + name: acorn-job-output-helper + schedule: '*/5 * * * * *' + successfulJobsHistoryLimit: 1 +status: {} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: delete-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: delete-only + namespace: app-created-namespace + +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: delete-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: delete-only + namespace: app-created-namespace +spec: + concurrencyPolicy: Replace + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: delete-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + template: + metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + acorn.io/container-spec: '{"events":["delete"],"image":"delete-only-image","metrics":{},"probes":null,"schedule":"*/5 + * * * * *"}' + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: delete-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + containers: + - image: delete-only-image + name: delete-only + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + - command: + - /usr/local/bin/acorn-job-helper-init + image: ghcr.io/acorn-io/runtime:main + imagePullPolicy: IfNotPresent + name: acorn-job-output-helper + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + enableServiceLinks: false + imagePullSecrets: + - name: delete-only-pull-1234567890ab + restartPolicy: Never + serviceAccountName: delete-only + terminationGracePeriodSeconds: 5 + volumes: + - emptyDir: + medium: Memory + sizeLimit: 1M + name: acorn-job-output-helper + schedule: '*/5 * * * * *' + successfulJobsHistoryLimit: 1 +status: {} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: stop-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: stop-only + namespace: app-created-namespace + +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: stop-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: stop-only + namespace: app-created-namespace +spec: + concurrencyPolicy: Replace + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: stop-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + template: + metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + acorn.io/container-spec: '{"events":["stop"],"image":"stop-only-image","metrics":{},"probes":null,"schedule":"*/5 + * * * * *"}' + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: stop-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + containers: + - image: stop-only-image + name: stop-only + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + - command: + - /usr/local/bin/acorn-job-helper-init + image: ghcr.io/acorn-io/runtime:main + imagePullPolicy: IfNotPresent + name: acorn-job-output-helper + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + enableServiceLinks: false + imagePullSecrets: + - name: stop-only-pull-1234567890ab + restartPolicy: Never + serviceAccountName: stop-only + terminationGracePeriodSeconds: 5 + volumes: + - emptyDir: + medium: Memory + sizeLimit: 1M + name: acorn-job-output-helper + schedule: '*/5 * * * * *' + successfulJobsHistoryLimit: 1 +status: {} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: update-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: update-only + namespace: app-created-namespace + +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: update-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + name: update-only + namespace: app-created-namespace +spec: + concurrencyPolicy: Replace + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: update-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + template: + metadata: + annotations: + acorn.io/app-generation: "1" + acorn.io/config-hash: "" + acorn.io/container-spec: '{"events":["update"],"image":"update-only-image","metrics":{},"probes":null,"schedule":"*/5 + * * * * *"}' + creationTimestamp: null + labels: + acorn.io/app-name: app-name + acorn.io/app-namespace: app-namespace + acorn.io/app-public-name: app-name + acorn.io/job-name: update-only + acorn.io/managed: "true" + acorn.io/project-name: app-namespace + spec: + containers: + - image: update-only-image + name: update-only + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + - command: + - /usr/local/bin/acorn-job-helper-init + image: ghcr.io/acorn-io/runtime:main + imagePullPolicy: IfNotPresent + name: acorn-job-output-helper + resources: {} + volumeMounts: + - mountPath: /run/secrets + name: acorn-job-output-helper + enableServiceLinks: false + imagePullSecrets: + - name: update-only-pull-1234567890ab + restartPolicy: Never + serviceAccountName: update-only + terminationGracePeriodSeconds: 5 + volumes: + - emptyDir: + medium: Memory + sizeLimit: 1M + name: acorn-job-output-helper + schedule: '*/5 * * * * *' + successfulJobsHistoryLimit: 1 +status: {} + +--- +apiVersion: internal.acorn.io/v1 +kind: AppInstance +metadata: + creationTimestamp: null + generation: 1 + name: app-name + namespace: app-namespace + uid: 1234567890abcdef +spec: + image: test +status: + appImage: + buildContext: {} + id: test + imageData: {} + vcs: {} + appSpec: + jobs: + create-only: + events: + - create + image: create-only-image + metrics: {} + probes: null + schedule: '*/5 * * * * *' + delete-only: + events: + - delete + image: delete-only-image + metrics: {} + probes: null + schedule: '*/5 * * * * *' + stop-only: + events: + - stop + image: stop-only-image + metrics: {} + probes: null + schedule: '*/5 * * * * *' + update-only: + events: + - update + image: update-only-image + metrics: {} + probes: null + schedule: '*/5 * * * * *' + appStatus: + jobs: + create-only: {} + delete-only: + skipped: true + stop-only: + skipped: true + update-only: + skipped: true + columns: {} + conditions: + observedGeneration: 1 + reason: Success + status: "True" + success: true + type: defined + defaults: {} + namespace: app-created-namespace + resolvedOfferings: {} + staged: + appImage: + buildContext: {} + imageData: {} + vcs: {} + summary: {} +` diff --git a/pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/input.yaml b/pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/input.yaml new file mode 100644 index 000000000..33d5db0fc --- /dev/null +++ b/pkg/controller/appdefinition/testdata/job/event-jobs-create-app-with-schedule/input.yaml @@ -0,0 +1,31 @@ +kind: AppInstance +apiVersion: internal.acorn.io/v1 +metadata: + generation: 1 + name: app-name + namespace: app-namespace + uid: 1234567890abcdef +spec: + image: test +status: + namespace: app-created-namespace + appImage: + id: test + appSpec: + jobs: + delete-only: + events: ["delete"] + image: "delete-only-image" + schedule: "*/5 * * * * *" + create-only: + events: ["create"] + image: "create-only-image" + schedule: "*/5 * * * * *" + update-only: + events: ["update"] + image: "update-only-image" + schedule: "*/5 * * * * *" + stop-only: + events: ["stop"] + image: "stop-only-image" + schedule: "*/5 * * * * *" diff --git a/pkg/controller/appdefinition/testdata/job/labels/expected.golden b/pkg/controller/appdefinition/testdata/job/labels/expected.golden index e3846cc4d..fc01637c5 100644 --- a/pkg/controller/appdefinition/testdata/job/labels/expected.golden +++ b/pkg/controller/appdefinition/testdata/job/labels/expected.golden @@ -73,6 +73,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"annotations":{"job3a":"value"},"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","labels":{"job3":"value"},"metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null}' alljobsa: value diff --git a/pkg/controller/appdefinition/testdata/memory/job/expected.golden b/pkg/controller/appdefinition/testdata/memory/job/expected.golden index faa70c183..152ecd296 100644 --- a/pkg/controller/appdefinition/testdata/memory/job/expected.golden +++ b/pkg/controller/appdefinition/testdata/memory/job/expected.golden @@ -53,6 +53,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/permissions/both/expected.golden b/pkg/controller/appdefinition/testdata/permissions/both/expected.golden index 3c06c1276..7e8afae32 100644 --- a/pkg/controller/appdefinition/testdata/permissions/both/expected.golden +++ b/pkg/controller/appdefinition/testdata/permissions/both/expected.golden @@ -377,6 +377,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/permissions/bothwithnopermissions/expected.golden b/pkg/controller/appdefinition/testdata/permissions/bothwithnopermissions/expected.golden index f56631e56..746a76d47 100644 --- a/pkg/controller/appdefinition/testdata/permissions/bothwithnopermissions/expected.golden +++ b/pkg/controller/appdefinition/testdata/permissions/bothwithnopermissions/expected.golden @@ -179,6 +179,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/permissions/job/expected.golden b/pkg/controller/appdefinition/testdata/permissions/job/expected.golden index 72bbc9aab..55235f4e4 100644 --- a/pkg/controller/appdefinition/testdata/permissions/job/expected.golden +++ b/pkg/controller/appdefinition/testdata/permissions/job/expected.golden @@ -154,6 +154,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appdefinition/testdata/permissions/multiplejobs/expected.golden b/pkg/controller/appdefinition/testdata/permissions/multiplejobs/expected.golden index 69038915e..69bc08cd0 100644 --- a/pkg/controller/appdefinition/testdata/permissions/multiplejobs/expected.golden +++ b/pkg/controller/appdefinition/testdata/permissions/multiplejobs/expected.golden @@ -168,6 +168,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null @@ -378,6 +379,7 @@ spec: template: metadata: annotations: + acorn.io/app-generation: "0" acorn.io/config-hash: "" acorn.io/container-spec: '{"build":{"context":".","dockerfile":"Dockerfile"},"image":"image-name","metrics":{},"ports":[{"port":80,"protocol":"http","targetPort":81}],"probes":null,"sidecars":{"left":{"image":"foo","metrics":{},"ports":[{"port":90,"protocol":"tcp","targetPort":91}],"probes":null}}}' creationTimestamp: null diff --git a/pkg/controller/appstatus/jobs.go b/pkg/controller/appstatus/jobs.go index 6d6cd86cc..9684fbb3a 100644 --- a/pkg/controller/appstatus/jobs.go +++ b/pkg/controller/appstatus/jobs.go @@ -52,14 +52,12 @@ func (a *appStatusRenderer) readJobs() error { c.JobName = jobName c.JobNamespace = a.app.Status.Namespace - if c.Skipped { + if c.Skipped && jobDef.Schedule == "" { c.CreationTime = &a.app.CreationTimestamp c.State = "completed" c.Ready = true c.UpToDate = true c.Defined = true - c.ErrorCount = 0 - c.RunningCount = 0 c.Dependencies = nil a.app.Status.AppStatus.Jobs[jobName] = c continue @@ -67,10 +65,13 @@ func (a *appStatusRenderer) readJobs() error { var job batchv1.Job err = a.c.Get(a.ctx, router.Key(a.app.Status.Namespace, jobName), &job) - if apierror.IsNotFound(err) { + if apierror.IsNotFound(err) || job.Status.Succeeded > 0 && jobDef.Schedule != "" && job.Annotations[labels.AcornAppGeneration] == strconv.Itoa(int(a.app.Generation)) && job.Annotations[labels.AcornConfigHashAnnotation] == hash { + // If the job is not found, or it has succeeded and there should be an associated cronjob, then process that cronjob instead. var cronJob batchv1.CronJob - err := a.c.Get(a.ctx, router.Key(a.app.Status.Namespace, jobName), &cronJob) - if err == nil { + err = a.c.Get(a.ctx, router.Key(a.app.Status.Namespace, jobName), &cronJob) + if kclient.IgnoreNotFound(err) != nil { + return err + } else if err == nil { c.CreationTime = &cronJob.CreationTimestamp c.LastRun = cronJob.Status.LastScheduleTime c.CompletionTime = cronJob.Status.LastSuccessfulTime @@ -87,24 +88,20 @@ func (a *appStatusRenderer) readJobs() error { c.ErrorCount += int(nestedJob.Status.Failed) } - if cronJob.Status.LastSuccessfulTime != nil { - c.CreateEventSucceeded = true - c.Ready = c.UpToDate - } + c.CreateEventSucceeded = cronJob.Status.LastSuccessfulTime != nil + c.Ready = c.UpToDate && c.ErrorCount == 0 - nextRun, err := nextRun(c.Schedule, cronJob.CreationTimestamp, cronJob.Status.LastScheduleTime) + c.NextRun, err = nextRun(c.Schedule, cronJob.CreationTimestamp, cronJob.Status.LastScheduleTime) if err != nil { return err } - c.NextRun = nextRun - } else if kclient.IgnoreNotFound(err) != nil { - return err } } else if err != nil { return err } else { c.CreationTime = &job.CreationTimestamp c.CompletionTime = job.Status.CompletionTime + c.StartTime = job.Status.StartTime c.LastRun = job.Status.StartTime c.Defined = true c.UpToDate = job.Annotations[labels.AcornAppGeneration] == strconv.Itoa(int(a.app.Generation)) && (c.Skipped || job.Annotations[labels.AcornConfigHashAnnotation] == hash) @@ -233,7 +230,5 @@ func nextRun(expression string, creation metav1.Time, last *metav1.Time) (*metav last = &creation } - return z.Pointer( - metav1.NewTime(schedule.Next(last.Time)), - ), nil + return z.Pointer(metav1.NewTime(schedule.Next(last.Time))), nil } diff --git a/pkg/controller/appstatus/jobs_test.go b/pkg/controller/appstatus/jobs_test.go new file mode 100644 index 000000000..98de53be3 --- /dev/null +++ b/pkg/controller/appstatus/jobs_test.go @@ -0,0 +1,1280 @@ +package appstatus + +import ( + "context" + "testing" + "time" + + v1 "github.com/acorn-io/runtime/pkg/apis/internal.acorn.io/v1" + "github.com/acorn-io/runtime/pkg/labels" + "github.com/acorn-io/runtime/pkg/scheme" + "github.com/acorn-io/z" + cronv3 "github.com/robfig/cron/v3" + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestReadJobsWithNoJobs(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Empty(t, app.Status.AppStatus.Jobs) +} + +func TestReadJobsSkippedJob(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": {}, + }, + }, + AppStatus: v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + Skipped: true, + }, + }, + }, + }, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CreationTime: z.Pointer(metav1.NewTime(time.Time{})), + Skipped: true, + CommonStatus: v1.CommonStatus{ + Ready: true, + UpToDate: true, + Defined: true, + State: "completed", + ConfigHash: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobNotCreated(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": {}, + }, + }, + }, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CommonStatus: v1.CommonStatus{ + UpToDate: false, + Defined: false, + ConfigHash: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobNotComplete(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": {}, + }, + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Active: 1, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CreationTime: &job.CreationTimestamp, + LastRun: job.Status.StartTime, + StartTime: job.Status.StartTime, + RunningCount: int(job.Status.Active), + CommonStatus: v1.CommonStatus{ + UpToDate: true, + Defined: true, + ConfigHash: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobComplete(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": {}, + }, + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Succeeded: 1, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CreationTime: &job.CreationTimestamp, + LastRun: job.Status.StartTime, + StartTime: job.Status.StartTime, + CreateEventSucceeded: true, + CommonStatus: v1.CommonStatus{ + Ready: true, + UpToDate: true, + Defined: true, + ConfigHash: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobFailed(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": {}, + }, + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Failed: 1, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CreationTime: &job.CreationTimestamp, + LastRun: job.Status.StartTime, + StartTime: job.Status.StartTime, + ErrorCount: 1, + CommonStatus: v1.CommonStatus{ + UpToDate: true, + Defined: true, + ConfigHash: "f72bbc9aab1c45a4075711c05dda5814398882c2ea25282ae558c9903c340cc1", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsSkippedCronJobNotSkipped(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + AppStatus: v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + Skipped: true, + }, + }, + }, + }, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Skipped: true, + CommonStatus: v1.CommonStatus{ + Ready: false, + UpToDate: false, + Defined: false, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsCronNotCreated(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + }, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CommonStatus: v1.CommonStatus{ + UpToDate: false, + Defined: false, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsNestedNotCreated(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + CommonStatus: v1.CommonStatus{ + Ready: true, + UpToDate: true, + Defined: true, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsNestedRunning(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Active: 1, + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + LastRun: cronJob.Status.LastScheduleTime, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + RunningCount: 1, + CommonStatus: v1.CommonStatus{ + Ready: true, + UpToDate: true, + Defined: true, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsNestedCompleted(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + LastSuccessfulTime: z.Pointer(metav1.NewTime(time.Now().Add(-1 * time.Second).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Succeeded: 1, + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + LastRun: cronJob.Status.LastScheduleTime, + CompletionTime: cronJob.Status.LastSuccessfulTime, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + CreateEventSucceeded: true, + CommonStatus: v1.CommonStatus{ + Ready: true, + UpToDate: true, + Defined: true, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsNestedFailed(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Failed: 1, + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + LastRun: cronJob.Status.LastScheduleTime, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + ErrorCount: 1, + CommonStatus: v1.CommonStatus{ + UpToDate: true, + Defined: true, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsNestedRunningAgain(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + LastSuccessfulTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Minute).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Active: 1, + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + CompletionTime: cronJob.Status.LastSuccessfulTime, + LastRun: cronJob.Status.LastScheduleTime, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + RunningCount: 1, + CreateEventSucceeded: true, + CommonStatus: v1.CommonStatus{ + Ready: true, + UpToDate: true, + Defined: true, + ConfigHash: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsJobEventPrecedenceOverCron(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + Events: []string{"create"}, + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + LastSuccessfulTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Minute).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + nestedJob := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Failed: 1, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Active: 1, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CreationTime: &job.CreationTimestamp, + LastRun: job.Status.StartTime, + StartTime: job.Status.StartTime, + RunningCount: int(job.Status.Active), + CommonStatus: v1.CommonStatus{ + UpToDate: true, + Defined: true, + ConfigHash: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, nestedJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsJobEventPrecedenceOverCronWhenUpdateNeeded(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + Events: []string{"create"}, + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + LastSuccessfulTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Minute).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + nestedJob := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Failed: 1, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "3fb37507a299e87036973de4a49b1d75e43bdf690191b0a78bef842cf52b79df", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Succeeded: 1, + }, + } + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + CreationTime: &job.CreationTimestamp, + LastRun: job.Status.StartTime, + StartTime: job.Status.StartTime, + CreateEventSucceeded: true, + RunningCount: int(job.Status.Active), + CommonStatus: v1.CommonStatus{ + Defined: true, + ConfigHash: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, nestedJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsJobEventSucceededAndCronFailed(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + Events: []string{"create"}, + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + nestedJob := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Failed: 1, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Succeeded: 1, + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + LastRun: cronJob.Status.LastScheduleTime, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + ErrorCount: 1, + CommonStatus: v1.CommonStatus{ + UpToDate: true, + Defined: true, + ConfigHash: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, nestedJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} + +func TestReadJobsJobEventSucceededAndCronSucceeded(t *testing.T) { + app := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-project", + Generation: 1, + }, + Status: v1.AppInstanceStatus{ + EmbeddedAppStatus: v1.EmbeddedAppStatus{ + Namespace: "test-namespace", + AppSpec: v1.AppSpec{ + Jobs: map[string]v1.Container{ + "test-job": { + Schedule: "*/5 * * * *", + Events: []string{"create"}, + }, + }, + }, + }, + }, + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + Status: batchv1.CronJobStatus{ + LastScheduleTime: z.Pointer(metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second))), + LastSuccessfulTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Active: []corev1.ObjectReference{ + { + Namespace: "test-namespace", + Name: "test-nested-job", + }, + }, + }, + } + + nestedJob := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-nested-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Succeeded: 1, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + labels.AcornAppGeneration: "1", + labels.AcornConfigHashAnnotation: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + CreationTimestamp: metav1.NewTime(time.Now().Add(-10 * time.Second).Truncate(time.Second)), + Name: "test-job", + Namespace: "test-namespace", + }, + Status: batchv1.JobStatus{ + StartTime: z.Pointer(metav1.NewTime(time.Now().Add(-5 * time.Second).Truncate(time.Second))), + Succeeded: 1, + }, + } + + schedule, err := cronv3.ParseStandard(cronJob.Spec.Schedule) + assert.NoError(t, err) + + expectedAppStatus := v1.AppStatus{ + Jobs: map[string]v1.JobStatus{ + "test-job": { + JobName: "test-job", + JobNamespace: "test-namespace", + Schedule: cronJob.Spec.Schedule, + CreationTime: &cronJob.CreationTimestamp, + CompletionTime: cronJob.Status.LastSuccessfulTime, + LastRun: cronJob.Status.LastScheduleTime, + NextRun: z.Pointer(metav1.NewTime(schedule.Next(cronJob.CreationTimestamp.Time))), + CreateEventSucceeded: true, + CommonStatus: v1.CommonStatus{ + UpToDate: true, + Defined: true, + Ready: true, + ConfigHash: "a898b8bc39b4f2510970ab40e0f283fec941fb197b1e3015f11e84b0b999a63f", + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cronJob, nestedJob, job).Build() + assert.NoError(t, (&appStatusRenderer{ + ctx: context.Background(), + c: client, + app: app, + }).readJobs()) + + assert.Equal(t, expectedAppStatus, app.Status.AppStatus) +} diff --git a/pkg/jobs/jobs.go b/pkg/jobs/jobs.go index 578b44716..17304782b 100644 --- a/pkg/jobs/jobs.go +++ b/pkg/jobs/jobs.go @@ -118,7 +118,8 @@ func getOutput(ctx context.Context, c kclient.Client, appInstance *v1.AppInstanc // ShouldRunForEvent returns true if the job is configured to run for the given event. func ShouldRunForEvent(eventName string, container v1.Container) bool { - if len(container.Events) == 0 { + if len(container.Events) == 0 && container.Schedule == "" { + // The default for non cronjobs is "create" and "update". return slices.Contains([]string{"create", "update"}, eventName) } return slices.Contains(container.Events, eventName)