package parser import ( "fmt" "os" "path/filepath" "gopkg.in/yaml.v3" ) // известные служебные ключи таска — всё остальное в YAML-мапе // считается именем модуля + его аргументами. var taskMetaKeys = map[string]bool{ "name": true, "when": true, "loop": true, "tags": true, "register": true, "notify": true, "allow_shell": true, "after": true, "before": true, } // UnmarshalYAML у Task разбирает произвольную YAML-мапу: известные поля // (name/when/loop/...) уходят в соответствующие структурные поля, // единственный оставшийся неизвестный ключ — это имя модуля, а его // значение — аргументы модуля. func (t *Task) UnmarshalYAML(node *yaml.Node) error { raw := map[string]interface{}{} if err := node.Decode(&raw); err != nil { return fmt.Errorf("decode task: %w", err) } t.Raw = raw if v, ok := raw["name"].(string); ok { t.Name = v } if v, ok := raw["when"].(string); ok { t.When = v } if v, ok := raw["loop"]; ok { t.Loop = v } if v, ok := raw["register"].(string); ok { t.Register = v } if v, ok := raw["allow_shell"].(bool); ok { t.AllowShell = v } if v, ok := raw["tags"]; ok { t.Tags = toStringSlice(v) } if v, ok := raw["notify"]; ok { t.Notify = toStringSlice(v) } if v, ok := raw["after"]; ok { t.After = toStringSlice(v) } if v, ok := raw["before"]; ok { t.Before = toStringSlice(v) } moduleName, moduleArgs, err := extractModule(raw) if err != nil { return err } t.Module = moduleName t.Args = moduleArgs return nil } // extractModule находит единственный ключ, не входящий в taskMetaKeys, // и трактует его как имя модуля. Если такой ключ не один — это ошибка // плейбука (двусмысленность, какой модуль на самом деле выполняется). func extractModule(raw map[string]interface{}) (string, map[string]interface{}, error) { var moduleName string found := 0 for k := range raw { if taskMetaKeys[k] { continue } moduleName = k found++ } if found == 0 { return "", nil, fmt.Errorf("таск без модуля: %v", raw) } if found > 1 { return "", nil, fmt.Errorf("таск с несколькими модулями сразу — неоднозначно: %v", raw) } switch v := raw[moduleName].(type) { case map[string]interface{}: return moduleName, v, nil case string: // короткая форма: `shell: "apt-get update"` — заворачиваем в единственный // аргумент "cmd", которого ждут shell/command модули. return moduleName, map[string]interface{}{"cmd": v}, nil case nil: return moduleName, map[string]interface{}{}, nil default: return "", nil, fmt.Errorf("модуль %q: неожиданный тип аргументов %T", moduleName, v) } } func toStringSlice(v interface{}) []string { switch vv := v.(type) { case []interface{}: out := make([]string, 0, len(vv)) for _, item := range vv { out = append(out, fmt.Sprintf("%v", item)) } return out case string: return []string{vv} default: return nil } } // LoadPlaybook читает playbook.yml, парсит все play и подгружает роли, // на которые есть ссылки в `roles:`, из директории `roles/` рядом с плейбуком. func LoadPlaybook(path string) (*Playbook, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("читаю плейбук: %w", err) } var plays []Play if err := yaml.Unmarshal(data, &plays); err != nil { return nil, fmt.Errorf("парсинг плейбука: %w", err) } pb := &Playbook{ Plays: plays, Roles: map[string]*Role{}, Dir: filepath.Dir(path), } rolesDir := filepath.Join(pb.Dir, "roles") for _, play := range plays { for _, roleName := range play.Roles { if _, ok := pb.Roles[roleName]; ok { continue } role, err := loadRole(rolesDir, roleName) if err != nil { return nil, fmt.Errorf("роль %q: %w", roleName, err) } pb.Roles[roleName] = role } } return pb, nil } // loadRole читает директорийную структуру одной роли: // tasks/main.yml, defaults/main.yml, vars/main.yml, handlers/main.yml. // Отсутствующие файлы — не ошибка, просто пустые значения. func loadRole(rolesDir, name string) (*Role, error) { dir := filepath.Join(rolesDir, name) role := &Role{Name: name, Dir: dir} tasks, err := loadTaskFile(filepath.Join(dir, "tasks", "main.yml")) if err != nil { return nil, err } role.Tasks = tasks handlers, err := loadTaskFile(filepath.Join(dir, "handlers", "main.yml")) if err != nil { return nil, err } role.Handlers = handlers role.Defaults, err = loadVarsFile(filepath.Join(dir, "defaults", "main.yml")) if err != nil { return nil, err } role.Vars, err = loadVarsFile(filepath.Join(dir, "vars", "main.yml")) if err != nil { return nil, err } return role, nil } func loadTaskFile(path string) ([]Task, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, err } var tasks []Task if err := yaml.Unmarshal(data, &tasks); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return tasks, nil } func loadVarsFile(path string) (map[string]interface{}, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return map[string]interface{}{}, nil } if err != nil { return nil, err } vars := map[string]interface{}{} if err := yaml.Unmarshal(data, &vars); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return vars, nil }