feat: Create job action (#12174 and #4116) (#12925)

* Kind wildcard support in health customizations

Signed-off-by: reggie <reginakagan@gmail.com>

* Updated health customizations docs to using the correct field with a /

Signed-off-by: reggie <reginakagan@gmail.com>

* Updated health customizations docs to using the correct field with a /

Signed-off-by: reggie <reginakagan@gmail.com>

* Document resource kind wildcard for custom health check

Signed-off-by: reggie <reginakagan@gmail.com>

* Implemented wildcard * support in API Group and Resource Kind and updated docs

Signed-off-by: reggie <reginakagan@gmail.com>

* Implemented wildcard * support in API Group and Resource Kind and updated docs

Signed-off-by: reggie <reginakagan@gmail.com>

* Implemented wildcard * support in API Group and Resource Kind and updated docs

Signed-off-by: reggie <reginakagan@gmail.com>

* Added a custom create-from CronJob action

Signed-off-by: reggie <reginakagan@gmail.com>

* in progress

Signed-off-by: reggie <reginakagan@gmail.com>

* in progress

Signed-off-by: reggie <reginakagan@gmail.com>

* in progress

Signed-off-by: reggie <reginakagan@gmail.com>

* in progress

Signed-off-by: reggie <reginakagan@gmail.com>

* added a ns in the action.lua and fixed tests

Signed-off-by: reggie <reginakagan@gmail.com>

* create-job

Signed-off-by: reggie <reginakagan@gmail.com>

* in progress

Signed-off-by: reggie <reginakagan@gmail.com>

* more changes

Signed-off-by: reggie <reginakagan@gmail.com>

* full unit tests and action returning an array

Signed-off-by: reggie <reginakagan@gmail.com>

* cleanup

Signed-off-by: reggie <reginakagan@gmail.com>

* fix the custom tests

Signed-off-by: reggie <reginakagan@gmail.com>

* e2e tests

Signed-off-by: reggie <reginakagan@gmail.com>

* json marshaling annotations ImpactedResource, e2e tests and docs

Signed-off-by: reggie <reginakagan@gmail.com>

* more docs and tests

Signed-off-by: reggie <reginakagan@gmail.com>

* upstream sync

Signed-off-by: reggie <reginakagan@gmail.com>

* fix wrong return upon going over the impacted resources + docs + fixing e2e tests

Signed-off-by: reggie <reginakagan@gmail.com>

* docs

Signed-off-by: reggie <reginakagan@gmail.com>

* better error handling

Signed-off-by: reggie <reginakagan@gmail.com>

* K8SOperation as an enum

Signed-off-by: reggie <reginakagan@gmail.com>

* added dry-run for create operation

Signed-off-by: reggie <reginakagan@gmail.com>

* small changes

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* ref to my gitops-engine fork out

Signed-off-by: reggie <reginakagan@gmail.com>

* ref to my gitops-engine fork out

Signed-off-by: reggie <reginakagan@gmail.com>

* ref to my gitops-engine fork out

Signed-off-by: reggie <reginakagan@gmail.com>

* ref to my gitops-engine fork out

Signed-off-by: reggie <reginakagan@gmail.com>

* ref to my gitops-engine fork out

Signed-off-by: reggie <reginakagan@gmail.com>

* gitops engine dependency and test fixes

Signed-off-by: reggie <reginakagan@gmail.com>

* add workflows action

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* cronworkflow and workflowtemplate actions

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

* update gitops-engine

Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>

---------

Signed-off-by: reggie <reginakagan@gmail.com>
Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
This commit is contained in:
reggie-k
2023-06-23 21:45:53 +03:00
committed by GitHub
parent 54c3f814a7
commit 9d904ae7c0
32 changed files with 1587 additions and 50 deletions

View File

@@ -538,13 +538,26 @@ argocd admin settings resource-overrides action run /tmp/deploy.yaml restart --a
modifiedRes, err := luaVM.ExecuteResourceAction(&res, action.ActionLua)
errors.CheckError(err)
if reflect.DeepEqual(&res, modifiedRes) {
_, _ = fmt.Printf("No fields had been changed by action: \n%s\n", action.Name)
return
for _, impactedResource := range modifiedRes {
result := impactedResource.UnstructuredObj
switch impactedResource.K8SOperation {
// No default case since a not supported operation would have failed upon unmarshaling earlier
case lua.PatchOperation:
if reflect.DeepEqual(&res, modifiedRes) {
_, _ = fmt.Printf("No fields had been changed by action: \n%s\n", action.Name)
return
}
_, _ = fmt.Printf("Following fields have been changed:\n\n")
_ = cli.PrintDiff(res.GetName(), &res, result)
case lua.CreateOperation:
yamlBytes, err := yaml.Marshal(impactedResource.UnstructuredObj)
errors.CheckError(err)
fmt.Println("Following resource was created:")
fmt.Println(bytes.NewBuffer(yamlBytes).String())
}
}
_, _ = fmt.Printf("Following fields have been changed:\n\n")
_ = cli.PrintDiff(res.GetName(), &res, modifiedRes)
})
},
}

View File

