]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/config/groups.go
Improve and add comments
[safcm/safcm.git] / cmd / safcm / config / groups.go
1 // Config: parse groups.yaml
2
3 // Copyright (C) 2021  Simon Ruderich
4 //
5 // This program is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // This program is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 package config
19
20 import (
21         "fmt"
22         "os"
23         "regexp"
24         "sort"
25         "strings"
26
27         "gopkg.in/yaml.v2"
28 )
29
30 const (
31         GroupAll              = "all"
32         GroupDetectedPrefix   = "detected"
33         GroupSpecialSeparator = ":"
34         GroupRemoveSuffix     = GroupSpecialSeparator + "remove"
35 )
36
37 // Keep in sync with cmd/safcm/sync_info.go:infoGroupDetectedRegexp
38 var groupNameRegexp = regexp.MustCompile(`^[a-z0-9_-]+$`)
39
40 func LoadGroups(cfg *Config, hosts *Hosts) (map[string][]string, error) {
41         const path = "groups.yaml"
42
43         var groups map[string][]string
44         x, err := os.ReadFile(path)
45         if err != nil {
46                 return nil, err
47         }
48         err = yaml.UnmarshalStrict(x, &groups)
49         if err != nil {
50                 return nil, fmt.Errorf("%s: failed to load: %v", path, err)
51         }
52
53         // Sanity checks; cannot expand groups yet because detected groups are
54         // only known after connecting to the host
55         for name, members := range groups {
56                 errPrefix := fmt.Sprintf("%s: group %q:", path, name)
57
58                 if name == GroupAll || name == GroupAll+GroupRemoveSuffix {
59                         return nil, fmt.Errorf(
60                                 "%s conflict with pre-defined group %q",
61                                 errPrefix, name)
62                 }
63                 if hosts.Map[name] != nil ||
64                         hosts.Map[strings.TrimSuffix(name,
65                                 GroupRemoveSuffix)] != nil {
66                         return nil, fmt.Errorf(
67                                 "%s conflict with existing host",
68                                 errPrefix)
69                 }
70
71                 if strings.HasPrefix(name, GroupDetectedPrefix) {
72                         return nil, fmt.Errorf(
73                                 "%s name must not start with %q "+
74                                         "(reserved for detected groups)",
75                                 errPrefix, GroupDetectedPrefix)
76                 }
77                 if !groupNameRegexp.MatchString(
78                         strings.TrimSuffix(name, GroupRemoveSuffix)) {
79                         return nil, fmt.Errorf(
80                                 "%s name contains invalid characters "+
81                                         "(must match %s)",
82                                 errPrefix, groupNameRegexp)
83                 }
84
85                 for _, x := range members {
86                         if x == GroupAll {
87                                 continue
88                         }
89                         // Don't validate against groupNameRegexp because
90                         // hosts have less strict restrictions.
91                         if strings.Contains(x, GroupSpecialSeparator) {
92                                 return nil, fmt.Errorf(
93                                         "%s member %q must not contain %q",
94                                         errPrefix, x, GroupSpecialSeparator)
95                         }
96                         if strings.HasPrefix(x, GroupDetectedPrefix) {
97                                 continue
98                         }
99                         if hosts.Map[x] != nil || groups[x] != nil {
100                                 continue
101                         }
102                         return nil, fmt.Errorf("%s member %q not found",
103                                 errPrefix, x)
104                 }
105         }
106
107         // Sanity check for global configuration
108         for _, x := range cfg.GroupPriority {
109                 const errPrefix = "config.yaml: group_priority:"
110
111                 if x == GroupAll {
112                         continue
113                 }
114                 if strings.Contains(x, GroupSpecialSeparator) {
115                         return nil, fmt.Errorf("%s invalid group name %q",
116                                 errPrefix, x)
117                 }
118                 if strings.HasPrefix(x, GroupDetectedPrefix) {
119                         continue
120                 }
121                 if groups[x] != nil {
122                         continue
123                 }
124                 return nil, fmt.Errorf("%s group %q does not exist",
125                         errPrefix, x)
126         }
127
128         return groups, nil
129 }
130
131 func ResolveHostGroups(host string,
132         groups map[string][]string,
133         detectedGroups []string) ([]string, error) {
134
135         const maxDepth = 100
136
137         detectedGroupsMap := make(map[string]bool)
138         for _, x := range detectedGroups {
139                 detectedGroupsMap[x] = true
140         }
141
142         var cycle *string
143         // Recursively check if host belongs to this group (or any referenced
144         // groups).
145         var lookup func(string, int) bool
146         lookup = func(group string, depth int) bool {
147                 if depth > maxDepth {
148                         cycle = &group
149                         return false
150                 }
151                 for _, x := range groups[group] {
152                         if x == host || detectedGroupsMap[x] || x == GroupAll {
153                                 return true
154                         }
155                         if lookup(x, depth+1) &&
156                                 !lookup(x+GroupRemoveSuffix, depth+1) {
157                                 return true
158                         }
159                 }
160                 return false
161         }
162
163         // Deterministic iteration order for error messages and tests
164         var names []string
165         for x := range groups {
166                 names = append(names, x)
167         }
168         sort.Strings(names)
169
170         var res []string
171         for _, x := range names {
172                 if strings.HasSuffix(x, GroupRemoveSuffix) {
173                         continue
174                 }
175
176                 if lookup(x, 0) && !lookup(x+GroupRemoveSuffix, 0) {
177                         res = append(res, x)
178                 }
179         }
180         if cycle != nil {
181                 return nil, fmt.Errorf(
182                         "groups.yaml: cycle while expanding group %q",
183                         *cycle)
184         }
185
186         res = append(res, detectedGroups...)
187         res = append(res, GroupAll) // contains all hosts
188         res = append(res, host)     // host itself is also group
189
190         sort.Strings(res)
191         return res, nil
192 }
193
194 // TransitivelyDetectedGroups returns all groups which depend on "detected"
195 // groups, either directly or by depending on groups which transitively depend
196 // on "detected" groups.
197 func TransitivelyDetectedGroups(groups map[string][]string) []string {
198         work := make(map[string][]string)
199         for k, v := range groups {
200                 work[k] = v
201         }
202
203         // Mark all groups which contain "detected" groups as long as new
204         // (transitive) "detected" groups are found.
205         detected := make(map[string]bool)
206         for {
207                 change := false
208                 for group, members := range work {
209                         for _, x := range members {
210                                 if !detected[x] && !strings.HasPrefix(x,
211                                         GroupDetectedPrefix) {
212                                         continue
213                                 }
214                                 detected[strings.TrimSuffix(group,
215                                         GroupRemoveSuffix)] = true
216                                 delete(work, group)
217                                 change = true
218                         }
219                 }
220                 if !change {
221                         break
222                 }
223         }
224
225         var res []string
226         for x := range detected {
227                 res = append(res, x)
228         }
229         sort.Strings(res)
230         return res
231 }