diff --git a/cmd/argocd/commands/admin/settings.go b/cmd/argocd/commands/admin/settings.go index 73363b10145ae..40371705db1f8 100644 --- a/cmd/argocd/commands/admin/settings.go +++ b/cmd/argocd/commands/admin/settings.go @@ -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) }) }, } diff --git a/cmd/argocd/commands/admin/settings_test.go b/cmd/argocd/commands/admin/settings_test.go index 94f80667b8a6e..55c1c8c767012 100644 --- a/cmd/argocd/commands/admin/settings_test.go +++ b/cmd/argocd/commands/admin/settings_test.go @@ -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") + }) } diff --git a/docs/operator-manual/resource_actions.md b/docs/operator-manual/resource_actions.md index 2dbe58749cad4..b720f589ae8d0 100644 --- a/docs/operator-manual/resource_actions.md +++ b/docs/operator-manual/resource_actions.md @@ -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.` 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.` 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.`. ```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 +``` \ No newline at end of file diff --git a/docs/operator-manual/upgrading/2.7-2.8.md b/docs/operator-manual/upgrading/2.7-2.8.md index f8db7f31cbae4..12622642d5eff 100644 --- a/docs/operator-manual/upgrading/2.7-2.8.md +++ b/docs/operator-manual/upgrading/2.7-2.8.md @@ -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 +``` diff --git a/go.mod b/go.mod index 0833fdfaf494c..76dd36e7036bb 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 113ddb77d72f9..e8d71cbba49d0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/resource_customizations/apps/Deployment/actions/testdata/deployment-pause.yaml b/resource_customizations/apps/Deployment/actions/testdata/deployment-pause.yaml index 38cb1faf8498f..3ddbbe3e5cef2 100644 --- a/resource_customizations/apps/Deployment/actions/testdata/deployment-pause.yaml +++ b/resource_customizations/apps/Deployment/actions/testdata/deployment-pause.yaml @@ -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 diff --git a/resource_customizations/apps/Deployment/actions/testdata/deployment-resume.yaml b/resource_customizations/apps/Deployment/actions/testdata/deployment-resume.yaml index ea8d3b14de51d..8ccb8dcab0802 100644 --- a/resource_customizations/apps/Deployment/actions/testdata/deployment-resume.yaml +++ b/resource_customizations/apps/Deployment/actions/testdata/deployment-resume.yaml @@ -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 diff --git a/resource_customizations/argoproj.io/CronWorkflow/actions/action_test.yaml b/resource_customizations/argoproj.io/CronWorkflow/actions/action_test.yaml new file mode 100644 index 0000000000000..4c7aa77ff127a --- /dev/null +++ b/resource_customizations/argoproj.io/CronWorkflow/actions/action_test.yaml @@ -0,0 +1,4 @@ +actionTests: +- action: create-workflow + inputPath: testdata/cronworkflow.yaml + expectedOutputPath: testdata/workflow.yaml diff --git a/resource_customizations/argoproj.io/CronWorkflow/actions/create-workflow/action.lua b/resource_customizations/argoproj.io/CronWorkflow/actions/create-workflow/action.lua new file mode 100644 index 0000000000000..35f7a66b80413 --- /dev/null +++ b/resource_customizations/argoproj.io/CronWorkflow/actions/create-workflow/action.lua @@ -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/" 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 \ No newline at end of file diff --git a/resource_customizations/argoproj.io/CronWorkflow/actions/discovery.lua b/resource_customizations/argoproj.io/CronWorkflow/actions/discovery.lua new file mode 100644 index 0000000000000..5e16c6c1c14d8 --- /dev/null +++ b/resource_customizations/argoproj.io/CronWorkflow/actions/discovery.lua @@ -0,0 +1,3 @@ +actions = {} +actions["create-workflow"] = {} +return actions \ No newline at end of file diff --git a/resource_customizations/argoproj.io/CronWorkflow/actions/testdata/cronworkflow.yaml b/resource_customizations/argoproj.io/CronWorkflow/actions/testdata/cronworkflow.yaml new file mode 100644 index 0000000000000..2a2c7d1807db4 --- /dev/null +++ b/resource_customizations/argoproj.io/CronWorkflow/actions/testdata/cronworkflow.yaml @@ -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] diff --git a/resource_customizations/argoproj.io/CronWorkflow/actions/testdata/workflow.yaml b/resource_customizations/argoproj.io/CronWorkflow/actions/testdata/workflow.yaml new file mode 100644 index 0000000000000..9f231dbb5c5b3 --- /dev/null +++ b/resource_customizations/argoproj.io/CronWorkflow/actions/testdata/workflow.yaml @@ -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 diff --git a/resource_customizations/argoproj.io/WorkflowTemplate/actions/action_test.yaml b/resource_customizations/argoproj.io/WorkflowTemplate/actions/action_test.yaml new file mode 100644 index 0000000000000..db503fe0b6aae --- /dev/null +++ b/resource_customizations/argoproj.io/WorkflowTemplate/actions/action_test.yaml @@ -0,0 +1,4 @@ +actionTests: +- action: create-workflow + inputPath: testdata/workflowtemplate.yaml + expectedOutputPath: testdata/workflow.yaml diff --git a/resource_customizations/argoproj.io/WorkflowTemplate/actions/create-workflow/action.lua b/resource_customizations/argoproj.io/WorkflowTemplate/actions/create-workflow/action.lua new file mode 100644 index 0000000000000..66f5ec55d3a16 --- /dev/null +++ b/resource_customizations/argoproj.io/WorkflowTemplate/actions/create-workflow/action.lua @@ -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/" 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 \ No newline at end of file diff --git a/resource_customizations/argoproj.io/WorkflowTemplate/actions/discovery.lua b/resource_customizations/argoproj.io/WorkflowTemplate/actions/discovery.lua new file mode 100644 index 0000000000000..5e16c6c1c14d8 --- /dev/null +++ b/resource_customizations/argoproj.io/WorkflowTemplate/actions/discovery.lua @@ -0,0 +1,3 @@ +actions = {} +actions["create-workflow"] = {} +return actions \ No newline at end of file diff --git a/resource_customizations/argoproj.io/WorkflowTemplate/actions/testdata/workflow.yaml b/resource_customizations/argoproj.io/WorkflowTemplate/actions/testdata/workflow.yaml new file mode 100644 index 0000000000000..46063bee03397 --- /dev/null +++ b/resource_customizations/argoproj.io/WorkflowTemplate/actions/testdata/workflow.yaml @@ -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 diff --git a/resource_customizations/argoproj.io/WorkflowTemplate/actions/testdata/workflowtemplate.yaml b/resource_customizations/argoproj.io/WorkflowTemplate/actions/testdata/workflowtemplate.yaml new file mode 100644 index 0000000000000..5b7d2319e9c9e --- /dev/null +++ b/resource_customizations/argoproj.io/WorkflowTemplate/actions/testdata/workflowtemplate.yaml @@ -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 diff --git a/resource_customizations/batch/CronJob/actions/action_test.yaml b/resource_customizations/batch/CronJob/actions/action_test.yaml new file mode 100644 index 0000000000000..a9b5320db5721 --- /dev/null +++ b/resource_customizations/batch/CronJob/actions/action_test.yaml @@ -0,0 +1,4 @@ +actionTests: +- action: create-job + inputPath: testdata/cronjob.yaml + expectedOutputPath: testdata/job.yaml diff --git a/resource_customizations/batch/CronJob/actions/create-job/action.lua b/resource_customizations/batch/CronJob/actions/create-job/action.lua new file mode 100644 index 0000000000000..119e0d35eccb6 --- /dev/null +++ b/resource_customizations/batch/CronJob/actions/create-job/action.lua @@ -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=" 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 \ No newline at end of file diff --git a/resource_customizations/batch/CronJob/actions/discovery.lua b/resource_customizations/batch/CronJob/actions/discovery.lua new file mode 100644 index 0000000000000..f90293c1aa671 --- /dev/null +++ b/resource_customizations/batch/CronJob/actions/discovery.lua @@ -0,0 +1,3 @@ +actions = {} +actions["create-job"] = {} +return actions \ No newline at end of file diff --git a/resource_customizations/batch/CronJob/actions/testdata/cronjob.yaml b/resource_customizations/batch/CronJob/actions/testdata/cronjob.yaml new file mode 100644 index 0000000000000..118fc83929e96 --- /dev/null +++ b/resource_customizations/batch/CronJob/actions/testdata/cronjob.yaml @@ -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 \ No newline at end of file diff --git a/resource_customizations/batch/CronJob/actions/testdata/job.yaml b/resource_customizations/batch/CronJob/actions/testdata/job.yaml new file mode 100644 index 0000000000000..cf0f92da24818 --- /dev/null +++ b/resource_customizations/batch/CronJob/actions/testdata/job.yaml @@ -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 \ No newline at end of file diff --git a/server/application/application.go b/server/application/application.go index ffd206b126b7d..0a82be5f2f35c 100644 --- a/server/application/application.go +++ b/server/application/application.go @@ -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 } diff --git a/server/application/application_test.go b/server/application/application_test.go index 37c1761b1af38..c620bb8e97ce7 100644 --- a/server/application/application_test.go +++ b/server/application/application_test.go @@ -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) + }) +} diff --git a/test/e2e/app_management_test.go b/test/e2e/app_management_test.go index d33e3166735d7..67cea4681c4d9 100644 --- a/test/e2e/app_management_test.go +++ b/test/e2e/app_management_test.go @@ -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). diff --git a/test/e2e/testdata/resource-actions/cron-job.yaml b/test/e2e/testdata/resource-actions/cron-job.yaml new file mode 100644 index 0000000000000..3ab1fb9b1cd8a --- /dev/null +++ b/test/e2e/testdata/resource-actions/cron-job.yaml @@ -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 \ No newline at end of file diff --git a/util/lua/custom_actions_test.go b/util/lua/custom_actions_test.go index 5f45e685f7f78..8f7d0e7495513 100644 --- a/util/lua/custom_actions_test.go +++ b/util/lua/custom_actions_test.go @@ -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 +} diff --git a/util/lua/health_test.go b/util/lua/health_test.go index 7016c46506438..d4fa537bc2964 100644 --- a/util/lua/health_test.go +++ b/util/lua/health_test.go @@ -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} } diff --git a/util/lua/impacted_resource.go b/util/lua/impacted_resource.go new file mode 100644 index 0000000000000..de6ab5933372d --- /dev/null +++ b/util/lua/impacted_resource.go @@ -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) + } +} diff --git a/util/lua/lua.go b/util/lua/lua.go index 8f1daba22406c..d9849708e8c95 100644 --- a/util/lua/lua.go +++ b/util/lua/lua.go @@ -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") + } + // 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}) } - cleanedNewObj := cleanReturnedObj(newObj.Object, obj.Object) - newObj.Object = cleanedNewObj - return newObj, nil + + 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 diff --git a/util/lua/lua_test.go b/util/lua/lua_test.go index 9ff648ebf44db..1beb3d261e617 100644 --- a/util/lua/lua_test.go +++ b/util/lua/lua_test.go @@ -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{} + newObjects, err := vm.ExecuteResourceAction(testObj, validActionLua) + assert.Nil(t, err) + 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{} - newObj, err := vm.ExecuteResourceAction(testObj, validActionLua) + newObjects, err := vm.ExecuteResourceAction(testObj, createMultipleJobsActionLua) assert.Nil(t, err) - assert.Equal(t, expectedObj, newObj) + 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) {