@@ -226,6 +226,17 @@ spec:
replicas: 0`
)
const (
testCronJobYAML = `apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
namespace: test-ns
uid: "123"
spec:
schedule: "* * * * *"`
)
func tempFile(content string) (string, io.Closer, error) {
f, err := os.CreateTemp("", "*.yaml")
if err != nil {
@@ -335,6 +346,12 @@ func TestResourceOverrideAction(t *testing.T) {
}
defer utils.Close(closer)
cronJobFile, closer, err := tempFile(testCronJobYAML)
if !assert.NoError(t, err) {
return
}
defer utils.Close(closer)
t.Run("NoActions", func(t *testing.T) {
cmd := NewResourceOverridesCommand(newCmdContext(map[string]string{
"resource.customizations": `apps/Deployment: {}`}))
@@ -347,7 +364,7 @@ func TestResourceOverrideAction(t *testing.T) {
assert.Contains(t, out, "Actions are not configured")
})
t.Run("ActionConfigured", func(t *testing.T) {
t.Run("OldStyleActionConfigured", func(t *testing.T) {
cmd := NewResourceOverridesCommand(newCmdContext(map[string]string{
"resource.customizations": `apps/Deployment:
actions: |
@@ -381,4 +398,50 @@ restart false
resume false
`)
})
t.Run("NewStyleActionConfigured", func(t *testing.T) {
cmd := NewResourceOverridesCommand(newCmdContext(map[string]string{
"resource.customizations": `batch/CronJob:
actions: |
discovery.lua: |
actions = {}
actions["create-a-job"] = {["disabled"] = false}
return actions
definitions:
- name: test
action.lua: |
job1 = {}
job1.apiVersion = "batch/v1"
job1.kind = "Job"
job1.metadata = {}
job1.metadata.name = "hello-1"
job1.metadata.namespace = "obj.metadata.namespace"
impactedResource1 = {}
impactedResource1.operation = "create"
impactedResource1.resource = job1
result = {}
result[1] = impactedResource1
return result
`}))
out, err := captureStdout(func() {
cmd.SetArgs([]string{"run-action", cronJobFile, "test"})
err := cmd.Execute()
assert.NoError(t, err)
})
assert.NoError(t, err)
assert.Contains(t, out, "resource was created:")
assert.Contains(t, out, "hello-1")
out, err = captureStdout(func() {
cmd.SetArgs([]string{"list-actions", cronJobFile})
err := cmd.Execute()
assert.NoError(t, err)
})
assert.NoError(t, err)
assert.Contains(t, out, "NAME")
assert.Contains(t, out, "ENABLED")
assert.Contains(t, out, "create-a-job")
assert.Contains(t, out, "false")
})
}

View File

@@ -12,11 +12,30 @@ Argo CD supports custom resource actions written in [Lua](https://www.lua.org/).
* Have a custom resource for which Argo CD does not provide any built-in actions.
* Have a commonly performed manual task that might be error prone if executed by users via `kubectl`
The resource actions act on a single object.
You can define your own custom resource actions in the `argocd-cm` ConfigMap.
### Custom Resource Action Types
#### An action that modifies the source resource
This action modifies and returns the source resource.
This kind of action was the only one available till 2.8, and it is still supported.
#### An action that produces a list of new or modified resources
**An alpha feature, introduced in 2.8.**
This action returns a list of impacted resources, each impacted resource has a K8S resource and an operation to perform on.
Currently supported operations are "create" and "patch", "patch" is only supported for the source resource.
Creating new resources is possible, by specifying a "create" operation for each such resource in the returned list.
One of the returned resources can be the modified source object, with a "patch" operation, if needed.
See the definition examples below.
### Define a Custom Resource Action in `argocd-cm` ConfigMap
Custom resource actions can be defined in `resource.customizations.actions.<group_kind>` field of `argocd-cm`. Following example demonstrates a set of custom actions for `CronJob` resources.
Custom resource actions can be defined in `resource.customizations.actions.<group_kind>` field of `argocd-cm`. Following example demonstrates a set of custom actions for `CronJob` resources, each such action returns the modified CronJob.
The customizations key is in the format of `resource.customizations.actions.<apiGroup_Kind>`.
```yaml
@@ -51,4 +70,114 @@ resource.customizations.actions.batch_CronJob: |
The `discovery.lua` script must return a table where the key name represents the action name. You can optionally include logic to enable or disable certain actions based on the current object state.
Each action name must be represented in the list of `definitions` with an accompanying `action.lua` script to control the resource modifications. The `obj` is a global variable which contains the resource. Each action script must return an optionally modified version of the resource. In this example, we are simply setting `.spec.suspend` to either `true` or `false`.
Each action name must be represented in the list of `definitions` with an accompanying `action.lua` script to control the resource modifications. The `obj` is a global variable which contains the resource. Each action script returns an optionally modified version of the resource. In this example, we are simply setting `.spec.suspend` to either `true` or `false`.
#### Creating new resources with a custom action
!!! important
Creating resources via the Argo CD UI is an intentional, strategic departure from GitOps principles. We recommend
that you use this feature sparingly and only for resources that are not part of the desired state of the
application.
The resource the action is invoked on would be referred to as the `source resource`.
The new resource and all the resources implicitly created as a result, must be permitted on the AppProject level, otherwise the creation will fail.
##### Creating a source resource child resources with a custom action
If the new resource represents a k8s child of the source resource, the source resource ownerReference must be set on the new resource.
Here is an example Lua snippet, that takes care of constructing a Job resource that is a child of a source CronJob resource - the `obj` is a global variable, which contains the source resource:
```lua
-- ...
ownerRef = {}
ownerRef.apiVersion = obj.apiVersion
ownerRef.kind = obj.kind
ownerRef.name = obj.metadata.name
ownerRef.uid = obj.metadata.uid
job = {}
job.metadata = {}
job.metadata.ownerReferences = {}
job.metadata.ownerReferences[1] = ownerRef
-- ...
```
##### Creating independent child resources with a custom action
If the new resource is independent of the source resource, the default behavior of such new resource is that it is not known by the App of the source resource (as it is not part of the desired state and does not have an `ownerReference`).
To make the App aware of the new resource, the `app.kubernetes.io/instance` label (or other ArgoCD tracking label, if configured) must be set on the resource.
It can be copied from the source resource, like this:
```lua
-- ...
newObj = {}
newObj.metadata = {}
newObj.metadata.labels = {}
newObj.metadata.labels["app.kubernetes.io/instance"] = obj.metadata.labels["app.kubernetes.io/instance"]
-- ...
```
While the new resource will be part of the App with the tracking label in place, it will be immediately deleted if auto prune is set on the App.
To keep the resource, set `Prune=false` annotation on the resource, with this Lua snippet:
```lua
-- ...
newObj.metadata.annotations = {}
newObj.metadata.annotations["argocd.argoproj.io/sync-options"] = "Prune=false"
-- ...
```
(If setting `Prune=false` behavior, the resource will not be deleted upon the deletion of the App, and will require a manual cleanup).
The resource and the App will now appear out of sync - which is the expected ArgoCD behavior upon creating a resource that is not part of the desired state.
If you wish to treat such an App as a synced one, add the following resource annotation in Lua code:
```lua
-- ...
newObj.metadata.annotations["argocd.argoproj.io/compare-options"] = "IgnoreExtraneous"
-- ...
```
#### An action that produces a list of resources - a complete example:
```yaml
resource.customizations.actions.ConfigMap: |
discovery.lua: |
actions = {}
actions["do-things"] = {}
return actions
definitions:
- name: do-things
action.lua: |
-- Create a new ConfigMap
cm1 = {}
cm1.apiVersion = "v1"
cm1.kind = "ConfigMap"
cm1.metadata = {}
cm1.metadata.name = "cm1"
cm1.metadata.namespace = obj.metadata.namespace
cm1.metadata.labels = {}
-- Copy ArgoCD tracking label so that the resource is recognized by the App
cm1.metadata.labels["app.kubernetes.io/instance"] = obj.metadata.labels["app.kubernetes.io/instance"]
cm1.metadata.annotations = {}
-- For Apps with auto-prune, set the prune false on the resource, so it does not get deleted
cm1.metadata.annotations["argocd.argoproj.io/sync-options"] = "Prune=false"
-- Keep the App synced even though it has a resource that is not in Git
cm1.metadata.annotations["argocd.argoproj.io/compare-options"] = "IgnoreExtraneous"
cm1.data = {}
cm1.data.myKey1 = "myValue1"
impactedResource1 = {}
impactedResource1.operation = "create"
impactedResource1.resource = cm1
-- Patch the original cm
obj.metadata.labels["aKey"] = "aValue"
impactedResource2 = {}
impactedResource2.operation = "patch"
impactedResource2.resource = obj
result = {}
result[1] = impactedResource1
result[2] = impactedResource2
return result
```

View File

@@ -23,3 +23,45 @@ Prior to `v2.8`, the `List` endpoint on the `ClusterService` did **not** filter
clusters when responding, despite accepting query parameters. This bug has
been addressed, and query parameters are now taken into account to filter the
resulting list of clusters.
## Configure RBAC to account for new actions
2.8 introduces three new actions:
* Create a Job from a CronJob
* Create a Workflow from a CronWorkflow
* Create a Workflow from a WorkflowTemplate
When you upgrade to 2.8, RBAC policies with `applications` in the *resource*
field and `*` or `action/*` in the action field, it will automatically grant the
ability to use these new actions.
If you would like to avoid granting these new permissions, you can update your RBAC policies to be more specific.
### Example
Old:
```csv
p, role:action-runner, applications, actions/, *, allow
```
New:
```csv
p, role:action-runner, applications, action/argoproj.io/Rollout/abort, *, allow
p, role:action-runner, applications, action/argoproj.io/Rollout/promote-full, *, allow
p, role:action-runner, applications, action/argoproj.io/Rollout/retry, *, allow
p, role:action-runner, applications, action/argoproj.io/Rollout/resume, *, allow
p, role:action-runner, applications, action/argoproj.io/Rollout/restart, *, allow
p, role:action-runner, applications, action/argoproj.io/AnalysisRun/terminate, *, allow
p, role:action-runner, applications, action/apps/DaemonSet/restart, *, allow
p, role:action-runner, applications, action/apps/StatefulSet/restart, *, allow
p, role:action-runner, applications, action/apps/Deployment/pause, *, allow
p, role:action-runner, applications, action/apps/Deployment/resume, *, allow
p, role:action-runner, applications, action/apps/Deployment/restart, *, allow
# If you don't want to grant the new permissions, don't include the following lines
p, role:action-runner, applications, action/argoproj.io/WorkflowTemplate/create-workflow, *, allow
p, role:action-runner, applications, action/argoproj.io/CronWorkflow/create-workflow, *, allow
p, role:action-runner, applications, action/batch/CronJob/create-job, *, allow
```

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/TomOnTime/utfutil v0.0.0-20180511104225-09c41003ee1d
github.com/alicebob/miniredis/v2 v2.30.3
github.com/antonmedv/expr v1.12.5
github.com/argoproj/gitops-engine v0.7.1-0.20230526233214-ad9a694fe4bc
github.com/argoproj/gitops-engine v0.7.1-0.20230607163028-425d65e07695
github.com/argoproj/notifications-engine v0.4.1-0.20230620204159-3446d4ae8520
github.com/argoproj/pkg v0.13.7-0.20221221191914-44694015343d
github.com/aws/aws-sdk-go v1.44.288

4
go.sum
View File

@@ -121,8 +121,8 @@ github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20210826220005-b48c857c3a0e/go.m
github.com/antonmedv/expr v1.12.5 h1:Fq4okale9swwL3OeLLs9WD9H6GbgBLJyN/NUHRv+n0E=
github.com/antonmedv/expr v1.12.5/go.mod h1:FPC8iWArxls7axbVLsW+kpg1mz29A1b2M6jt+hZfDkU=
github.com/appscode/go v0.0.0-20191119085241-0887d8ec2ecc/go.mod h1:OawnOmAL4ZX3YaPdN+8HTNwBveT1jMsqP74moa9XUbE=
github.com/argoproj/gitops-engine v0.7.1-0.20230526233214-ad9a694fe4bc h1:i6OgOvFFsoWiGBFEhhDNcYoRtxxtrVwcD7wCEeqhct4=
github.com/argoproj/gitops-engine v0.7.1-0.20230526233214-ad9a694fe4bc/go.mod h1:WpA/B7tgwfz+sdNE3LqrTrb7ArEY1FOPI2pAGI0hfPc=
github.com/argoproj/gitops-engine v0.7.1-0.20230607163028-425d65e07695 h1:w8OPbqHyhWxLyC4LZgs5JBUe7AOkJpNZqFa92yy7Kmc=
github.com/argoproj/gitops-engine v0.7.1-0.20230607163028-425d65e07695/go.mod h1:WpA/B7tgwfz+sdNE3LqrTrb7ArEY1FOPI2pAGI0hfPc=
github.com/argoproj/notifications-engine v0.4.1-0.20230620204159-3446d4ae8520 h1:ZCpg1Zk78E8QxMI52w6ZIddxkBHv27YWmfWQdxxWUkw=
github.com/argoproj/notifications-engine v0.4.1-0.20230620204159-3446d4ae8520/go.mod h1:sbhf4EjAUGAqRdHIzifDIiWsjlsTfmytVJJCCiUdyVA=
github.com/argoproj/pkg v0.13.7-0.20221221191914-44694015343d h1:7fXEKF3OQ9i1PrgieA6FLrXOL3UAKyiotomn0RHevds=

