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  }