Files
deploy/util/lua/lua.go
T
Chetan BanavikalmuttandGitHub e2bb62dc0d feat: allow admins to enable the usage of lua standard library (#6087)
Currently, the usage of standard lua library is always disabled, making it difficult to implement complex health check scripts.
This feat allow admins to control the usage of standard library by setting "health.lua.useOpenLibs" (merged-keys convention)/"resource.customizations.useOpenLibs.<group_kind>"
(split-keys convention) field in argocd-cm ConfigMap.

Signed-off-by: Chetan Banavikalmutt <[email protected]>
2021-05-04 08:25:51 +02:00

402 lines
11 KiB
Go

package lua
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/argoproj/gitops-engine/pkg/health"
"github.com/gobuffalo/packr"
lua "github.com/yuin/gopher-lua"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
luajson "layeh.com/gopher-json"
appv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
)
const (
incorrectReturnType = "expect %s output from Lua script, not %s"
invalidHealthStatus = "Lua returned an invalid health status"
resourceCustomizationBuiltInPath = "../../resource_customizations"
healthScriptFile = "health.lua"
actionScriptFile = "action.lua"
actionDiscoveryScriptFile = "discovery.lua"
)
var (
box packr.Box
)
func init() {
box = packr.NewBox(resourceCustomizationBuiltInPath)
}
type ResourceHealthOverrides map[string]appv1.ResourceOverride
func (overrides ResourceHealthOverrides) GetResourceHealth(obj *unstructured.Unstructured) (*health.HealthStatus, error) {
luaVM := VM{
ResourceOverrides: overrides,
}
script, useOpenLibs, err := luaVM.GetHealthScript(obj)
if err != nil {
return nil, err
}
if script == "" {
return nil, nil
}
// enable/disable the usage of lua standard library
luaVM.UseOpenLibs = useOpenLibs
result, err := luaVM.ExecuteHealthLua(obj, script)
if err != nil {
return nil, err
}
return result, nil
}
// VM Defines a struct that implements the luaVM
type VM struct {
ResourceOverrides map[string]appv1.ResourceOverride
// UseOpenLibs flag to enable open libraries. Libraries are disabled by default while running, but enabled during testing to allow the use of print statements
UseOpenLibs bool
}
func (vm VM) runLua(obj *unstructured.Unstructured, script string) (*lua.LState, error) {
l := lua.NewState(lua.Options{
SkipOpenLibs: !vm.UseOpenLibs,
})
defer l.Close()
// Opens table library to allow access to functions to manipulate tables
for _, pair := range []struct {
n string
f lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage},
{lua.BaseLibName, lua.OpenBase},
{lua.TabLibName, lua.OpenTable},
// load our 'safe' version of the os library
{lua.OsLibName, OpenSafeOs},
} {
if err := l.CallByParam(lua.P{
Fn: l.NewFunction(pair.f),
NRet: 0,
Protect: true,
}, lua.LString(pair.n)); err != nil {
panic(err)
}
}
// preload our 'safe' version of the os library. Allows the 'local os = require("os")' to work
l.PreloadModule(lua.OsLibName, SafeOsLoader)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
l.SetContext(ctx)
objectValue := decodeValue(l, obj.Object)
l.SetGlobal("obj", objectValue)
err := l.DoString(script)
return l, err
}
// ExecuteHealthLua runs the lua script to generate the health status of a resource
func (vm VM) ExecuteHealthLua(obj *unstructured.Unstructured, script string) (*health.HealthStatus, error) {
l, err := vm.runLua(obj, script)
if err != nil {
return nil, err
}
returnValue := l.Get(-1)
if returnValue.Type() == lua.LTTable {
jsonBytes, err := luajson.Encode(returnValue)
if err != nil {
return nil, err
}
healthStatus := &health.HealthStatus{}
err = json.Unmarshal(jsonBytes, healthStatus)
if err != nil {
return nil, err
}
if !isValidHealthStatusCode(healthStatus.Status) {
return &health.HealthStatus{
Status: health.HealthStatusUnknown,
Message: invalidHealthStatus,
}, nil
}
return healthStatus, nil
}
return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
}
// GetHealthScript attempts to read lua script from config and then filesystem for that resource
func (vm VM) GetHealthScript(obj *unstructured.Unstructured) (string, bool, error) {
key := getConfigMapKey(obj)
if script, ok := vm.ResourceOverrides[key]; ok && script.HealthLua != "" {
return script.HealthLua, script.UseOpenLibs, nil
}
builtInScript, err := vm.getPredefinedLuaScripts(key, healthScriptFile)
// standard libraries will be enabled for all built-in scripts
return builtInScript, true, err
}
func (vm VM) ExecuteResourceAction(obj *unstructured.Unstructured, script string) (*unstructured.Unstructured, error) {
l, err := vm.runLua(obj, script)
if err != nil {
return nil, err
}
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
}
cleanedNewObj := cleanReturnedObj(newObj.Object, obj.Object)
newObj.Object = cleanedNewObj
return newObj, nil
}
return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
}
// 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
func cleanReturnedObj(newObj, obj map[string]interface{}) map[string]interface{} {
mapToReturn := newObj
for key := range obj {
if newValueInterface, ok := newObj[key]; ok {
oldValueInterface, ok := obj[key]
if !ok {
continue
}
switch newValue := newValueInterface.(type) {
case map[string]interface{}:
if oldValue, ok := oldValueInterface.(map[string]interface{}); ok {
convertedMap := cleanReturnedObj(newValue, oldValue)
mapToReturn[key] = convertedMap
}
case []interface{}:
switch oldValue := oldValueInterface.(type) {
case map[string]interface{}:
if len(newValue) == 0 {
mapToReturn[key] = oldValue
}
case []interface{}:
newArray := cleanReturnedArray(newValue, oldValue)
mapToReturn[key] = newArray
}
}
}
}
return mapToReturn
}
// cleanReturnedArray allows Argo CD to recurse into nested arrays when checking for unintentional empty struct to
// empty array conversions.
func cleanReturnedArray(newObj, obj []interface{}) []interface{} {
arrayToReturn := newObj
for i := range newObj {
switch newValue := newObj[i].(type) {
case map[string]interface{}:
if oldValue, ok := obj[i].(map[string]interface{}); ok {
convertedMap := cleanReturnedObj(newValue, oldValue)
arrayToReturn[i] = convertedMap
}
case []interface{}:
if oldValue, ok := obj[i].([]interface{}); ok {
convertedMap := cleanReturnedArray(newValue, oldValue)
arrayToReturn[i] = convertedMap
}
}
}
return arrayToReturn
}
func (vm VM) ExecuteResourceActionDiscovery(obj *unstructured.Unstructured, script string) ([]appv1.ResourceAction, error) {
l, err := vm.runLua(obj, script)
if err != nil {
return nil, err
}
returnValue := l.Get(-1)
if returnValue.Type() == lua.LTTable {
jsonBytes, err := luajson.Encode(returnValue)
if err != nil {
return nil, err
}
availableActions := make([]appv1.ResourceAction, 0)
if noAvailableActions(jsonBytes) {
return availableActions, nil
}
availableActionsMap := make(map[string]interface{})
err = json.Unmarshal(jsonBytes, &availableActionsMap)
if err != nil {
return nil, err
}
for key := range availableActionsMap {
value := availableActionsMap[key]
resourceAction := appv1.ResourceAction{Name: key, Disabled: isActionDisabled(value)}
if emptyResourceActionFromLua(value) {
availableActions = append(availableActions, resourceAction)
continue
}
resourceActionBytes, err := json.Marshal(value)
if err != nil {
return nil, err
}
err = json.Unmarshal(resourceActionBytes, &resourceAction)
if err != nil {
return nil, err
}
availableActions = append(availableActions, resourceAction)
}
return availableActions, err
}
return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String())
}
// Actions are enabled by default
func isActionDisabled(actionsMap interface{}) bool {
actions, ok := actionsMap.(map[string]interface{})
if !ok {
return false
}
for key, val := range actions {
switch vv := val.(type) {
case bool:
if key == "disabled" {
return vv
}
}
}
return false
}
func emptyResourceActionFromLua(i interface{}) bool {
_, ok := i.([]interface{})
return ok
}
func noAvailableActions(jsonBytes []byte) bool {
// When the Lua script returns an empty table, it is decoded as a empty array.
return string(jsonBytes) == "[]"
}
func (vm VM) GetResourceActionDiscovery(obj *unstructured.Unstructured) (string, error) {
key := getConfigMapKey(obj)
override, ok := vm.ResourceOverrides[key]
if ok && override.Actions != "" {
actions, err := override.GetActions()
if err != nil {
return "", err
}
return actions.ActionDiscoveryLua, nil
}
discoveryKey := fmt.Sprintf("%s/actions/", key)
discoveryScript, err := vm.getPredefinedLuaScripts(discoveryKey, actionDiscoveryScriptFile)
if err != nil {
return "", err
}
return discoveryScript, nil
}
// GetResourceAction attempts to read lua script from config and then filesystem for that resource
func (vm VM) GetResourceAction(obj *unstructured.Unstructured, actionName string) (appv1.ResourceActionDefinition, error) {
key := getConfigMapKey(obj)
override, ok := vm.ResourceOverrides[key]
if ok && override.Actions != "" {
actions, err := override.GetActions()
if err != nil {
return appv1.ResourceActionDefinition{}, err
}
for _, action := range actions.Definitions {
if action.Name == actionName {
return action, nil
}
}
}
actionKey := fmt.Sprintf("%s/actions/%s", key, actionName)
actionScript, err := vm.getPredefinedLuaScripts(actionKey, actionScriptFile)
if err != nil {
return appv1.ResourceActionDefinition{}, err
}
return appv1.ResourceActionDefinition{
Name: actionName,
ActionLua: actionScript,
}, nil
}
func getConfigMapKey(obj *unstructured.Unstructured) string {
gvk := obj.GroupVersionKind()
if gvk.Group == "" {
return gvk.Kind
}
return fmt.Sprintf("%s/%s", gvk.Group, gvk.Kind)
}
func (vm VM) getPredefinedLuaScripts(objKey string, scriptFile string) (string, error) {
data, err := box.MustBytes(filepath.Join(objKey, scriptFile))
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
func isValidHealthStatusCode(statusCode health.HealthStatusCode) bool {
switch statusCode {
case health.HealthStatusUnknown, health.HealthStatusProgressing, health.HealthStatusSuspended, health.HealthStatusHealthy, health.HealthStatusDegraded, health.HealthStatusMissing:
return true
}
return false
}
// Took logic from the link below and added the int, int32, and int64 types since the value would have type int64
// while actually running in the controller and it was not reproducible through testing.
// https://github.com/layeh/gopher-json/blob/97fed8db84274c421dbfffbb28ec859901556b97/json.go#L154
func decodeValue(L *lua.LState, value interface{}) lua.LValue {
switch converted := value.(type) {
case bool:
return lua.LBool(converted)
case float64:
return lua.LNumber(converted)
case string:
return lua.LString(converted)
case json.Number:
return lua.LString(converted)
case int:
return lua.LNumber(converted)
case int32:
return lua.LNumber(converted)
case int64:
return lua.LNumber(converted)
case []interface{}:
arr := L.CreateTable(len(converted), 0)
for _, item := range converted {
arr.Append(decodeValue(L, item))
}
return arr
case map[string]interface{}:
tbl := L.CreateTable(0, len(converted))
for key, item := range converted {
tbl.RawSetH(lua.LString(key), decodeValue(L, item))
}
return tbl
case nil:
return lua.LNil
}
return lua.LNil
}