View File

@@ -4,6 +4,8 @@ metadata:
annotations:
deployment.kubernetes.io/revision: "1"
creationTimestamp: "2021-09-21T22:35:20Z"
name: nginx-deploy
namespace: default
generation: 2
spec:
paused: true

View File

@@ -5,6 +5,8 @@ metadata:
deployment.kubernetes.io/revision: "1"
creationTimestamp: "2021-09-21T22:35:20Z"
generation: 3
name: nginx-deploy
namespace: default
spec:
progressDeadlineSeconds: 600
replicas: 3

View File

@@ -0,0 +1,4 @@
actionTests:
- action: create-workflow
inputPath: testdata/cronworkflow.yaml
expectedOutputPath: testdata/workflow.yaml

View File

@@ -0,0 +1,82 @@
local os = require("os")
-- This action constructs a Workflow resource from a CronWorkflow resource, to enable creating a CronWorkflow instance
-- on demand.
-- It returns an array with a single member - a table with the operation to perform (create) and the Workflow resource.
-- It mimics the output of "argo submit --from=CronWorkflow/<CRON_WORKFLOW_NAME>" command, declaratively.
-- This code is written to mimic what the Argo Workflows API server does to create a Workflow from a CronWorkflow.
-- https://github.com/argoproj/argo-workflows/blob/873a58de7dd9dad76d5577b8c4294a58b52849b8/workflow/common/convert.go#L12
-- Deep-copying an object is a ChatGPT generated code.
-- Since empty tables are treated as empty arrays, the resulting k8s resource might be invalid (arrays instead of maps).
-- So empty tables are not cloned to the target object.
function deepCopy(object)
local lookup_table = {}
local function _copy(obj)
if type(obj) ~= "table" then
return obj
elseif lookup_table[obj] then
return lookup_table[obj]
elseif next(obj) == nil then
return nil
else
local new_table = {}
lookup_table[obj] = new_table
for key, value in pairs(obj) do
new_table[_copy(key)] = _copy(value)
end
return setmetatable(new_table, getmetatable(obj))
end
end
return _copy(object)
end
workflow = {}
workflow.apiVersion = "argoproj.io/v1alpha1"
workflow.kind = "Workflow"
workflow.metadata = {}
workflow.metadata.name = obj.metadata.name .. "-" ..os.date("!%Y%m%d%H%M")
workflow.metadata.namespace = obj.metadata.namespace
workflow.metadata.labels = {}
workflow.metadata.annotations = {}
if (obj.spec.workflowMetadata ~= nil) then
if (obj.spec.workflowMetadata.labels ~= nil) then
workflow.metadata.labels = deepCopy(obj.spec.workflowMetadata.labels)
end
if (obj.spec.workflowMetadata.annotations ~= nil) then
workflow.metadata.annotations = deepCopy(obj.spec.workflowMetadata.annotations)
end
end
workflow.metadata.labels["workflows.argoproj.io/cron-workflow"] = obj.metadata.name
if (obj.metadata.labels["workflows.argoproj.io/controller-instanceid"] ~= nil) then
workflow.metadata.labels["workflows.argoproj.io/controller-instanceid"] = obj.metadata.labels["workflows.argoproj.io/controller-instanceid"]
end
workflow.metadata.annotations["workflows.argoproj.io/scheduled-time"] = os.date("!%Y-%m-%dT%d:%H:%MZ")
workflow.finalizers = {}
-- add all finalizers from obj.spec.workflowMetadata.finalizers
if (obj.spec.workflowMetadata ~= nil and obj.spec.workflowMetadata.finalizers ~= nil) then
for i, finalizer in ipairs(obj.spec.workflowMetadata.finalizers) do
workflow.finalizers[i] = finalizer
end
end
ownerRef = {}
ownerRef.apiVersion = obj.apiVersion
ownerRef.kind = obj.kind
ownerRef.name = obj.metadata.name
ownerRef.uid = obj.metadata.uid
workflow.metadata.ownerReferences = {}
workflow.metadata.ownerReferences[1] = ownerRef
workflow.spec = deepCopy(obj.spec.workflowSpec)
impactedResource = {}
impactedResource.operation = "create"
impactedResource.resource = workflow
result = {}
result[1] = impactedResource
return result

View File

@@ -0,0 +1,3 @@
actions = {}
actions["create-workflow"] = {}
return actions

View File

@@ -0,0 +1,34 @@
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
annotations:
cronworkflows.argoproj.io/last-used-schedule: CRON_TZ=America/Los_Angeles * * * * *
labels:
workflows.argoproj.io/controller-instanceid: test-instance
app.kubernetes.io/instance: test
name: hello-world
namespace: default
spec:
concurrencyPolicy: Replace
failedJobsHistoryLimit: 4
schedule: '* * * * *'
startingDeadlineSeconds: 0
successfulJobsHistoryLimit: 4
suspend: true
timezone: America/Los_Angeles
workflowSpec:
entrypoint: whalesay
templates:
- container:
args:
- "\U0001F553 hello world. Scheduled on: {{workflow.scheduledTime}}"
command:
- cowsay
image: 'docker/whalesay:latest'
name: whalesay
workflowMetadata:
labels:
example: test
annotations:
another-example: another-test
finalizers: [test-finalizer]

View File

@@ -0,0 +1,28 @@
- k8sOperation: create
unstructuredObj:
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
annotations:
another-example: another-test
labels:
workflows.argoproj.io/cron-workflow: hello-world
workflows.argoproj.io/controller-instanceid: test-instance
example: test
name: hello-world-202306221736
namespace: default
ownerReferences:
- apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
name: hello-world
finalizers: [test-finalizer]
spec:
entrypoint: whalesay
templates:
- container:
args:
- "\U0001F553 hello world. Scheduled on: {{workflow.scheduledTime}}"
command:
- cowsay
image: 'docker/whalesay:latest'
name: whalesay

View File

@@ -0,0 +1,4 @@
actionTests:
- action: create-workflow
inputPath: testdata/workflowtemplate.yaml
expectedOutputPath: testdata/workflow.yaml

View File

@@ -0,0 +1,39 @@
local os = require("os")
-- This action constructs a Workflow resource from a WorkflowTemplate resource, to enable creating a WorkflowTemplate instance
-- on demand.
-- It returns an array with a single member - a table with the operation to perform (create) and the Workflow resource.
-- It mimics the output of "argo submit --from=workflowtemplate/<WORKFLOW_TEMPLATE_NAME>" command, declaratively.
-- This code is written to mimic what the Argo Workflows API server does to create a Workflow from a WorkflowTemplate.
-- https://github.com/argoproj/argo-workflows/blob/873a58de7dd9dad76d5577b8c4294a58b52849b8/workflow/common/convert.go#L34
workflow = {}
workflow.apiVersion = "argoproj.io/v1alpha1"
workflow.kind = "Workflow"
workflow.metadata = {}
workflow.metadata.name = obj.metadata.name .. "-" ..os.date("!%Y%m%d%H%M")
workflow.metadata.namespace = obj.metadata.namespace
workflow.metadata.labels = {}
workflow.metadata.labels["workflows.argoproj.io/workflow-template"] = obj.metadata.name
workflow.spec = {}
workflow.spec.workflowTemplateRef = {}
workflow.spec.workflowTemplateRef.name = obj.metadata.name
ownerRef = {}
ownerRef.apiVersion = obj.apiVersion
ownerRef.kind = obj.kind
ownerRef.name = obj.metadata.name
ownerRef.uid = obj.metadata.uid
workflow.metadata.ownerReferences = {}
workflow.metadata.ownerReferences[1] = ownerRef
impactedResource = {}
impactedResource.operation = "create"
impactedResource.resource = workflow
result = {}
result[1] = impactedResource
return result

View File

@@ -0,0 +1,3 @@
actions = {}
actions["create-workflow"] = {}
return actions

View File

