]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/config/groups.go
config: forbid ":remove" groups which conflict with hosts
[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 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                         if strings.Contains(x, GroupSpecialSeparator) {
90                                 return nil, fmt.Errorf(
91                                         "%s member %q must not contain %q",
92                                         errPrefix, x, GroupSpecialSeparator)
93                         }
94                         if strings.HasPrefix(x, GroupDetectedPrefix) {
95                                 continue
96                         }
97                         if hosts.Map[x] != nil || groups[x] != nil {
98                                 continue
99                         }
100                         return nil, fmt.Errorf("%s group %q not found",
101                                 errPrefix, x)
102                 }
103         }
104
105         // Sanity check for global configuration
106         for _, x := range cfg.GroupOrder {
107                 const errPrefix = "config.yaml: group_order:"
108
109                 if x == GroupAll {
110                         continue
111                 }
112                 if strings.Contains(x, GroupSpecialSeparator) {
113                         return nil, fmt.Errorf("%s invalid group name %q",
114                                 errPrefix, x)
115                 }
116                 if strings.HasPrefix(x, GroupDetectedPrefix) {
117                         continue
118                 }
119                 if groups[x] != nil {
120                         continue
121                 }
122                 return nil, fmt.Errorf("%s group %q does not exist",
123                         errPrefix, x)
124         }
125
126         return groups, nil
127 }
128
129 func ResolveHostGroups(host string,
130         groups map[string][]string,
131         detectedGroups []string) ([]string, error) {
132
133         const maxDepth = 100
134
135         detectedGroupsMap := make(map[string]bool)
136         for _, x := range detectedGroups {
137                 detectedGroupsMap[x] = true
138         }
139
140         var cycle *string
141         // Recursively check if host belongs to this group (or any referenced
142         // groups).
143         var lookup func(string, int) bool
144         lookup = func(group string, depth int) bool {
145                 if depth > maxDepth {
146                         cycle = &group
147                         return false
148                 }
149                 for _, x := range groups[group] {
150                         if x == host || detectedGroupsMap[x] || x == GroupAll {
151                                 return true
152                         }
153                         if lookup(x, depth+1) &&
154                                 !lookup(x+GroupRemoveSuffix, depth+1) {
155                                 return true
156                         }
157                 }
158                 return false
159         }
160
161         // Deterministic iteration order for error messages and tests
162         var names []string
163         for x := range groups {
164                 names = append(names, x)
165         }
166         sort.Strings(names)
167
168         var res []string
169         for _, x := range names {
170                 if strings.HasSuffix(x, GroupRemoveSuffix) {
171                         continue
172                 }
173
174                 if lookup(x, 0) && !lookup(x+GroupRemoveSuffix, 0) {
175                         res = append(res, x)
176                 }
177         }
178         if cycle != nil {
179                 return nil, fmt.Errorf(
180                         "groups.yaml: cycle while expanding group %q",
181                         *cycle)
182         }
183
184         res = append(res, detectedGroups...)
185         res = append(res, GroupAll) // contains all hosts
186         res = append(res, host)     // host itself is also group
187
188         sort.Strings(res)
189         return res, nil
190 }
191
192 // TransitivelyDetectedGroups returns all groups which depend on "detected"
193 // groups, either directly or by depending on groups which transitively depend
194 // on "detected" groups.
195 func TransitivelyDetectedGroups(groups map[string][]string) []string {
196         work := make(map[string][]string)
197         for k, v := range groups {
198                 work[k] = v
199         }
200
201         // Mark all groups which contain "detected" groups as long as new
202         // (transitive) "detected" groups are found.
203         detected := make(map[string]bool)
204         for {
205                 change := false
206                 for group, members := range work {
207                         for _, x := range members {
208                                 if !detected[x] && !strings.HasPrefix(x,
209                                         GroupDetectedPrefix) {
210                                         continue
211                                 }
212                                 detected[strings.TrimSuffix(group,
213                                         GroupRemoveSuffix)] = true
214                                 delete(work, group)
215                                 change = true
216                         }
217                 }
218                 if !change {
219                         break
220                 }
221         }
222
223         var res []string
224         for x := range detected {
225                 res = append(res, x)
226         }
227         sort.Strings(res)
228         return res
229 }