mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-03-28 19:38:48 +01:00
377 lines
11 KiB
Go
377 lines
11 KiB
Go
package lua
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"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/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)
|
|
}
|
|
|
|
// VM Defines a struct that implements the luaVM
|
|
type VM struct {
|
|
ResourceOverrides map[string]appv1.ResourceOverride
|
|
// UseOpenLibs flag to enable open libraries. Libraries are always disabled 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) (*appv1.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 := &appv1.HealthStatus{}
|
|
err = json.Unmarshal(jsonBytes, healthStatus)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !isValidHealthStatusCode(healthStatus.Status) {
|
|
return &appv1.HealthStatus{
|
|
Status: appv1.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, error) {
|
|
key := getConfigMapKey(obj)
|
|
if script, ok := vm.ResourceOverrides[key]; ok && script.HealthLua != "" {
|
|
return script.HealthLua, nil
|
|
}
|
|
return vm.getPredefinedLuaScripts(key, healthScriptFile)
|
|
}
|
|
|
|
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 string) bool {
|
|
switch statusCode {
|
|
case appv1.HealthStatusUnknown, appv1.HealthStatusProgressing, appv1.HealthStatusSuspended, appv1.HealthStatusHealthy, appv1.HealthStatusDegraded, appv1.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
|
|
}
|