@@ -0,0 +1,16 @@
- k8sOperation: create
unstructuredObj:
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
labels:
workflows.argoproj.io/workflow-template: workflow-template-submittable
name: workflow-template-submittable-202306221735
namespace: default
ownerReferences:
- apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
name: workflow-template-submittable
spec:
workflowTemplateRef:
name: workflow-template-submittable

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
labels:
app.kubernetes.io/instance: test
name: workflow-template-submittable
namespace: default
spec:
arguments:
parameters:
- name: message
value: hello world
entrypoint: whalesay-template
templates:
- container:
args:
- '{{inputs.parameters.message}}'
command:
- cowsay
image: docker/whalesay
inputs:
parameters:
- name: message
name: whalesay-template

View File

@@ -0,0 +1,4 @@
actionTests:
- action: create-job
inputPath: testdata/cronjob.yaml
expectedOutputPath: testdata/job.yaml

View File

@@ -0,0 +1,58 @@
local os = require("os")
-- This action constructs a Job resource from a CronJob resource, to enable creating a CronJob instance on demand.
-- It returns an array with a single member - a table with the operation to perform (create) and the Job resource.
-- It mimics the output of "kubectl create job --from=<CRON_JOB_NAME>" command, declaratively.
-- Deep-copying an object is a ChatGPT generated code.
-- Since empty tables are treated as empty arrays, the resulting k8s resource might be invalid (arrays instead of maps).
-- So empty tables are not cloned to the target object.
function deepCopy(object)
local lookup_table = {}
local function _copy(obj)
if type(obj) ~= "table" then
return obj
elseif lookup_table[obj] then
return lookup_table[obj]
elseif next(obj) == nil then
return nil
else
local new_table = {}
lookup_table[obj] = new_table
for key, value in pairs(obj) do
new_table[_copy(key)] = _copy(value)
end
return setmetatable(new_table, getmetatable(obj))
end
end
return _copy(object)
end
job = {}
job.apiVersion = "batch/v1"
job.kind = "Job"
job.metadata = {}
job.metadata.name = obj.metadata.name .. "-" ..os.date("!%Y%m%d%H%M")
job.metadata.namespace = obj.metadata.namespace
ownerRef = {}
ownerRef.apiVersion = obj.apiVersion
ownerRef.kind = obj.kind
ownerRef.name = obj.metadata.name
ownerRef.uid = obj.metadata.uid
job.metadata.ownerReferences = {}
job.metadata.ownerReferences[1] = ownerRef
job.spec = {}
job.spec.suspend = false
job.spec.template = {}
job.spec.template.spec = deepCopy(obj.spec.jobTemplate.spec.template.spec)
impactedResource = {}
impactedResource.operation = "create"
impactedResource.resource = job
result = {}
result[1] = impactedResource
return result

View File

@@ -0,0 +1,3 @@
actions = {}
actions["create-job"] = {}
return actions

View File

@@ -0,0 +1,22 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
namespace: test-ns
uid: "123"
spec:
schedule: "* * * * *"
jobTemplate:
spec:
template:
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,19 @@
- k8sOperation: create
unstructuredObj:
apiVersion: batch/v1
kind: Job
metadata:
name: hello-00000000000
namespace: test-ns
spec:
template:
spec:
containers:
- name: hello
image: busybox:1.28
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

View File

