github.com/argoproj/argo-cd/v3@v3.2.1/util/lua/custom_actions_test.go (about) 1 package lua 2 3 import ( 4 "bytes" 5 "fmt" 6 "os" 7 "path/filepath" 8 "strings" 9 "testing" 10 11 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/require" 13 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 "sigs.k8s.io/yaml" 15 16 "github.com/argoproj/gitops-engine/pkg/diff" 17 18 applicationpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/application" 19 appsv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 20 "github.com/argoproj/argo-cd/v3/util/cli" 21 ) 22 23 type testNormalizer struct{} 24 25 func (t testNormalizer) Normalize(un *unstructured.Unstructured) error { 26 if un == nil { 27 return nil 28 } 29 switch un.GetKind() { 30 case "Job": 31 return t.normalizeJob(un) 32 case "DaemonSet", "Deployment", "StatefulSet": 33 err := unstructured.SetNestedStringMap(un.Object, map[string]string{"kubectl.kubernetes.io/restartedAt": "0001-01-01T00:00:00Z"}, "spec", "template", "metadata", "annotations") 34 if err != nil { 35 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 36 } 37 } 38 switch un.GetKind() { 39 case "Deployment": 40 err := unstructured.SetNestedField(un.Object, nil, "status") 41 if err != nil { 42 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 43 } 44 err = unstructured.SetNestedField(un.Object, nil, "metadata", "creationTimestamp") 45 if err != nil { 46 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 47 } 48 err = unstructured.SetNestedField(un.Object, nil, "metadata", "generation") 49 if err != nil { 50 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 51 } 52 case "Rollout": 53 err := unstructured.SetNestedField(un.Object, nil, "spec", "restartAt") 54 if err != nil { 55 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 56 } 57 case "ExternalSecret", "PushSecret": 58 err := unstructured.SetNestedStringMap(un.Object, map[string]string{"force-sync": "0001-01-01T00:00:00Z"}, "metadata", "annotations") 59 if err != nil { 60 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 61 } 62 case "Workflow": 63 err := unstructured.SetNestedField(un.Object, nil, "metadata", "resourceVersion") 64 if err != nil { 65 return fmt.Errorf("failed to normalize Rollout: %w", err) 66 } 67 err = unstructured.SetNestedField(un.Object, nil, "metadata", "uid") 68 if err != nil { 69 return fmt.Errorf("failed to normalize Rollout: %w", err) 70 } 71 err = unstructured.SetNestedField(un.Object, nil, "metadata", "annotations", "workflows.argoproj.io/scheduled-time") 72 if err != nil { 73 return fmt.Errorf("failed to normalize Rollout: %w", err) 74 } 75 case "HelmRelease", "ImageRepository", "ImageUpdateAutomation", "Kustomization", "Receiver", "Bucket", "GitRepository", "HelmChart", "HelmRepository", "OCIRepository": 76 err := unstructured.SetNestedStringMap(un.Object, map[string]string{"reconcile.fluxcd.io/requestedAt": "By Argo CD at: 0001-01-01T00:00:00"}, "metadata", "annotations") 77 if err != nil { 78 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 79 } 80 } 81 return nil 82 } 83 84 func (t testNormalizer) normalizeJob(un *unstructured.Unstructured) error { 85 if conditions, exist, err := unstructured.NestedSlice(un.Object, "status", "conditions"); err != nil { 86 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 87 } else if exist { 88 changed := false 89 for i := range conditions { 90 condition := conditions[i].(map[string]any) 91 cType := condition["type"].(string) 92 if cType == "FailureTarget" { 93 condition["lastTransitionTime"] = "0001-01-01T00:00:00Z" 94 changed = true 95 } 96 } 97 if changed { 98 if err := unstructured.SetNestedSlice(un.Object, conditions, "status", "conditions"); err != nil { 99 return fmt.Errorf("failed to normalize %s: %w", un.GetKind(), err) 100 } 101 } 102 } 103 return nil 104 } 105 106 type ActionTestStructure struct { 107 DiscoveryTests []IndividualDiscoveryTest `yaml:"discoveryTests"` 108 ActionTests []IndividualActionTest `yaml:"actionTests"` 109 } 110 111 type IndividualDiscoveryTest struct { 112 InputPath string `yaml:"inputPath"` 113 Result []appsv1.ResourceAction `yaml:"result"` 114 } 115 116 type IndividualActionTest struct { 117 Action string `yaml:"action"` 118 InputPath string `yaml:"inputPath"` 119 ExpectedOutputPath string `yaml:"expectedOutputPath"` 120 ExpectedErrorMessage string `yaml:"expectedErrorMessage"` 121 InputStr string `yaml:"input"` 122 Parameters map[string]string `yaml:"parameters"` 123 } 124 125 func TestLuaResourceActionsScript(t *testing.T) { 126 err := filepath.Walk("../../resource_customizations", func(path string, _ os.FileInfo, err error) error { 127 if !strings.Contains(path, "action_test.yaml") { 128 return nil 129 } 130 require.NoError(t, err) 131 dir := filepath.Dir(path) 132 yamlBytes, err := os.ReadFile(filepath.Join(dir, "action_test.yaml")) 133 require.NoError(t, err) 134 var resourceTest ActionTestStructure 135 err = yaml.Unmarshal(yamlBytes, &resourceTest) 136 require.NoError(t, err) 137 for i := range resourceTest.DiscoveryTests { 138 test := resourceTest.DiscoveryTests[i] 139 testName := "discovery/" + test.InputPath 140 t.Run(testName, func(t *testing.T) { 141 vm := VM{ 142 UseOpenLibs: true, 143 } 144 obj := getObj(t, filepath.Join(dir, test.InputPath)) 145 discoveryLua, err := vm.GetResourceActionDiscovery(obj) 146 require.NoError(t, err) 147 result, err := vm.ExecuteResourceActionDiscovery(obj, discoveryLua) 148 require.NoError(t, err) 149 for i := range result { 150 assert.Contains(t, test.Result, result[i]) 151 } 152 }) 153 } 154 for i := range resourceTest.ActionTests { 155 test := resourceTest.ActionTests[i] 156 testName := fmt.Sprintf("actions/%s/%s", test.Action, test.InputPath) 157 158 t.Run(testName, func(t *testing.T) { 159 vm := VM{ 160 // Uncomment the following line if you need to use lua libraries debugging 161 // purposes. Otherwise, leave this false to ensure tests reflect the same 162 // privileges that API server has. 163 // UseOpenLibs: true, 164 } 165 sourceObj := getObj(t, filepath.Join(dir, test.InputPath)) 166 action, err := vm.GetResourceAction(sourceObj, test.Action) 167 168 require.NoError(t, err) 169 170 // Log the action Lua script 171 t.Logf("Action Lua script: %s", action.ActionLua) 172 173 // Parse action parameters 174 var params []*applicationpkg.ResourceActionParameters 175 if test.Parameters != nil { 176 for k, v := range test.Parameters { 177 params = append(params, &applicationpkg.ResourceActionParameters{ 178 Name: &k, 179 Value: &v, 180 }) 181 } 182 } 183 184 if len(params) > 0 { 185 // Log the parameters 186 t.Logf("Parameters: %+v", params) 187 } 188 189 require.NoError(t, err) 190 impactedResources, err := vm.ExecuteResourceAction(sourceObj, action.ActionLua, params) 191 192 // Handle expected errors 193 if test.ExpectedErrorMessage != "" { 194 assert.EqualError(t, err, test.ExpectedErrorMessage) 195 return 196 } 197 198 require.NoError(t, err) 199 200 // Treat the Lua expected output as a list 201 expectedObjects := getExpectedObjectList(t, filepath.Join(dir, test.ExpectedOutputPath)) 202 203 for _, impactedResource := range impactedResources { 204 result := impactedResource.UnstructuredObj 205 206 // The expected output is a list of objects 207 // Find the actual impacted resource in the expected output 208 expectedObj := findFirstMatchingItem(expectedObjects.Items, func(u unstructured.Unstructured) bool { 209 // Some resources' name is derived from the source object name, so the returned name is not actually equal to the testdata output name 210 // Considering the resource found in the testdata output if its name starts with source object name 211 // TODO: maybe this should use a normalizer function instead of hard-coding the resource specifics here 212 if (result.GetKind() == "Job" && sourceObj.GetKind() == "CronJob") || (result.GetKind() == "Workflow" && (sourceObj.GetKind() == "CronWorkflow" || sourceObj.GetKind() == "WorkflowTemplate")) { 213 return u.GroupVersionKind() == result.GroupVersionKind() && strings.HasPrefix(u.GetName(), sourceObj.GetName()) && u.GetNamespace() == result.GetNamespace() 214 } 215 return u.GroupVersionKind() == result.GroupVersionKind() && u.GetName() == result.GetName() && u.GetNamespace() == result.GetNamespace() 216 }) 217 218 assert.NotNil(t, expectedObj) 219 220 switch impactedResource.K8SOperation { 221 // No default case since a not supported operation would have failed upon unmarshaling earlier 222 case PatchOperation: 223 // Patching is only allowed for the source resource, so the GVK + name + ns must be the same as the impacted resource 224 assert.Equal(t, sourceObj.GroupVersionKind(), result.GroupVersionKind()) 225 assert.Equal(t, sourceObj.GetName(), result.GetName()) 226 assert.Equal(t, sourceObj.GetNamespace(), result.GetNamespace()) 227 case CreateOperation: 228 switch result.GetKind() { 229 case "Job", "Workflow": 230 // 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 231 result.SetName(expectedObj.GetName()) 232 } 233 } 234 235 // 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. 236 diffResult, err := diff.Diff(expectedObj, result, diff.WithNormalizer(testNormalizer{})) 237 require.NoError(t, err) 238 if diffResult.Modified { 239 t.Error("Output does not match input:") 240 err = cli.PrintDiff(test.Action, expectedObj, result) 241 require.NoError(t, err) 242 } 243 } 244 }) 245 } 246 247 return nil 248 }) 249 require.NoError(t, err) 250 } 251 252 // Handling backward compatibility. 253 // The old-style actions return a single object in the expected output from testdata, so will wrap them in a list 254 func getExpectedObjectList(t *testing.T, path string) *unstructured.UnstructuredList { 255 t.Helper() 256 yamlBytes, err := os.ReadFile(path) 257 require.NoError(t, err) 258 unstructuredList := &unstructured.UnstructuredList{} 259 yamlString := bytes.NewBuffer(yamlBytes).String() 260 if yamlString[0] == '-' { 261 // The string represents a new-style action array output, where each member is a wrapper around a k8s unstructured resource 262 objList := make([]map[string]any, 5) 263 err = yaml.Unmarshal(yamlBytes, &objList) 264 require.NoError(t, err) 265 unstructuredList.Items = make([]unstructured.Unstructured, len(objList)) 266 // Append each map in objList to the Items field of the new object 267 for i, obj := range objList { 268 unstructuredObj, ok := obj["unstructuredObj"].(map[string]any) 269 assert.True(t, ok, "Wrong type of unstructuredObj") 270 unstructuredList.Items[i] = unstructured.Unstructured{Object: unstructuredObj} 271 } 272 } else { 273 // The string represents an old-style action object output, which is a k8s unstructured resource 274 obj := make(map[string]any) 275 err = yaml.Unmarshal(yamlBytes, &obj) 276 require.NoError(t, err) 277 unstructuredList.Items = make([]unstructured.Unstructured, 1) 278 unstructuredList.Items[0] = unstructured.Unstructured{Object: obj} 279 } 280 return unstructuredList 281 } 282 283 func findFirstMatchingItem(items []unstructured.Unstructured, f func(unstructured.Unstructured) bool) *unstructured.Unstructured { 284 var matching *unstructured.Unstructured 285 for _, item := range items { 286 if f(item) { 287 matching = &item 288 break 289 } 290 } 291 return matching 292 }