├── LICENSE ├── Makefile ├── README.md ├── aini.go ├── aini_test.go ├── cmd └── ainidump │ └── main.go ├── go.mod ├── go.sum ├── inventory.go ├── marshal.go ├── marshal_test.go ├── marshal_test_inventory.json ├── match.go ├── match_test.go ├── ordered.go ├── ordered_test.go ├── parser.go ├── test_data ├── group_vars │ ├── empty │ │ └── .gitkeep │ ├── nginx.yml │ ├── tomcat.yml │ └── web │ │ ├── any_vars.yml │ │ ├── junk_file.txt │ │ └── some_vars.yml ├── host_vars │ ├── empty │ │ └── .gitkeep │ ├── host1.yml │ ├── host2 │ │ ├── any_vars.yml │ │ ├── junk_file.txt │ │ └── some_file.yml │ └── host7.yml └── inventory ├── vars.go └── vars_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 RELEX Oy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES ?= $(shell find . -name '*.go') 2 | SOURCES_NONTEST ?= $(shell find . -name '*.go' -not -name '*_test.go') 3 | 4 | .PHONY: test 5 | test: 6 | go test -timeout $${TEST_TIMEOUT:-10s} -v ./... 7 | 8 | # test-all ignores testcache (go clean testcache) 9 | .PHONY: test-all 10 | test-all: 11 | go test -timeout $${TEST_TIMEOUT:-10s} -v -count=1 ./... 12 | 13 | .PHONY: upgrade 14 | upgrade: 15 | rm -f go.sum 16 | go get -u -d ./...; go mod tidy 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aini 2 | 3 | Go library for Parsing Ansible inventory files. 4 | We are trying to follow the logic of Ansible parser as close as possible. 5 | 6 | Documentation on ansible inventory files can be found here: 7 | https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html 8 | 9 | ## Supported features: 10 | - [X] Variables 11 | - [X] Host patterns 12 | - [X] Nested groups 13 | - [X] Load variables from `group_vars` and `host_vars` 14 | 15 | ## Public API 16 | ```godoc 17 | package aini // import "github.com/relex/aini" 18 | 19 | 20 | FUNCTIONS 21 | 22 | func MatchGroups(groups map[string]*Group, pattern string) (map[string]*Group, error) 23 | MatchGroups looks for groups that match the pattern 24 | 25 | func MatchHosts(hosts map[string]*Host, pattern string) (map[string]*Host, error) 26 | MatchHosts looks for hosts that match the pattern 27 | 28 | func MatchVars(vars map[string]string, pattern string) (map[string]string, error) 29 | MatchVars looks for vars that match the pattern 30 | 31 | 32 | TYPES 33 | 34 | type Group struct { 35 | Name string 36 | Vars map[string]string 37 | Hosts map[string]*Host 38 | Children map[string]*Group 39 | Parents map[string]*Group 40 | 41 | // Has unexported fields. 42 | } 43 | Group represents ansible group 44 | 45 | func GroupMapListValues(mymap map[string]*Group) []*Group 46 | GroupMapListValues transforms map of Groups into Group list in lexical order 47 | 48 | func (group *Group) MatchHosts(pattern string) (map[string]*Host, error) 49 | MatchHosts looks for hosts that match the pattern 50 | 51 | func (group *Group) MatchVars(pattern string) (map[string]string, error) 52 | MatchVars looks for vars that match the pattern 53 | 54 | func (group Group) String() string 55 | 56 | type Host struct { 57 | Name string 58 | Port int 59 | Vars map[string]string 60 | Groups map[string]*Group 61 | 62 | // Has unexported fields. 63 | } 64 | Host represents ansible host 65 | 66 | func HostMapListValues(mymap map[string]*Host) []*Host 67 | HostMapListValues transforms map of Hosts into Host list in lexical order 68 | 69 | func (host *Host) MatchGroups(pattern string) (map[string]*Group, error) 70 | MatchGroups looks for groups that match the pattern 71 | 72 | func (host *Host) MatchVars(pattern string) (map[string]string, error) 73 | MatchVars looks for vars that match the pattern 74 | 75 | func (host Host) String() string 76 | 77 | type InventoryData struct { 78 | Groups map[string]*Group 79 | Hosts map[string]*Host 80 | } 81 | InventoryData contains parsed inventory representation Note: Groups and 82 | Hosts fields contain all the groups and hosts, not only top-level 83 | 84 | func Parse(r io.Reader) (*InventoryData, error) 85 | Parse using some Reader 86 | 87 | func ParseFile(f string) (*InventoryData, error) 88 | ParseFile parses Inventory represented as a file 89 | 90 | func ParseString(input string) (*InventoryData, error) 91 | ParseString parses Inventory represented as a string 92 | 93 | func (inventory *InventoryData) AddVars(path string) error 94 | AddVars take a path that contains group_vars and host_vars directories and 95 | adds these variables to the InventoryData 96 | 97 | func (inventory *InventoryData) AddVarsLowerCased(path string) error 98 | AddVarsLowerCased does the same as AddVars, but converts hostnames and 99 | groups name to lowercase. Use this function if you've executed 100 | `inventory.HostsToLower` or `inventory.GroupsToLower` 101 | 102 | func (inventory *InventoryData) GroupsToLower() 103 | GroupsToLower transforms all group names to lowercase 104 | 105 | func (inventory *InventoryData) HostsToLower() 106 | HostsToLower transforms all host names to lowercase 107 | 108 | func (inventory *InventoryData) Match(pattern string) []*Host 109 | Match looks for hosts that match the pattern Deprecated: Use `MatchHosts`, 110 | which does proper error handling 111 | 112 | func (inventory *InventoryData) MatchGroups(pattern string) (map[string]*Group, error) 113 | MatchGroups looks for groups that match the pattern 114 | 115 | func (inventory *InventoryData) MatchHosts(pattern string) (map[string]*Host, error) 116 | MatchHosts looks for hosts that match the pattern 117 | 118 | func (inventory *InventoryData) Reconcile() 119 | Reconcile ensures inventory basic rules, run after updates. After initial 120 | inventory file processing, only direct relationships are set. 121 | 122 | This method: 123 | 124 | * (re)sets Children and Parents for hosts and groups 125 | * ensures that mandatory groups exist 126 | * calculates variables for hosts and groups 127 | 128 | ``` 129 | 130 | ## Usage example 131 | ```go 132 | import ( 133 | "strings" 134 | 135 | "github.com/relex/aini" 136 | ) 137 | 138 | func main() { 139 | // Load from string example 140 | inventoryReader := strings.NewReader(` 141 | host1:2221 142 | [web] 143 | host2 ansible_ssh_user=root 144 | `) 145 | var inventory InventoryData = aini.Parse(inventoryReader) 146 | 147 | // Querying hosts 148 | _ = inventory.Hosts["host1"].Name == "host1" // true 149 | _ = inventory.Hosts["host1"].Port == 2221 // true 150 | _ = inventory.Hosts["host2"].Name == "host2"] // true 151 | _ = inventory.Hosts["host2"].Post == 22] // true 152 | 153 | _ = len(inventory.Hosts["host1"].Groups) == 2 // all, ungrouped 154 | _ = len(inventory.Hosts["host2"].Groups) == 2 // all, web 155 | 156 | _ = len(inventory.Match("host*")) == 2 // host1, host2 157 | 158 | _ = // Querying groups 159 | _ = inventory.Groups["web"].Hosts[0].Name == "host2" // true 160 | _ = len(inventory.Groups["all"].Hosts) == 2 // true 161 | } 162 | ``` 163 | 164 | ## Command-line Tool 165 | 166 | ```bash 167 | go install github.com/relex/aini/cmd/ainidump@latest 168 | ``` 169 | 170 | #### Dump entire inventory 171 | 172 | ```bash 173 | ainidump ~/my-playbook/inventory/ansible-hosts 174 | ``` 175 | 176 | Host and group variable files in the inventory directory are always loaded. The result is in JSON: 177 | - Host's groups and Group's parents are ordered by level from bottom to top 178 | - Rest are ordered by names 179 | 180 | ```json 181 | { 182 | "Hosts": [ 183 | { 184 | "Name": "myhost1.domain", 185 | "Groups": [ 186 | "myhosts", 187 | "companyhosts", 188 | "india", 189 | "all" 190 | ], 191 | "Vars": { 192 | "ansible_host": "1.2.3.4", 193 | "region": "india" 194 | } 195 | }, 196 | { 197 | "Name": "myhost2.domain", 198 | // ... 199 | } 200 | ], 201 | "Groups": [ 202 | { 203 | "Name": "companyhosts", 204 | "Parents": [ 205 | "india", 206 | "all" 207 | ], 208 | "Descendants": [ 209 | "myhosts", 210 | "otherhosts" 211 | ], 212 | "Hosts": [ 213 | "myhost1.domain", 214 | "myhost2.domain", 215 | "myhost3.domain" 216 | ], 217 | "Vars": { 218 | "region": "india", 219 | } 220 | } 221 | ] 222 | } 223 | ``` 224 | 225 | #### Match hosts by patterns 226 | 227 | Find hosts matched by Ansible [target patterns](https://docs.ansible.com/ansible/latest/inventory_guide/intro_patterns.html), works for both hostnames and group names. 228 | 229 | ```bash 230 | ainidump ~/my-playbook/inventory/ansible-hosts 'recent[1-3]:extrahost*:&eu:!finland' 231 | ``` 232 | 233 | The result is a dictionary of hosts in the same format above. 234 | -------------------------------------------------------------------------------- /aini.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "os" 8 | "path" 9 | "sort" 10 | "strings" 11 | ) 12 | 13 | // InventoryData contains parsed inventory representation 14 | // Note: Groups and Hosts fields contain all the groups and hosts, not only top-level 15 | type InventoryData struct { 16 | Groups map[string]*Group 17 | Hosts map[string]*Host 18 | } 19 | 20 | // Group represents ansible group 21 | type Group struct { 22 | Name string 23 | Vars map[string]string 24 | Hosts map[string]*Host 25 | Children map[string]*Group 26 | Parents map[string]*Group 27 | 28 | DirectParents map[string]*Group 29 | // Vars set in inventory 30 | InventoryVars map[string]string 31 | // Vars set in group_vars 32 | FileVars map[string]string 33 | // Projection of all parent inventory variables 34 | AllInventoryVars map[string]string 35 | // Projection of all parent group_vars variables 36 | AllFileVars map[string]string 37 | } 38 | 39 | // Host represents ansible host 40 | type Host struct { 41 | Name string 42 | Port int 43 | Vars map[string]string 44 | Groups map[string]*Group 45 | 46 | DirectGroups map[string]*Group 47 | // Vars set in inventory 48 | InventoryVars map[string]string 49 | // Vars set in host_vars 50 | FileVars map[string]string 51 | } 52 | 53 | // ParseFile parses Inventory represented as a file 54 | func ParseFile(f string) (*InventoryData, error) { 55 | bs, err := os.ReadFile(f) 56 | if err != nil { 57 | return &InventoryData{}, err 58 | } 59 | 60 | return Parse(bytes.NewReader(bs)) 61 | } 62 | 63 | // ParseString parses Inventory represented as a string 64 | func ParseString(input string) (*InventoryData, error) { 65 | return Parse(strings.NewReader(input)) 66 | } 67 | 68 | // Parse using some Reader 69 | func Parse(r io.Reader) (*InventoryData, error) { 70 | input := bufio.NewReader(r) 71 | inventory := &InventoryData{} 72 | err := inventory.parse(input) 73 | if err != nil { 74 | return inventory, err 75 | } 76 | inventory.Reconcile() 77 | return inventory, nil 78 | } 79 | 80 | // Match looks for hosts that match the pattern 81 | // Deprecated: Use `MatchHosts`, which does proper error handling 82 | func (inventory *InventoryData) Match(pattern string) []*Host { 83 | matchedHosts := make([]*Host, 0) 84 | for _, host := range inventory.Hosts { 85 | if m, err := path.Match(pattern, host.Name); err == nil && m { 86 | matchedHosts = append(matchedHosts, host) 87 | } 88 | } 89 | return matchedHosts 90 | } 91 | 92 | // GroupMapListValues transforms map of Groups into Group list in lexical order 93 | func GroupMapListValues(mymap map[string]*Group) []*Group { 94 | values := make([]*Group, len(mymap)) 95 | 96 | i := 0 97 | for _, v := range mymap { 98 | values[i] = v 99 | i++ 100 | } 101 | sort.Slice(values, func(i, j int) bool { 102 | return values[i].Name < values[j].Name 103 | }) 104 | return values 105 | } 106 | 107 | // HostMapListValues transforms map of Hosts into Host list in lexical order 108 | func HostMapListValues(mymap map[string]*Host) []*Host { 109 | values := make([]*Host, len(mymap)) 110 | 111 | i := 0 112 | for _, v := range mymap { 113 | values[i] = v 114 | i++ 115 | } 116 | sort.Slice(values, func(i, j int) bool { 117 | return values[i].Name < values[j].Name 118 | }) 119 | return values 120 | } 121 | 122 | // HostsToLower transforms all host names to lowercase 123 | func (inventory *InventoryData) HostsToLower() { 124 | inventory.Hosts = hostMapToLower(inventory.Hosts, false) 125 | for _, group := range inventory.Groups { 126 | group.Hosts = hostMapToLower(group.Hosts, true) 127 | } 128 | } 129 | 130 | func hostMapToLower(hosts map[string]*Host, keysOnly bool) map[string]*Host { 131 | newHosts := make(map[string]*Host, len(hosts)) 132 | for hostname, host := range hosts { 133 | hostname = strings.ToLower(hostname) 134 | if !keysOnly { 135 | host.Name = hostname 136 | } 137 | newHosts[hostname] = host 138 | } 139 | return newHosts 140 | } 141 | 142 | // GroupsToLower transforms all group names to lowercase 143 | func (inventory *InventoryData) GroupsToLower() { 144 | inventory.Groups = groupMapToLower(inventory.Groups, false) 145 | for _, host := range inventory.Hosts { 146 | host.DirectGroups = groupMapToLower(host.DirectGroups, true) 147 | host.Groups = groupMapToLower(host.Groups, true) 148 | } 149 | } 150 | 151 | func (group Group) String() string { 152 | return group.Name 153 | } 154 | 155 | func (host Host) String() string { 156 | return host.Name 157 | } 158 | 159 | func groupMapToLower(groups map[string]*Group, keysOnly bool) map[string]*Group { 160 | newGroups := make(map[string]*Group, len(groups)) 161 | for groupname, group := range groups { 162 | groupname = strings.ToLower(groupname) 163 | if !keysOnly { 164 | group.Name = groupname 165 | group.DirectParents = groupMapToLower(group.DirectParents, true) 166 | group.Parents = groupMapToLower(group.Parents, true) 167 | group.Children = groupMapToLower(group.Children, true) 168 | } 169 | newGroups[groupname] = group 170 | } 171 | return newGroups 172 | } 173 | -------------------------------------------------------------------------------- /aini_test.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func parseString(t *testing.T, input string) *InventoryData { 11 | v, err := ParseString(input) 12 | assert.Nil(t, err, fmt.Sprintf("Error occurred while parsing: %s", err)) 13 | return v 14 | } 15 | 16 | func TestBelongToBasicGroups(t *testing.T) { 17 | v := parseString(t, ` 18 | host1:2221 # Comments 19 | [web] # should 20 | host2 # be 21 | # ignored 22 | `) 23 | 24 | assert.Len(t, v.Hosts, 2, "Exactly two hosts expected") 25 | assert.Len(t, v.Groups, 3, "Expected three groups: web, all and ungrouped") 26 | 27 | assert.Contains(t, v.Groups, "web") 28 | assert.Contains(t, v.Groups, "all") 29 | assert.Contains(t, v.Groups, "ungrouped") 30 | 31 | assert.Contains(t, v.Hosts, "host1") 32 | assert.Len(t, v.Hosts["host1"].Groups, 2, "Host1 must belong to two groups: ungrouped and all") 33 | assert.NotNil(t, 2, v.Hosts["host1"].Groups["all"], "Host1 must belong to two groups: ungrouped and all") 34 | assert.NotNil(t, 2, v.Hosts["host1"].Groups["ungrouped"], "Host1 must belong to ungrouped group") 35 | 36 | assert.Contains(t, v.Hosts, "host2") 37 | assert.Len(t, v.Hosts["host2"].Groups, 2, "Host2 must belong to two groups: ungrouped and all") 38 | assert.NotNil(t, 2, v.Hosts["host2"].Groups["all"], "Host2 must belong to two groups: ungrouped and all") 39 | assert.NotNil(t, 2, v.Hosts["host2"].Groups["ungrouped"], "Host2 must belong to ungrouped group") 40 | 41 | assert.Equal(t, 2, len(v.Groups["all"].Hosts), "Group all must contain two hosts") 42 | assert.Contains(t, v.Groups["all"].Hosts, "host1") 43 | assert.Contains(t, v.Groups["all"].Hosts, "host2") 44 | 45 | assert.Len(t, v.Groups["web"].Hosts, 1, "Group web must contain one host") 46 | assert.Contains(t, v.Groups["web"].Hosts, "host2") 47 | 48 | assert.Len(t, v.Groups["ungrouped"].Hosts, 1, "Group ungrouped must contain one host") 49 | assert.Contains(t, v.Groups["ungrouped"].Hosts, "host1") 50 | assert.NotContains(t, v.Groups["ungrouped"].Hosts, "host2") 51 | 52 | assert.Equal(t, 2221, v.Hosts["host1"].Port, "Host1 port is set") 53 | assert.Equal(t, 22, v.Hosts["host2"].Port, "Host2 port is set") 54 | } 55 | 56 | func TestGroupStructure(t *testing.T) { 57 | v := parseString(t, ` 58 | host5 59 | 60 | [web:children] 61 | nginx 62 | apache 63 | 64 | [web] 65 | host1 66 | host2 67 | 68 | [nginx] 69 | host1 70 | host3 71 | host4 72 | 73 | [apache] 74 | host5 75 | host6 76 | `) 77 | 78 | assert.Contains(t, v.Groups, "web") 79 | assert.Contains(t, v.Groups, "apache") 80 | assert.Contains(t, v.Groups, "nginx") 81 | assert.Contains(t, v.Groups, "all") 82 | assert.Contains(t, v.Groups, "ungrouped") 83 | 84 | assert.Len(t, v.Groups, 5, "Five groups must be present: web, apache, nginx, all, ungrouped") 85 | 86 | assert.Contains(t, v.Groups["web"].Children, "nginx") 87 | assert.Contains(t, v.Groups["web"].Children, "apache") 88 | assert.Contains(t, v.Groups["nginx"].Parents, "web") 89 | assert.Contains(t, v.Groups["apache"].Parents, "web") 90 | 91 | assert.Contains(t, v.Groups["web"].Hosts, "host1") 92 | assert.Contains(t, v.Groups["web"].Hosts, "host2") 93 | assert.Contains(t, v.Groups["web"].Hosts, "host3") 94 | assert.Contains(t, v.Groups["web"].Hosts, "host4") 95 | assert.Contains(t, v.Groups["web"].Hosts, "host5") 96 | 97 | assert.Contains(t, v.Groups["nginx"].Hosts, "host1") 98 | 99 | assert.Contains(t, v.Hosts["host1"].Groups, "web") 100 | assert.Contains(t, v.Hosts["host1"].Groups, "nginx") 101 | 102 | assert.Empty(t, v.Groups["ungrouped"].Hosts) 103 | } 104 | 105 | func TestGroupNotExplicitlyDefined(t *testing.T) { 106 | v := parseString(t, ` 107 | [web:children] 108 | nginx 109 | 110 | [nginx] 111 | host1 112 | `) 113 | 114 | assert.Contains(t, v.Groups, "web") 115 | assert.Contains(t, v.Groups, "nginx") 116 | assert.Contains(t, v.Groups, "all") 117 | assert.Contains(t, v.Groups, "ungrouped") 118 | 119 | assert.Len(t, v.Groups, 4, "Four groups must present: web, nginx, all, ungrouped") 120 | 121 | assert.Contains(t, v.Groups["web"].Children, "nginx") 122 | assert.Contains(t, v.Groups["nginx"].Parents, "web") 123 | 124 | assert.Contains(t, v.Groups["web"].Hosts, "host1") 125 | assert.Contains(t, v.Groups["nginx"].Hosts, "host1") 126 | 127 | assert.Contains(t, v.Hosts["host1"].Groups, "web") 128 | assert.Contains(t, v.Hosts["host1"].Groups, "nginx") 129 | 130 | assert.Empty(t, v.Groups["ungrouped"].Hosts, "Group ungrouped should be empty") 131 | } 132 | 133 | func TestAllGroup(t *testing.T) { 134 | v := parseString(t, ` 135 | host7 136 | host5 137 | 138 | [web:children] 139 | nginx 140 | apache 141 | 142 | [web] 143 | host1 144 | host2 145 | 146 | [nginx] 147 | host1 148 | host3 149 | host4 150 | 151 | [apache] 152 | host5 153 | host6 154 | `) 155 | 156 | allGroup := v.Groups["all"] 157 | assert.NotNil(t, allGroup) 158 | assert.Empty(t, allGroup.Parents) 159 | assert.NotContains(t, allGroup.Children, "all") 160 | assert.Len(t, allGroup.Children, 4) 161 | assert.Len(t, allGroup.Hosts, 7) 162 | for _, group := range v.Groups { 163 | if group.Name == "all" { 164 | continue 165 | } 166 | assert.Contains(t, allGroup.Children, group.Name) 167 | assert.Contains(t, group.Parents, allGroup.Name) 168 | } 169 | for _, host := range v.Hosts { 170 | assert.Contains(t, allGroup.Hosts, host.Name) 171 | assert.Contains(t, host.Groups, allGroup.Name) 172 | 173 | } 174 | } 175 | 176 | func TestHostExpansionFullNumericPattern(t *testing.T) { 177 | v := parseString(t, ` 178 | host-[001:015:3]-web:23 179 | `) 180 | 181 | assert.Contains(t, v.Hosts, "host-001-web") 182 | assert.Contains(t, v.Hosts, "host-004-web") 183 | assert.Contains(t, v.Hosts, "host-007-web") 184 | assert.Contains(t, v.Hosts, "host-010-web") 185 | assert.Contains(t, v.Hosts, "host-013-web") 186 | assert.Len(t, v.Hosts, 5) 187 | 188 | for _, host := range v.Hosts { 189 | assert.Equalf(t, 23, host.Port, "%s port is set", host.Name) 190 | } 191 | } 192 | 193 | func TestHostExpansionFullAlphabeticPattern(t *testing.T) { 194 | v := parseString(t, ` 195 | host-[a:o:3]-web 196 | `) 197 | 198 | assert.Contains(t, v.Hosts, "host-a-web") 199 | assert.Contains(t, v.Hosts, "host-d-web") 200 | assert.Contains(t, v.Hosts, "host-g-web") 201 | assert.Contains(t, v.Hosts, "host-j-web") 202 | assert.Contains(t, v.Hosts, "host-m-web") 203 | assert.Len(t, v.Hosts, 5) 204 | } 205 | 206 | func TestHostExpansionShortNumericPattern(t *testing.T) { 207 | v := parseString(t, ` 208 | host-[:05]-web 209 | `) 210 | assert.Contains(t, v.Hosts, "host-00-web") 211 | assert.Contains(t, v.Hosts, "host-01-web") 212 | assert.Contains(t, v.Hosts, "host-02-web") 213 | assert.Contains(t, v.Hosts, "host-03-web") 214 | assert.Contains(t, v.Hosts, "host-04-web") 215 | assert.Contains(t, v.Hosts, "host-05-web") 216 | assert.Len(t, v.Hosts, 6) 217 | } 218 | 219 | func TestHostExpansionShortAlphabeticPattern(t *testing.T) { 220 | v := parseString(t, ` 221 | host-[a:c]-web 222 | `) 223 | assert.Contains(t, v.Hosts, "host-a-web") 224 | assert.Contains(t, v.Hosts, "host-b-web") 225 | assert.Contains(t, v.Hosts, "host-c-web") 226 | assert.Len(t, v.Hosts, 3) 227 | } 228 | 229 | func TestHostExpansionMultiplePatterns(t *testing.T) { 230 | v := parseString(t, ` 231 | host-[1:2]-[a:b]-web 232 | `) 233 | assert.Contains(t, v.Hosts, "host-1-a-web") 234 | assert.Contains(t, v.Hosts, "host-1-b-web") 235 | assert.Contains(t, v.Hosts, "host-2-a-web") 236 | assert.Contains(t, v.Hosts, "host-2-b-web") 237 | assert.Len(t, v.Hosts, 4) 238 | } 239 | 240 | func TestVariablesPriority(t *testing.T) { 241 | v := parseString(t, ` 242 | host-ungrouped-with-x x=a 243 | host-ungrouped 244 | 245 | [web] 246 | host-web x=b 247 | 248 | [web:vars] 249 | x=c 250 | 251 | [web:children] 252 | nginx 253 | 254 | [nginx:vars] 255 | x=d 256 | 257 | [nginx] 258 | host-nginx 259 | host-nginx-with-x x=e 260 | 261 | [all:vars] 262 | x=f 263 | `) 264 | 265 | assert.Equal(t, "a", v.Hosts["host-ungrouped-with-x"].Vars["x"]) 266 | assert.Equal(t, "b", v.Hosts["host-web"].Vars["x"]) 267 | assert.Equal(t, "c", v.Groups["web"].Vars["x"]) 268 | assert.Equal(t, "d", v.Hosts["host-nginx"].Vars["x"]) 269 | assert.Equal(t, "e", v.Hosts["host-nginx-with-x"].Vars["x"]) 270 | assert.Equal(t, "f", v.Hosts["host-ungrouped"].Vars["x"]) 271 | } 272 | 273 | func TestHostsToLower(t *testing.T) { 274 | v := parseString(t, ` 275 | CatFish 276 | [web:children] 277 | TomCat 278 | 279 | [TomCat] 280 | TomCat 281 | tomcat-1 282 | cat 283 | `) 284 | assert.Contains(t, v.Hosts, "CatFish") 285 | assert.Contains(t, v.Groups["ungrouped"].Hosts, "CatFish") 286 | assert.Contains(t, v.Hosts, "TomCat") 287 | 288 | v.HostsToLower() 289 | 290 | assert.NotContains(t, v.Hosts, "CatFish") 291 | assert.Contains(t, v.Hosts, "catfish") 292 | assert.Equal(t, "catfish", v.Hosts["catfish"].Name, "Host catfish should have a matching name") 293 | 294 | assert.NotContains(t, v.Hosts, "TomCat") 295 | assert.Contains(t, v.Hosts, "tomcat") 296 | assert.Equal(t, "tomcat", v.Hosts["tomcat"].Name, "Host tomcat should have a matching name") 297 | 298 | assert.NotContains(t, v.Groups["ungrouped"].Hosts, "CatFish") 299 | assert.Contains(t, v.Groups["ungrouped"].Hosts, "catfish") 300 | assert.NotContains(t, v.Groups["web"].Hosts, "TomCat") 301 | assert.Contains(t, v.Groups["web"].Hosts, "tomcat") 302 | } 303 | 304 | func TestGroupsToLower(t *testing.T) { 305 | v := parseString(t, ` 306 | [Web] 307 | CatFish 308 | 309 | [Web:children] 310 | TomCat 311 | 312 | [TomCat] 313 | TomCat 314 | tomcat-1 315 | cat 316 | `) 317 | assert.Contains(t, v.Groups, "Web") 318 | assert.Contains(t, v.Groups, "TomCat") 319 | v.GroupsToLower() 320 | assert.NotContains(t, v.Groups, "Web") 321 | assert.NotContains(t, v.Groups, "TomCat") 322 | assert.Contains(t, v.Groups, "web") 323 | assert.Contains(t, v.Groups, "tomcat") 324 | 325 | assert.Equal(t, "web", v.Groups["web"].Name, "Group web should have matching name") 326 | assert.Contains(t, v.Groups["web"].Children, "tomcat") 327 | assert.Contains(t, v.Groups["web"].Hosts, "TomCat") 328 | 329 | assert.Equal(t, "tomcat", v.Groups["tomcat"].Name, "Group tomcat should have matching name") 330 | assert.Contains(t, v.Groups["tomcat"].Hosts, "TomCat") 331 | assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat-1") 332 | assert.Contains(t, v.Groups["tomcat"].Hosts, "cat") 333 | } 334 | 335 | func TestGroupsAndHostsToLower(t *testing.T) { 336 | v := parseString(t, ` 337 | [Web] 338 | CatFish 339 | 340 | [Web:children] 341 | TomCat 342 | 343 | [TomCat] 344 | TomCat 345 | tomcat-1 346 | `) 347 | assert.Contains(t, v.Groups, "Web") 348 | assert.Contains(t, v.Groups, "TomCat") 349 | 350 | assert.Contains(t, v.Hosts, "CatFish") 351 | assert.Contains(t, v.Hosts, "TomCat") 352 | assert.Contains(t, v.Hosts, "tomcat-1") 353 | 354 | v.GroupsToLower() 355 | v.HostsToLower() 356 | 357 | assert.NotContains(t, v.Groups, "Web") 358 | assert.NotContains(t, v.Groups, "TomCat") 359 | assert.Contains(t, v.Groups, "web") 360 | assert.Contains(t, v.Groups, "tomcat") 361 | 362 | assert.NotContains(t, v.Hosts, "CatFish") 363 | assert.NotContains(t, v.Hosts, "TomCat") 364 | assert.Contains(t, v.Hosts, "catfish") 365 | assert.Contains(t, v.Hosts, "tomcat") 366 | assert.Contains(t, v.Hosts, "tomcat-1") 367 | 368 | assert.Contains(t, v.Groups["web"].Hosts, "catfish") 369 | assert.Contains(t, v.Groups["web"].Children, "tomcat") 370 | assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat") 371 | assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat-1") 372 | } 373 | 374 | func TestGroupLoops(t *testing.T) { 375 | v := parseString(t, ` 376 | [group1] 377 | host1 378 | 379 | [group1:children] 380 | group2 381 | 382 | [group2:children] 383 | group1 384 | `) 385 | 386 | assert.Contains(t, v.Groups, "group1") 387 | assert.Contains(t, v.Groups, "group2") 388 | assert.Contains(t, v.Groups["group1"].Parents, "all") 389 | assert.Contains(t, v.Groups["group1"].Parents, "group2") 390 | assert.NotContains(t, v.Groups["group1"].Parents, "group1") 391 | assert.Len(t, v.Groups["group1"].Parents, 2) 392 | assert.Contains(t, v.Groups["group2"].Parents, "group1") 393 | } 394 | 395 | func TestVariablesEscaping(t *testing.T) { 396 | v := parseString(t, ` 397 | host ansible_ssh_common_args="-o ProxyCommand='ssh -W %h:%p somehost'" other_var_same_value="-o ProxyCommand='ssh -W %h:%p somehost'" # comment 398 | `) 399 | assert.Contains(t, v.Hosts, "host") 400 | assert.Equal(t, "-o ProxyCommand='ssh -W %h:%p somehost'", v.Hosts["host"].Vars["ansible_ssh_common_args"]) 401 | assert.Equal(t, "-o ProxyCommand='ssh -W %h:%p somehost'", v.Hosts["host"].Vars["other_var_same_value"]) 402 | } 403 | 404 | func TestComments(t *testing.T) { 405 | v := parseString(t, ` 406 | catfish # I'm a comment 407 | # Whole-line comment 408 | [web:children] # Look, there is a cat in comment! 409 | tomcat # This is a group! 410 | # Whole-line comment with a leading space 411 | [tomcat] # And here is another cat 🐈 412 | tomcat # Host comment 413 | tomcat-1 # Small indention comment 414 | cat # Big indention comment 415 | `) 416 | assert.Contains(t, v.Groups, "web") 417 | assert.Contains(t, v.Groups, "tomcat") 418 | assert.Contains(t, v.Groups["web"].Children, "tomcat") 419 | 420 | assert.Contains(t, v.Hosts, "tomcat") 421 | assert.Contains(t, v.Hosts, "tomcat-1") 422 | assert.Contains(t, v.Hosts, "cat") 423 | assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat") 424 | assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat-1") 425 | assert.Contains(t, v.Groups["tomcat"].Hosts, "cat") 426 | assert.Contains(t, v.Hosts, "catfish") 427 | assert.Contains(t, v.Groups["ungrouped"].Hosts, "catfish") 428 | } 429 | 430 | func TestHostMatching(t *testing.T) { 431 | v := parseString(t, ` 432 | catfish 433 | [web:children] # Look, there is a cat in comment! 434 | tomcat # This is a group! 435 | 436 | [tomcat] # And here is another cat 🐈 437 | tomcat 438 | tomcat-1 439 | cat 440 | `) 441 | hosts := v.Match("*cat*") 442 | assert.Len(t, hosts, 4) 443 | } 444 | 445 | func TestHostMapListValues(t *testing.T) { 446 | v := parseString(t, ` 447 | host1 448 | host2 449 | host3 450 | `) 451 | 452 | hosts := HostMapListValues(v.Hosts) 453 | assert.Len(t, hosts, 3) 454 | for _, v := range hosts { 455 | assert.Contains(t, hosts, v) 456 | } 457 | } 458 | 459 | func TestGroupMapListValues(t *testing.T) { 460 | v := parseString(t, ` 461 | [group1] 462 | [group2] 463 | [group3] 464 | `) 465 | 466 | groups := GroupMapListValues(v.Groups) 467 | assert.Len(t, groups, 5) 468 | for _, v := range groups { 469 | assert.Contains(t, groups, v) 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /cmd/ainidump/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/relex/aini" 13 | "github.com/samber/lo" 14 | "golang.org/x/exp/maps" 15 | ) 16 | 17 | func main() { 18 | if len(os.Args) < 2 || len(os.Args) > 3 { 19 | fmt.Fprintln(os.Stderr, "Usage: ainidump inventory_file [host_or_group_patterns]") 20 | os.Exit(1) 21 | } 22 | 23 | inventoryPath, err := filepath.Abs(os.Args[1]) 24 | if err != nil { 25 | fmt.Fprintf(os.Stderr, "Failed to resolve inventory file path %s: %v\n", os.Args[1], err) 26 | os.Exit(2) 27 | } 28 | 29 | inventory, err := aini.ParseFile(inventoryPath) 30 | if err != nil { 31 | fmt.Fprintf(os.Stderr, "Failed to parse inventory file %s: %v\n", inventoryPath, err) 32 | os.Exit(3) 33 | } 34 | 35 | inventory.HostsToLower() 36 | inventory.GroupsToLower() 37 | 38 | inventoryDir := filepath.Dir(inventoryPath) 39 | if err := inventory.AddVarsLowerCased(inventoryDir); err != nil { 40 | fmt.Fprintf(os.Stderr, "Failed to load inventory variables %s: %v\n", inventoryDir, err) 41 | os.Exit(4) 42 | } 43 | 44 | if len(os.Args) == 2 { 45 | result := exportResult(inventory.Hosts, inventory.Groups) 46 | j, err := json.MarshalIndent(result, "", " ") 47 | if err != nil { 48 | panic(err) 49 | } 50 | fmt.Println(string(j)) 51 | return 52 | } 53 | 54 | patterns := os.Args[2] 55 | 56 | matchedHostsMap, err := inventory.MatchHostsByPatterns(patterns) 57 | if err != nil { 58 | fmt.Fprintf(os.Stderr, "Failed to match hosts with patterns %s: %v\n", patterns, err) 59 | os.Exit(5) 60 | } 61 | j, err := json.MarshalIndent(lo.MapEntries(matchedHostsMap, func(name string, host *aini.Host) (string, ResultHost) { 62 | return host.Name, ResultHost{ 63 | Name: host.Name, 64 | Groups: getGroupNames(host.ListGroupsOrdered()), 65 | Vars: host.Vars, 66 | } 67 | }), "", " ") 68 | if err != nil { 69 | panic(err) 70 | } 71 | fmt.Println(string(j)) 72 | } 73 | 74 | type ResultHost struct { 75 | Name string 76 | Groups []string 77 | Vars map[string]string 78 | } 79 | type ResultGroup struct { 80 | Name string 81 | Parents []string 82 | Descendants []string 83 | Hosts []string 84 | Vars map[string]string 85 | } 86 | 87 | func exportResult(hostMap map[string]*aini.Host, groupMap map[string]*aini.Group) any { 88 | type Result struct { 89 | Hosts []ResultHost 90 | Groups []ResultGroup 91 | } 92 | result := &Result{ 93 | Hosts: make([]ResultHost, 0, len(hostMap)), 94 | Groups: make([]ResultGroup, 0, len(groupMap)), 95 | } 96 | 97 | orderedHosts := maps.Values(hostMap) 98 | slices.SortStableFunc(orderedHosts, func(a, b *aini.Host) int { 99 | return strings.Compare(a.Name, b.Name) 100 | }) 101 | for _, host := range orderedHosts { 102 | result.Hosts = append(result.Hosts, ResultHost{ 103 | Name: host.Name, 104 | Groups: getGroupNames(host.ListGroupsOrdered()), 105 | Vars: host.Vars, 106 | }) 107 | } 108 | 109 | orderedGroups := maps.Values(groupMap) 110 | slices.SortStableFunc(orderedGroups, func(a, b *aini.Group) int { 111 | return strings.Compare(a.Name, b.Name) 112 | }) 113 | for _, group := range orderedGroups { 114 | orderedDescendantNames := getGroupNames(maps.Values(group.Children)) 115 | sort.Strings(orderedDescendantNames) 116 | 117 | orderedHostNames := getHostNames(maps.Values(group.Hosts)) 118 | sort.Strings(orderedHostNames) 119 | 120 | result.Groups = append(result.Groups, ResultGroup{ 121 | Name: group.Name, 122 | Parents: getGroupNames(group.ListParentGroupsOrdered()), 123 | Descendants: orderedDescendantNames, 124 | Hosts: orderedHostNames, 125 | Vars: group.Vars, 126 | }) 127 | } 128 | 129 | return &result 130 | } 131 | 132 | func getGroupNames(groups []*aini.Group) []string { 133 | groupNames := make([]string, 0, len(groups)) 134 | for _, grp := range groups { 135 | groupNames = append(groupNames, grp.Name) 136 | } 137 | return groupNames 138 | } 139 | 140 | func getHostNames(hosts []*aini.Host) []string { 141 | hostNames := make([]string, 0, len(hosts)) 142 | for _, hst := range hosts { 143 | hostNames = append(hostNames, hst.Name) 144 | } 145 | return hostNames 146 | } 147 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/relex/aini 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 7 | github.com/samber/lo v1.38.1 8 | github.com/stretchr/testify v1.7.0 9 | golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.0 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 4 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= 8 | github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 11 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 12 | golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= 13 | golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /inventory.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | // Inventory-related helper methods 4 | 5 | // Reconcile ensures inventory basic rules, run after updates. 6 | // After initial inventory file processing, only direct relationships are set. 7 | // 8 | // This method: 9 | // * (re)sets Children and Parents for hosts and groups 10 | // * ensures that mandatory groups exist 11 | // * calculates variables for hosts and groups 12 | func (inventory *InventoryData) Reconcile() { 13 | // Clear all computed data 14 | for _, host := range inventory.Hosts { 15 | host.clearData() 16 | } 17 | // a group can be empty (with no hosts in it), so the previous method will not clean it 18 | // on the other hand, a group could have been attached to a host by a user, but not added to the inventory.Groups map 19 | // so it's safer just to clean everything 20 | for _, group := range inventory.Groups { 21 | group.clearData(make(map[string]struct{}, len(inventory.Groups))) 22 | } 23 | 24 | allGroup := inventory.getOrCreateGroup("all") 25 | ungroupedGroup := inventory.getOrCreateGroup("ungrouped") 26 | ungroupedGroup.DirectParents[allGroup.Name] = allGroup 27 | 28 | // First, ensure that inventory.Groups contains all the groups 29 | for _, host := range inventory.Hosts { 30 | for _, group := range host.DirectGroups { 31 | inventory.Groups[group.Name] = group 32 | for _, ancestor := range group.ListParentGroupsOrdered() { 33 | inventory.Groups[ancestor.Name] = ancestor 34 | } 35 | } 36 | } 37 | 38 | // Calculate intergroup relationships 39 | for _, group := range inventory.Groups { 40 | group.DirectParents[allGroup.Name] = allGroup 41 | for _, ancestor := range group.ListParentGroupsOrdered() { 42 | group.Parents[ancestor.Name] = ancestor 43 | ancestor.Children[group.Name] = group 44 | } 45 | } 46 | 47 | // Now set hosts for groups and groups for hosts 48 | for _, host := range inventory.Hosts { 49 | host.Groups[allGroup.Name] = allGroup 50 | for _, group := range host.DirectGroups { 51 | group.Hosts[host.Name] = host 52 | host.Groups[group.Name] = group 53 | for _, parent := range group.Parents { 54 | group.Parents[parent.Name] = parent 55 | parent.Children[group.Name] = group 56 | parent.Hosts[host.Name] = host 57 | host.Groups[parent.Name] = parent 58 | } 59 | } 60 | } 61 | inventory.reconcileVars() 62 | } 63 | 64 | func (host *Host) clearData() { 65 | host.Groups = make(map[string]*Group) 66 | host.Vars = make(map[string]string) 67 | for _, group := range host.DirectGroups { 68 | group.clearData(make(map[string]struct{}, len(host.Groups))) 69 | } 70 | } 71 | 72 | func (group *Group) clearData(visited map[string]struct{}) { 73 | if _, ok := visited[group.Name]; ok { 74 | return 75 | } 76 | group.Hosts = make(map[string]*Host) 77 | group.Parents = make(map[string]*Group) 78 | group.Children = make(map[string]*Group) 79 | group.Vars = make(map[string]string) 80 | group.AllInventoryVars = nil 81 | group.AllFileVars = nil 82 | visited[group.Name] = struct{}{} 83 | for _, parent := range group.DirectParents { 84 | parent.clearData(visited) 85 | } 86 | } 87 | 88 | // getOrCreateGroup return group from inventory if exists or creates empty Group with given name 89 | func (inventory *InventoryData) getOrCreateGroup(groupName string) *Group { 90 | if group, ok := inventory.Groups[groupName]; ok { 91 | return group 92 | } 93 | g := &Group{ 94 | Name: groupName, 95 | Hosts: make(map[string]*Host), 96 | Vars: make(map[string]string), 97 | Children: make(map[string]*Group), 98 | Parents: make(map[string]*Group), 99 | 100 | DirectParents: make(map[string]*Group), 101 | InventoryVars: make(map[string]string), 102 | FileVars: make(map[string]string), 103 | } 104 | inventory.Groups[groupName] = g 105 | return g 106 | } 107 | 108 | // getOrCreateHost return host from inventory if exists or creates empty Host with given name 109 | func (inventory *InventoryData) getOrCreateHost(hostName string) *Host { 110 | if host, ok := inventory.Hosts[hostName]; ok { 111 | return host 112 | } 113 | h := &Host{ 114 | Name: hostName, 115 | Port: 22, 116 | Groups: make(map[string]*Group), 117 | Vars: make(map[string]string), 118 | 119 | DirectGroups: make(map[string]*Group), 120 | InventoryVars: make(map[string]string), 121 | FileVars: make(map[string]string), 122 | } 123 | inventory.Hosts[hostName] = h 124 | return h 125 | } 126 | 127 | // addValues fills `to` map with values from `from` map 128 | func addValues(to map[string]string, from map[string]string) { 129 | for k, v := range from { 130 | to[k] = v 131 | } 132 | } 133 | 134 | // copyStringMap creates a non-deep copy of the map 135 | func copyStringMap(from map[string]string) map[string]string { 136 | result := make(map[string]string, len(from)) 137 | addValues(result, from) 138 | return result 139 | } 140 | -------------------------------------------------------------------------------- /marshal.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/samber/lo" 7 | "golang.org/x/exp/maps" 8 | ) 9 | 10 | type alwaysNil interface{} // to hold place for Group and Host references; must be nil in serialized form 11 | 12 | func (group *Group) MarshalJSON() ([]byte, error) { 13 | type groupWithoutCustomMarshal Group 14 | 15 | return json.Marshal(&struct { 16 | groupWithoutCustomMarshal 17 | Hosts map[string]alwaysNil 18 | Children map[string]alwaysNil 19 | Parents map[string]alwaysNil 20 | DirectParents map[string]alwaysNil 21 | }{ 22 | groupWithoutCustomMarshal: groupWithoutCustomMarshal(*group), 23 | Hosts: makeNilValueMap(group.Hosts), 24 | Children: makeNilValueMap(group.Children), 25 | Parents: makeNilValueMap(group.Parents), 26 | DirectParents: makeNilValueMap(group.DirectParents), 27 | }) 28 | } 29 | 30 | func (host *Host) MarshalJSON() ([]byte, error) { 31 | type hostWithoutCustomMarshal Host 32 | 33 | return json.Marshal(&struct { 34 | hostWithoutCustomMarshal 35 | Groups map[string]alwaysNil 36 | DirectGroups map[string]alwaysNil 37 | }{ 38 | hostWithoutCustomMarshal: hostWithoutCustomMarshal(*host), 39 | Groups: makeNilValueMap(host.Groups), 40 | DirectGroups: makeNilValueMap(host.DirectGroups), 41 | }) 42 | } 43 | 44 | func makeNilValueMap[K comparable, V any](m map[K]*V) map[K]alwaysNil { 45 | return lo.MapValues(m, func(_ *V, _ K) alwaysNil { return nil }) 46 | } 47 | 48 | func (inventory *InventoryData) UnmarshalJSON(data []byte) error { 49 | type inventoryWithoutCustomUnmarshal InventoryData 50 | var rawInventory inventoryWithoutCustomUnmarshal 51 | if err := json.Unmarshal(data, &rawInventory); err != nil { 52 | return err 53 | } 54 | // rawInventory's Groups and Hosts should now contain all properties, 55 | // except child group maps and host maps are filled with original keys and null values 56 | 57 | // reassign child groups and hosts to reference rawInventory.Hosts and .Groups 58 | 59 | for _, group := range rawInventory.Groups { 60 | group.Hosts = lo.PickByKeys(rawInventory.Hosts, maps.Keys(group.Hosts)) 61 | group.Children = lo.PickByKeys(rawInventory.Groups, maps.Keys(group.Children)) 62 | group.Parents = lo.PickByKeys(rawInventory.Groups, maps.Keys(group.Parents)) 63 | group.DirectParents = lo.PickByKeys(rawInventory.Groups, maps.Keys(group.DirectParents)) 64 | } 65 | 66 | for _, host := range rawInventory.Hosts { 67 | host.Groups = lo.PickByKeys(rawInventory.Groups, maps.Keys(host.Groups)) 68 | host.DirectGroups = lo.PickByKeys(rawInventory.Groups, maps.Keys(host.DirectGroups)) 69 | } 70 | 71 | inventory.Groups = rawInventory.Groups 72 | inventory.Hosts = rawInventory.Hosts 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /marshal_test.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | const minMarshalInventory = `[Animals] 12 | ET 13 | 14 | [Animals:children] 15 | Cats 16 | 17 | [Cats] 18 | Lion 19 | ` 20 | 21 | //go:embed marshal_test_inventory.json 22 | var minMarshalJSON string 23 | 24 | func TestMarshalJSON(t *testing.T) { 25 | v, err := ParseString(minMarshalInventory) 26 | assert.Nil(t, err) 27 | 28 | j, err := json.MarshalIndent(v, "", " ") 29 | assert.Nil(t, err) 30 | assert.Equal(t, minMarshalJSON, string(j)) 31 | 32 | t.Run("unmarshal", func(t *testing.T) { 33 | var v2 InventoryData 34 | assert.Nil(t, json.Unmarshal(j, &v2)) 35 | assert.Equal(t, v.Hosts["Lion"], v2.Hosts["Lion"]) 36 | assert.Equal(t, v.Groups["Cats"], v2.Groups["Cats"]) 37 | }) 38 | } 39 | 40 | func TestMarshalWithVars(t *testing.T) { 41 | v, err := ParseFile("test_data/inventory") 42 | assert.Nil(t, err) 43 | 44 | v.HostsToLower() 45 | v.GroupsToLower() 46 | v.AddVarsLowerCased("test_data") 47 | 48 | j, err := json.MarshalIndent(v, "", " ") 49 | assert.Nil(t, err) 50 | 51 | t.Run("unmarshal", func(t *testing.T) { 52 | var v2 InventoryData 53 | assert.Nil(t, json.Unmarshal(j, &v2)) 54 | assert.Equal(t, v.Hosts["host1"], v2.Hosts["host1"]) 55 | assert.Equal(t, v.Groups["tomcat"], v2.Groups["tomcat"]) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /marshal_test_inventory.json: -------------------------------------------------------------------------------- 1 | { 2 | "Groups": { 3 | "Animals": { 4 | "Name": "Animals", 5 | "Vars": {}, 6 | "InventoryVars": {}, 7 | "FileVars": {}, 8 | "AllInventoryVars": {}, 9 | "AllFileVars": {}, 10 | "Hosts": { 11 | "ET": null, 12 | "Lion": null 13 | }, 14 | "Children": { 15 | "Cats": null 16 | }, 17 | "Parents": { 18 | "all": null 19 | }, 20 | "DirectParents": { 21 | "all": null 22 | } 23 | }, 24 | "Cats": { 25 | "Name": "Cats", 26 | "Vars": {}, 27 | "InventoryVars": {}, 28 | "FileVars": {}, 29 | "AllInventoryVars": {}, 30 | "AllFileVars": {}, 31 | "Hosts": { 32 | "Lion": null 33 | }, 34 | "Children": {}, 35 | "Parents": { 36 | "Animals": null, 37 | "all": null 38 | }, 39 | "DirectParents": { 40 | "Animals": null, 41 | "all": null 42 | } 43 | }, 44 | "all": { 45 | "Name": "all", 46 | "Vars": {}, 47 | "InventoryVars": {}, 48 | "FileVars": {}, 49 | "AllInventoryVars": {}, 50 | "AllFileVars": {}, 51 | "Hosts": { 52 | "ET": null, 53 | "Lion": null 54 | }, 55 | "Children": { 56 | "Animals": null, 57 | "Cats": null, 58 | "ungrouped": null 59 | }, 60 | "Parents": {}, 61 | "DirectParents": { 62 | "all": null 63 | } 64 | }, 65 | "ungrouped": { 66 | "Name": "ungrouped", 67 | "Vars": {}, 68 | "InventoryVars": {}, 69 | "FileVars": {}, 70 | "AllInventoryVars": {}, 71 | "AllFileVars": {}, 72 | "Hosts": {}, 73 | "Children": {}, 74 | "Parents": { 75 | "all": null 76 | }, 77 | "DirectParents": { 78 | "all": null 79 | } 80 | } 81 | }, 82 | "Hosts": { 83 | "ET": { 84 | "Name": "ET", 85 | "Port": 22, 86 | "Vars": {}, 87 | "InventoryVars": {}, 88 | "FileVars": {}, 89 | "Groups": { 90 | "Animals": null, 91 | "all": null 92 | }, 93 | "DirectGroups": { 94 | "Animals": null 95 | } 96 | }, 97 | "Lion": { 98 | "Name": "Lion", 99 | "Port": 22, 100 | "Vars": {}, 101 | "InventoryVars": {}, 102 | "FileVars": {}, 103 | "Groups": { 104 | "Animals": null, 105 | "Cats": null, 106 | "all": null 107 | }, 108 | "DirectGroups": { 109 | "Cats": null 110 | } 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /match.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strings" 7 | 8 | "golang.org/x/exp/maps" 9 | ) 10 | 11 | // MatchHostsByPatterns looks for all hosts that match the Ansible host patterns as described in https://docs.ansible.com/ansible/latest/inventory_guide/intro_patterns.html 12 | // 13 | // e.g. "webservers:gateways:myhost.domain:!atlanta" 14 | func (inventory *InventoryData) MatchHostsByPatterns(patterns string) (map[string]*Host, error) { 15 | patternList := strings.Split(patterns, ":") 16 | 17 | matchedHosts := make(map[string]*Host) 18 | for _, host := range inventory.Hosts { 19 | matched, err := host.MatchPatterns(patternList) 20 | if err != nil { 21 | return matchedHosts, err 22 | } 23 | if matched { 24 | matchedHosts[host.Name] = host 25 | } 26 | } 27 | return matchedHosts, nil 28 | } 29 | 30 | // MatchPatterns checks whether the given host matches the list of Ansible host patterns. 31 | // 32 | // e.g. [webservers, gateways, myhost.domain, !atlanta] 33 | func (host *Host) MatchPatterns(patterns []string) (bool, error) { 34 | allNames := make([]string, 0, 1+len(host.Groups)) 35 | allNames = append(allNames, host.Name) 36 | allNames = append(allNames, maps.Keys(host.Groups)...) 37 | return MatchNamesByPatterns(allNames, patterns) 38 | } 39 | 40 | // MatchNamesByPatterns checks whether the give hostname and group names match list of Ansible host patterns. 41 | // 42 | // e.g. [webservers, gateways, myhost.domain, !atlanta] 43 | func MatchNamesByPatterns(allNames []string, patterns []string) (bool, error) { 44 | numPositiveMatch := 0 45 | 46 | for index, pattern := range patterns { 47 | switch { 48 | case pattern == "": 49 | if index == 0 { 50 | return false, nil 51 | } 52 | continue 53 | case pattern == "all" || pattern == "*": 54 | numPositiveMatch++ 55 | case pattern[0] == '!': 56 | if index == 0 { 57 | return false, fmt.Errorf("exclusion pattern \"%s\" cannot be the first pattern", pattern) 58 | } 59 | any, err := matchAnyName(pattern[1:], allNames) 60 | if err != nil { 61 | return false, err 62 | } 63 | if any { 64 | return false, err 65 | } 66 | case pattern[0] == '&': 67 | if index == 0 { 68 | return false, fmt.Errorf("intersection pattern \"%s\" cannot be the first pattern", pattern) 69 | } 70 | any, err := matchAnyName(pattern[1:], allNames) 71 | if err != nil { 72 | return false, err 73 | } 74 | if !any { 75 | return false, err 76 | } 77 | default: 78 | any, err := matchAnyName(pattern, allNames) 79 | if err != nil { 80 | return false, err 81 | } 82 | if any { 83 | numPositiveMatch++ 84 | } 85 | } 86 | } 87 | return numPositiveMatch > 0, nil 88 | } 89 | 90 | func matchAnyName(pattern string, allNames []string) (bool, error) { 91 | for _, name := range allNames { 92 | matched, err := path.Match(pattern, name) 93 | if err != nil { 94 | return false, err 95 | } 96 | if matched { 97 | return true, nil 98 | } 99 | } 100 | return false, nil 101 | } 102 | 103 | // MatchHosts looks for hosts whose hostnames match the pattern. Group memberships are not considered. 104 | func (inventory *InventoryData) MatchHosts(pattern string) (map[string]*Host, error) { 105 | return MatchHosts(inventory.Hosts, pattern) 106 | } 107 | 108 | // MatchHosts looks for hosts whose hostnames match the pattern. Group memberships are not considered. 109 | func (group *Group) MatchHosts(pattern string) (map[string]*Host, error) { 110 | return MatchHosts(group.Hosts, pattern) 111 | } 112 | 113 | // MatchHosts looks for hosts whose hostnames match the pattern. Group memberships are not considered. 114 | func MatchHosts(hosts map[string]*Host, pattern string) (map[string]*Host, error) { 115 | matchedHosts := make(map[string]*Host) 116 | for _, host := range hosts { 117 | m, err := path.Match(pattern, host.Name) 118 | if err != nil { 119 | return nil, err 120 | } 121 | if m { 122 | matchedHosts[host.Name] = host 123 | } 124 | } 125 | return matchedHosts, nil 126 | } 127 | 128 | // MatchGroups looks for groups that match the pattern 129 | func (inventory *InventoryData) MatchGroups(pattern string) (map[string]*Group, error) { 130 | return MatchGroups(inventory.Groups, pattern) 131 | } 132 | 133 | // MatchGroups looks for groups that match the pattern 134 | func (host *Host) MatchGroups(pattern string) (map[string]*Group, error) { 135 | return MatchGroups(host.Groups, pattern) 136 | } 137 | 138 | // MatchGroups looks for groups that match the pattern 139 | func MatchGroups(groups map[string]*Group, pattern string) (map[string]*Group, error) { 140 | matchedGroups := make(map[string]*Group) 141 | for _, group := range groups { 142 | m, err := path.Match(pattern, group.Name) 143 | if err != nil { 144 | return nil, err 145 | } 146 | if m { 147 | matchedGroups[group.Name] = group 148 | } 149 | } 150 | return matchedGroups, nil 151 | } 152 | 153 | // MatchVars looks for vars that match the pattern 154 | func (group *Group) MatchVars(pattern string) (map[string]string, error) { 155 | return MatchVars(group.Vars, pattern) 156 | } 157 | 158 | // MatchVars looks for vars that match the pattern 159 | func (host *Host) MatchVars(pattern string) (map[string]string, error) { 160 | return MatchVars(host.Vars, pattern) 161 | } 162 | 163 | // MatchVars looks for vars that match the pattern 164 | func MatchVars(vars map[string]string, pattern string) (map[string]string, error) { 165 | matchedVars := make(map[string]string) 166 | for k, v := range vars { 167 | m, err := path.Match(pattern, v) 168 | if err != nil { 169 | return nil, err 170 | } 171 | if m { 172 | matchedVars[k] = v 173 | } 174 | } 175 | return matchedVars, nil 176 | } 177 | -------------------------------------------------------------------------------- /match_test.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMultiPatterns(t *testing.T) { 10 | var ok bool 11 | var err error 12 | 13 | ok, err = MatchNamesByPatterns([]string{"myhost", "web", "tmp"}, []string{"all"}) 14 | assert.Nil(t, err) 15 | assert.True(t, ok) 16 | 17 | ok, err = MatchNamesByPatterns([]string{"myhost", "web", "tmp"}, []string{"all", "tmp"}) 18 | assert.Nil(t, err) 19 | assert.True(t, ok) 20 | 21 | ok, err = MatchNamesByPatterns([]string{"myhost", "web", "tmp"}, []string{"all", "!tmp"}) 22 | assert.Nil(t, err) 23 | assert.False(t, ok) 24 | 25 | ok, err = MatchNamesByPatterns([]string{"myhost", "web", "tmp"}, []string{"all", "&tmp"}) 26 | assert.Nil(t, err) 27 | assert.True(t, ok) 28 | 29 | ok, err = MatchNamesByPatterns([]string{"myhost", "web", "tmp"}, []string{"web", "myhost"}) 30 | assert.Nil(t, err) 31 | assert.True(t, ok) 32 | 33 | ok, err = MatchNamesByPatterns([]string{"myhost", "web", "tmp"}, []string{"web", "myhost", "&hello"}) 34 | assert.Nil(t, err) 35 | assert.False(t, ok) 36 | } 37 | 38 | func TestGroupsMatching(t *testing.T) { 39 | v := parseString(t, ` 40 | host1 41 | host2 42 | [myGroup1] 43 | host1 44 | [myGroup2] 45 | host1 46 | [groupForCats] 47 | host1 48 | `) 49 | 50 | groups, err := v.MatchGroups("*Group*") 51 | assert.Nil(t, err) 52 | assert.Contains(t, groups, "myGroup1") 53 | assert.Contains(t, groups, "myGroup2") 54 | assert.Len(t, groups, 2) 55 | 56 | groups, err = v.Hosts["host1"].MatchGroups("*Group*") 57 | assert.Nil(t, err) 58 | assert.Contains(t, groups, "myGroup1") 59 | assert.Contains(t, groups, "myGroup2") 60 | assert.Len(t, groups, 2) 61 | 62 | ok, err := v.Hosts["host1"].MatchPatterns([]string{"host[12]", "myGroup*", "&groupForCats"}) 63 | assert.Nil(t, err) 64 | assert.True(t, ok) 65 | 66 | ok, err = v.Hosts["host1"].MatchPatterns([]string{"host[12]", "myGroup*", "&groupForCats", "¬Here"}) 67 | assert.Nil(t, err) 68 | assert.False(t, ok) 69 | 70 | ok, err = v.Hosts["host1"].MatchPatterns([]string{"host[12]", "myGroup*", "!groupFor*"}) 71 | assert.Nil(t, err) 72 | assert.False(t, ok) 73 | } 74 | 75 | func TestHostsMatching(t *testing.T) { 76 | v := parseString(t, ` 77 | myHost1 78 | otherHost2 79 | [group1] 80 | myHost1 81 | [group2] 82 | myHost1 83 | myHost2 84 | [group3:children] 85 | group2 86 | `) 87 | 88 | hosts, err := v.MatchHosts("my*") 89 | assert.Nil(t, err) 90 | assert.Contains(t, hosts, "myHost1") 91 | assert.Contains(t, hosts, "myHost2") 92 | assert.Len(t, hosts, 2) 93 | 94 | hosts, err = v.Groups["group1"].MatchHosts("*my*") 95 | assert.Nil(t, err) 96 | assert.Contains(t, hosts, "myHost1") 97 | assert.Len(t, hosts, 1) 98 | 99 | hosts, err = v.Groups["group2"].MatchHosts("*my*") 100 | assert.Nil(t, err) 101 | assert.Contains(t, hosts, "myHost1") 102 | assert.Contains(t, hosts, "myHost2") 103 | assert.Len(t, hosts, 2) 104 | 105 | hosts, err = v.MatchHostsByPatterns("group3:otherHost[0-9]:!group1") 106 | assert.Nil(t, err) 107 | assert.Len(t, hosts, 2) 108 | assert.NotContains(t, hosts, "myHost1") 109 | assert.Contains(t, hosts, "myHost2") 110 | assert.Contains(t, hosts, "otherHost2") 111 | } 112 | 113 | func TestVarsMatching(t *testing.T) { 114 | v := parseString(t, ` 115 | host1 myHostVar=myHostVarValue otherHostVar=otherHostVarValue 116 | 117 | [group1] 118 | host1 119 | 120 | [group1:vars] 121 | myGroupVar=myGroupVarValue 122 | otherGroupVar=otherGroupVarValue 123 | `) 124 | group := v.Groups["group1"] 125 | vars, err := group.MatchVars("my*") 126 | assert.Nil(t, err) 127 | assert.Contains(t, vars, "myGroupVar") 128 | assert.Len(t, vars, 1) 129 | assert.Equal(t, "myGroupVarValue", vars["myGroupVar"]) 130 | 131 | host := v.Hosts["host1"] 132 | vars, err = host.MatchVars("my*") 133 | assert.Nil(t, err) 134 | assert.Contains(t, vars, "myHostVar") 135 | assert.Contains(t, vars, "myGroupVar") 136 | assert.Len(t, vars, 2) 137 | assert.Equal(t, "myHostVarValue", vars["myHostVar"]) 138 | assert.Equal(t, "myGroupVarValue", vars["myGroupVar"]) 139 | } 140 | -------------------------------------------------------------------------------- /ordered.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "path" 5 | ) 6 | 7 | // MatchGroupsOrdered looks for groups that match the pattern 8 | // The result is a sorted array, where lower indexes corespond to more specific groups 9 | func (host *Host) MatchGroupsOrdered(pattern string) ([]*Group, error) { 10 | matchedGroups := make([]*Group, 0) 11 | groups := host.ListGroupsOrdered() 12 | 13 | for _, group := range groups { 14 | m, err := path.Match(pattern, group.Name) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if m { 19 | matchedGroups = append(matchedGroups, group) 20 | } 21 | } 22 | 23 | return matchedGroups, nil 24 | } 25 | 26 | // MatchGroupsOrdered looks for groups that match the pattern 27 | // The result is a sorted array, where lower indexes corespond to more specific groups 28 | func (group *Group) MatchGroupsOrdered(pattern string) ([]*Group, error) { 29 | matchedGroups := make([]*Group, 0) 30 | groups := group.ListParentGroupsOrdered() 31 | 32 | for _, group := range groups { 33 | m, err := path.Match(pattern, group.Name) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if m { 38 | matchedGroups = append(matchedGroups, group) 39 | } 40 | } 41 | 42 | return matchedGroups, nil 43 | } 44 | 45 | // ListGroupsOrdered returns all ancestor groups of a given host in level order 46 | func (host *Host) ListGroupsOrdered() []*Group { 47 | return listAncestorsOrdered(host.DirectGroups, nil, true) 48 | } 49 | 50 | // ListParentGroupsOrdered returns all ancestor groups of a given group in level order 51 | func (group *Group) ListParentGroupsOrdered() []*Group { 52 | visited := map[string]struct{}{group.Name: {}} 53 | return listAncestorsOrdered(group.DirectParents, visited, group.Name != "all") 54 | } 55 | 56 | // listAncestorsOrdered returns all ancestor groups of a given group map in level order 57 | func listAncestorsOrdered(groups map[string]*Group, visited map[string]struct{}, appendAll bool) []*Group { 58 | result := make([]*Group, 0) 59 | if visited == nil { 60 | visited = map[string]struct{}{} 61 | } 62 | var allGroup *Group 63 | for queue := GroupMapListValues(groups); len(queue) > 0; func() { 64 | copy(queue, queue[1:]) 65 | queue = queue[:len(queue)-1] 66 | }() { 67 | group := queue[0] 68 | // The all group should always be the last one 69 | if group.Name == "all" { 70 | allGroup = group 71 | continue 72 | } 73 | if _, ok := visited[group.Name]; ok { 74 | continue 75 | } 76 | visited[group.Name] = struct{}{} 77 | parentList := GroupMapListValues(group.DirectParents) 78 | result = append(result, group) 79 | queue = append(queue, parentList...) 80 | } 81 | if allGroup != nil && appendAll { 82 | result = append(result, allGroup) 83 | } 84 | return result 85 | } 86 | -------------------------------------------------------------------------------- /ordered_test.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestListAncestorsOrdered(t *testing.T) { 10 | v := parseString(t, ` 11 | host1 12 | [notMyGroup3] 13 | [myGroup2] 14 | [myGroup1] 15 | host1 16 | [myGroup2:children] 17 | myGroup1 18 | [notMyGroup3:children] 19 | myGroup2 20 | `) 21 | 22 | host1 := v.Hosts["host1"] 23 | assert.NotNil(t, host1) 24 | assert.Len(t, host1.Groups, 4) 25 | 26 | groups := host1.ListGroupsOrdered() 27 | assert.Len(t, groups, 4) 28 | assert.Equal(t, groups[0].Name, "myGroup1") 29 | assert.Equal(t, groups[1].Name, "myGroup2") 30 | assert.Equal(t, groups[2].Name, "notMyGroup3") 31 | assert.Equal(t, groups[3].Name, "all") 32 | 33 | group1 := v.Groups["myGroup1"] 34 | assert.NotNil(t, group1) 35 | groups = group1.ListParentGroupsOrdered() 36 | assert.NotNil(t, groups) 37 | 38 | assert.Len(t, groups, 3) 39 | assert.Equal(t, groups[0].Name, "myGroup2") 40 | assert.Equal(t, groups[1].Name, "notMyGroup3") 41 | assert.Equal(t, groups[2].Name, "all") 42 | } 43 | 44 | func TestMatchGroupsOrdered(t *testing.T) { 45 | v := parseString(t, ` 46 | host1 47 | [notMyGroup3] 48 | [myGroup2] 49 | [myGroup1] 50 | host1 51 | [myGroup2:children] 52 | myGroup1 53 | [notMyGroup3:children] 54 | myGroup2 55 | `) 56 | 57 | host1 := v.Hosts["host1"] 58 | assert.NotNil(t, host1) 59 | assert.Len(t, host1.Groups, 4) 60 | 61 | groups, err := host1.MatchGroupsOrdered("my*") 62 | assert.Nil(t, err) 63 | assert.Len(t, groups, 2) 64 | assert.Equal(t, groups[0].Name, "myGroup1") 65 | assert.Equal(t, groups[1].Name, "myGroup2") 66 | 67 | group1 := v.Groups["myGroup1"] 68 | assert.NotNil(t, group1) 69 | groups, err = group1.MatchGroupsOrdered("my*") 70 | assert.Nil(t, err) 71 | assert.NotNil(t, groups) 72 | 73 | assert.Len(t, groups, 1) 74 | assert.Equal(t, groups[0].Name, "myGroup2") 75 | } 76 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "math" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/google/shlex" 12 | ) 13 | 14 | // state enum 15 | type state int 16 | 17 | const ( 18 | hostsState state = 0 19 | childrenState state = 1 20 | varsState state = 2 21 | ) 22 | 23 | func getState(str string) (state, bool) { 24 | var result state 25 | var ok bool = true 26 | if str == "" || str == "hosts" { 27 | result = hostsState 28 | } else if str == "children" { 29 | result = childrenState 30 | } else if str == "vars" { 31 | result = varsState 32 | } else { 33 | ok = false 34 | } 35 | return result, ok 36 | } 37 | 38 | // state enum end 39 | 40 | // parser performs parsing of inventory file from some Reader 41 | func (inventory *InventoryData) parse(reader *bufio.Reader) error { 42 | // This regexp is copy-pasted from ansible sources 43 | sectionRegex := regexp.MustCompile(`^\[([^:\]\s]+)(?::(\w+))?\]\s*(?:\#.*)?$`) 44 | scanner := bufio.NewScanner(reader) 45 | inventory.Groups = make(map[string]*Group) 46 | inventory.Hosts = make(map[string]*Host) 47 | activeState := hostsState 48 | activeGroup := inventory.getOrCreateGroup("ungrouped") 49 | 50 | for scanner.Scan() { 51 | line := strings.TrimSpace(scanner.Text()) 52 | if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") || line == "" { 53 | continue 54 | } 55 | matches := sectionRegex.FindAllStringSubmatch(line, -1) 56 | if matches != nil { 57 | activeGroup = inventory.getOrCreateGroup(matches[0][1]) 58 | var ok bool 59 | if activeState, ok = getState(matches[0][2]); !ok { 60 | return fmt.Errorf("section [%s] has unknown type: %s", line, matches[0][2]) 61 | } 62 | 63 | continue 64 | } else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { 65 | return fmt.Errorf("invalid section entry: '%s'. Make sure that there are no spaces or other characters in the section entry", line) 66 | } 67 | 68 | if activeState == hostsState { 69 | hosts, err := inventory.getHosts(line, activeGroup) 70 | if err != nil { 71 | return err 72 | } 73 | for _, host := range hosts { 74 | host.DirectGroups[activeGroup.Name] = activeGroup 75 | inventory.Hosts[host.Name] = host 76 | if activeGroup.Name != "ungrouped" { 77 | delete(host.DirectGroups, "ungrouped") 78 | } 79 | } 80 | } 81 | if activeState == childrenState { 82 | parsed, err := shlex.Split(line) 83 | if err != nil { 84 | return err 85 | } 86 | groupName := parsed[0] 87 | newGroup := inventory.getOrCreateGroup(groupName) 88 | newGroup.DirectParents[activeGroup.Name] = activeGroup 89 | inventory.Groups[line] = newGroup 90 | } 91 | if activeState == varsState { 92 | k, v, err := splitKV(line) 93 | if err != nil { 94 | return err 95 | } 96 | activeGroup.InventoryVars[k] = v 97 | } 98 | } 99 | inventory.Groups[activeGroup.Name] = activeGroup 100 | return nil 101 | } 102 | 103 | // getHosts parses given "host" line from inventory 104 | func (inventory *InventoryData) getHosts(line string, group *Group) (map[string]*Host, error) { 105 | parts, err := shlex.Split(line) 106 | if err != nil { 107 | return nil, err 108 | } 109 | hostpattern, port, err := getHostPort(parts[0]) 110 | if err != nil { 111 | return nil, err 112 | } 113 | hostnames, err := expandHostPattern(hostpattern) 114 | if err != nil { 115 | return nil, err 116 | } 117 | result := make(map[string]*Host, len(hostnames)) 118 | for _, hostname := range hostnames { 119 | params := parts[1:] 120 | vars := make(map[string]string, len(params)) 121 | for _, param := range params { 122 | k, v, err := splitKV(param) 123 | if err != nil { 124 | return nil, err 125 | } 126 | vars[k] = v 127 | } 128 | 129 | host := inventory.getOrCreateHost(hostname) 130 | host.Port = port 131 | host.DirectGroups[group.Name] = group 132 | addValues(host.InventoryVars, vars) 133 | 134 | result[host.Name] = host 135 | } 136 | return result, nil 137 | } 138 | 139 | // splitKV splits `key=value` into two string: key and value 140 | func splitKV(kv string) (string, string, error) { 141 | keyval := strings.SplitN(kv, "=", 2) 142 | if len(keyval) == 1 { 143 | return "", "", fmt.Errorf("bad key=value pair supplied: %s", kv) 144 | } 145 | return strings.TrimSpace(keyval[0]), strings.TrimSpace(keyval[1]), nil 146 | } 147 | 148 | // getHostPort splits string like `host-[a:b]-c:22` into `host-[a:b]-c` and `22` 149 | func getHostPort(str string) (string, int, error) { 150 | port := 22 151 | parts := strings.Split(str, ":") 152 | if len(parts) == 1 { 153 | return str, port, nil 154 | } 155 | lastPart := parts[len(parts)-1] 156 | if strings.Contains(lastPart, "]") { 157 | // We are in expand pattern, so no port were specified 158 | return str, port, nil 159 | } 160 | port, err := strconv.Atoi(lastPart) 161 | return strings.Join(parts[:len(parts)-1], ":"), port, err 162 | } 163 | 164 | // expandHostPattern turns `host-[a:b]-c` into a flat list of hosts 165 | func expandHostPattern(hostpattern string) ([]string, error) { 166 | lbrac := strings.Replace(hostpattern, "[", "|", 1) 167 | rbrac := strings.Replace(lbrac, "]", "|", 1) 168 | parts := strings.Split(rbrac, "|") 169 | 170 | if len(parts) == 1 { 171 | // No pattern detected 172 | return []string{hostpattern}, nil 173 | } 174 | if len(parts) != 3 { 175 | return nil, fmt.Errorf("wrong host pattern: %s", hostpattern) 176 | } 177 | 178 | head, nrange, tail := parts[0], parts[1], parts[2] 179 | bounds := strings.Split(nrange, ":") 180 | if len(bounds) < 2 || len(bounds) > 3 { 181 | return nil, fmt.Errorf("wrong host pattern: %s", hostpattern) 182 | } 183 | 184 | var begin, end []rune 185 | var step = 1 186 | if len(bounds) == 3 { 187 | step, _ = strconv.Atoi(bounds[2]) 188 | } 189 | 190 | end = []rune(bounds[1]) 191 | if bounds[0] == "" { 192 | if isRunesNumber(end) { 193 | format := fmt.Sprintf("%%0%dd", len(end)) 194 | begin = []rune(fmt.Sprintf(format, 0)) 195 | } else { 196 | return nil, fmt.Errorf("skipping range start in not allowed with alphabetical range: %s", hostpattern) 197 | } 198 | } else { 199 | begin = []rune(bounds[0]) 200 | } 201 | 202 | var chars []int 203 | isNumberRange := false 204 | 205 | if isRunesNumber(begin) && isRunesNumber(end) { 206 | chars = makeRange(runesToInt(begin), runesToInt(end), step) 207 | isNumberRange = true 208 | } else if !isRunesNumber(begin) && !isRunesNumber(end) && len(begin) == 1 && len(end) == 1 { 209 | dict := append(makeRange('a', 'z', 1), makeRange('A', 'Z', 1)...) 210 | chars = makeRange( 211 | find(dict, int(begin[0])), 212 | find(dict, int(end[0])), 213 | step, 214 | ) 215 | for i, c := range chars { 216 | chars[i] = dict[c] 217 | } 218 | } 219 | 220 | if len(chars) == 0 { 221 | return nil, fmt.Errorf("bad range specified: %s", nrange) 222 | } 223 | 224 | var hosts []string 225 | var format string 226 | if isNumberRange { 227 | format = fmt.Sprintf("%%s%%0%dd%%s", len(begin)) 228 | } else { 229 | format = "%s%c%s" 230 | } 231 | 232 | for _, c := range chars { 233 | hosts = append(hosts, fmt.Sprintf(format, head, c, tail)) 234 | } 235 | 236 | var result []string 237 | for _, hostpattern := range hosts { 238 | newHosts, err := expandHostPattern(hostpattern) 239 | if err != nil { 240 | return nil, err 241 | } 242 | result = append(result, newHosts...) 243 | } 244 | 245 | return result, nil 246 | } 247 | 248 | func isRunesNumber(runes []rune) bool { 249 | for _, rune := range runes { 250 | if rune < '0' || rune > '9' { 251 | return false 252 | } 253 | } 254 | return true 255 | } 256 | 257 | // runesToInt turn runes into corresponding number, ex. '7' -> 7 258 | // should be called only on "number" runes! (see `isRunesNumber` function) 259 | func runesToInt(runes []rune) int { 260 | result := 0 261 | for i, rune := range runes { 262 | result += int((rune - '0')) * int(math.Pow10(len(runes)-1-i)) 263 | } 264 | return result 265 | } 266 | 267 | func makeRange(start, end, step int) []int { 268 | s := make([]int, 0, 1+(end-start)/step) 269 | for start <= end { 270 | s = append(s, start) 271 | start += step 272 | } 273 | return s 274 | } 275 | 276 | func find(a []int, x int) int { 277 | for i, n := range a { 278 | if x == n { 279 | return i 280 | } 281 | } 282 | return len(a) 283 | } 284 | -------------------------------------------------------------------------------- /test_data/group_vars/empty/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/relex/aini/b67a1e990856ac488c888cab9bfcda9fe62015df/test_data/group_vars/empty/.gitkeep -------------------------------------------------------------------------------- /test_data/group_vars/nginx.yml: -------------------------------------------------------------------------------- 1 | --- 2 | nginx_int_var: 1 3 | nginx_string_var: string 4 | nginx_bool_var: true 5 | nginx_object_var: 6 | this: 7 | is: object 8 | -------------------------------------------------------------------------------- /test_data/group_vars/tomcat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # File name's case doesn't match group name's case in inventory 3 | tomcat_string_var: string 4 | -------------------------------------------------------------------------------- /test_data/group_vars/web/any_vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This variable will be overwritten since the file is earlier in lexical order 3 | web_int_var: 0 4 | web_string_var: string1 5 | web_object_var: 6 | this: 7 | is: object? 8 | -------------------------------------------------------------------------------- /test_data/group_vars/web/junk_file.txt: -------------------------------------------------------------------------------- 1 | This file should not be read 2 | -------------------------------------------------------------------------------- /test_data/group_vars/web/some_vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | web_int_var: 1 3 | web_string_var: string 4 | web_object_var: 5 | this: 6 | is: object 7 | -------------------------------------------------------------------------------- /test_data/host_vars/empty/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/relex/aini/b67a1e990856ac488c888cab9bfcda9fe62015df/test_data/host_vars/empty/.gitkeep -------------------------------------------------------------------------------- /test_data/host_vars/host1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | host1_int_var: 1 3 | host1_string_var: string 4 | host1_object_var: 5 | this: 6 | is: object 7 | -------------------------------------------------------------------------------- /test_data/host_vars/host2/any_vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This variable will be overwritten since the file is earlier in lexical order 3 | host2_int_var: 0 4 | host2_string_var: string1 5 | host2_object_var: 6 | this: 7 | is: object? 8 | -------------------------------------------------------------------------------- /test_data/host_vars/host2/junk_file.txt: -------------------------------------------------------------------------------- 1 | This file should not be read 2 | -------------------------------------------------------------------------------- /test_data/host_vars/host2/some_file.yml: -------------------------------------------------------------------------------- 1 | --- 2 | host2_int_var: 1 3 | host2_string_var: string 4 | host2_object_var: 5 | this: 6 | is: object 7 | -------------------------------------------------------------------------------- /test_data/host_vars/host7.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # File name's case doesn't match group name's case in inventory 3 | host7_string_var: string 4 | -------------------------------------------------------------------------------- /test_data/inventory: -------------------------------------------------------------------------------- 1 | host5 2 | 3 | [web:children] 4 | nginx 5 | apache 6 | 7 | [web:vars] 8 | web_string_var=should be overwritten 9 | web_inventory_string_var=present 10 | 11 | [web] 12 | host1 13 | host2 14 | 15 | [nginx] 16 | host1 host1_string_var="should be overwritten" host1_inventory_string_var="present" 17 | host3 18 | host4 19 | 20 | [apache] 21 | host5 22 | host6 23 | 24 | [TomCat] 25 | Host7 26 | -------------------------------------------------------------------------------- /vars.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/fs" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // AddVars take a path that contains group_vars and host_vars directories 17 | // and adds these variables to the InventoryData 18 | func (inventory *InventoryData) AddVars(path string) error { 19 | return inventory.doAddVars(path, false) 20 | } 21 | 22 | // AddVarsLowerCased does the same as AddVars, but converts hostnames and groups name to lowercase. 23 | // Use this function if you've executed `inventory.HostsToLower` or `inventory.GroupsToLower` 24 | func (inventory *InventoryData) AddVarsLowerCased(path string) error { 25 | return inventory.doAddVars(path, true) 26 | } 27 | 28 | func (inventory *InventoryData) doAddVars(path string, lowercased bool) error { 29 | _, err := os.Stat(path) 30 | if err != nil { 31 | return err 32 | } 33 | walk(path, "group_vars", inventory.getGroupsMap(), lowercased) 34 | walk(path, "host_vars", inventory.getHostsMap(), lowercased) 35 | inventory.reconcileVars() 36 | return nil 37 | } 38 | 39 | type fileVarsGetter interface { 40 | getFileVars() map[string]string 41 | } 42 | 43 | func (host *Host) getFileVars() map[string]string { 44 | return host.FileVars 45 | } 46 | 47 | func (group *Group) getFileVars() map[string]string { 48 | return group.FileVars 49 | } 50 | 51 | func (inventory InventoryData) getHostsMap() map[string]fileVarsGetter { 52 | result := make(map[string]fileVarsGetter, len(inventory.Hosts)) 53 | for k, v := range inventory.Hosts { 54 | result[k] = v 55 | } 56 | return result 57 | } 58 | 59 | func (inventory InventoryData) getGroupsMap() map[string]fileVarsGetter { 60 | result := make(map[string]fileVarsGetter, len(inventory.Groups)) 61 | for k, v := range inventory.Groups { 62 | result[k] = v 63 | } 64 | return result 65 | } 66 | 67 | func walk(root string, subdir string, m map[string]fileVarsGetter, lowercased bool) error { 68 | path := filepath.Join(root, subdir) 69 | _, err := os.Stat(path) 70 | // If the dir doesn't exist we can just skip it 71 | if err != nil { 72 | return nil 73 | } 74 | f := getWalkerFn(path, m, lowercased) 75 | return filepath.WalkDir(path, f) 76 | } 77 | 78 | func getWalkerFn(root string, m map[string]fileVarsGetter, lowercased bool) fs.WalkDirFunc { 79 | var currentVars map[string]string 80 | return func(path string, d fs.DirEntry, err error) error { 81 | if err != nil { 82 | return nil 83 | } 84 | if filepath.Dir(path) == root { 85 | filename := filepath.Base(path) 86 | ext := filepath.Ext(path) 87 | itemName := strings.TrimSuffix(filename, ext) 88 | if lowercased { 89 | itemName = strings.ToLower(itemName) 90 | } 91 | if currentItem, ok := m[itemName]; ok { 92 | currentVars = currentItem.getFileVars() 93 | } else { 94 | return nil 95 | } 96 | } 97 | if d.IsDir() { 98 | return nil 99 | } 100 | return addVarsFromFile(currentVars, path) 101 | } 102 | } 103 | 104 | func addVarsFromFile(currentVars map[string]string, path string) error { 105 | if currentVars == nil { 106 | // Group or Host doesn't exist in the inventory, ignoring 107 | return nil 108 | } 109 | ext := filepath.Ext(path) 110 | if ext != ".yaml" && ext != ".yml" { 111 | return nil 112 | } 113 | f, err := ioutil.ReadFile(path) 114 | if err != nil { 115 | return err 116 | } 117 | vars := make(map[string]interface{}) 118 | err = yaml.Unmarshal(f, &vars) 119 | if err != nil { 120 | return err 121 | } 122 | for k, v := range vars { 123 | switch v := v.(type) { 124 | case string: 125 | currentVars[k] = v 126 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: 127 | currentVars[k] = fmt.Sprint(v) 128 | case bool: 129 | currentVars[k] = strconv.FormatBool(v) 130 | default: 131 | data, err := json.Marshal(v) 132 | if err != nil { 133 | return err 134 | } 135 | currentVars[k] = string(data) 136 | } 137 | } 138 | return nil 139 | } 140 | 141 | func (inventory *InventoryData) reconcileVars() { 142 | /* 143 | Priority of variables is defined here: https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#understanding-variable-precedence 144 | Distilled list looks like this: 145 | 1. inventory file group vars 146 | 2. group_vars/* 147 | 3. inventory file host vars 148 | 4. inventory host_vars/* 149 | */ 150 | for _, group := range inventory.Groups { 151 | group.AllInventoryVars = nil 152 | group.AllFileVars = nil 153 | } 154 | for _, group := range inventory.Groups { 155 | group.Vars = make(map[string]string) 156 | group.populateInventoryVars() 157 | group.populateFileVars() 158 | // At this point we already "populated" all parent's inventory and file vars 159 | // So it's fine to build Vars right away, without needing the second pass 160 | group.Vars = copyStringMap(group.AllInventoryVars) 161 | addValues(group.Vars, group.AllFileVars) 162 | } 163 | for _, host := range inventory.Hosts { 164 | host.Vars = make(map[string]string) 165 | for _, group := range GroupMapListValues(host.DirectGroups) { 166 | addValues(host.Vars, group.Vars) 167 | } 168 | addValues(host.Vars, host.InventoryVars) 169 | addValues(host.Vars, host.FileVars) 170 | } 171 | } 172 | 173 | func (group *Group) populateInventoryVars() { 174 | if group.AllInventoryVars != nil { 175 | return 176 | } 177 | group.AllInventoryVars = make(map[string]string) 178 | for _, parent := range GroupMapListValues(group.DirectParents) { 179 | parent.populateInventoryVars() 180 | addValues(group.AllInventoryVars, parent.AllInventoryVars) 181 | } 182 | addValues(group.AllInventoryVars, group.InventoryVars) 183 | } 184 | 185 | func (group *Group) populateFileVars() { 186 | if group.AllFileVars != nil { 187 | return 188 | } 189 | group.AllFileVars = make(map[string]string) 190 | for _, parent := range GroupMapListValues(group.DirectParents) { 191 | parent.populateFileVars() 192 | addValues(group.AllFileVars, parent.AllFileVars) 193 | } 194 | addValues(group.AllFileVars, group.FileVars) 195 | } 196 | -------------------------------------------------------------------------------- /vars_test.go: -------------------------------------------------------------------------------- 1 | package aini 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAddVars(t *testing.T) { 10 | v, err := ParseFile("test_data/inventory") 11 | assert.Nil(t, err) 12 | 13 | assert.Equal(t, "present", v.Groups["web"].Vars["web_inventory_string_var"]) 14 | assert.Equal(t, "should be overwritten", v.Groups["web"].Vars["web_string_var"]) 15 | 16 | assert.Equal(t, "present", v.Hosts["host1"].Vars["host1_inventory_string_var"]) 17 | assert.Equal(t, "should be overwritten", v.Hosts["host1"].Vars["host1_string_var"]) 18 | 19 | err = v.AddVars("test_data") 20 | assert.Nil(t, err) 21 | 22 | assert.Equal(t, "1", v.Groups["web"].Vars["web_int_var"]) 23 | assert.Equal(t, "string", v.Groups["web"].Vars["web_string_var"]) 24 | assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Groups["web"].Vars["web_object_var"]) 25 | assert.Equal(t, "present", v.Groups["web"].Vars["web_inventory_string_var"]) 26 | 27 | assert.Equal(t, "1", v.Groups["nginx"].Vars["nginx_int_var"]) 28 | assert.Equal(t, "string", v.Groups["nginx"].Vars["nginx_string_var"]) 29 | assert.Equal(t, "true", v.Groups["nginx"].Vars["nginx_bool_var"]) 30 | assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Groups["nginx"].Vars["nginx_object_var"]) 31 | 32 | assert.Equal(t, "1", v.Hosts["host1"].Vars["host1_int_var"]) 33 | assert.Equal(t, "string", v.Hosts["host1"].Vars["host1_string_var"]) 34 | assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Hosts["host1"].Vars["host1_object_var"]) 35 | assert.Equal(t, "present", v.Hosts["host1"].Vars["host1_inventory_string_var"]) 36 | 37 | assert.Equal(t, "1", v.Hosts["host2"].Vars["host2_int_var"]) 38 | assert.Equal(t, "string", v.Hosts["host2"].Vars["host2_string_var"]) 39 | assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Hosts["host2"].Vars["host2_object_var"]) 40 | 41 | assert.NotContains(t, v.Groups, "tomcat") 42 | assert.NotContains(t, v.Hosts, "host7") 43 | } 44 | 45 | func TestAddVarsLowerCased(t *testing.T) { 46 | v, err := ParseFile("test_data/inventory") 47 | assert.Nil(t, err) 48 | 49 | v.HostsToLower() 50 | v.GroupsToLower() 51 | v.AddVarsLowerCased("test_data") 52 | 53 | assert.Contains(t, v.Groups, "tomcat") 54 | assert.Contains(t, v.Hosts, "host7") 55 | assert.Equal(t, "string", v.Groups["tomcat"].Vars["tomcat_string_var"]) 56 | assert.Equal(t, "string", v.Hosts["host7"].Vars["host7_string_var"]) 57 | } 58 | --------------------------------------------------------------------------------