@@ -28,6 +28,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
@@ -2068,6 +2069,7 @@ func (s *Server) getUnstructuredLiveResourceOrApp(ctx context.Context, rbacReque
return nil, nil, nil, nil, err
}
obj, err = s.kubectl.GetResource(ctx, config, res.GroupKindVersion(), res.Name, res.Namespace)
}
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("error getting resource: %w", err)
@@ -2111,6 +2113,11 @@ func (s *Server) RunResourceAction(ctx context.Context, q *application.ResourceA
return nil, err
}
liveObjBytes, err := json.Marshal(liveObj)
if err != nil {
return nil, fmt.Errorf("error marshaling live object: %w", err)
}
resourceOverrides, err := s.settingsMgr.GetResourceOverrides()
if err != nil {
return nil, fmt.Errorf("error getting resource overrides: %w", err)
@@ -2124,21 +2131,80 @@ func (s *Server) RunResourceAction(ctx context.Context, q *application.ResourceA
return nil, fmt.Errorf("error getting Lua resource action: %w", err)
}
newObj, err := luaVM.ExecuteResourceAction(liveObj, action.ActionLua)
newObjects, err := luaVM.ExecuteResourceAction(liveObj, action.ActionLua)
if err != nil {
return nil, fmt.Errorf("error executing Lua resource action: %w", err)
}
newObjBytes, err := json.Marshal(newObj)
if err != nil {
return nil, fmt.Errorf("error marshaling new object: %w", err)
var app *appv1.Application
// Only bother getting the app if we know we're going to need it for a resource permission check.
if len(newObjects) > 0 {
// No need for an RBAC check, we checked above that the user is allowed to run this action.
app, err = s.appLister.Applications(s.appNamespaceOrDefault(q.GetAppNamespace())).Get(q.GetName())
if err != nil {
return nil, err
}
}
liveObjBytes, err := json.Marshal(liveObj)
if err != nil {
return nil, fmt.Errorf("error marshaling live object: %w", err)
// First, make sure all the returned resources are permitted, for each operation.
// Also perform create with dry-runs for all create-operation resources.
// This is performed separately to reduce the risk of only some of the resources being successfully created later.
// TODO: when apply/delete operations would be supported for custom actions,
// the dry-run for relevant apply/delete operation would have to be invoked as well.
for _, impactedResource := range newObjects {
newObj := impactedResource.UnstructuredObj
err := s.verifyResourcePermitted(ctx, app, newObj)
if err != nil {
return nil, err
}
switch impactedResource.K8SOperation {
case lua.CreateOperation:
createOptions := metav1.CreateOptions{DryRun: []string{"All"}}
_, err := s.kubectl.CreateResource(ctx, config, newObj.GroupVersionKind(), newObj.GetName(), newObj.GetNamespace(), newObj, createOptions)
if err != nil {
return nil, err
}
}
}
// Now, perform the actual operations.
// The creation itself is not transactional.
// TODO: maybe create a k8s list representation of the resources,
// and invoke create on this list resource to make it semi-transactional (there is still patch operation that is separate,
// thus can fail separately from create).
for _, impactedResource := range newObjects {
newObj := impactedResource.UnstructuredObj
newObjBytes, err := json.Marshal(newObj)
if err != nil {
return nil, fmt.Errorf("error marshaling new object: %w", err)
}
switch impactedResource.K8SOperation {
// No default case since a not supported operation would have failed upon unmarshaling earlier
case lua.PatchOperation:
_, err := s.patchResource(ctx, config, liveObjBytes, newObjBytes, newObj)
if err != nil {
return nil, err
}
case lua.CreateOperation:
_, err := s.createResource(ctx, config, newObj)
if err != nil {
return nil, err
}
}
}
if res == nil {
s.logAppEvent(a, ctx, argo.EventReasonResourceActionRan, fmt.Sprintf("ran action %s", q.GetAction()))
} else {
s.logAppEvent(a, ctx, argo.EventReasonResourceActionRan, fmt.Sprintf("ran action %s on resource %s/%s/%s", q.GetAction(), res.Group, res.Kind, res.Name))
s.logResourceEvent(res, ctx, argo.EventReasonResourceActionRan, fmt.Sprintf("ran action %s", q.GetAction()))
}
return &application.ApplicationResponse{}, nil
}
func (s *Server) patchResource(ctx context.Context, config *rest.Config, liveObjBytes, newObjBytes []byte, newObj *unstructured.Unstructured) (*application.ApplicationResponse, error) {
diffBytes, err := jsonpatch.CreateMergePatch(liveObjBytes, newObjBytes)
if err != nil {
return nil, fmt.Errorf("error calculating merge patch: %w", err)
@@ -2178,12 +2244,38 @@ func (s *Server) RunResourceAction(ctx context.Context, q *application.ResourceA
return nil, fmt.Errorf("error patching resource: %w", err)
}
}
return &application.ApplicationResponse{}, nil
}
if res == nil {
s.logAppEvent(a, ctx, argo.EventReasonResourceActionRan, fmt.Sprintf("ran action %s", q.GetAction()))
} else {
s.logAppEvent(a, ctx, argo.EventReasonResourceActionRan, fmt.Sprintf("ran action %s on resource %s/%s/%s", q.GetAction(), res.Group, res.Kind, res.Name))
s.logResourceEvent(res, ctx, argo.EventReasonResourceActionRan, fmt.Sprintf("ran action %s", q.GetAction()))
func (s *Server) verifyResourcePermitted(ctx context.Context, app *appv1.Application, obj *unstructured.Unstructured) error {
proj, err := argo.GetAppProject(app, applisters.NewAppProjectLister(s.projInformer.GetIndexer()), s.ns, s.settingsMgr, s.db, ctx)
if err != nil {
if apierr.IsNotFound(err) {
return fmt.Errorf("application references project %s which does not exist", app.Spec.Project)
}
return fmt.Errorf("failed to get project %s: %w", app.Spec.Project, err)
}
permitted, err := proj.IsResourcePermitted(schema.GroupKind{Group: obj.GroupVersionKind().Group, Kind: obj.GroupVersionKind().Kind}, obj.GetNamespace(), app.Spec.Destination, func(project string) ([]*appv1.Cluster, error) {
clusters, err := s.db.GetProjectClusters(context.TODO(), project)
if err != nil {
return nil, fmt.Errorf("failed to get project clusters: %w", err)
}
return clusters, nil
})
if err != nil {
return fmt.Errorf("error checking resource permissions: %w", err)
}
if !permitted {
return fmt.Errorf("application %s is not permitted to manage %s/%s/%s in %s", app.RBACName(s.ns), obj.GroupVersionKind().Group, obj.GroupVersionKind().Kind, obj.GetName(), obj.GetNamespace())
}
return nil
}
func (s *Server) createResource(ctx context.Context, config *rest.Config, newObj *unstructured.Unstructured) (*application.ApplicationResponse, error) {
_, err := s.kubectl.CreateResource(ctx, config, newObj.GroupVersionKind(), newObj.GetName(), newObj.GetNamespace(), newObj, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("error creating resource: %w", err)
}
return &application.ApplicationResponse{}, nil
}

View File

@@ -23,6 +23,8 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
k8sappsv1 "k8s.io/api/apps/v1"
k8sbatchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -2000,3 +2002,203 @@ func TestInferResourcesStatusHealth(t *testing.T) {
assert.Equal(t, health.HealthStatusDegraded, testApp.Status.Resources[0].Health.Status)
assert.Nil(t, testApp.Status.Resources[1].Health)
}
func TestRunNewStyleResourceAction(t *testing.T) {
cacheClient := cacheutil.NewCache(cacheutil.NewInMemoryCache(1 * time.Hour))
group := "batch"
kind := "CronJob"
version := "v1"
resourceName := "my-cron-job"
namespace := testNamespace
action := "create-job"
uid := "1"
resources := []appsv1.ResourceStatus{{
Group: group,
Kind: kind,
Name: resourceName,
Namespace: testNamespace,
Version: version,
}}
appStateCache := appstate.NewCache(cacheClient, time.Minute)
nodes := []appsv1.ResourceNode{{
ResourceRef: appsv1.ResourceRef{
Group: group,
Kind: kind,
Version: version,
Name: resourceName,
Namespace: testNamespace,
UID: uid,
},
}}
createJobDenyingProj := &appsv1.AppProject{
ObjectMeta: metav1.ObjectMeta{Name: "createJobDenyingProj", Namespace: "default"},
Spec: appsv1.AppProjectSpec{
SourceRepos: []string{"*"},
Destinations: []appsv1.ApplicationDestination{{Server: "*", Namespace: "*"}},
NamespaceResourceWhitelist: []metav1.GroupKind{{Group: "never", Kind: "mind"}},
},
}
cronJob := k8sbatchv1.CronJob{
TypeMeta: metav1.TypeMeta{
APIVersion: "batch/v1",
Kind: "CronJob",
},
ObjectMeta: metav1.ObjectMeta{
Name: "my-cron-job",
Namespace: testNamespace,
Labels: map[string]string{
"some": "label",
},
},
Spec: k8sbatchv1.CronJobSpec{
Schedule: "* * * * *",
JobTemplate: k8sbatchv1.JobTemplateSpec{
Spec: k8sbatchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "hello",
Image: "busybox:1.28",
ImagePullPolicy: "IfNotPresent",
Command: []string{"/bin/sh", "-c", "date; echo Hello from the Kubernetes cluster"},
},
},
RestartPolicy: corev1.RestartPolicyOnFailure,
},
},
},
},
},
}
t.Run("CreateOperationNotPermitted", func(t *testing.T) {
testApp := newTestApp()
testApp.Spec.Project = "createJobDenyingProj"
testApp.Status.ResourceHealthSource = appsv1.ResourceHealthLocationAppTree
testApp.Status.Resources = resources
appServer := newTestAppServer(t, testApp, createJobDenyingProj, kube.MustToUnstructured(&cronJob))
appServer.cache = servercache.NewCache(appStateCache, time.Minute, time.Minute, time.Minute)
err := appStateCache.SetAppResourcesTree(testApp.Name, &appsv1.ApplicationTree{Nodes: nodes})
require.NoError(t, err)
appResponse, runErr := appServer.RunResourceAction(context.Background(), &application.ResourceActionRunRequest{
Name: &testApp.Name,
Namespace: &namespace,
Action: &action,
AppNamespace: &testApp.Namespace,
ResourceName: &resourceName,
Version: &version,
Group: &group,
Kind: &kind,
})
assert.Contains(t, runErr.Error(), "is not permitted to manage")
assert.Nil(t, appResponse)
})
t.Run("CreateOperationPermitted", func(t *testing.T) {
testApp := newTestApp()
testApp.Status.ResourceHealthSource = appsv1.ResourceHealthLocationAppTree
testApp.Status.Resources = resources
appServer := newTestAppServer(t, testApp, kube.MustToUnstructured(&cronJob))
appServer.cache = servercache.NewCache(appStateCache, time.Minute, time.Minute, time.Minute)
err := appStateCache.SetAppResourcesTree(testApp.Name, &appsv1.ApplicationTree{Nodes: nodes})
require.NoError(t, err)
appResponse, runErr := appServer.RunResourceAction(context.Background(), &application.ResourceActionRunRequest{
Name: &testApp.Name,
Namespace: &namespace,
Action: &action,
AppNamespace: &testApp.Namespace,
ResourceName: &resourceName,
Version: &version,
Group: &group,
Kind: &kind,
})
require.NoError(t, runErr)
assert.NotNil(t, appResponse)
})
}
func TestRunOldStyleResourceAction(t *testing.T) {
cacheClient := cacheutil.NewCache(cacheutil.NewInMemoryCache(1 * time.Hour))
group := "apps"
kind := "Deployment"
version := "v1"
resourceName := "nginx-deploy"
namespace := testNamespace
action := "pause"
uid := "2"
resources := []appsv1.ResourceStatus{{
Group: group,
Kind: kind,
Name: resourceName,
Namespace: testNamespace,
Version: version,
}}
appStateCache := appstate.NewCache(cacheClient, time.Minute)
nodes := []appsv1.ResourceNode{{
ResourceRef: appsv1.ResourceRef{
Group: group,
Kind: kind,
Version: version,
Name: resourceName,
Namespace: testNamespace,
UID: uid,
},
}}
deployment := k8sappsv1.Deployment{
TypeMeta: metav1.TypeMeta{
APIVersion: "apps/v1",
Kind: "Deployment",
},
ObjectMeta: metav1.ObjectMeta{
Name: "nginx-deploy",
Namespace: testNamespace,
},
}
t.Run("DefaultPatchOperation", func(t *testing.T) {
testApp := newTestApp()
testApp.Status.ResourceHealthSource = appsv1.ResourceHealthLocationAppTree
testApp.Status.Resources = resources
// appServer := newTestAppServer(t, testApp, returnDeployment())
appServer := newTestAppServer(t, testApp, kube.MustToUnstructured(&deployment))
appServer.cache = servercache.NewCache(appStateCache, time.Minute, time.Minute, time.Minute)
err := appStateCache.SetAppResourcesTree(testApp.Name, &appsv1.ApplicationTree{Nodes: nodes})
require.NoError(t, err)
appResponse, runErr := appServer.RunResourceAction(context.Background(), &application.ResourceActionRunRequest{
Name: &testApp.Name,
Namespace: &namespace,
Action: &action,
AppNamespace: &testApp.Namespace,
ResourceName: &resourceName,
Version: &version,
Group: &group,
Kind: &kind,
})
require.NoError(t, runErr)
assert.NotNil(t, appResponse)
})
}

View File

