feat(health): CronJob health and suspend, resume and terminate Job actions (#23991)

Signed-off-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
This commit is contained in:
Alexandre Gaudreault
2025-08-13 16:08:24 -04:00
committed by GitHub
parent 5510bdfd71
commit d3de4435ce
40 changed files with 946 additions and 49 deletions

View File

@@ -27,10 +27,9 @@ func setApplicationHealth(resources []managedResource, statuses []appv1.Resource
if res.Target != nil && hookutil.Skip(res.Target) {
continue
}
if res.Target != nil && res.Target.GetAnnotations() != nil && res.Target.GetAnnotations()[common.AnnotationIgnoreHealthCheck] == "true" {
if res.Live != nil && res.Live.GetAnnotations() != nil && res.Live.GetAnnotations()[common.AnnotationIgnoreHealthCheck] == "true" {
continue
}
if res.Live != nil && (hookutil.IsHook(res.Live) || ignore.Ignore(res.Live)) {
continue
}

View File

@@ -82,7 +82,7 @@ func TestSetApplicationHealth(t *testing.T) {
// The app is considered healthy
failedJob.SetAnnotations(nil)
failedJobIgnoreHealthcheck := resourceFromFile("./testdata/job-failed-ignore-healthcheck.yaml")
resources[1].Target = &failedJobIgnoreHealthcheck
resources[1].Live = &failedJobIgnoreHealthcheck
healthStatus, err = setApplicationHealth(resources, resourceStatuses, nil, app, true)
require.NoError(t, err)
assert.Equal(t, health.HealthStatusHealthy, healthStatus)

View File

@@ -216,7 +216,7 @@ The `fa-fw` class ensures that the icon is displayed with a fixed width, to avoi
```lua
local actions = {}
actions["create-workflow"] = {
["iconClass"] = "fa fa-fw fa-play",
["iconClass"] = "fa fa-fw fa-plus",
["displayName"] = "Create Workflow"
}
return actions

View File

@@ -16,6 +16,11 @@
- [argoproj.io/Rollout/skip-current-step](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/argoproj.io/Rollout/actions/skip-current-step/action.lua)
- [argoproj.io/WorkflowTemplate/create-workflow](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/argoproj.io/WorkflowTemplate/actions/create-workflow/action.lua)
- [batch/CronJob/create-job](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/batch/CronJob/actions/create-job/action.lua)
- [batch/CronJob/resume](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/batch/CronJob/actions/resume/action.lua)
- [batch/CronJob/suspend](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/batch/CronJob/actions/suspend/action.lua)
- [batch/Job/resume](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/batch/Job/actions/resume/action.lua)
- [batch/Job/suspend](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/batch/Job/actions/suspend/action.lua)
- [batch/Job/terminate](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/batch/Job/actions/terminate/action.lua)
- [external-secrets.io/ExternalSecret/refresh](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/external-secrets.io/ExternalSecret/actions/refresh/action.lua)
- [external-secrets.io/PushSecret/push](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/external-secrets.io/PushSecret/actions/push/action.lua)
- [helm.toolkit.fluxcd.io/HelmRelease/reconcile](https://github.com/argoproj/argo-cd/blob/master/resource_customizations/helm.toolkit.fluxcd.io/HelmRelease/actions/reconcile/action.lua)

View File

@@ -1,6 +1,6 @@
local actions = {}
actions["create-workflow"] = {
["iconClass"] = "fa fa-fw fa-play",
["iconClass"] = "fa fa-fw fa-plus",
["displayName"] = "Create Workflow"
}
return actions
return actions

View File

@@ -1,6 +1,6 @@
local actions = {}
actions["create-workflow"] = {
["iconClass"] = "fa fa-fw fa-play",
["iconClass"] = "fa fa-fw fa-plus",
["displayName"] = "Create Workflow"
}
return actions
return actions

View File

@@ -1,4 +1,33 @@
discoveryTests:
- inputPath: testdata/cronjob.yaml
result:
- name: create-job
displayName: 'Create Job'
iconClass: 'fa fa-fw fa-plus'
- name: suspend
iconClass: 'fa fa-fw fa-pause'
- inputPath: testdata/cronjob-resumed.yaml
result:
- name: create-job
displayName: 'Create Job'
iconClass: 'fa fa-fw fa-plus'
- name: suspend
iconClass: 'fa fa-fw fa-pause'
- inputPath: testdata/cronjob-suspended.yaml
result:
- name: create-job
displayName: 'Create Job'
iconClass: 'fa fa-fw fa-plus'
- name: resume
iconClass: 'fa fa-fw fa-play'
actionTests:
- action: create-job
inputPath: testdata/cronjob.yaml
expectedOutputPath: testdata/job.yaml
- action: create-job
inputPath: testdata/cronjob.yaml
expectedOutputPath: testdata/job.yaml
- action: suspend
inputPath: testdata/cronjob.yaml
expectedOutputPath: testdata/cronjob-suspended.yaml
- action: resume
inputPath: testdata/cronjob-suspended.yaml
expectedOutputPath: testdata/cronjob-resumed.yaml

View File

@@ -1,6 +1,10 @@
local actions = {}
actions["create-job"] = {
["iconClass"] = "fa fa-fw fa-play",
["displayName"] = "Create Job"
}
return actions
actions["create-job"] = {["iconClass"] = "fa fa-fw fa-plus", ["displayName"] = "Create Job"}
if obj.spec.suspend ~= nil and obj.spec.suspend then
actions["resume"] = {["iconClass"] = "fa fa-fw fa-play" }
else
actions["suspend"] = {["iconClass"] = "fa fa-fw fa-pause"}
end
return actions

View File

@@ -0,0 +1,4 @@
if obj.spec.suspend ~= nil and obj.spec.suspend then
obj.spec.suspend = false
end
return obj

View File

@@ -0,0 +1,2 @@
obj.spec.suspend = true
return obj

View File

@@ -0,0 +1,34 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
namespace: test-ns
uid: '123'
spec:
suspend: false
schedule: '* * * * *'
jobTemplate:
metadata:
labels:
my: label
annotations:
my: annotation
spec:
ttlSecondsAfterFinished: 100
template:
metadata:
labels:
pod: label
annotations:
pod: annotation
spec:
containers:
- name: hello
image: busybox:1.28
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
resources: {}
restartPolicy: OnFailure

View File

@@ -0,0 +1,34 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
namespace: test-ns
uid: '123'
spec:
suspend: true
schedule: '* * * * *'
jobTemplate:
metadata:
labels:
my: label
annotations:
my: annotation
spec:
ttlSecondsAfterFinished: 100
template:
metadata:
labels:
pod: label
annotations:
pod: annotation
spec:
containers:
- name: hello
image: busybox:1.28
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
resources: {}
restartPolicy: OnFailure

View File

@@ -3,6 +3,13 @@
apiVersion: batch/v1
kind: Job
metadata:
ownerReferences:
- apiVersion: batch/v1
blockOwnerDeletion: true
controller: true
kind: CronJob
name: hello
uid: '123'
name: hello-00000000000
namespace: test-ns
labels:
@@ -20,11 +27,11 @@
pod: annotation
spec:
containers:
- name: hello
image: busybox:1.28
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
- name: hello
image: busybox:1.28
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

View File

@@ -0,0 +1,40 @@
hs = {}
if obj.spec.suspend == true then
hs.status = "Suspended"
hs.message = "CronJob is Suspended"
return hs
end
if obj.status ~= nil then
if obj.status.active ~= nil and table.getn(obj.status.active) > 0 then
-- We could be Progressing very often, depending on the Cron schedule, which would bubble up
-- to the Application health. If this is undesired, the annotation `argocd.argoproj.io/ignore-healthcheck: "true"`
-- can be added on the CronJob.
hs.status = "Progressing"
hs.message = string.format("Waiting for %d Jobs to complete", table.getn(obj.status.active))
return hs
end
-- If the CronJob has no active jobs and the lastSuccessfulTime < lastScheduleTime
-- then we know it failed the last execution
if obj.status.lastScheduleTime ~= nil then
-- No issue comparing time as text
if obj.status.lastSuccessfulTime == nil or obj.status.lastSuccessfulTime < obj.status.lastScheduleTime then
hs.status = "Degraded"
hs.message = "CronJob has not completed its last execution successfully"
return hs
end
hs.message = "CronJob has completed its last execution successfully"
end
-- There is no way to know if as CronJob missed its execution based on status
-- so we assume Healthy even if a cronJob is not getting scheduled.
-- https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#job-creation
hs.status = "Healthy"
return hs
end
hs.status = "Progressing"
hs.message = "Waiting for CronJob"
return hs

View File

@@ -0,0 +1,25 @@
tests:
- healthStatus:
status: Healthy
message: CronJob has completed its last execution successfully
inputPath: testdata/healthy.yaml
- healthStatus:
status: Healthy
message: ''
inputPath: testdata/never-scheduled.yaml
- healthStatus:
status: Degraded
message: CronJob has not completed its last execution successfully
inputPath: testdata/degraded.yaml
- healthStatus:
status: Degraded
message: CronJob has not completed its last execution successfully
inputPath: testdata/never-succeeded.yaml
- healthStatus:
status: Progressing
message: Waiting for 1 Jobs to complete
inputPath: testdata/active.yaml
- healthStatus:
status: Suspended
message: CronJob is Suspended
inputPath: testdata/suspended.yaml

View File

@@ -0,0 +1,34 @@
apiVersion: batch/v1
kind: CronJob
metadata:
annotations:
argocd.argoproj.io/tracking-id: test-cronjob:batch/CronJob:test-cronjob/hello
labels:
app.kubernetes.io/instance: test-cronjob
name: hello
namespace: test-cronjob
spec:
jobTemplate:
spec:
template:
spec:
containers:
- command:
- /bin/sh
- '-c'
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
schedule: '* * * * *'
status:
active:
- apiVersion: batch/v1
kind: Job
name: hello-29231490
namespace: test-cronjob
resourceVersion: '21226'
uid: 996e6ed6-8494-4c9a-9862-93de4af310cb
lastScheduleTime: '2025-07-30T13:46:00Z'
lastSuccessfulTime: '2025-07-30T13:44:19Z'

View File

@@ -0,0 +1,27 @@
apiVersion: batch/v1
kind: CronJob
metadata:
annotations:
argocd.argoproj.io/tracking-id: test-cronjob:batch/CronJob:test-cronjob/hello
labels:
app.kubernetes.io/instance: test-cronjob
name: hello
namespace: test-cronjob
spec:
jobTemplate:
spec:
template:
spec:
containers:
- command:
- /bin/sh
- '-c'
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
schedule: '* * * * *'
status:
lastScheduleTime: '2025-07-30T13:46:00Z'
lastSuccessfulTime: '2025-07-30T13:44:19Z'

View File

@@ -0,0 +1,27 @@
apiVersion: batch/v1
kind: CronJob
metadata:
annotations:
argocd.argoproj.io/tracking-id: test-cronjob:batch/CronJob:test-cronjob/hello
labels:
app.kubernetes.io/instance: test-cronjob
name: hello
namespace: test-cronjob
spec:
jobTemplate:
spec:
template:
spec:
containers:
- command:
- /bin/sh
- '-c'
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
schedule: '* * * * *'
status:
lastScheduleTime: '2025-07-30T13:42:00Z'
lastSuccessfulTime: '2025-07-30T13:44:19Z'

View File

@@ -0,0 +1,25 @@
apiVersion: batch/v1
kind: CronJob
metadata:
annotations:
argocd.argoproj.io/tracking-id: test-cronjob:batch/CronJob:test-cronjob/hello
labels:
app.kubernetes.io/instance: test-cronjob
name: hello
namespace: test-cronjob
spec:
jobTemplate:
spec:
template:
spec:
containers:
- command:
- /bin/sh
- '-c'
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
schedule: '* * * * *'
status: {}

View File

@@ -0,0 +1,26 @@
apiVersion: batch/v1
kind: CronJob
metadata:
annotations:
argocd.argoproj.io/tracking-id: test-cronjob:batch/CronJob:test-cronjob/hello
labels:
app.kubernetes.io/instance: test-cronjob
name: hello
namespace: test-cronjob
spec:
jobTemplate:
spec:
template:
spec:
containers:
- command:
- /bin/sh
- '-c'
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
schedule: '* * * * *'
status:
lastScheduleTime: '2025-07-30T13:46:00Z'

View File

@@ -0,0 +1,35 @@
apiVersion: batch/v1
kind: CronJob
metadata:
annotations:
argocd.argoproj.io/tracking-id: test-cronjob:batch/CronJob:test-cronjob/hello
labels:
app.kubernetes.io/instance: test-cronjob
name: hello
namespace: test-cronjob
spec:
suspend: true
jobTemplate:
spec:
template:
spec:
containers:
- command:
- /bin/sh
- '-c'
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
schedule: '* * * * *'
status:
active:
- apiVersion: batch/v1
kind: Job
name: hello-29231490
namespace: test-cronjob
resourceVersion: '21226'
uid: 996e6ed6-8494-4c9a-9862-93de4af310cb
lastScheduleTime: '2025-07-30T13:46:00Z'
lastSuccessfulTime: '2025-07-30T13:44:19Z'

View File

@@ -0,0 +1,42 @@
discoveryTests:
- inputPath: testdata/suspended.yaml
result:
- name: resume
iconClass: 'fa fa-fw fa-play'
- name: terminate
iconClass: 'fa fa-fw fa-stop'
- inputPath: testdata/active.yaml
result:
- name: suspend
iconClass: 'fa fa-fw fa-pause'
- name: terminate
iconClass: 'fa fa-fw fa-stop'
- inputPath: testdata/completed.yaml
result: []
- inputPath: testdata/failed.yaml
result: []
- inputPath: testdata/terminated.yaml
result: []
actionTests:
- action: resume
inputPath: testdata/suspended.yaml
expectedOutputPath: testdata/suspended-output.yaml
- action: suspend
inputPath: testdata/resumed.yaml
expectedOutputPath: testdata/resumed-output.yaml
- action: suspend
inputPath: testdata/created.yaml
expectedOutputPath: testdata/created-output.yaml
- action: terminate
inputPath: testdata/active.yaml
expectedOutputPath: testdata/terminated.yaml
- action: terminate
inputPath: testdata/resumed.yaml
expectedOutputPath: testdata/resumed-terminated-output.yaml
- action: terminate
inputPath: testdata/completed.yaml
expectedOutputPath: testdata/completed.yaml
- action: terminate
inputPath: testdata/failed.yaml
expectedOutputPath: testdata/failed.yaml

View File

@@ -0,0 +1,30 @@
local actions = {}
local completed = false
if obj.status ~= nil then
if obj.status.conditions ~= nil then
for i, condition in pairs(obj.status.conditions) do
if condition.type == "Complete" and condition.status == "True" then
completed = true
elseif condition.type == "Failed" and condition.status == "True" then
completed = true
elseif condition.type == "FailureTarget" and condition.status == "True" then
completed = true
elseif condition.type == "SuccessCriteriaMet" and condition.status == "True" then
completed = true
end
end
end
end
if not(completed) and obj.spec.suspend then
actions["resume"] = {["iconClass"] = "fa fa-fw fa-play" }
elseif not(completed) and (obj.spec.suspend == nil or not(obj.spec.suspend)) then
actions["suspend"] = {["iconClass"] = "fa fa-fw fa-pause" }
end
if not(completed) and obj.status ~= nil then
actions["terminate"] = {["iconClass"] = "fa fa-fw fa-stop" }
end
return actions

View File

@@ -0,0 +1,4 @@
if obj.spec.suspend ~= nil and obj.spec.suspend then
obj.spec.suspend = false
end
return obj

View File

@@ -0,0 +1,2 @@
obj.spec.suspend = true
return obj

View File

@@ -0,0 +1,32 @@
local os = require("os")
local completed = false
if obj.status ~= nil then
if obj.status.conditions ~= nil then
for i, condition in pairs(obj.status.conditions) do
if condition.type == "Complete" and condition.status == "True" then
completed = true
elseif condition.type == "Failed" and condition.status == "True" then
completed = true
elseif condition.type == "FailureTarget" and condition.status == "True" then
completed = true
elseif condition.type == "SuccessCriteriaMet" and condition.status == "True" then
completed = true
end
end
end
if not(completed) then
obj.status.conditions = obj.status.conditions or {}
table.insert(obj.status.conditions, {
lastTransitionTime = os.date("!%Y-%m-%dT%XZ"),
message = "Job was terminated explicitly through Argo CD",
reason = "ManuallyTerminated",
status = "True",
type = "FailureTarget"
})
end
end
return obj

View File

@@ -0,0 +1,21 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: false
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
ready: 0
startTime: '2025-07-28T19:37:00Z'
terminating: 0

View File

@@ -0,0 +1,35 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
completionTime: '2025-07-28T22:15:24Z'
conditions:
- lastProbeTime: '2025-07-28T22:15:24Z'
lastTransitionTime: '2025-07-28T22:15:24Z'
message: Reached expected number of succeeded pods
reason: CompletionsReached
status: 'True'
type: SuccessCriteriaMet
- lastProbeTime: '2025-07-28T22:15:24Z'
lastTransitionTime: '2025-07-28T22:15:24Z'
message: Reached expected number of succeeded pods
reason: CompletionsReached
status: 'True'
type: Complete
ready: 0
startTime: '2025-07-28T19:37:00Z'
succeeded: 1
terminating: 0

View File

@@ -0,0 +1,17 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: true
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure

View File

@@ -0,0 +1,16 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure

View File

@@ -0,0 +1,33 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: false
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastTransitionTime: '0001-01-01T00:00:00Z'
message: Job was terminated explicitly through Argo CD
reason: ManuallyTerminated
status: 'True'
type: FailureTarget
- lastProbeTime: '2025-07-28T20:47:17Z'
lastTransitionTime: '2025-07-28T20:47:17Z'
message: Job was terminated explicitly through Argo CD
reason: ManuallyTerminated
status: 'True'
type: Failed
ready: 0
startTime: '2025-07-28T19:37:00Z'
terminating: 0

View File

@@ -0,0 +1,28 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: true
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastProbeTime: '2025-07-28T22:02:46Z'
lastTransitionTime: '2025-07-28T22:02:46Z'
message: Job resumed
reason: JobResumed
status: 'False'
type: Suspended
ready: 0
startTime: '2025-07-28T22:02:46Z'
terminating: 0

View File

@@ -0,0 +1,33 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: false
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastProbeTime: '2025-07-28T22:02:46Z'
lastTransitionTime: '2025-07-28T22:02:46Z'
message: Job resumed
reason: JobResumed
status: 'False'
type: Suspended
- lastTransitionTime: '0001-01-01T00:00:00Z'
message: Job was terminated explicitly through Argo CD
reason: ManuallyTerminated
status: 'True'
type: FailureTarget
ready: 0
startTime: '2025-07-28T22:02:46Z'
terminating: 0

View File

@@ -0,0 +1,28 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: false
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastProbeTime: '2025-07-28T22:02:46Z'
lastTransitionTime: '2025-07-28T22:02:46Z'
message: Job resumed
reason: JobResumed
status: 'False'
type: Suspended
ready: 0
startTime: '2025-07-28T22:02:46Z'
terminating: 0

View File

@@ -0,0 +1,28 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: false
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastProbeTime: '2025-07-28T20:01:53Z'
lastTransitionTime: '2025-07-28T20:01:53Z'
message: Job suspended
reason: JobSuspended
status: 'True'
type: Suspended
ready: 0
startTime: '2025-07-28T19:37:00Z'
terminating: 0

View File

@@ -0,0 +1,28 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: true
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastProbeTime: '2025-07-28T20:01:53Z'
lastTransitionTime: '2025-07-28T20:01:53Z'
message: Job suspended
reason: JobSuspended
status: 'True'
type: Suspended
ready: 0
startTime: '2025-07-28T19:37:00Z'
terminating: 0

View File

@@ -0,0 +1,27 @@
apiVersion: batch/v1
kind: Job
metadata:
name: test-29228857
spec:
suspend: false
template:
spec:
containers:
- command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: hello
restartPolicy: OnFailure
status:
conditions:
- lastTransitionTime: '0001-01-01T00:00:00Z'
message: Job was terminated explicitly through Argo CD
reason: ManuallyTerminated
status: 'True'
type: FailureTarget
ready: 0
startTime: '2025-07-28T19:37:00Z'
terminating: 0

View File

@@ -26,13 +26,9 @@ func (t testNormalizer) Normalize(un *unstructured.Unstructured) error {
if un == nil {
return nil
}
if un.GetKind() == "Job" {
err := unstructured.SetNestedField(un.Object, map[string]any{"name": "not sure why this works"}, "metadata")
if err != nil {
return fmt.Errorf("failed to normalize Job: %w", err)
}
}
switch un.GetKind() {
case "Job":
return t.normalizeJob(un)
case "DaemonSet", "Deployment", "StatefulSet":
err := unstructured.SetNestedStringMap(un.Object, map[string]string{"kubectl.kubernetes.io/restartedAt": "0001-01-01T00:00:00Z"}, "spec", "template", "metadata", "annotations")
if err != nil {
@@ -85,6 +81,28 @@ func (t testNormalizer) Normalize(un *unstructured.Unstructured) error {
return nil
}
func (t testNormalizer) normalizeJob(un *unstructured.Unstructured) error {
if conditions, exist, err := unstructured.NestedSlice(un.Object, "status", "conditions"); err != nil {
return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err)
} else if exist {
changed := false
for i := range conditions {
condition := conditions[i].(map[string]any)
cType := condition["type"].(string)
if cType == "FailureTarget" {
condition["lastTransitionTime"] = "0001-01-01T00:00:00Z"
changed = true
}
}
if changed {
if err := unstructured.SetNestedSlice(un.Object, conditions, "status", "conditions"); err != nil {
return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err)
}
}
}
return nil
}
type ActionTestStructure struct {
DiscoveryTests []IndividualDiscoveryTest `yaml:"discoveryTests"`
ActionTests []IndividualActionTest `yaml:"actionTests"`
@@ -208,8 +226,7 @@ func TestLuaResourceActionsScript(t *testing.T) {
assert.Equal(t, sourceObj.GetNamespace(), result.GetNamespace())
case CreateOperation:
switch result.GetKind() {
case "Job":
case "Workflow":
case "Job", "Workflow":
// The name of the created resource is derived from the source object name, so the returned name is not actually equal to the testdata output name
result.SetName(expectedObj.GetName())
}

View File

@@ -290,7 +290,8 @@ func cleanReturnedObj(newObj, obj map[string]any) map[string]any {
switch oldValue := oldValueInterface.(type) {
case map[string]any:
if len(newValue) == 0 {
mapToReturn[key] = oldValue
// Lua incorrectly decoded the empty object as an empty array, so set it to an empty object
mapToReturn[key] = map[string]any{}
}
case []any:
newArray := cleanReturnedArray(newValue, oldValue)
@@ -307,6 +308,10 @@ func cleanReturnedObj(newObj, obj map[string]any) map[string]any {
func cleanReturnedArray(newObj, obj []any) []any {
arrayToReturn := newObj
for i := range newObj {
if i >= len(obj) {
// If the new object is longer than the old one, we added an item to the array
break
}
switch newValue := newObj[i].(type) {
case map[string]any:
if oldValue, ok := obj[i].(map[string]any); ok {

View File

@@ -705,7 +705,9 @@ func TestExecuteResourceActionInvalidUnstructured(t *testing.T) {
require.Error(t, err)
}
const objWithEmptyStruct = `
func TestCleanPatch(t *testing.T) {
t.Run("Empty Struct preserved", func(t *testing.T) {
const obj = `
apiVersion: argoproj.io/v1alpha1
kind: Test
metadata:
@@ -717,7 +719,8 @@ metadata:
resourceVersion: "123"
spec:
resources: {}
paused: true
updated:
something: true
containers:
- name: name1
test: {}
@@ -725,8 +728,7 @@ spec:
- name: name2
test2: {}
`
const expectedUpdatedObjWithEmptyStruct = `
const expected = `
apiVersion: argoproj.io/v1alpha1
kind: Test
metadata:
@@ -738,7 +740,7 @@ metadata:
resourceVersion: "123"
spec:
resources: {}
paused: false
updated: {}
containers:
- name: name1
test: {}
@@ -746,21 +748,133 @@ spec:
- name: name2
test2: {}
`
const pausedToFalseLua = `
obj.spec.paused = false
const luaAction = `
obj.spec.updated = {}
return obj
`
testObj := StrToUnstructured(obj)
expectedObj := StrToUnstructured(expected)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, luaAction, nil)
require.NoError(t, err)
assert.Len(t, newObjects, 1)
assert.Equal(t, newObjects[0].K8SOperation, K8SOperation("patch"))
assert.Equal(t, expectedObj, newObjects[0].UnstructuredObj)
})
func TestCleanPatch(t *testing.T) {
testObj := StrToUnstructured(objWithEmptyStruct)
expectedObj := StrToUnstructured(expectedUpdatedObjWithEmptyStruct)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, pausedToFalseLua, nil)
require.NoError(t, err)
assert.Len(t, newObjects, 1)
assert.Equal(t, newObjects[0].K8SOperation, K8SOperation("patch"))
assert.Equal(t, expectedObj, newObjects[0].UnstructuredObj)
t.Run("New item added to array", func(t *testing.T) {
const obj = `
apiVersion: argoproj.io/v1alpha1
kind: Test
metadata:
labels:
app.kubernetes.io/instance: helm-guestbook
test: test
name: helm-guestbook
namespace: default
resourceVersion: "123"
spec:
containers:
- name: name1
test: {}
anotherList:
- name: name2
test2: {}
`
const expected = `
apiVersion: argoproj.io/v1alpha1
kind: Test
metadata:
labels:
app.kubernetes.io/instance: helm-guestbook
test: test
name: helm-guestbook
namespace: default
resourceVersion: "123"
spec:
containers:
- name: name1
test: {}
anotherList:
- name: name2
test2: {}
- name: added
#test: {} ### would be decoded as an empty array and is not supported. The type is unknown
testArray: [] ### works since it is decoded in the correct type
another:
supported: true
`
// `test: {}` in new container would be decoded as an empty array and is not supported. The type is unknown
// `testArray: []` works since it is decoded in the correct type
const luaAction = `
table.insert(obj.spec.containers, {name = "added", testArray = {}, another = {supported = true}})
return obj
`
testObj := StrToUnstructured(obj)
expectedObj := StrToUnstructured(expected)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, luaAction, nil)
require.NoError(t, err)
assert.Len(t, newObjects, 1)
assert.Equal(t, newObjects[0].K8SOperation, K8SOperation("patch"))
assert.Equal(t, expectedObj, newObjects[0].UnstructuredObj)
})
t.Run("Last item removed from array", func(t *testing.T) {
const obj = `
apiVersion: argoproj.io/v1alpha1
kind: Test
metadata:
labels:
app.kubernetes.io/instance: helm-guestbook
test: test
name: helm-guestbook
namespace: default
resourceVersion: "123"
spec:
containers:
- name: name1
test: {}
anotherList:
- name: name2
test2: {}
- name: name3
test: {}
anotherList:
- name: name4
test2: {}
`
const expected = `
apiVersion: argoproj.io/v1alpha1
kind: Test
metadata:
labels:
app.kubernetes.io/instance: helm-guestbook
test: test
name: helm-guestbook
namespace: default
resourceVersion: "123"
spec:
containers:
- name: name1
test: {}
anotherList:
- name: name2
test2: {}
`
const luaAction = `
table.remove(obj.spec.containers)
return obj
`
testObj := StrToUnstructured(obj)
expectedObj := StrToUnstructured(expected)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, luaAction, nil)
require.NoError(t, err)
assert.Len(t, newObjects, 1)
assert.Equal(t, newObjects[0].K8SOperation, K8SOperation("patch"))
assert.Equal(t, expectedObj, newObjects[0].UnstructuredObj)
})
}
func TestGetResourceHealth(t *testing.T) {