goherence/internal/parser/playbook.go
2026-09-11 10:17:25 +03:00

209 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}