@@ -50,6 +50,7 @@ const (
guestbookPathLocal = "./testdata/guestbook_local"
globalWithNoNameSpace = "global-with-no-namespace"
guestbookWithNamespace = "guestbook-with-namespace"
resourceActions = "resource-actions"
appLogsRetryCount = 5
)
@@ -939,7 +940,7 @@ definitions:
obj.metadata.labels.sample = 'test'
return obj`
func TestResourceAction(t *testing.T) {
func TestOldStyleResourceAction(t *testing.T) {
Given(t).
Path(guestbookPath).
ResourceOverrides(map[string]ResourceOverride{"apps/Deployment": {Actions: actionsConfig}}).
@@ -981,6 +982,224 @@ func TestResourceAction(t *testing.T) {
})
}
const newStyleActionsConfig = `discovery.lua: return { sample = {} }
definitions:
- name: sample
action.lua: |
local os = require("os")
function deepCopy(object)
local lookup_table = {}
local function _copy(obj)
if type(obj) ~= "table" then
return obj
elseif lookup_table[obj] then
return lookup_table[obj]
elseif next(obj) == nil then
return nil
else
local new_table = {}
lookup_table[obj] = new_table
for key, value in pairs(obj) do
new_table[_copy(key)] = _copy(value)
end
return setmetatable(new_table, getmetatable(obj))
end
end
return _copy(object)
end
job = {}
job.apiVersion = "batch/v1"
job.kind = "Job"
job.metadata = {}
job.metadata.name = obj.metadata.name .. "-123"
job.metadata.namespace = obj.metadata.namespace
ownerRef = {}
ownerRef.apiVersion = obj.apiVersion
ownerRef.kind = obj.kind
ownerRef.name = obj.metadata.name
ownerRef.uid = obj.metadata.uid
job.metadata.ownerReferences = {}
job.metadata.ownerReferences[1] = ownerRef
job.spec = {}
job.spec.suspend = false
job.spec.template = {}
job.spec.template.spec = deepCopy(obj.spec.jobTemplate.spec.template.spec)
impactedResource = {}
impactedResource.operation = "create"
impactedResource.resource = job
result = {}
result[1] = impactedResource
return result`
func TestNewStyleResourceActionPermitted(t *testing.T) {
Given(t).
Path(resourceActions).
ResourceOverrides(map[string]ResourceOverride{"batch/CronJob": {Actions: newStyleActionsConfig}}).
ProjectSpec(AppProjectSpec{
SourceRepos: []string{"*"},
Destinations: []ApplicationDestination{{Namespace: "*", Server: "*"}},
NamespaceResourceWhitelist: []metav1.GroupKind{
{Group: "batch", Kind: "Job"},
{Group: "batch", Kind: "CronJob"},
}}).
When().
CreateApp().
Sync().
Then().
And(func(app *Application) {
closer, client, err := ArgoCDClientset.NewApplicationClient()
assert.NoError(t, err)
defer io.Close(closer)
actions, err := client.ListResourceActions(context.Background(), &applicationpkg.ApplicationResourceRequest{
Name: &app.Name,
Group: pointer.String("batch"),
Kind: pointer.String("CronJob"),
Version: pointer.String("v1"),
Namespace: pointer.String(DeploymentNamespace()),
ResourceName: pointer.String("hello"),
})
assert.NoError(t, err)
assert.Equal(t, []*ResourceAction{{Name: "sample", Disabled: false}}, actions.Actions)
_, err = client.RunResourceAction(context.Background(), &applicationpkg.ResourceActionRunRequest{Name: &app.Name,
Group: pointer.String("batch"),
Kind: pointer.String("CronJob"),
Version: pointer.String("v1"),
Namespace: pointer.String(DeploymentNamespace()),
ResourceName: pointer.String("hello"),
Action: pointer.String("sample"),
})
assert.NoError(t, err)
_, err = KubeClientset.BatchV1().Jobs(DeploymentNamespace()).Get(context.Background(), "hello-123", metav1.GetOptions{})
assert.NoError(t, err)
})
}
const newStyleActionsConfigMixedOk = `discovery.lua: return { sample = {} }
definitions:
- name: sample
action.lua: |
local os = require("os")
function deepCopy(object)
local lookup_table = {}
local function _copy(obj)
if type(obj) ~= "table" then
return obj
elseif lookup_table[obj] then
return lookup_table[obj]
elseif next(obj) == nil then
return nil
else
local new_table = {}
lookup_table[obj] = new_table
for key, value in pairs(obj) do
new_table[_copy(key)] = _copy(value)
end
return setmetatable(new_table, getmetatable(obj))
end
end
return _copy(object)
end
job = {}
job.apiVersion = "batch/v1"
job.kind = "Job"
job.metadata = {}
job.metadata.name = obj.metadata.name .. "-123"
job.metadata.namespace = obj.metadata.namespace
ownerRef = {}
ownerRef.apiVersion = obj.apiVersion
ownerRef.kind = obj.kind
ownerRef.name = obj.metadata.name
ownerRef.uid = obj.metadata.uid
job.metadata.ownerReferences = {}
job.metadata.ownerReferences[1] = ownerRef
job.spec = {}
job.spec.suspend = false
job.spec.template = {}
job.spec.template.spec = deepCopy(obj.spec.jobTemplate.spec.template.spec)
impactedResource1 = {}
impactedResource1.operation = "create"
impactedResource1.resource = job
result = {}
result[1] = impactedResource1
obj.metadata.labels["aKey"] = 'aValue'
impactedResource2 = {}
impactedResource2.operation = "patch"
impactedResource2.resource = obj
result[2] = impactedResource2
return result`
func TestNewStyleResourceActionMixedOk(t *testing.T) {
Given(t).
Path(resourceActions).
ResourceOverrides(map[string]ResourceOverride{"batch/CronJob": {Actions: newStyleActionsConfigMixedOk}}).
ProjectSpec(AppProjectSpec{
SourceRepos: []string{"*"},
Destinations: []ApplicationDestination{{Namespace: "*", Server: "*"}},
NamespaceResourceWhitelist: []metav1.GroupKind{
{Group: "batch", Kind: "Job"},
{Group: "batch", Kind: "CronJob"},
}}).
When().
CreateApp().
Sync().
Then().
And(func(app *Application) {
closer, client, err := ArgoCDClientset.NewApplicationClient()
assert.NoError(t, err)
defer io.Close(closer)
actions, err := client.ListResourceActions(context.Background(), &applicationpkg.ApplicationResourceRequest{
Name: &app.Name,
Group: pointer.String("batch"),
Kind: pointer.String("CronJob"),
Version: pointer.String("v1"),
Namespace: pointer.String(DeploymentNamespace()),
ResourceName: pointer.String("hello"),
})
assert.NoError(t, err)
assert.Equal(t, []*ResourceAction{{Name: "sample", Disabled: false}}, actions.Actions)
_, err = client.RunResourceAction(context.Background(), &applicationpkg.ResourceActionRunRequest{Name: &app.Name,
Group: pointer.String("batch"),
Kind: pointer.String("CronJob"),
Version: pointer.String("v1"),
Namespace: pointer.String(DeploymentNamespace()),
ResourceName: pointer.String("hello"),
Action: pointer.String("sample"),
})
assert.NoError(t, err)
// Assert new Job was created
_, err = KubeClientset.BatchV1().Jobs(DeploymentNamespace()).Get(context.Background(), "hello-123", metav1.GetOptions{})
assert.NoError(t, err)
// Assert the original CronJob was patched
cronJob, err := KubeClientset.BatchV1().CronJobs(DeploymentNamespace()).Get(context.Background(), "hello", metav1.GetOptions{})
assert.Equal(t, "aValue", cronJob.Labels["aKey"])
assert.NoError(t, err)
})
}
func TestSyncResourceByLabel(t *testing.T) {
Given(t).
Path(guestbookPath).

View File

@@ -0,0 +1,19 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
spec:
schedule: "* * * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: hello
image: busybox:1.28
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

View File

@@ -1,19 +1,22 @@
package lua
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/argoproj/gitops-engine/pkg/diff"
"github.com/stretchr/testify/assert"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"
"github.com/argoproj/gitops-engine/pkg/diff"
appsv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/v2/util/cli"
"github.com/argoproj/argo-cd/v2/util/errors"
)
type testNormalizer struct{}
@@ -23,6 +26,13 @@ func (t testNormalizer) Normalize(un *unstructured.Unstructured) error {
return nil
}
switch un.GetKind() {
case "Job":
err := unstructured.SetNestedField(un.Object, map[string]interface{}{"name": "not sure why this works"}, "metadata")
if err != nil {
return fmt.Errorf("failed to normalize Job: %w", err)
}
}
switch un.GetKind() {
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 {
@@ -53,6 +63,19 @@ func (t testNormalizer) Normalize(un *unstructured.Unstructured) error {
if err != nil {
return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err)
}
case "Workflow":
err := unstructured.SetNestedField(un.Object, nil, "metadata", "resourceVersion")
if err != nil {
return fmt.Errorf("failed to normalize Rollout: %w", err)
}
err = unstructured.SetNestedField(un.Object, nil, "metadata", "uid")
if err != nil {
return fmt.Errorf("failed to normalize Rollout: %w", err)
}
err = unstructured.SetNestedField(un.Object, nil, "metadata", "annotations", "workflows.argoproj.io/scheduled-time")
if err != nil {
return fmt.Errorf("failed to normalize Rollout: %w", err)
}
}
return nil
}
@@ -107,6 +130,7 @@ func TestLuaResourceActionsScript(t *testing.T) {
for i := range resourceTest.ActionTests {
test := resourceTest.ActionTests[i]
testName := fmt.Sprintf("actions/%s/%s", test.Action, test.InputPath)
t.Run(testName, func(t *testing.T) {
vm := VM{
// Uncomment the following line if you need to use lua libraries debugging
@@ -114,22 +138,59 @@ func TestLuaResourceActionsScript(t *testing.T) {
// privileges that API server has.
//UseOpenLibs: true,
}
obj := getObj(filepath.Join(dir, test.InputPath))
action, err := vm.GetResourceAction(obj, test.Action)
sourceObj := getObj(filepath.Join(dir, test.InputPath))
action, err := vm.GetResourceAction(sourceObj, test.Action)
assert.NoError(t, err)
assert.NoError(t, err)
result, err := vm.ExecuteResourceAction(obj, action.ActionLua)
impactedResources, err := vm.ExecuteResourceAction(sourceObj, action.ActionLua)
assert.NoError(t, err)
expectedObj := getObj(filepath.Join(dir, test.ExpectedOutputPath))
// Ideally, we would use a assert.Equal to detect the difference, but the Lua VM returns a object with float64 instead of the original int32. As a result, the assert.Equal is never true despite that the change has been applied.
diffResult, err := diff.Diff(expectedObj, result, diff.WithNormalizer(testNormalizer{}))
assert.NoError(t, err)
if diffResult.Modified {
t.Error("Output does not match input:")
err = cli.PrintDiff(test.Action, expectedObj, result)
// Treat the Lua expected output as a list
expectedObjects := getExpectedObjectList(t, filepath.Join(dir, test.ExpectedOutputPath))
for _, impactedResource := range impactedResources {
result := impactedResource.UnstructuredObj
// The expected output is a list of objects
// Find the actual impacted resource in the expected output
expectedObj := findFirstMatchingItem(expectedObjects.Items, func(u unstructured.Unstructured) bool {
// Some resources' name is derived from the source object name, so the returned name is not actually equal to the testdata output name
// Considering the resource found in the testdata output if its name starts with source object name
// TODO: maybe this should use a normalizer function instead of hard-coding the resource specifics here
if (result.GetKind() == "Job" && sourceObj.GetKind() == "CronJob") || (result.GetKind() == "Workflow" && (sourceObj.GetKind() == "CronWorkflow" || sourceObj.GetKind() == "WorkflowTemplate")) {
return u.GroupVersionKind() == result.GroupVersionKind() && strings.HasPrefix(u.GetName(), sourceObj.GetName()) && u.GetNamespace() == result.GetNamespace()
} else {
return u.GroupVersionKind() == result.GroupVersionKind() && u.GetName() == result.GetName() && u.GetNamespace() == result.GetNamespace()
}
})
assert.NotNil(t, expectedObj)
switch impactedResource.K8SOperation {
// No default case since a not supported operation would have failed upon unmarshaling earlier
case PatchOperation:
// Patching is only allowed for the source resource, so the GVK + name + ns must be the same as the impacted resource
assert.EqualValues(t, sourceObj.GroupVersionKind(), result.GroupVersionKind())
assert.EqualValues(t, sourceObj.GetName(), result.GetName())
assert.EqualValues(t, sourceObj.GetNamespace(), result.GetNamespace())
case CreateOperation:
switch result.GetKind() {
case "Job":
case "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())
}
}
// Ideally, we would use a assert.Equal to detect the difference, but the Lua VM returns a object with float64 instead of the original int32. As a result, the assert.Equal is never true despite that the change has been applied.
diffResult, err := diff.Diff(expectedObj, result, diff.WithNormalizer(testNormalizer{}))
assert.NoError(t, err)
if diffResult.Modified {
t.Error("Output does not match input:")
err = cli.PrintDiff(test.Action, expectedObj, result)
assert.NoError(t, err)
}
}
})
}
@@ -138,3 +199,46 @@ func TestLuaResourceActionsScript(t *testing.T) {
})
assert.Nil(t, err)
}
// Handling backward compatibility.
// The old-style actions return a single object in the expected output from testdata, so will wrap them in a list
func getExpectedObjectList(t *testing.T, path string) *unstructured.UnstructuredList {
yamlBytes, err := os.ReadFile(path)
errors.CheckError(err)
unstructuredList := &unstructured.UnstructuredList{}
yamlString := bytes.NewBuffer(yamlBytes).String()
if yamlString[0] == '-' {
// The string represents a new-style action array output, where each member is a wrapper around a k8s unstructured resource
objList := make([]map[string]interface{}, 5)
err = yaml.Unmarshal(yamlBytes, &objList)
errors.CheckError(err)
unstructuredList.Items = make([]unstructured.Unstructured, len(objList))
// Append each map in objList to the Items field of the new object
for i, obj := range objList {
unstructuredObj, ok := obj["unstructuredObj"].(map[string]interface{})
if !ok {
t.Error("Wrong type of unstructuredObj")
}
unstructuredList.Items[i] = unstructured.Unstructured{Object: unstructuredObj}
}
} else {
// The string represents an old-style action object output, which is a k8s unstructured resource
obj := make(map[string]interface{})
err = yaml.Unmarshal(yamlBytes, &obj)
errors.CheckError(err)
unstructuredList.Items = make([]unstructured.Unstructured, 1)
unstructuredList.Items[0] = unstructured.Unstructured{Object: obj}
}
return unstructuredList
}
func findFirstMatchingItem(items []unstructured.Unstructured, f func(unstructured.Unstructured) bool) *unstructured.Unstructured {
var matching *unstructured.Unstructured = nil
for _, item := range items {
if f(item) {
matching = &item
break
}
}
return matching
}

View File

@@ -29,6 +29,7 @@ func getObj(path string) *unstructured.Unstructured {
obj := make(map[string]interface{})
err = yaml.Unmarshal(yamlBytes, &obj)
errors.CheckError(err)
return &unstructured.Unstructured{Object: obj}
}

View File

@@ -0,0 +1,50 @@
package lua
import (
"fmt"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// This struct represents a wrapper, that is returned from Lua custom action script, around the unstructured k8s resource + a k8s operation
// that will need to be performed on this returned resource.
// Currently only "create" and "patch" operations are supported for custom actions.
// This replaces the traditional architecture of "Lua action returns the source resource for ArgoCD to patch".
// This enables ArgoCD to create NEW resources upon custom action.
// Note that the Lua code in the custom action is coupled to this type, since Lua json output is then unmarshalled to this struct.
// Avoided using iota, since need the mapping of the string value the end users will write in Lua code ("create" and "patch").
// TODO: maybe there is a nicer general way to marshal and unmarshal, instead of explicit iteration over the enum values.
type K8SOperation string
const (
CreateOperation K8SOperation = "create"
PatchOperation K8SOperation = "patch"
)
type ImpactedResource struct {
UnstructuredObj *unstructured.Unstructured `json:"resource"`
K8SOperation K8SOperation `json:"operation"`
}
func (op *K8SOperation) UnmarshalJSON(data []byte) error {
switch string(data) {
case `"create"`:
*op = CreateOperation
case `"patch"`:
*op = PatchOperation
default:
return fmt.Errorf("unsupported operation: %s", data)
}
return nil
}
func (op K8SOperation) MarshalJSON() ([]byte, error) {
switch op {
case CreateOperation:
return []byte(`"create"`), nil
case PatchOperation:
return []byte(`"patch"`), nil
default:
return nil, fmt.Errorf("unsupported operation: %s", op)
}
}

View File

@@ -1,6 +1,7 @@
package lua
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -21,6 +22,7 @@ import (
const (
incorrectReturnType = "expect %s output from Lua script, not %s"
incorrectInnerType = "expect %s inner type from Lua script, not %s"
invalidHealthStatus = "Lua returned an invalid health status"
healthScriptFile = "health.lua"
actionScriptFile = "action.lua"
@@ -100,6 +102,7 @@ func (vm VM) ExecuteHealthLua(obj *unstructured.Unstructured, script string) (*h
}
returnValue := l.Get(-1)
if returnValue.Type() == lua.LTTable {
jsonBytes, err := luajson.Encode(returnValue)
if err != nil {
return nil, err
@@ -146,7 +149,7 @@ func (vm VM) GetHealthScript(obj *unstructured.Unstructured) (string, bool, erro
return builtInScript, true, err
}
func (vm VM) ExecuteResourceAction(obj *unstructured.Unstructured, script string) (*unstructured.Unstructured, error) {
func (vm VM) ExecuteResourceAction(obj *unstructured.Unstructured, script string) ([]ImpactedResource, error) {
l, err := vm.runLua(obj, script)
if err != nil {
return nil, err
@@ -154,20 +157,63 @@ func (vm VM) ExecuteResourceAction(obj *unstructured.Unstructured, script string
returnValue := l.Get(-1)
if returnValue.Type() == lua.LTTable {
jsonBytes, err := luajson.Encode(returnValue)
if err != nil {
return nil, err
}
newObj, err := appv1.UnmarshalToUnstructured(string(jsonBytes))
if err != nil {
return nil, err
var impactedResources []ImpactedResource
jsonString := bytes.NewBuffer(jsonBytes).String()
if len(jsonString) < 2 {
return nil, fmt.Errorf("Lua output was not a valid json object or array")
}
cleanedNewObj := cleanReturnedObj(newObj.Object, obj.Object)
newObj.Object = cleanedNewObj
return newObj, nil
// The output from Lua is either an object (old-style action output) or an array (new-style action output).
// Check whether the string starts with an opening square bracket and ends with a closing square bracket,
// avoiding programming by exception.
if jsonString[0] == '[' && jsonString[len(jsonString)-1] == ']' {
// The string represents a new-style action array output
impactedResources, err = UnmarshalToImpactedResources(string(jsonBytes))
if err != nil {
return nil, err
}
} else {
// The string represents an old-style action object output
newObj, err := appv1.UnmarshalToUnstructured(string(jsonBytes))
if err != nil {
return nil, err
}
// Wrap the old-style action output with a single-member array.
// The default definition of the old-style action is a "patch" one.
impactedResources = append(impactedResources, ImpactedResource{newObj, PatchOperation})
}
for _, impactedResource := range impactedResources {
// Cleaning the resource is only relevant to "patch"
if impactedResource.K8SOperation == PatchOperation {
impactedResource.UnstructuredObj.Object = cleanReturnedObj(impactedResource.UnstructuredObj.Object, obj.Object)
}
}
return impactedResources, nil
}
return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
}
// UnmarshalToImpactedResources unmarshals an ImpactedResource array representation in JSON to ImpactedResource array
func UnmarshalToImpactedResources(resources string) ([]ImpactedResource, error) {
if resources == "" || resources == "null" {
return nil, nil
}
var impactedResources []ImpactedResource
err := json.Unmarshal([]byte(resources), &impactedResources)
if err != nil {
return nil, err
}
return impactedResources, nil
}
// cleanReturnedObj Lua cannot distinguish an empty table as an array or map, and the library we are using choose to
// decoded an empty table into an empty array. This function prevents the lua scripts from unintentionally changing an
// empty struct into empty arrays

View File

@@ -1,6 +1,7 @@
package lua
import (
"bytes"
"fmt"
"testing"
@@ -24,6 +25,7 @@ metadata:
namespace: default
resourceVersion: "123"
`
const objWithNoScriptJSON = `
apiVersion: not-an-endpoint.io/v1alpha1
kind: Test
@@ -370,7 +372,7 @@ obj.metadata.labels["test"] = "test"
return obj
`
const expectedUpdatedObj = `
const expectedLuaUpdatedResult = `
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
@@ -382,13 +384,220 @@ metadata:
resourceVersion: "123"
`
func TestExecuteResourceAction(t *testing.T) {
// Test an action that returns a single k8s resource json
func TestExecuteOldStyleResourceAction(t *testing.T) {
testObj := StrToUnstructured(objJSON)
expectedObj := StrToUnstructured(expectedUpdatedObj)
expectedLuaUpdatedObj := StrToUnstructured(expectedLuaUpdatedResult)
vm := VM{}
newObj, err := vm.ExecuteResourceAction(testObj, validActionLua)
newObjects, err := vm.ExecuteResourceAction(testObj, validActionLua)
assert.Nil(t, err)
assert.Equal(t, expectedObj, newObj)
assert.Equal(t, len(newObjects), 1)
assert.Equal(t, newObjects[0].K8SOperation, K8SOperation("patch"))
assert.Equal(t, expectedLuaUpdatedObj, newObjects[0].UnstructuredObj)
}
const cronJobObjYaml = `
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
namespace: test-ns
`
const expectedCreatedJobObjList = `
- operation: create
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: hello-1
namespace: test-ns
`
const expectedCreatedMultipleJobsObjList = `
- operation: create
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: hello-1
namespace: test-ns
- operation: create
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: hello-2
namespace: test-ns
`
const expectedActionMixedOperationObjList = `
- operation: create
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: hello-1
namespace: test-ns
- operation: patch
resource:
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
namespace: test-ns
labels:
test: test
`
const createJobActionLua = `
job = {}
job.apiVersion = "batch/v1"
job.kind = "Job"
job.metadata = {}
job.metadata.name = "hello-1"
job.metadata.namespace = "test-ns"
impactedResource = {}
impactedResource.operation = "create"
impactedResource.resource = job
result = {}
result[1] = impactedResource
return result
`
const createMultipleJobsActionLua = `
job1 = {}
job1.apiVersion = "batch/v1"
job1.kind = "Job"
job1.metadata = {}
job1.metadata.name = "hello-1"
job1.metadata.namespace = "test-ns"
impactedResource1 = {}
impactedResource1.operation = "create"
impactedResource1.resource = job1
result = {}
result[1] = impactedResource1
job2 = {}
job2.apiVersion = "batch/v1"
job2.kind = "Job"
job2.metadata = {}
job2.metadata.name = "hello-2"
job2.metadata.namespace = "test-ns"
impactedResource2 = {}
impactedResource2.operation = "create"
impactedResource2.resource = job2
result[2] = impactedResource2
return result
`
const mixedOperationActionLuaOk = `
job1 = {}
job1.apiVersion = "batch/v1"
job1.kind = "Job"
job1.metadata = {}
job1.metadata.name = "hello-1"
job1.metadata.namespace = obj.metadata.namespace
impactedResource1 = {}
impactedResource1.operation = "create"
impactedResource1.resource = job1
result = {}
result[1] = impactedResource1
obj.metadata.labels = {}
obj.metadata.labels["test"] = "test"
impactedResource2 = {}
impactedResource2.operation = "patch"
impactedResource2.resource = obj
result[2] = impactedResource2
return result
`
const createMixedOperationActionLuaFailing = `
job1 = {}
job1.apiVersion = "batch/v1"
job1.kind = "Job"
job1.metadata = {}
job1.metadata.name = "hello-1"
job1.metadata.namespace = obj.metadata.namespace
impactedResource1 = {}
impactedResource1.operation = "create"
impactedResource1.resource = job1
result = {}
result[1] = impactedResource1
obj.metadata.labels = {}
obj.metadata.labels["test"] = "test"
impactedResource2 = {}
impactedResource2.operation = "thisShouldFail"
impactedResource2.resource = obj
result[2] = impactedResource2
return result
`
func TestExecuteNewStyleCreateActionSingleResource(t *testing.T) {
testObj := StrToUnstructured(cronJobObjYaml)
jsonBytes, err := yaml.YAMLToJSON([]byte(expectedCreatedJobObjList))
assert.Nil(t, err)
t.Log(bytes.NewBuffer(jsonBytes).String())
expectedObjects, err := UnmarshalToImpactedResources(bytes.NewBuffer(jsonBytes).String())
assert.Nil(t, err)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, createJobActionLua)
assert.Nil(t, err)
assert.Equal(t, expectedObjects, newObjects)
}
func TestExecuteNewStyleCreateActionMultipleResources(t *testing.T) {
testObj := StrToUnstructured(cronJobObjYaml)
jsonBytes, err := yaml.YAMLToJSON([]byte(expectedCreatedMultipleJobsObjList))
assert.Nil(t, err)
// t.Log(bytes.NewBuffer(jsonBytes).String())
expectedObjects, err := UnmarshalToImpactedResources(bytes.NewBuffer(jsonBytes).String())
assert.Nil(t, err)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, createMultipleJobsActionLua)
assert.Nil(t, err)
assert.Equal(t, expectedObjects, newObjects)
}
func TestExecuteNewStyleActionMixedOperationsOk(t *testing.T) {
testObj := StrToUnstructured(cronJobObjYaml)
jsonBytes, err := yaml.YAMLToJSON([]byte(expectedActionMixedOperationObjList))
assert.Nil(t, err)
// t.Log(bytes.NewBuffer(jsonBytes).String())
expectedObjects, err := UnmarshalToImpactedResources(bytes.NewBuffer(jsonBytes).String())
assert.Nil(t, err)
vm := VM{}
newObjects, err := vm.ExecuteResourceAction(testObj, mixedOperationActionLuaOk)
assert.Nil(t, err)
assert.Equal(t, expectedObjects, newObjects)
}
func TestExecuteNewStyleActionMixedOperationsFailure(t *testing.T) {
testObj := StrToUnstructured(cronJobObjYaml)
vm := VM{}
_, err := vm.ExecuteResourceAction(testObj, createMixedOperationActionLuaFailing)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsupported operation")
}
func TestExecuteResourceActionNonTableReturn(t *testing.T) {
@@ -461,10 +670,11 @@ func TestCleanPatch(t *testing.T) {
testObj := StrToUnstructured(objWithEmptyStruct)
expectedObj := StrToUnstructured(expectedUpdatedObjWithEmptyStruct)
vm := VM{}
newObj, err := vm.ExecuteResourceAction(testObj, pausedToFalseLua)
newObjects, err := vm.ExecuteResourceAction(testObj, pausedToFalseLua)
assert.Nil(t, err)
assert.Equal(t, expectedObj, newObj)
assert.Equal(t, len(newObjects), 1)
assert.Equal(t, newObjects[0].K8SOperation, K8SOperation("patch"))
assert.Equal(t, expectedObj, newObjects[0].UnstructuredObj)
}
func TestGetResourceHealth(t *testing.T) {