209 lines
5.8 KiB
Go
209 lines
5.8 KiB
Go
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
|
||
}
|