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 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 return nil, fmt.Errorf(
65 "%s conflict with existing host",
69 if strings.HasPrefix(name, GroupDetectedPrefix) {
70 return nil, fmt.Errorf(
71 "%s name must not start with %q "+
72 "(reserved for detected groups)",
73 errPrefix, GroupDetectedPrefix)
75 if !groupNameRegexp.MatchString(
76 strings.TrimSuffix(name, GroupRemoveSuffix)) {
77 return nil, fmt.Errorf(
78 "%s name contains invalid characters "+
80 errPrefix, groupNameRegexp)
83 for _, x := range members {
87 if strings.Contains(x, GroupSpecialSeparator) {
88 return nil, fmt.Errorf(
89 "%s member %q must not contain %q",
90 errPrefix, x, GroupSpecialSeparator)
92 if strings.HasPrefix(x, GroupDetectedPrefix) {
95 if hosts.Map[x] != nil || groups[x] != nil {
98 return nil, fmt.Errorf("%s group %q not found",
103 // Sanity check for global configuration
104 for _, x := range cfg.GroupOrder {
105 const errPrefix = "config.yaml: group_order:"
110 if strings.Contains(x, GroupSpecialSeparator) {
111 return nil, fmt.Errorf("%s invalid group name %q",
114 if strings.HasPrefix(x, GroupDetectedPrefix) {
117 if groups[x] != nil {
120 return nil, fmt.Errorf("%s group %q does not exist",
127 func ResolveHostGroups(host string,
128 groups map[string][]string,
129 detectedGroups []string) ([]string, error) {
133 detectedGroupsMap := make(map[string]bool)
134 for _, x := range detectedGroups {
135 detectedGroupsMap[x] = true
139 // Recursively check if host belongs to this group (or any referenced
141 var lookup func(string, int) bool
142 lookup = func(group string, depth int) bool {
143 if depth > maxDepth {
147 for _, x := range groups[group] {
148 if x == host || detectedGroupsMap[x] || x == GroupAll {
151 if lookup(x, depth+1) &&
152 !lookup(x+GroupRemoveSuffix, depth+1) {
159 // Deterministic iteration order for error messages and tests
161 for x := range groups {
162 names = append(names, x)
167 for _, x := range names {
168 if strings.HasSuffix(x, GroupRemoveSuffix) {
172 if lookup(x, 0) && !lookup(x+GroupRemoveSuffix, 0) {
177 return nil, fmt.Errorf(
178 "groups.yaml: cycle while expanding group %q",
182 res = append(res, detectedGroups...)
183 res = append(res, GroupAll) // contains all hosts
184 res = append(res, host) // host itself is also group