yaml.go

v0.5.0
Doc Versions Source
1
// Package inventory provides loaders that build a legatus.Inventory from
2
// declarative sources. It is an opt-in battery: callers that prefer to
3
// construct an inventory programmatically can ignore it entirely.
4
package inventory
5
6
import (
7
	"io"
8
	"os"
9
10
	"gopkg.in/yaml.v3"
11
12
	"go.bigb.es/auxilia/culpa"
13
	"go.bigb.es/auxilia/legatus"
14
)
15
16
// yamlSchema is the on-disk shape of a YAML inventory.
17
//
18
//	hosts:
19
//	  - name: web1
20
//	    addr: 10.0.0.1
21
//	    port: 22
22
//	    user: deploy
23
//	    tags: [web, prod]
24
//	    groups: [frontend]
25
//	    vars: {role: nginx}
26
//	groups:            # optional: membership declared by host name
27
//	  frontend: [web1, web2]
28
//
29
// Group membership from both forms (inline host.groups and the named groups
30
// map) is merged onto each Host's Groups field, which legatus treats as a pure
31
// predicate via InGroup.
32
type yamlSchema struct {
33
	Hosts  []yamlHost          `yaml:"hosts"`
34
	Groups map[string][]string `yaml:"groups"`
35
}
36
37
type yamlHost struct {
38
	Name   string         `yaml:"name"`
39
	Addr   string         `yaml:"addr"`
40
	Port   int            `yaml:"port"`
41
	User   string         `yaml:"user"`
42
	Tags   []string       `yaml:"tags"`
43
	Groups []string       `yaml:"groups"`
44
	Vars   map[string]any `yaml:"vars"`
45
}
46
47
// LoadYAML parses a YAML inventory from r and returns the resulting Inventory.
48
//
49
// It raises (never silently drops) on malformed YAML, hosts without a name,
50
// duplicate host names, and groups that reference an unknown host.
51
func LoadYAML(r io.Reader) (*legatus.Inventory, error) {
52
	var schema yamlSchema
53
	dec := yaml.NewDecoder(r)
54
	dec.KnownFields(true)
55
	if err := dec.Decode(&schema); err != nil {
56
		return nil, culpa.WithCode(culpa.Wrap(err, "parse YAML inventory"), "INVENTORY_PARSE")
57
	}
58
	return build(&schema)
59
}
60
61
// LoadYAMLFile reads the file at path and parses it as a YAML inventory.
62
func LoadYAMLFile(path string) (*legatus.Inventory, error) {
63
	f, err := os.Open(path)
64
	if err != nil {
65
		return nil, culpa.WithCode(culpa.Wrapf(err, "open inventory file %q", path), "INVENTORY_READ")
66
	}
67
	defer f.Close()
68
69
	return LoadYAML(f)
70
}
71
72
func build(schema *yamlSchema) (*legatus.Inventory, error) {
73
	byName := make(map[string]*legatus.Host, len(schema.Hosts))
74
	hosts := make([]*legatus.Host, 0, len(schema.Hosts))
75
76
	for i := range schema.Hosts {
77
		yh := &schema.Hosts[i]
78
		if yh.Name == "" {
79
			return nil, culpa.WithCode(
80
				culpa.Errorf("host at index %d has no name", i), "INVENTORY_INVALID_HOST")
81
		}
82
		if _, dup := byName[yh.Name]; dup {
83
			return nil, culpa.WithCode(
84
				culpa.Errorf("duplicate host %q", yh.Name), "INVENTORY_DUPLICATE_HOST")
85
		}
86
87
		h := &legatus.Host{
88
			Name:   yh.Name,
89
			Addr:   yh.Addr,
90
			Port:   yh.Port,
91
			User:   yh.User,
92
			Tags:   yh.Tags,
93
			Groups: yh.Groups,
94
			Vars:   yh.Vars,
95
		}
96
		byName[h.Name] = h
97
		hosts = append(hosts, h)
98
	}
99
100
	if err := mergeGroups(schema.Groups, byName); err != nil {
101
		return nil, err
102
	}
103
104
	return legatus.NewInventory(hosts...), nil
105
}
106
107
// mergeGroups folds the named-groups map into each referenced host's Groups
108
// field, deduplicating against memberships already declared inline. A group
109
// referencing an unknown host is an error, not a silently skipped entry.
110
func mergeGroups(groups map[string][]string, byName map[string]*legatus.Host) error {
111
	for group, members := range groups {
112
		for _, name := range members {
113
			h, ok := byName[name]
114
			if !ok {
115
				return culpa.WithCode(
116
					culpa.Errorf("group %q references unknown host %q", group, name),
117
					"INVENTORY_UNKNOWN_HOST")
118
			}
119
			if !contains(h.Groups, group) {
120
				h.Groups = append(h.Groups, group)
121
			}
122
		}
123
	}
124
	return nil
125
}
126
127
func contains(haystack []string, needle string) bool {
128
	for _, s := range haystack {
129
		if s == needle {
130
			return true
131
		}
132
	}
133
	return false
134
}
135

Source Files