github.com/argoproj/argo-cd@v1.8.7/util/lua/lua.go (about)

     1  package lua
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"os"
     8  	"path/filepath"
     9  	"time"
    10  
    11  	"github.com/argoproj/gitops-engine/pkg/health"
    12  	"github.com/gobuffalo/packr"
    13  	lua "github.com/yuin/gopher-lua"
    14  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    15  	luajson "layeh.com/gopher-json"
    16  
    17  	appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
    18  )
    19  
    20  const (
    21  	incorrectReturnType              = "expect %s output from Lua script, not %s"
    22  	invalidHealthStatus              = "Lua returned an invalid health status"
    23  	resourceCustomizationBuiltInPath = "../../resource_customizations"
    24  	healthScriptFile                 = "health.lua"
    25  	actionScriptFile                 = "action.lua"
    26  	actionDiscoveryScriptFile        = "discovery.lua"
    27  )
    28  
    29  var (
    30  	box packr.Box
    31  )
    32  
    33  func init() {
    34  	box = packr.NewBox(resourceCustomizationBuiltInPath)
    35  }
    36  
    37  type ResourceHealthOverrides map[string]appv1.ResourceOverride
    38  
    39  func (overrides ResourceHealthOverrides) GetResourceHealth(obj *unstructured.Unstructured) (*health.HealthStatus, error) {
    40  	luaVM := VM{
    41  		ResourceOverrides: overrides,
    42  	}
    43  	script, err := luaVM.GetHealthScript(obj)
    44  	if err != nil {
    45  		return nil, err
    46  	}
    47  	if script == "" {
    48  		return nil, nil
    49  	}
    50  	result, err := luaVM.ExecuteHealthLua(obj, script)
    51  	if err != nil {
    52  		return nil, err
    53  	}
    54  	return result, nil
    55  }
    56  
    57  // VM Defines a struct that implements the luaVM
    58  type VM struct {
    59  	ResourceOverrides map[string]appv1.ResourceOverride
    60  	// UseOpenLibs flag to enable open libraries. Libraries are always disabled while running, but enabled during testing to allow the use of print statements
    61  	UseOpenLibs bool
    62  }
    63  
    64  func (vm VM) runLua(obj *unstructured.Unstructured, script string) (*lua.LState, error) {
    65  	l := lua.NewState(lua.Options{
    66  		SkipOpenLibs: !vm.UseOpenLibs,
    67  	})
    68  	defer l.Close()
    69  	// Opens table library to allow access to functions to manipulate tables
    70  	for _, pair := range []struct {
    71  		n string
    72  		f lua.LGFunction
    73  	}{
    74  		{lua.LoadLibName, lua.OpenPackage},
    75  		{lua.BaseLibName, lua.OpenBase},
    76  		{lua.TabLibName, lua.OpenTable},
    77  		// load our 'safe' version of the os library
    78  		{lua.OsLibName, OpenSafeOs},
    79  	} {
    80  		if err := l.CallByParam(lua.P{
    81  			Fn:      l.NewFunction(pair.f),
    82  			NRet:    0,
    83  			Protect: true,
    84  		}, lua.LString(pair.n)); err != nil {
    85  			panic(err)
    86  		}
    87  	}
    88  	// preload our 'safe' version of the os library. Allows the 'local os = require("os")' to work
    89  	l.PreloadModule(lua.OsLibName, SafeOsLoader)
    90  
    91  	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    92  	defer cancel()
    93  	l.SetContext(ctx)
    94  	objectValue := decodeValue(l, obj.Object)
    95  	l.SetGlobal("obj", objectValue)
    96  	err := l.DoString(script)
    97  	return l, err
    98  }
    99  
   100  // ExecuteHealthLua runs the lua script to generate the health status of a resource
   101  func (vm VM) ExecuteHealthLua(obj *unstructured.Unstructured, script string) (*health.HealthStatus, error) {
   102  	l, err := vm.runLua(obj, script)
   103  	if err != nil {
   104  		return nil, err
   105  	}
   106  	returnValue := l.Get(-1)
   107  	if returnValue.Type() == lua.LTTable {
   108  		jsonBytes, err := luajson.Encode(returnValue)
   109  		if err != nil {
   110  			return nil, err
   111  		}
   112  		healthStatus := &health.HealthStatus{}
   113  		err = json.Unmarshal(jsonBytes, healthStatus)
   114  		if err != nil {
   115  			return nil, err
   116  		}
   117  		if !isValidHealthStatusCode(healthStatus.Status) {
   118  			return &health.HealthStatus{
   119  				Status:  health.HealthStatusUnknown,
   120  				Message: invalidHealthStatus,
   121  			}, nil
   122  		}
   123  
   124  		return healthStatus, nil
   125  	}
   126  	return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
   127  }
   128  
   129  // GetHealthScript attempts to read lua script from config and then filesystem for that resource
   130  func (vm VM) GetHealthScript(obj *unstructured.Unstructured) (string, error) {
   131  	key := getConfigMapKey(obj)
   132  	if script, ok := vm.ResourceOverrides[key]; ok && script.HealthLua != "" {
   133  		return script.HealthLua, nil
   134  	}
   135  	return vm.getPredefinedLuaScripts(key, healthScriptFile)
   136  }
   137  
   138  func (vm VM) ExecuteResourceAction(obj *unstructured.Unstructured, script string) (*unstructured.Unstructured, error) {
   139  	l, err := vm.runLua(obj, script)
   140  	if err != nil {
   141  		return nil, err
   142  	}
   143  	returnValue := l.Get(-1)
   144  	if returnValue.Type() == lua.LTTable {
   145  		jsonBytes, err := luajson.Encode(returnValue)
   146  		if err != nil {
   147  			return nil, err
   148  		}
   149  		newObj, err := appv1.UnmarshalToUnstructured(string(jsonBytes))
   150  		if err != nil {
   151  			return nil, err
   152  		}
   153  		cleanedNewObj := cleanReturnedObj(newObj.Object, obj.Object)
   154  		newObj.Object = cleanedNewObj
   155  		return newObj, nil
   156  	}
   157  	return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
   158  }
   159  
   160  // cleanReturnedObj Lua cannot distinguish an empty table as an array or map, and the library we are using choose to
   161  // decoded an empty table into an empty array. This function prevents the lua scripts from unintentionally changing an
   162  // empty struct into empty arrays
   163  func cleanReturnedObj(newObj, obj map[string]interface{}) map[string]interface{} {
   164  	mapToReturn := newObj
   165  	for key := range obj {
   166  		if newValueInterface, ok := newObj[key]; ok {
   167  			oldValueInterface, ok := obj[key]
   168  			if !ok {
   169  				continue
   170  			}
   171  			switch newValue := newValueInterface.(type) {
   172  			case map[string]interface{}:
   173  				if oldValue, ok := oldValueInterface.(map[string]interface{}); ok {
   174  					convertedMap := cleanReturnedObj(newValue, oldValue)
   175  					mapToReturn[key] = convertedMap
   176  				}
   177  
   178  			case []interface{}:
   179  				switch oldValue := oldValueInterface.(type) {
   180  				case map[string]interface{}:
   181  					if len(newValue) == 0 {
   182  						mapToReturn[key] = oldValue
   183  					}
   184  				case []interface{}:
   185  					newArray := cleanReturnedArray(newValue, oldValue)
   186  					mapToReturn[key] = newArray
   187  				}
   188  			}
   189  		}
   190  	}
   191  	return mapToReturn
   192  }
   193  
   194  // cleanReturnedArray allows Argo CD to recurse into nested arrays when checking for unintentional empty struct to
   195  // empty array conversions.
   196  func cleanReturnedArray(newObj, obj []interface{}) []interface{} {
   197  	arrayToReturn := newObj
   198  	for i := range newObj {
   199  		switch newValue := newObj[i].(type) {
   200  		case map[string]interface{}:
   201  			if oldValue, ok := obj[i].(map[string]interface{}); ok {
   202  				convertedMap := cleanReturnedObj(newValue, oldValue)
   203  				arrayToReturn[i] = convertedMap
   204  			}
   205  		case []interface{}:
   206  			if oldValue, ok := obj[i].([]interface{}); ok {
   207  				convertedMap := cleanReturnedArray(newValue, oldValue)
   208  				arrayToReturn[i] = convertedMap
   209  			}
   210  		}
   211  	}
   212  	return arrayToReturn
   213  }
   214  
   215  func (vm VM) ExecuteResourceActionDiscovery(obj *unstructured.Unstructured, script string) ([]appv1.ResourceAction, error) {
   216  	l, err := vm.runLua(obj, script)
   217  	if err != nil {
   218  		return nil, err
   219  	}
   220  	returnValue := l.Get(-1)
   221  	if returnValue.Type() == lua.LTTable {
   222  
   223  		jsonBytes, err := luajson.Encode(returnValue)
   224  		if err != nil {
   225  			return nil, err
   226  		}
   227  		availableActions := make([]appv1.ResourceAction, 0)
   228  		if noAvailableActions(jsonBytes) {
   229  			return availableActions, nil
   230  		}
   231  		availableActionsMap := make(map[string]interface{})
   232  		err = json.Unmarshal(jsonBytes, &availableActionsMap)
   233  		if err != nil {
   234  			return nil, err
   235  		}
   236  		for key := range availableActionsMap {
   237  			value := availableActionsMap[key]
   238  			resourceAction := appv1.ResourceAction{Name: key, Disabled: isActionDisabled(value)}
   239  			if emptyResourceActionFromLua(value) {
   240  				availableActions = append(availableActions, resourceAction)
   241  				continue
   242  			}
   243  			resourceActionBytes, err := json.Marshal(value)
   244  			if err != nil {
   245  				return nil, err
   246  			}
   247  
   248  			err = json.Unmarshal(resourceActionBytes, &resourceAction)
   249  			if err != nil {
   250  				return nil, err
   251  			}
   252  			availableActions = append(availableActions, resourceAction)
   253  		}
   254  		return availableActions, err
   255  	}
   256  
   257  	return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
   258  }
   259  
   260  // Actions are enabled by default
   261  func isActionDisabled(actionsMap interface{}) bool {
   262  	actions, ok := actionsMap.(map[string]interface{})
   263  	if !ok {
   264  		return false
   265  	}
   266  	for key, val := range actions {
   267  		switch vv := val.(type) {
   268  		case bool:
   269  			if key == "disabled" {
   270  				return vv
   271  			}
   272  		}
   273  	}
   274  	return false
   275  }
   276  
   277  func emptyResourceActionFromLua(i interface{}) bool {
   278  	_, ok := i.([]interface{})
   279  	return ok
   280  }
   281  
   282  func noAvailableActions(jsonBytes []byte) bool {
   283  	// When the Lua script returns an empty table, it is decoded as a empty array.
   284  	return string(jsonBytes) == "[]"
   285  }
   286  
   287  func (vm VM) GetResourceActionDiscovery(obj *unstructured.Unstructured) (string, error) {
   288  	key := getConfigMapKey(obj)
   289  	override, ok := vm.ResourceOverrides[key]
   290  	if ok && override.Actions != "" {
   291  		actions, err := override.GetActions()
   292  		if err != nil {
   293  			return "", err
   294  		}
   295  		return actions.ActionDiscoveryLua, nil
   296  	}
   297  	discoveryKey := fmt.Sprintf("%s/actions/", key)
   298  	discoveryScript, err := vm.getPredefinedLuaScripts(discoveryKey, actionDiscoveryScriptFile)
   299  	if err != nil {
   300  		return "", err
   301  	}
   302  	return discoveryScript, nil
   303  }
   304  
   305  // GetResourceAction attempts to read lua script from config and then filesystem for that resource
   306  func (vm VM) GetResourceAction(obj *unstructured.Unstructured, actionName string) (appv1.ResourceActionDefinition, error) {
   307  	key := getConfigMapKey(obj)
   308  	override, ok := vm.ResourceOverrides[key]
   309  	if ok && override.Actions != "" {
   310  		actions, err := override.GetActions()
   311  		if err != nil {
   312  			return appv1.ResourceActionDefinition{}, err
   313  		}
   314  		for _, action := range actions.Definitions {
   315  			if action.Name == actionName {
   316  				return action, nil
   317  			}
   318  		}
   319  	}
   320  
   321  	actionKey := fmt.Sprintf("%s/actions/%s", key, actionName)
   322  	actionScript, err := vm.getPredefinedLuaScripts(actionKey, actionScriptFile)
   323  	if err != nil {
   324  		return appv1.ResourceActionDefinition{}, err
   325  	}
   326  
   327  	return appv1.ResourceActionDefinition{
   328  		Name:      actionName,
   329  		ActionLua: actionScript,
   330  	}, nil
   331  }
   332  
   333  func getConfigMapKey(obj *unstructured.Unstructured) string {
   334  	gvk := obj.GroupVersionKind()
   335  	if gvk.Group == "" {
   336  		return gvk.Kind
   337  	}
   338  	return fmt.Sprintf("%s/%s", gvk.Group, gvk.Kind)
   339  
   340  }
   341  
   342  func (vm VM) getPredefinedLuaScripts(objKey string, scriptFile string) (string, error) {
   343  	data, err := box.MustBytes(filepath.Join(objKey, scriptFile))
   344  	if err != nil {
   345  		if os.IsNotExist(err) {
   346  			return "", nil
   347  		}
   348  		return "", err
   349  	}
   350  	return string(data), nil
   351  }
   352  
   353  func isValidHealthStatusCode(statusCode health.HealthStatusCode) bool {
   354  	switch statusCode {
   355  	case health.HealthStatusUnknown, health.HealthStatusProgressing, health.HealthStatusSuspended, health.HealthStatusHealthy, health.HealthStatusDegraded, health.HealthStatusMissing:
   356  		return true
   357  	}
   358  	return false
   359  }
   360  
   361  // Took logic from the link below and added the int, int32, and int64 types since the value would have type int64
   362  // while actually running in the controller and it was not reproducible through testing.
   363  // https://github.com/layeh/gopher-json/blob/97fed8db84274c421dbfffbb28ec859901556b97/json.go#L154
   364  func decodeValue(L *lua.LState, value interface{}) lua.LValue {
   365  	switch converted := value.(type) {
   366  	case bool:
   367  		return lua.LBool(converted)
   368  	case float64:
   369  		return lua.LNumber(converted)
   370  	case string:
   371  		return lua.LString(converted)
   372  	case json.Number:
   373  		return lua.LString(converted)
   374  	case int:
   375  		return lua.LNumber(converted)
   376  	case int32:
   377  		return lua.LNumber(converted)
   378  	case int64:
   379  		return lua.LNumber(converted)
   380  	case []interface{}:
   381  		arr := L.CreateTable(len(converted), 0)
   382  		for _, item := range converted {
   383  			arr.Append(decodeValue(L, item))
   384  		}
   385  		return arr
   386  	case map[string]interface{}:
   387  		tbl := L.CreateTable(0, len(converted))
   388  		for key, item := range converted {
   389  			tbl.RawSetH(lua.LString(key), decodeValue(L, item))
   390  		}
   391  		return tbl
   392  	case nil:
   393  		return lua.LNil
   394  	}
   395  
   396  	return lua.LNil
   397  }