]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/config/groups.go
config: return map from TransitivelyDetectedGroups()
[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, groups map[string][]string,
132         detectedGroups []string) ([]string, error) {
133
134         const maxRecursionDepth = 100
135
136         detectedGroupsMap := make(map[string]bool)
137         for _, x := range detectedGroups {
138                 detectedGroupsMap[x] = true
139         }
140
141         var cycle *string
142         // Recursively check if host belongs to this group (or any referenced
143         // groups).
144         var lookup func(string, int) bool
145         lookup = func(group string, depth int) bool {
146                 if depth > maxRecursionDepth {
147                         cycle = &group
148                         return false
149                 }
150                 for _, x := range groups[group] {
151                         if x == host || detectedGroupsMap[x] || x == GroupAll {
152                                 return true
153                         }
154                         if lookup(x, depth+1) &&
155                                 !lookup(x+GroupRemoveSuffix, depth+1) {
156                                 return true
157                         }
158                 }
159                 return false
160         }
161
162         // Deterministic iteration order for error messages and tests
163         var names []string
164         for x := range groups {
165                 names = append(names, x)
166         }
167         sort.Strings(names)
168
169         var res []string
170         for _, x := range names {
171                 if strings.HasSuffix(x, GroupRemoveSuffix) {
172                         continue
173                 }
174
175                 if lookup(x, 0) && !lookup(x+GroupRemoveSuffix, 0) {
176                         res = append(res, x)
177                 }
178         }
179         if cycle != nil {
180                 return nil, fmt.Errorf(
181                         "groups.yaml: cycle while expanding group %q",
182                         *cycle)
183         }
184
185         res = append(res, detectedGroups...)
186         res = append(res, GroupAll) // contains all hosts
187         res = append(res, host)     // host itself is also group
188
189         sort.Strings(res)
190         return res, nil
191 }
192
193 // TransitivelyDetectedGroups returns all groups which depend on "detected"
194 // groups, either directly or by depending on groups which transitively depend
195 // on "detected" groups.
196 func TransitivelyDetectedGroups(groups map[string][]string) map[string]bool {
197         work := make(map[string][]string)
198         for k, v := range groups {
199                 work[k] = v
200         }
201
202         // Mark all groups which contain "detected" groups as long as new
203         // (transitive) "detected" groups are found.
204         detected := make(map[string]bool)
205         for {
206                 change := false
207                 for group, members := range work {
208                         for _, x := range members {
209                                 if !detected[x] && !strings.HasPrefix(x,
210                                         GroupDetectedPrefix) {
211                                         continue
212                                 }
213                                 detected[strings.TrimSuffix(group,
214                                         GroupRemoveSuffix)] = true
215                                 delete(work, group)
216                                 change = true
217                         }
218                 }
219                 if !change {
220                         break
221                 }
222         }
223         return detected
224 }