diff --git a/executor.go b/executor.go index fbf6e60..267fb16 100644 --- a/executor.go +++ b/executor.go @@ -8,6 +8,7 @@ import ( "io" "io/fs" "maps" + "os" "path" "path/filepath" "reflect" @@ -3590,20 +3591,16 @@ func (e *Executor) handleLookup(expr string, host string, task *Task) string { } func (e *Executor) lookupValue(expr string, host string, task *Task) (any, bool) { - // Parse lookup('type', 'arg') - re := regexp.MustCompile(`lookup\s*\(\s*['"](\w+)['"]\s*,\s*(.+?)\s*\)`) + // Parse lookup('type', 'arg') and accept fully-qualified lookup names. + re := regexp.MustCompile(`lookup\s*\(\s*['"]([\w.]+)['"]\s*,\s*(.+?)\s*\)`) match := re.FindStringSubmatch(expr) if len(match) < 3 { return nil, false } - lookupType := match[1] + lookupType := normalizeLookupName(match[1]) arg := strings.TrimSpace(match[2]) - if len(arg) >= 2 { - if (arg[0] == '\'' && arg[len(arg)-1] == '\'') || (arg[0] == '"' && arg[len(arg)-1] == '"') { - arg = arg[1 : len(arg)-1] - } - } + arg, quoted := unquoteLookupArg(arg) switch lookupType { case "env": @@ -3634,11 +3631,121 @@ func (e *Executor) lookupValue(expr string, host string, task *Task) (any, bool) if value, ok := e.lookupPassword(arg); ok { return value, true } + case "first_found": + if value, ok := e.lookupFirstFound(arg, quoted, host, task); ok { + return value, true + } } return nil, false } +func normalizeLookupName(name string) string { + name = corexTrimSpace(name) + if name == "" { + return "" + } + + if idx := strings.LastIndex(name, "."); idx >= 0 { + return name[idx+1:] + } + + return name +} + +func unquoteLookupArg(arg string) (string, bool) { + if len(arg) >= 2 { + if (arg[0] == '\'' && arg[len(arg)-1] == '\'') || (arg[0] == '"' && arg[len(arg)-1] == '"') { + return arg[1 : len(arg)-1], true + } + } + return arg, false +} + +func (e *Executor) lookupFirstFound(arg string, quoted bool, host string, task *Task) (string, bool) { + resolved := any(arg) + if !quoted { + if value, ok := e.lookupConditionValue(arg, host, task, nil); ok { + resolved = value + } + } + + files, paths := firstFoundTerms(resolved) + if len(files) == 0 { + return "", false + } + + candidates := make([]string, 0, len(files)) + if len(paths) == 0 { + candidates = append(candidates, files...) + } else { + for _, base := range paths { + if base == "" { + continue + } + for _, file := range files { + if file == "" { + continue + } + if pathIsAbs(file) { + candidates = append(candidates, file) + continue + } + candidates = append(candidates, joinPath(base, file)) + } + } + } + + for _, candidate := range candidates { + resolvedPath := e.resolveLocalPath(candidate) + if info, err := os.Stat(resolvedPath); err == nil && !info.IsDir() { + return resolvedPath, true + } + } + + return "", false +} + +func firstFoundTerms(value any) ([]string, []string) { + switch v := value.(type) { + case nil: + return nil, nil + case string: + return normalizeStringList(v), nil + case []string: + return append([]string(nil), v...), nil + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := corexTrimSpace(corexSprint(item)); s != "" && s != "" { + out = append(out, s) + } + } + return out, nil + case map[string]any: + files := normalizeStringArgs(v["files"]) + if len(files) == 0 { + files = normalizeStringArgs(v["terms"]) + } + paths := normalizeStringArgs(v["paths"]) + return files, paths + case map[any]any: + converted := make(map[string]any, len(v)) + for key, val := range v { + if s, ok := key.(string); ok { + converted[s] = val + } + } + return firstFoundTerms(converted) + default: + s := corexTrimSpace(corexSprint(v)) + if s == "" || s == "" { + return nil, nil + } + return []string{s}, nil + } +} + func (e *Executor) lookupPassword(arg string) (string, bool) { spec := parsePasswordLookupSpec(arg) if spec.path == "" { diff --git a/executor_extra_test.go b/executor_extra_test.go index f6c35af..c1b7d1e 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -947,6 +947,37 @@ func TestExecutorExtra_HandleLookup_Good_PasswordLookupCreatesFile(t *testing.T) assert.Len(t, content, 12) } +func TestExecutorExtra_HandleLookup_Good_FirstFoundLookupReturnsFirstExistingPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, writeTestFile(joinPath(dir, "defaults", "common.yml"), []byte("common: true\n"), 0644)) + require.NoError(t, writeTestFile(joinPath(dir, "defaults", "production.yml"), []byte("env: prod\n"), 0644)) + + e := NewExecutor(dir) + e.SetVar("findme", map[string]any{ + "files": []any{"missing.yml", "production.yml", "common.yml"}, + "paths": []any{"defaults"}, + }) + + result := e.handleLookup("lookup('first_found', findme)", "", nil) + + assert.Equal(t, joinPath(dir, "defaults", "production.yml"), result) +} + +func TestExecutorExtra_HandleLookup_Good_FirstFoundLookupAcceptsFQCN(t *testing.T) { + dir := t.TempDir() + require.NoError(t, writeTestFile(joinPath(dir, "vars", "selected.yml"), []byte("selected: true\n"), 0644)) + + e := NewExecutor(dir) + e.SetVar("findme", map[string]any{ + "files": []any{"missing.yml", "selected.yml"}, + "paths": []any{"vars"}, + }) + + result := e.handleLookup("lookup('ansible.builtin.first_found', findme)", "", nil) + + assert.Equal(t, joinPath(dir, "vars", "selected.yml"), result) +} + func TestExecutorExtra_RunTaskOnHost_Good_LoopFromFileGlobLookup(t *testing.T) { dir := t.TempDir() require.NoError(t, writeTestFile(joinPath(dir, "files", "a.txt"), []byte("alpha"), 0644))