github.com/argoproj/argo-cd/v3@v3.2.1/util/lua/lua.go (about) 1 package lua 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io/fs" 10 "os" 11 "path/filepath" 12 "reflect" 13 "slices" 14 "strings" 15 "sync" 16 "time" 17 18 "github.com/argoproj/gitops-engine/pkg/health" 19 glob "github.com/bmatcuk/doublestar/v4" 20 lua "github.com/yuin/gopher-lua" 21 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 22 "k8s.io/apimachinery/pkg/runtime/schema" 23 luajson "layeh.com/gopher-json" 24 25 applicationpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/application" 26 appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 27 "github.com/argoproj/argo-cd/v3/resource_customizations" 28 argoglob "github.com/argoproj/argo-cd/v3/util/glob" 29 ) 30 31 const ( 32 incorrectReturnType = "expect %s output from Lua script, not %s" 33 invalidHealthStatus = "Lua returned an invalid health status" 34 healthScriptFile = "health.lua" 35 actionScriptFile = "action.lua" 36 actionDiscoveryScriptFile = "discovery.lua" 37 ) 38 39 // errScriptDoesNotExist is an error type for when a built-in script does not exist. 40 var errScriptDoesNotExist = errors.New("built-in script does not exist") 41 42 type ResourceHealthOverrides map[string]appv1.ResourceOverride 43 44 func (overrides ResourceHealthOverrides) GetResourceHealth(obj *unstructured.Unstructured) (*health.HealthStatus, error) { 45 luaVM := VM{ 46 ResourceOverrides: overrides, 47 } 48 script, useOpenLibs, err := luaVM.GetHealthScript(obj) 49 if err != nil { 50 return nil, err 51 } 52 if script == "" { 53 return nil, nil 54 } 55 // enable/disable the usage of lua standard library 56 luaVM.UseOpenLibs = useOpenLibs 57 result, err := luaVM.ExecuteHealthLua(obj, script) 58 if err != nil { 59 return nil, err 60 } 61 return result, nil 62 } 63 64 // VM Defines a struct that implements the luaVM 65 type VM struct { 66 ResourceOverrides map[string]appv1.ResourceOverride 67 // UseOpenLibs flag to enable open libraries. Libraries are disabled by default while running, but enabled during testing to allow the use of print statements 68 UseOpenLibs bool 69 } 70 71 func (vm VM) runLua(obj *unstructured.Unstructured, script string) (*lua.LState, error) { 72 return vm.runLuaWithResourceActionParameters(obj, script, nil) 73 } 74 75 func (vm VM) runLuaWithResourceActionParameters(obj *unstructured.Unstructured, script string, resourceActionParameters []*applicationpkg.ResourceActionParameters) (*lua.LState, error) { 76 l := lua.NewState(lua.Options{ 77 SkipOpenLibs: !vm.UseOpenLibs, 78 }) 79 defer l.Close() 80 // Opens table library to allow access to functions to manipulate tables 81 for _, pair := range []struct { 82 n string 83 f lua.LGFunction 84 }{ 85 {lua.LoadLibName, lua.OpenPackage}, 86 {lua.BaseLibName, lua.OpenBase}, 87 {lua.TabLibName, lua.OpenTable}, 88 // load our 'safe' version of the OS library 89 {lua.OsLibName, OpenSafeOs}, 90 } { 91 if err := l.CallByParam(lua.P{ 92 Fn: l.NewFunction(pair.f), 93 NRet: 0, 94 Protect: true, 95 }, lua.LString(pair.n)); err != nil { 96 panic(err) 97 } 98 } 99 // preload our 'safe' version of the OS library. Allows the 'local os = require("os")' to work 100 l.PreloadModule(lua.OsLibName, SafeOsLoader) 101 102 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 103 defer cancel() 104 l.SetContext(ctx) 105 106 // Inject action parameters as a hash table global variable 107 actionParams := l.CreateTable(0, len(resourceActionParameters)) 108 for _, resourceActionParameter := range resourceActionParameters { 109 value := decodeValue(l, resourceActionParameter.GetValue()) 110 actionParams.RawSetH(lua.LString(resourceActionParameter.GetName()), value) 111 } 112 l.SetGlobal("actionParams", actionParams) // Set the actionParams table as a global variable 113 114 objectValue := decodeValue(l, obj.Object) 115 l.SetGlobal("obj", objectValue) 116 err := l.DoString(script) 117 118 // Remove the default lua stack trace from execution errors since these 119 // errors will make it back to the user 120 var apiErr *lua.ApiError 121 if errors.As(err, &apiErr) { 122 if apiErr.Type == lua.ApiErrorRun { 123 apiErr.StackTrace = "" 124 err = apiErr 125 } 126 } 127 128 return l, err 129 } 130 131 // ExecuteHealthLua runs the lua script to generate the health status of a resource 132 func (vm VM) ExecuteHealthLua(obj *unstructured.Unstructured, script string) (*health.HealthStatus, error) { 133 l, err := vm.runLua(obj, script) 134 if err != nil { 135 return nil, err 136 } 137 returnValue := l.Get(-1) 138 if returnValue.Type() == lua.LTTable { 139 jsonBytes, err := luajson.Encode(returnValue) 140 if err != nil { 141 return nil, err 142 } 143 healthStatus := &health.HealthStatus{} 144 err = json.Unmarshal(jsonBytes, healthStatus) 145 if err != nil { 146 // Validate if the error is caused by an empty object 147 typeError := &json.UnmarshalTypeError{Value: "array", Type: reflect.TypeOf(healthStatus)} 148 if errors.As(err, &typeError) { 149 return &health.HealthStatus{}, nil 150 } 151 return nil, err 152 } 153 if !isValidHealthStatusCode(healthStatus.Status) { 154 return &health.HealthStatus{ 155 Status: health.HealthStatusUnknown, 156 Message: invalidHealthStatus, 157 }, nil 158 } 159 160 return healthStatus, nil 161 } else if returnValue.Type() == lua.LTNil { 162 return &health.HealthStatus{}, nil 163 } 164 return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String()) 165 } 166 167 // GetHealthScript attempts to read lua script from config and then filesystem for that resource. If none exists, return 168 // an empty string. 169 func (vm VM) GetHealthScript(obj *unstructured.Unstructured) (script string, useOpenLibs bool, err error) { 170 // first, search the gvk as is in the ResourceOverrides 171 key := GetConfigMapKey(obj.GroupVersionKind()) 172 173 if script, ok := vm.ResourceOverrides[key]; ok && script.HealthLua != "" { 174 return script.HealthLua, script.UseOpenLibs, nil 175 } 176 177 // if not found as is, perhaps it matches a wildcard entry in the configmap 178 getWildcardHealthOverride, useOpenLibs := getWildcardHealthOverrideLua(vm.ResourceOverrides, obj.GroupVersionKind()) 179 180 if getWildcardHealthOverride != "" { 181 return getWildcardHealthOverride, useOpenLibs, nil 182 } 183 184 // if not found in the ResourceOverrides at all, search it as is in the built-in scripts 185 // (as built-in scripts are files in folders, named after the GVK, currently there is no wildcard support for them) 186 builtInScript, err := vm.getPredefinedLuaScripts(key, healthScriptFile) 187 if err != nil { 188 if errors.Is(err, errScriptDoesNotExist) { 189 // Try to find a wildcard built-in health script 190 builtInScript, err = getWildcardBuiltInHealthOverrideLua(key) 191 if err != nil { 192 return "", false, fmt.Errorf("error while fetching built-in health script: %w", err) 193 } 194 if builtInScript != "" { 195 return builtInScript, true, nil 196 } 197 198 // It's okay if no built-in health script exists. Just return an empty string and let the caller handle it. 199 return "", false, nil 200 } 201 return "", false, err 202 } 203 // standard libraries will be enabled for all built-in scripts 204 return builtInScript, true, err 205 } 206 207 func (vm VM) ExecuteResourceAction(obj *unstructured.Unstructured, script string, resourceActionParameters []*applicationpkg.ResourceActionParameters) ([]ImpactedResource, error) { 208 l, err := vm.runLuaWithResourceActionParameters(obj, script, resourceActionParameters) 209 if err != nil { 210 return nil, err 211 } 212 returnValue := l.Get(-1) 213 if returnValue.Type() == lua.LTTable { 214 jsonBytes, err := luajson.Encode(returnValue) 215 if err != nil { 216 return nil, err 217 } 218 219 var impactedResources []ImpactedResource 220 221 jsonString := bytes.NewBuffer(jsonBytes).String() 222 // nolint:staticcheck // Lua is fine to be capitalized. 223 if len(jsonString) < 2 { 224 return nil, errors.New("Lua output was not a valid json object or array") 225 } 226 // The output from Lua is either an object (old-style action output) or an array (new-style action output). 227 // Check whether the string starts with an opening square bracket and ends with a closing square bracket, 228 // avoiding programming by exception. 229 if jsonString[0] == '[' && jsonString[len(jsonString)-1] == ']' { 230 // The string represents a new-style action array output 231 impactedResources, err = UnmarshalToImpactedResources(string(jsonBytes)) 232 if err != nil { 233 return nil, err 234 } 235 } else { 236 // The string represents an old-style action object output 237 newObj, err := appv1.UnmarshalToUnstructured(string(jsonBytes)) 238 if err != nil { 239 return nil, err 240 } 241 // Wrap the old-style action output with a single-member array. 242 // The default definition of the old-style action is a "patch" one. 243 impactedResources = append(impactedResources, ImpactedResource{newObj, PatchOperation}) 244 } 245 246 for _, impactedResource := range impactedResources { 247 // Cleaning the resource is only relevant to "patch" 248 if impactedResource.K8SOperation == PatchOperation { 249 impactedResource.UnstructuredObj.Object = cleanReturnedObj(impactedResource.UnstructuredObj.Object, obj.Object) 250 } 251 } 252 return impactedResources, nil 253 } 254 return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String()) 255 } 256 257 // UnmarshalToImpactedResources unmarshals an ImpactedResource array representation in JSON to ImpactedResource array 258 func UnmarshalToImpactedResources(resources string) ([]ImpactedResource, error) { 259 if resources == "" || resources == "null" { 260 return nil, nil 261 } 262 263 var impactedResources []ImpactedResource 264 err := json.Unmarshal([]byte(resources), &impactedResources) 265 if err != nil { 266 return nil, err 267 } 268 return impactedResources, nil 269 } 270 271 // cleanReturnedObj Lua cannot distinguish an empty table as an array or map, and the library we are using choose to 272 // decoded an empty table into an empty array. This function prevents the lua scripts from unintentionally changing an 273 // empty struct into empty arrays 274 func cleanReturnedObj(newObj, obj map[string]any) map[string]any { 275 mapToReturn := newObj 276 for key := range obj { 277 if newValueInterface, ok := newObj[key]; ok { 278 oldValueInterface, ok := obj[key] 279 if !ok { 280 continue 281 } 282 switch newValue := newValueInterface.(type) { 283 case map[string]any: 284 if oldValue, ok := oldValueInterface.(map[string]any); ok { 285 convertedMap := cleanReturnedObj(newValue, oldValue) 286 mapToReturn[key] = convertedMap 287 } 288 289 case []any: 290 switch oldValue := oldValueInterface.(type) { 291 case map[string]any: 292 if len(newValue) == 0 { 293 // Lua incorrectly decoded the empty object as an empty array, so set it to an empty object 294 mapToReturn[key] = map[string]any{} 295 } 296 case []any: 297 newArray := cleanReturnedArray(newValue, oldValue) 298 mapToReturn[key] = newArray 299 } 300 } 301 } 302 } 303 return mapToReturn 304 } 305 306 // cleanReturnedArray allows Argo CD to recurse into nested arrays when checking for unintentional empty struct to 307 // empty array conversions. 308 func cleanReturnedArray(newObj, obj []any) []any { 309 arrayToReturn := newObj 310 for i := range newObj { 311 if i >= len(obj) { 312 // If the new object is longer than the old one, we added an item to the array 313 break 314 } 315 switch newValue := newObj[i].(type) { 316 case map[string]any: 317 if oldValue, ok := obj[i].(map[string]any); ok { 318 convertedMap := cleanReturnedObj(newValue, oldValue) 319 arrayToReturn[i] = convertedMap 320 } 321 case []any: 322 if oldValue, ok := obj[i].([]any); ok { 323 convertedMap := cleanReturnedArray(newValue, oldValue) 324 arrayToReturn[i] = convertedMap 325 } 326 } 327 } 328 return arrayToReturn 329 } 330 331 func (vm VM) ExecuteResourceActionDiscovery(obj *unstructured.Unstructured, scripts []string) ([]appv1.ResourceAction, error) { 332 if len(scripts) == 0 { 333 return nil, errors.New("no action discovery script provided") 334 } 335 availableActionsMap := make(map[string]appv1.ResourceAction) 336 337 for _, script := range scripts { 338 l, err := vm.runLua(obj, script) 339 if err != nil { 340 return nil, err 341 } 342 returnValue := l.Get(-1) 343 if returnValue.Type() != lua.LTTable { 344 return nil, fmt.Errorf(incorrectReturnType, "table", returnValue.Type().String()) 345 } 346 jsonBytes, err := luajson.Encode(returnValue) 347 if err != nil { 348 return nil, fmt.Errorf("error in converting to lua table: %w", err) 349 } 350 if noAvailableActions(jsonBytes) { 351 continue 352 } 353 actionsMap := make(map[string]any) 354 err = json.Unmarshal(jsonBytes, &actionsMap) 355 if err != nil { 356 return nil, fmt.Errorf("error unmarshaling action table: %w", err) 357 } 358 for key, value := range actionsMap { 359 resourceAction := appv1.ResourceAction{Name: key, Disabled: isActionDisabled(value)} 360 if _, exist := availableActionsMap[key]; exist { 361 continue 362 } 363 if emptyResourceActionFromLua(value) { 364 availableActionsMap[key] = resourceAction 365 continue 366 } 367 resourceActionBytes, err := json.Marshal(value) 368 if err != nil { 369 return nil, fmt.Errorf("error marshaling resource action: %w", err) 370 } 371 372 err = json.Unmarshal(resourceActionBytes, &resourceAction) 373 if err != nil { 374 return nil, fmt.Errorf("error unmarshaling resource action: %w", err) 375 } 376 availableActionsMap[key] = resourceAction 377 } 378 } 379 380 availableActions := make([]appv1.ResourceAction, 0, len(availableActionsMap)) 381 for _, action := range availableActionsMap { 382 availableActions = append(availableActions, action) 383 } 384 385 return availableActions, nil 386 } 387 388 // Actions are enabled by default 389 func isActionDisabled(actionsMap any) bool { 390 actions, ok := actionsMap.(map[string]any) 391 if !ok { 392 return false 393 } 394 for key, val := range actions { 395 if vv, ok := val.(bool); ok { 396 if key == "disabled" { 397 return vv 398 } 399 } 400 } 401 return false 402 } 403 404 func emptyResourceActionFromLua(i any) bool { 405 _, ok := i.([]any) 406 return ok 407 } 408 409 func noAvailableActions(jsonBytes []byte) bool { 410 // When the Lua script returns an empty table, it is decoded as a empty array. 411 return string(jsonBytes) == "[]" 412 } 413 414 func (vm VM) GetResourceActionDiscovery(obj *unstructured.Unstructured) ([]string, error) { 415 key := GetConfigMapKey(obj.GroupVersionKind()) 416 var discoveryScripts []string 417 418 // Check if there are resource overrides for the given key 419 override, ok := vm.ResourceOverrides[key] 420 if ok && override.Actions != "" { 421 actions, err := override.GetActions() 422 if err != nil { 423 return nil, err 424 } 425 // Append the action discovery Lua script if built-in actions are to be included 426 if !actions.MergeBuiltinActions { 427 return []string{actions.ActionDiscoveryLua}, nil 428 } 429 discoveryScripts = append(discoveryScripts, actions.ActionDiscoveryLua) 430 } 431 432 // Fetch predefined Lua scripts 433 discoveryKey := key + "/actions/" 434 discoveryScript, err := vm.getPredefinedLuaScripts(discoveryKey, actionDiscoveryScriptFile) 435 if err != nil { 436 if errors.Is(err, errScriptDoesNotExist) { 437 // No worries, just return what we have. 438 return discoveryScripts, nil 439 } 440 return nil, fmt.Errorf("error while fetching predefined lua scripts: %w", err) 441 } 442 443 discoveryScripts = append(discoveryScripts, discoveryScript) 444 445 return discoveryScripts, nil 446 } 447 448 // GetResourceAction attempts to read lua script from config and then filesystem for that resource 449 func (vm VM) GetResourceAction(obj *unstructured.Unstructured, actionName string) (appv1.ResourceActionDefinition, error) { 450 key := GetConfigMapKey(obj.GroupVersionKind()) 451 override, ok := vm.ResourceOverrides[key] 452 if ok && override.Actions != "" { 453 actions, err := override.GetActions() 454 if err != nil { 455 return appv1.ResourceActionDefinition{}, err 456 } 457 for _, action := range actions.Definitions { 458 if action.Name == actionName { 459 return action, nil 460 } 461 } 462 } 463 464 actionKey := fmt.Sprintf("%s/actions/%s", key, actionName) 465 actionScript, err := vm.getPredefinedLuaScripts(actionKey, actionScriptFile) 466 if err != nil { 467 return appv1.ResourceActionDefinition{}, err 468 } 469 470 return appv1.ResourceActionDefinition{ 471 Name: actionName, 472 ActionLua: actionScript, 473 }, nil 474 } 475 476 func GetConfigMapKey(gvk schema.GroupVersionKind) string { 477 if gvk.Group == "" { 478 return gvk.Kind 479 } 480 return fmt.Sprintf("%s/%s", gvk.Group, gvk.Kind) 481 } 482 483 // getWildcardHealthOverrideLua returns the first encountered resource override which matches the wildcard and has a 484 // non-empty health script. Having multiple wildcards with non-empty health checks that can match the GVK is 485 // non-deterministic. 486 func getWildcardHealthOverrideLua(overrides map[string]appv1.ResourceOverride, gvk schema.GroupVersionKind) (string, bool) { 487 gvkKeyToMatch := GetConfigMapKey(gvk) 488 489 for key, override := range overrides { 490 if argoglob.Match(key, gvkKeyToMatch) && override.HealthLua != "" { 491 return override.HealthLua, override.UseOpenLibs 492 } 493 } 494 return "", false 495 } 496 497 func (vm VM) getPredefinedLuaScripts(objKey string, scriptFile string) (string, error) { 498 data, err := resource_customizations.Embedded.ReadFile(filepath.Join(objKey, scriptFile)) 499 if err != nil { 500 if os.IsNotExist(err) { 501 return "", errScriptDoesNotExist 502 } 503 return "", err 504 } 505 return string(data), nil 506 } 507 508 // globHealthScriptPathsOnce is a sync.Once instance to ensure that the globHealthScriptPaths are only initialized once. 509 // The globs come from an embedded filesystem, so it won't change at runtime. 510 var globHealthScriptPathsOnce sync.Once 511 512 // globHealthScriptPaths is a cache for the glob patterns of directories containing health.lua files. Don't use this 513 // directly, use getGlobHealthScriptPaths() instead. 514 var globHealthScriptPaths []string 515 516 // getGlobHealthScriptPaths returns the paths of the directories containing health.lua files where the path contains a 517 // glob pattern. It uses a sync.Once to ensure that the paths are only initialized once. 518 func getGlobHealthScriptPaths() ([]string, error) { 519 var err error 520 globHealthScriptPathsOnce.Do(func() { 521 // Walk through the embedded filesystem and get the directory names of all directories containing a health.lua. 522 var patterns []string 523 err = fs.WalkDir(resource_customizations.Embedded, ".", func(path string, d fs.DirEntry, err error) error { 524 if err != nil { 525 return fmt.Errorf("error walking path %q: %w", path, err) 526 } 527 528 // Skip non-directories at the top level 529 if d.IsDir() && filepath.Dir(path) == "." { 530 return nil 531 } 532 533 // Check if the directory contains a health.lua file 534 if filepath.Base(path) != healthScriptFile { 535 return nil 536 } 537 538 groupKindPath := filepath.Dir(path) 539 // Check if the path contains a wildcard. If it doesn't, skip it. 540 if !strings.Contains(groupKindPath, "_") { 541 return nil 542 } 543 544 pattern := strings.ReplaceAll(groupKindPath, "_", "*") 545 // Check that the pattern is valid. 546 if !glob.ValidatePattern(pattern) { 547 return fmt.Errorf("invalid glob pattern %q: %w", pattern, err) 548 } 549 550 patterns = append(patterns, groupKindPath) 551 return nil 552 }) 553 if err != nil { 554 return 555 } 556 557 // Sort the patterns to ensure deterministic choice of wildcard directory for a given GK. 558 slices.Sort(patterns) 559 560 globHealthScriptPaths = patterns 561 }) 562 if err != nil { 563 return nil, fmt.Errorf("error getting health script glob directories: %w", err) 564 } 565 return globHealthScriptPaths, nil 566 } 567 568 func getWildcardBuiltInHealthOverrideLua(objKey string) (string, error) { 569 // Check if the GVK matches any of the wildcard directories 570 globs, err := getGlobHealthScriptPaths() 571 if err != nil { 572 return "", fmt.Errorf("error getting health script globs: %w", err) 573 } 574 for _, g := range globs { 575 pattern := strings.ReplaceAll(g, "_", "*") 576 if !glob.PathMatchUnvalidated(pattern, objKey) { 577 continue 578 } 579 580 var script []byte 581 script, err = resource_customizations.Embedded.ReadFile(filepath.Join(g, healthScriptFile)) 582 if err != nil { 583 return "", fmt.Errorf("error reading %q file in embedded filesystem: %w", filepath.Join(objKey, healthScriptFile), err) 584 } 585 return string(script), nil 586 } 587 return "", nil 588 } 589 590 func isValidHealthStatusCode(statusCode health.HealthStatusCode) bool { 591 switch statusCode { 592 case health.HealthStatusUnknown, health.HealthStatusProgressing, health.HealthStatusSuspended, health.HealthStatusHealthy, health.HealthStatusDegraded, health.HealthStatusMissing: 593 return true 594 } 595 return false 596 } 597 598 // Took logic from the link below and added the int, int32, and int64 types since the value would have type int64 599 // while actually running in the controller and it was not reproducible through testing. 600 // https://github.com/layeh/gopher-json/blob/97fed8db84274c421dbfffbb28ec859901556b97/json.go#L154 601 func decodeValue(l *lua.LState, value any) lua.LValue { 602 switch converted := value.(type) { 603 case bool: 604 return lua.LBool(converted) 605 case float64: 606 return lua.LNumber(converted) 607 case string: 608 return lua.LString(converted) 609 case json.Number: 610 return lua.LString(converted) 611 case int: 612 return lua.LNumber(converted) 613 case int32: 614 return lua.LNumber(converted) 615 case int64: 616 return lua.LNumber(converted) 617 case []any: 618 arr := l.CreateTable(len(converted), 0) 619 for _, item := range converted { 620 arr.Append(decodeValue(l, item)) 621 } 622 return arr 623 case map[string]any: 624 tbl := l.CreateTable(0, len(converted)) 625 for key, item := range converted { 626 tbl.RawSetH(lua.LString(key), decodeValue(l, item)) 627 } 628 return tbl 629 case nil: 630 return lua.LNil 631 } 632 633 return lua.LNil 634 }