1 // Config: parse groups.yaml
3 // Copyright (C) 2021 Simon Ruderich
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.
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.
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/>.
32 GroupDetectedPrefix = "detected"
33 GroupSpecialSeparator = ":"
34 GroupRemoveSuffix = GroupSpecialSeparator + "remove"
37 // Keep in sync with cmd/safcm/sync_info.go:infoGroupDetectedRegexp
38 var groupNameRegexp = regexp.MustCompile(`^[a-z0-9_-]+$`)
40 func LoadGroups(cfg *Config, hosts *Hosts) (map[string][]string, error) {
41 const path = "groups.yaml"
43 var groups map[string][]string
44 x, err := os.ReadFile(path)
48 err = yaml.UnmarshalStrict(x, &groups)
50 return nil, fmt.Errorf("%s: failed to load: %v", path, err)
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)
58 if name == GroupAll || name == GroupAll+GroupRemoveSuffix {
59 return nil, fmt.Errorf(
60 "%s conflict with pre-defined group %q",
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",
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)
77 if !groupNameRegexp.MatchString(
78 strings.TrimSuffix(name, GroupRemoveSuffix)) {
79 return nil, fmt.Errorf(
80 "%s name contains invalid characters "+
82 errPrefix, groupNameRegexp)
85 for _, x := range members {
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)
96 if strings.HasPrefix(x, GroupDetectedPrefix) {
99 if hosts.Map[x] != nil || groups[x] != nil {
102 return nil, fmt.Errorf("%s member %q not found",
107 // Sanity check for global configuration
108 for _, x := range cfg.GroupPriority {
109 const errPrefix = "config.yaml: group_priority:"
114 if strings.Contains(x, GroupSpecialSeparator) {
115 return nil, fmt.Errorf("%s invalid group name %q",
118 if strings.HasPrefix(x, GroupDetectedPrefix) {
121 if groups[x] != nil {
124 return nil, fmt.Errorf("%s group %q does not exist",
131 func ResolveHostGroups(host string, groups map[string][]string,
132 detectedGroups []string) ([]string, error) {
134 const maxRecursionDepth = 100
136 detectedGroupsMap := make(map[string]bool)
137 for _, x := range detectedGroups {
138 detectedGroupsMap[x] = true
142 // Recursively check if host belongs to this group (or any referenced
144 var lookup func(string, int) bool
145 lookup = func(group string, depth int) bool {
146 if depth > maxRecursionDepth {
150 for _, x := range groups[group] {
151 if x == host || detectedGroupsMap[x] || x == GroupAll {
154 if lookup(x, depth+1) &&
155 !lookup(x+GroupRemoveSuffix, depth+1) {
162 // Deterministic iteration order for error messages and tests
164 for x := range groups {
165 names = append(names, x)
170 for _, x := range names {
171 if strings.HasSuffix(x, GroupRemoveSuffix) {
175 if lookup(x, 0) && !lookup(x+GroupRemoveSuffix, 0) {
180 return nil, fmt.Errorf(
181 "groups.yaml: cycle while expanding group %q",
185 res = append(res, detectedGroups...)
186 res = append(res, GroupAll) // contains all hosts
187 res = append(res, host) // host itself is also group
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 {
202 // Mark all groups which contain "detected" groups as long as new
203 // (transitive) "detected" groups are found.
204 detected := make(map[string]bool)
207 for group, members := range work {
208 for _, x := range members {
209 if !detected[x] && !strings.HasPrefix(x,
210 GroupDetectedPrefix) {
213 detected[strings.TrimSuffix(group,
214 GroupRemoveSuffix)] = true