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