pax_global_header00006660000000000000000000000064144551723010014514gustar00rootroot0000000000000052 comment=0a9c58689cb331462e5f62781108becb26b0edf6 aini-1.6.0/000077500000000000000000000000001445517230100124405ustar00rootroot00000000000000aini-1.6.0/LICENSE000066400000000000000000000020341445517230100134440ustar00rootroot00000000000000Copyright (c) 2020 RELEX Oy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. aini-1.6.0/Makefile000066400000000000000000000006051445517230100141010ustar00rootroot00000000000000SOURCES ?= $(shell find . -name '*.go') SOURCES_NONTEST ?= $(shell find . -name '*.go' -not -name '*_test.go') .PHONY: test test: go test -timeout $${TEST_TIMEOUT:-10s} -v ./... # test-all ignores testcache (go clean testcache) .PHONY: test-all test-all: go test -timeout $${TEST_TIMEOUT:-10s} -v -count=1 ./... .PHONY: upgrade upgrade: rm -f go.sum go get -u -d ./...; go mod tidy aini-1.6.0/README.md000066400000000000000000000120531445517230100137200ustar00rootroot00000000000000# aini Go library for Parsing Ansible inventory files. We are trying to follow the logic of Ansible parser as close as possible. Documentation on ansible inventory files can be found here: https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html ## Supported features: - [X] Variables - [X] Host patterns - [X] Nested groups - [X] Load variables from `group_vars` and `host_vars` ## Public API ```godoc package aini // import "github.com/relex/aini" FUNCTIONS func MatchGroups(groups map[string]*Group, pattern string) (map[string]*Group, error) MatchGroups looks for groups that match the pattern func MatchHosts(hosts map[string]*Host, pattern string) (map[string]*Host, error) MatchHosts looks for hosts that match the pattern func MatchVars(vars map[string]string, pattern string) (map[string]string, error) MatchVars looks for vars that match the pattern TYPES type Group struct { Name string Vars map[string]string Hosts map[string]*Host Children map[string]*Group Parents map[string]*Group // Has unexported fields. } Group represents ansible group func GroupMapListValues(mymap map[string]*Group) []*Group GroupMapListValues transforms map of Groups into Group list in lexical order func (group *Group) MatchHosts(pattern string) (map[string]*Host, error) MatchHosts looks for hosts that match the pattern func (group *Group) MatchVars(pattern string) (map[string]string, error) MatchVars looks for vars that match the pattern func (group Group) String() string type Host struct { Name string Port int Vars map[string]string Groups map[string]*Group // Has unexported fields. } Host represents ansible host func HostMapListValues(mymap map[string]*Host) []*Host HostMapListValues transforms map of Hosts into Host list in lexical order func (host *Host) MatchGroups(pattern string) (map[string]*Group, error) MatchGroups looks for groups that match the pattern func (host *Host) MatchVars(pattern string) (map[string]string, error) MatchVars looks for vars that match the pattern func (host Host) String() string type InventoryData struct { Groups map[string]*Group Hosts map[string]*Host } InventoryData contains parsed inventory representation Note: Groups and Hosts fields contain all the groups and hosts, not only top-level func Parse(r io.Reader) (*InventoryData, error) Parse using some Reader func ParseFile(f string) (*InventoryData, error) ParseFile parses Inventory represented as a file func ParseString(input string) (*InventoryData, error) ParseString parses Inventory represented as a string func (inventory *InventoryData) AddVars(path string) error AddVars take a path that contains group_vars and host_vars directories and adds these variables to the InventoryData func (inventory *InventoryData) AddVarsLowerCased(path string) error AddVarsLowerCased does the same as AddVars, but converts hostnames and groups name to lowercase. Use this function if you've executed `inventory.HostsToLower` or `inventory.GroupsToLower` func (inventory *InventoryData) GroupsToLower() GroupsToLower transforms all group names to lowercase func (inventory *InventoryData) HostsToLower() HostsToLower transforms all host names to lowercase func (inventory *InventoryData) Match(pattern string) []*Host Match looks for hosts that match the pattern Deprecated: Use `MatchHosts`, which does proper error handling func (inventory *InventoryData) MatchGroups(pattern string) (map[string]*Group, error) MatchGroups looks for groups that match the pattern func (inventory *InventoryData) MatchHosts(pattern string) (map[string]*Host, error) MatchHosts looks for hosts that match the pattern func (inventory *InventoryData) Reconcile() Reconcile ensures inventory basic rules, run after updates. After initial inventory file processing, only direct relationships are set. This method: * (re)sets Children and Parents for hosts and groups * ensures that mandatory groups exist * calculates variables for hosts and groups ``` ## Usage example ```go import ( "strings" "github.com/relex/aini" ) func main() { // Load from string example inventoryReader := strings.NewReader(` host1:2221 [web] host2 ansible_ssh_user=root `) var inventory InventoryData = aini.Parse(inventoryReader) // Querying hosts _ = inventory.Hosts["host1"].Name == "host1" // true _ = inventory.Hosts["host1"].Port == 2221 // true _ = inventory.Hosts["host2"].Name == "host2"] // true _ = inventory.Hosts["host2"].Post == 22] // true _ = len(inventory.Hosts["host1"].Groups) == 2 // all, ungrouped _ = len(inventory.Hosts["host2"].Groups) == 2 // all, web _ = len(inventory.Match("host*")) == 2 // host1, host2 _ = // Querying groups _ = inventory.Groups["web"].Hosts[0].Name == "host2" // true _ = len(inventory.Groups["all"].Hosts) == 2 // true } ``` aini-1.6.0/aini.go000066400000000000000000000103101445517230100137020ustar00rootroot00000000000000package aini import ( "bufio" "bytes" "io" "os" "path" "sort" "strings" ) // InventoryData contains parsed inventory representation // Note: Groups and Hosts fields contain all the groups and hosts, not only top-level type InventoryData struct { Groups map[string]*Group Hosts map[string]*Host } // Group represents ansible group type Group struct { Name string Vars map[string]string Hosts map[string]*Host Children map[string]*Group Parents map[string]*Group DirectParents map[string]*Group // Vars set in inventory InventoryVars map[string]string // Vars set in group_vars FileVars map[string]string // Projection of all parent inventory variables AllInventoryVars map[string]string // Projection of all parent group_vars variables AllFileVars map[string]string } // Host represents ansible host type Host struct { Name string Port int Vars map[string]string Groups map[string]*Group DirectGroups map[string]*Group // Vars set in inventory InventoryVars map[string]string // Vars set in host_vars FileVars map[string]string } // ParseFile parses Inventory represented as a file func ParseFile(f string) (*InventoryData, error) { bs, err := os.ReadFile(f) if err != nil { return &InventoryData{}, err } return Parse(bytes.NewReader(bs)) } // ParseString parses Inventory represented as a string func ParseString(input string) (*InventoryData, error) { return Parse(strings.NewReader(input)) } // Parse using some Reader func Parse(r io.Reader) (*InventoryData, error) { input := bufio.NewReader(r) inventory := &InventoryData{} err := inventory.parse(input) if err != nil { return inventory, err } inventory.Reconcile() return inventory, nil } // Match looks for hosts that match the pattern // Deprecated: Use `MatchHosts`, which does proper error handling func (inventory *InventoryData) Match(pattern string) []*Host { matchedHosts := make([]*Host, 0) for _, host := range inventory.Hosts { if m, err := path.Match(pattern, host.Name); err == nil && m { matchedHosts = append(matchedHosts, host) } } return matchedHosts } // GroupMapListValues transforms map of Groups into Group list in lexical order func GroupMapListValues(mymap map[string]*Group) []*Group { values := make([]*Group, len(mymap)) i := 0 for _, v := range mymap { values[i] = v i++ } sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) return values } // HostMapListValues transforms map of Hosts into Host list in lexical order func HostMapListValues(mymap map[string]*Host) []*Host { values := make([]*Host, len(mymap)) i := 0 for _, v := range mymap { values[i] = v i++ } sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) return values } // HostsToLower transforms all host names to lowercase func (inventory *InventoryData) HostsToLower() { inventory.Hosts = hostMapToLower(inventory.Hosts, false) for _, group := range inventory.Groups { group.Hosts = hostMapToLower(group.Hosts, true) } } func hostMapToLower(hosts map[string]*Host, keysOnly bool) map[string]*Host { newHosts := make(map[string]*Host, len(hosts)) for hostname, host := range hosts { hostname = strings.ToLower(hostname) if !keysOnly { host.Name = hostname } newHosts[hostname] = host } return newHosts } // GroupsToLower transforms all group names to lowercase func (inventory *InventoryData) GroupsToLower() { inventory.Groups = groupMapToLower(inventory.Groups, false) for _, host := range inventory.Hosts { host.DirectGroups = groupMapToLower(host.DirectGroups, true) host.Groups = groupMapToLower(host.Groups, true) } } func (group Group) String() string { return group.Name } func (host Host) String() string { return host.Name } func groupMapToLower(groups map[string]*Group, keysOnly bool) map[string]*Group { newGroups := make(map[string]*Group, len(groups)) for groupname, group := range groups { groupname = strings.ToLower(groupname) if !keysOnly { group.Name = groupname group.DirectParents = groupMapToLower(group.DirectParents, true) group.Parents = groupMapToLower(group.Parents, true) group.Children = groupMapToLower(group.Children, true) } newGroups[groupname] = group } return newGroups } aini-1.6.0/aini_test.go000066400000000000000000000302261445517230100147510ustar00rootroot00000000000000package aini import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func parseString(t *testing.T, input string) *InventoryData { v, err := ParseString(input) assert.Nil(t, err, fmt.Sprintf("Error occurred while parsing: %s", err)) return v } func TestBelongToBasicGroups(t *testing.T) { v := parseString(t, ` host1:2221 # Comments [web] # should host2 # be # ignored `) assert.Len(t, v.Hosts, 2, "Exactly two hosts expected") assert.Len(t, v.Groups, 3, "Expected three groups: web, all and ungrouped") assert.Contains(t, v.Groups, "web") assert.Contains(t, v.Groups, "all") assert.Contains(t, v.Groups, "ungrouped") assert.Contains(t, v.Hosts, "host1") assert.Len(t, v.Hosts["host1"].Groups, 2, "Host1 must belong to two groups: ungrouped and all") assert.NotNil(t, 2, v.Hosts["host1"].Groups["all"], "Host1 must belong to two groups: ungrouped and all") assert.NotNil(t, 2, v.Hosts["host1"].Groups["ungrouped"], "Host1 must belong to ungrouped group") assert.Contains(t, v.Hosts, "host2") assert.Len(t, v.Hosts["host2"].Groups, 2, "Host2 must belong to two groups: ungrouped and all") assert.NotNil(t, 2, v.Hosts["host2"].Groups["all"], "Host2 must belong to two groups: ungrouped and all") assert.NotNil(t, 2, v.Hosts["host2"].Groups["ungrouped"], "Host2 must belong to ungrouped group") assert.Equal(t, 2, len(v.Groups["all"].Hosts), "Group all must contain two hosts") assert.Contains(t, v.Groups["all"].Hosts, "host1") assert.Contains(t, v.Groups["all"].Hosts, "host2") assert.Len(t, v.Groups["web"].Hosts, 1, "Group web must contain one host") assert.Contains(t, v.Groups["web"].Hosts, "host2") assert.Len(t, v.Groups["ungrouped"].Hosts, 1, "Group ungrouped must contain one host") assert.Contains(t, v.Groups["ungrouped"].Hosts, "host1") assert.NotContains(t, v.Groups["ungrouped"].Hosts, "host2") assert.Equal(t, 2221, v.Hosts["host1"].Port, "Host1 port is set") assert.Equal(t, 22, v.Hosts["host2"].Port, "Host2 port is set") } func TestGroupStructure(t *testing.T) { v := parseString(t, ` host5 [web:children] nginx apache [web] host1 host2 [nginx] host1 host3 host4 [apache] host5 host6 `) assert.Contains(t, v.Groups, "web") assert.Contains(t, v.Groups, "apache") assert.Contains(t, v.Groups, "nginx") assert.Contains(t, v.Groups, "all") assert.Contains(t, v.Groups, "ungrouped") assert.Len(t, v.Groups, 5, "Five groups must be present: web, apache, nginx, all, ungrouped") assert.Contains(t, v.Groups["web"].Children, "nginx") assert.Contains(t, v.Groups["web"].Children, "apache") assert.Contains(t, v.Groups["nginx"].Parents, "web") assert.Contains(t, v.Groups["apache"].Parents, "web") assert.Contains(t, v.Groups["web"].Hosts, "host1") assert.Contains(t, v.Groups["web"].Hosts, "host2") assert.Contains(t, v.Groups["web"].Hosts, "host3") assert.Contains(t, v.Groups["web"].Hosts, "host4") assert.Contains(t, v.Groups["web"].Hosts, "host5") assert.Contains(t, v.Groups["nginx"].Hosts, "host1") assert.Contains(t, v.Hosts["host1"].Groups, "web") assert.Contains(t, v.Hosts["host1"].Groups, "nginx") assert.Empty(t, v.Groups["ungrouped"].Hosts) } func TestGroupNotExplicitlyDefined(t *testing.T) { v := parseString(t, ` [web:children] nginx [nginx] host1 `) assert.Contains(t, v.Groups, "web") assert.Contains(t, v.Groups, "nginx") assert.Contains(t, v.Groups, "all") assert.Contains(t, v.Groups, "ungrouped") assert.Len(t, v.Groups, 4, "Four groups must present: web, nginx, all, ungrouped") assert.Contains(t, v.Groups["web"].Children, "nginx") assert.Contains(t, v.Groups["nginx"].Parents, "web") assert.Contains(t, v.Groups["web"].Hosts, "host1") assert.Contains(t, v.Groups["nginx"].Hosts, "host1") assert.Contains(t, v.Hosts["host1"].Groups, "web") assert.Contains(t, v.Hosts["host1"].Groups, "nginx") assert.Empty(t, v.Groups["ungrouped"].Hosts, "Group ungrouped should be empty") } func TestAllGroup(t *testing.T) { v := parseString(t, ` host7 host5 [web:children] nginx apache [web] host1 host2 [nginx] host1 host3 host4 [apache] host5 host6 `) allGroup := v.Groups["all"] assert.NotNil(t, allGroup) assert.Empty(t, allGroup.Parents) assert.NotContains(t, allGroup.Children, "all") assert.Len(t, allGroup.Children, 4) assert.Len(t, allGroup.Hosts, 7) for _, group := range v.Groups { if group.Name == "all" { continue } assert.Contains(t, allGroup.Children, group.Name) assert.Contains(t, group.Parents, allGroup.Name) } for _, host := range v.Hosts { assert.Contains(t, allGroup.Hosts, host.Name) assert.Contains(t, host.Groups, allGroup.Name) } } func TestHostExpansionFullNumericPattern(t *testing.T) { v := parseString(t, ` host-[001:015:3]-web:23 `) assert.Contains(t, v.Hosts, "host-001-web") assert.Contains(t, v.Hosts, "host-004-web") assert.Contains(t, v.Hosts, "host-007-web") assert.Contains(t, v.Hosts, "host-010-web") assert.Contains(t, v.Hosts, "host-013-web") assert.Len(t, v.Hosts, 5) for _, host := range v.Hosts { assert.Equalf(t, 23, host.Port, "%s port is set", host.Name) } } func TestHostExpansionFullAlphabeticPattern(t *testing.T) { v := parseString(t, ` host-[a:o:3]-web `) assert.Contains(t, v.Hosts, "host-a-web") assert.Contains(t, v.Hosts, "host-d-web") assert.Contains(t, v.Hosts, "host-g-web") assert.Contains(t, v.Hosts, "host-j-web") assert.Contains(t, v.Hosts, "host-m-web") assert.Len(t, v.Hosts, 5) } func TestHostExpansionShortNumericPattern(t *testing.T) { v := parseString(t, ` host-[:05]-web `) assert.Contains(t, v.Hosts, "host-00-web") assert.Contains(t, v.Hosts, "host-01-web") assert.Contains(t, v.Hosts, "host-02-web") assert.Contains(t, v.Hosts, "host-03-web") assert.Contains(t, v.Hosts, "host-04-web") assert.Contains(t, v.Hosts, "host-05-web") assert.Len(t, v.Hosts, 6) } func TestHostExpansionShortAlphabeticPattern(t *testing.T) { v := parseString(t, ` host-[a:c]-web `) assert.Contains(t, v.Hosts, "host-a-web") assert.Contains(t, v.Hosts, "host-b-web") assert.Contains(t, v.Hosts, "host-c-web") assert.Len(t, v.Hosts, 3) } func TestHostExpansionMultiplePatterns(t *testing.T) { v := parseString(t, ` host-[1:2]-[a:b]-web `) assert.Contains(t, v.Hosts, "host-1-a-web") assert.Contains(t, v.Hosts, "host-1-b-web") assert.Contains(t, v.Hosts, "host-2-a-web") assert.Contains(t, v.Hosts, "host-2-b-web") assert.Len(t, v.Hosts, 4) } func TestVariablesPriority(t *testing.T) { v := parseString(t, ` host-ungrouped-with-x x=a host-ungrouped [web] host-web x=b [web:vars] x=c [web:children] nginx [nginx:vars] x=d [nginx] host-nginx host-nginx-with-x x=e [all:vars] x=f `) assert.Equal(t, "a", v.Hosts["host-ungrouped-with-x"].Vars["x"]) assert.Equal(t, "b", v.Hosts["host-web"].Vars["x"]) assert.Equal(t, "c", v.Groups["web"].Vars["x"]) assert.Equal(t, "d", v.Hosts["host-nginx"].Vars["x"]) assert.Equal(t, "e", v.Hosts["host-nginx-with-x"].Vars["x"]) assert.Equal(t, "f", v.Hosts["host-ungrouped"].Vars["x"]) } func TestHostsToLower(t *testing.T) { v := parseString(t, ` CatFish [web:children] TomCat [TomCat] TomCat tomcat-1 cat `) assert.Contains(t, v.Hosts, "CatFish") assert.Contains(t, v.Groups["ungrouped"].Hosts, "CatFish") assert.Contains(t, v.Hosts, "TomCat") v.HostsToLower() assert.NotContains(t, v.Hosts, "CatFish") assert.Contains(t, v.Hosts, "catfish") assert.Equal(t, "catfish", v.Hosts["catfish"].Name, "Host catfish should have a matching name") assert.NotContains(t, v.Hosts, "TomCat") assert.Contains(t, v.Hosts, "tomcat") assert.Equal(t, "tomcat", v.Hosts["tomcat"].Name, "Host tomcat should have a matching name") assert.NotContains(t, v.Groups["ungrouped"].Hosts, "CatFish") assert.Contains(t, v.Groups["ungrouped"].Hosts, "catfish") assert.NotContains(t, v.Groups["web"].Hosts, "TomCat") assert.Contains(t, v.Groups["web"].Hosts, "tomcat") } func TestGroupsToLower(t *testing.T) { v := parseString(t, ` [Web] CatFish [Web:children] TomCat [TomCat] TomCat tomcat-1 cat `) assert.Contains(t, v.Groups, "Web") assert.Contains(t, v.Groups, "TomCat") v.GroupsToLower() assert.NotContains(t, v.Groups, "Web") assert.NotContains(t, v.Groups, "TomCat") assert.Contains(t, v.Groups, "web") assert.Contains(t, v.Groups, "tomcat") assert.Equal(t, "web", v.Groups["web"].Name, "Group web should have matching name") assert.Contains(t, v.Groups["web"].Children, "tomcat") assert.Contains(t, v.Groups["web"].Hosts, "TomCat") assert.Equal(t, "tomcat", v.Groups["tomcat"].Name, "Group tomcat should have matching name") assert.Contains(t, v.Groups["tomcat"].Hosts, "TomCat") assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat-1") assert.Contains(t, v.Groups["tomcat"].Hosts, "cat") } func TestGroupsAndHostsToLower(t *testing.T) { v := parseString(t, ` [Web] CatFish [Web:children] TomCat [TomCat] TomCat tomcat-1 `) assert.Contains(t, v.Groups, "Web") assert.Contains(t, v.Groups, "TomCat") assert.Contains(t, v.Hosts, "CatFish") assert.Contains(t, v.Hosts, "TomCat") assert.Contains(t, v.Hosts, "tomcat-1") v.GroupsToLower() v.HostsToLower() assert.NotContains(t, v.Groups, "Web") assert.NotContains(t, v.Groups, "TomCat") assert.Contains(t, v.Groups, "web") assert.Contains(t, v.Groups, "tomcat") assert.NotContains(t, v.Hosts, "CatFish") assert.NotContains(t, v.Hosts, "TomCat") assert.Contains(t, v.Hosts, "catfish") assert.Contains(t, v.Hosts, "tomcat") assert.Contains(t, v.Hosts, "tomcat-1") assert.Contains(t, v.Groups["web"].Hosts, "catfish") assert.Contains(t, v.Groups["web"].Children, "tomcat") assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat") assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat-1") } func TestGroupLoops(t *testing.T) { v := parseString(t, ` [group1] host1 [group1:children] group2 [group2:children] group1 `) assert.Contains(t, v.Groups, "group1") assert.Contains(t, v.Groups, "group2") assert.Contains(t, v.Groups["group1"].Parents, "all") assert.Contains(t, v.Groups["group1"].Parents, "group2") assert.NotContains(t, v.Groups["group1"].Parents, "group1") assert.Len(t, v.Groups["group1"].Parents, 2) assert.Contains(t, v.Groups["group2"].Parents, "group1") } func TestVariablesEscaping(t *testing.T) { v := parseString(t, ` host ansible_ssh_common_args="-o ProxyCommand='ssh -W %h:%p somehost'" other_var_same_value="-o ProxyCommand='ssh -W %h:%p somehost'" # comment `) assert.Contains(t, v.Hosts, "host") assert.Equal(t, "-o ProxyCommand='ssh -W %h:%p somehost'", v.Hosts["host"].Vars["ansible_ssh_common_args"]) assert.Equal(t, "-o ProxyCommand='ssh -W %h:%p somehost'", v.Hosts["host"].Vars["other_var_same_value"]) } func TestComments(t *testing.T) { v := parseString(t, ` catfish # I'm a comment # Whole-line comment [web:children] # Look, there is a cat in comment! tomcat # This is a group! # Whole-line comment with a leading space [tomcat] # And here is another cat 🐈 tomcat # Host comment tomcat-1 # Small indention comment cat # Big indention comment `) assert.Contains(t, v.Groups, "web") assert.Contains(t, v.Groups, "tomcat") assert.Contains(t, v.Groups["web"].Children, "tomcat") assert.Contains(t, v.Hosts, "tomcat") assert.Contains(t, v.Hosts, "tomcat-1") assert.Contains(t, v.Hosts, "cat") assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat") assert.Contains(t, v.Groups["tomcat"].Hosts, "tomcat-1") assert.Contains(t, v.Groups["tomcat"].Hosts, "cat") assert.Contains(t, v.Hosts, "catfish") assert.Contains(t, v.Groups["ungrouped"].Hosts, "catfish") } func TestHostMatching(t *testing.T) { v := parseString(t, ` catfish [web:children] # Look, there is a cat in comment! tomcat # This is a group! [tomcat] # And here is another cat 🐈 tomcat tomcat-1 cat `) hosts := v.Match("*cat*") assert.Len(t, hosts, 4) } func TestHostMapListValues(t *testing.T) { v := parseString(t, ` host1 host2 host3 `) hosts := HostMapListValues(v.Hosts) assert.Len(t, hosts, 3) for _, v := range hosts { assert.Contains(t, hosts, v) } } func TestGroupMapListValues(t *testing.T) { v := parseString(t, ` [group1] [group2] [group3] `) groups := GroupMapListValues(v.Groups) assert.Len(t, groups, 5) for _, v := range groups { assert.Contains(t, groups, v) } } aini-1.6.0/go.mod000066400000000000000000000005551445517230100135530ustar00rootroot00000000000000module github.com/relex/aini go 1.20 require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/samber/lo v1.38.1 github.com/stretchr/testify v1.7.0 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect ) aini-1.6.0/go.sum000066400000000000000000000032001445517230100135660ustar00rootroot00000000000000github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= aini-1.6.0/inventory.go000066400000000000000000000102301445517230100150200ustar00rootroot00000000000000package aini // Inventory-related helper methods // Reconcile ensures inventory basic rules, run after updates. // After initial inventory file processing, only direct relationships are set. // // This method: // * (re)sets Children and Parents for hosts and groups // * ensures that mandatory groups exist // * calculates variables for hosts and groups func (inventory *InventoryData) Reconcile() { // Clear all computed data for _, host := range inventory.Hosts { host.clearData() } // a group can be empty (with no hosts in it), so the previous method will not clean it // on the other hand, a group could have been attached to a host by a user, but not added to the inventory.Groups map // so it's safer just to clean everything for _, group := range inventory.Groups { group.clearData(make(map[string]struct{}, len(inventory.Groups))) } allGroup := inventory.getOrCreateGroup("all") ungroupedGroup := inventory.getOrCreateGroup("ungrouped") ungroupedGroup.DirectParents[allGroup.Name] = allGroup // First, ensure that inventory.Groups contains all the groups for _, host := range inventory.Hosts { for _, group := range host.DirectGroups { inventory.Groups[group.Name] = group for _, ancestor := range group.ListParentGroupsOrdered() { inventory.Groups[ancestor.Name] = ancestor } } } // Calculate intergroup relationships for _, group := range inventory.Groups { group.DirectParents[allGroup.Name] = allGroup for _, ancestor := range group.ListParentGroupsOrdered() { group.Parents[ancestor.Name] = ancestor ancestor.Children[group.Name] = group } } // Now set hosts for groups and groups for hosts for _, host := range inventory.Hosts { host.Groups[allGroup.Name] = allGroup for _, group := range host.DirectGroups { group.Hosts[host.Name] = host host.Groups[group.Name] = group for _, parent := range group.Parents { group.Parents[parent.Name] = parent parent.Children[group.Name] = group parent.Hosts[host.Name] = host host.Groups[parent.Name] = parent } } } inventory.reconcileVars() } func (host *Host) clearData() { host.Groups = make(map[string]*Group) host.Vars = make(map[string]string) for _, group := range host.DirectGroups { group.clearData(make(map[string]struct{}, len(host.Groups))) } } func (group *Group) clearData(visited map[string]struct{}) { if _, ok := visited[group.Name]; ok { return } group.Hosts = make(map[string]*Host) group.Parents = make(map[string]*Group) group.Children = make(map[string]*Group) group.Vars = make(map[string]string) group.AllInventoryVars = nil group.AllFileVars = nil visited[group.Name] = struct{}{} for _, parent := range group.DirectParents { parent.clearData(visited) } } // getOrCreateGroup return group from inventory if exists or creates empty Group with given name func (inventory *InventoryData) getOrCreateGroup(groupName string) *Group { if group, ok := inventory.Groups[groupName]; ok { return group } g := &Group{ Name: groupName, Hosts: make(map[string]*Host), Vars: make(map[string]string), Children: make(map[string]*Group), Parents: make(map[string]*Group), DirectParents: make(map[string]*Group), InventoryVars: make(map[string]string), FileVars: make(map[string]string), } inventory.Groups[groupName] = g return g } // getOrCreateHost return host from inventory if exists or creates empty Host with given name func (inventory *InventoryData) getOrCreateHost(hostName string) *Host { if host, ok := inventory.Hosts[hostName]; ok { return host } h := &Host{ Name: hostName, Port: 22, Groups: make(map[string]*Group), Vars: make(map[string]string), DirectGroups: make(map[string]*Group), InventoryVars: make(map[string]string), FileVars: make(map[string]string), } inventory.Hosts[hostName] = h return h } // addValues fills `to` map with values from `from` map func addValues(to map[string]string, from map[string]string) { for k, v := range from { to[k] = v } } // copyStringMap creates a non-deep copy of the map func copyStringMap(from map[string]string) map[string]string { result := make(map[string]string, len(from)) addValues(result, from) return result } aini-1.6.0/marshal.go000066400000000000000000000047501445517230100144240ustar00rootroot00000000000000package aini import ( "encoding/json" "github.com/samber/lo" "golang.org/x/exp/maps" ) type alwaysNil interface{} // to hold place for Group and Host references; must be nil in serialized form func (group *Group) MarshalJSON() ([]byte, error) { type groupWithoutCustomMarshal Group return json.Marshal(&struct { groupWithoutCustomMarshal Hosts map[string]alwaysNil Children map[string]alwaysNil Parents map[string]alwaysNil DirectParents map[string]alwaysNil }{ groupWithoutCustomMarshal: groupWithoutCustomMarshal(*group), Hosts: makeNilValueMap(group.Hosts), Children: makeNilValueMap(group.Children), Parents: makeNilValueMap(group.Parents), DirectParents: makeNilValueMap(group.DirectParents), }) } func (host *Host) MarshalJSON() ([]byte, error) { type hostWithoutCustomMarshal Host return json.Marshal(&struct { hostWithoutCustomMarshal Groups map[string]alwaysNil DirectGroups map[string]alwaysNil }{ hostWithoutCustomMarshal: hostWithoutCustomMarshal(*host), Groups: makeNilValueMap(host.Groups), DirectGroups: makeNilValueMap(host.DirectGroups), }) } func makeNilValueMap[K comparable, V any](m map[K]*V) map[K]alwaysNil { return lo.MapValues(m, func(_ *V, _ K) alwaysNil { return nil }) } func (inventory *InventoryData) UnmarshalJSON(data []byte) error { type inventoryWithoutCustomUnmarshal InventoryData var rawInventory inventoryWithoutCustomUnmarshal if err := json.Unmarshal(data, &rawInventory); err != nil { return err } // rawInventory's Groups and Hosts should now contain all properties, // except child group maps and host maps are filled with original keys and null values // reassign child groups and hosts to reference rawInventory.Hosts and .Groups for _, group := range rawInventory.Groups { group.Hosts = lo.PickByKeys(rawInventory.Hosts, maps.Keys(group.Hosts)) group.Children = lo.PickByKeys(rawInventory.Groups, maps.Keys(group.Children)) group.Parents = lo.PickByKeys(rawInventory.Groups, maps.Keys(group.Parents)) group.DirectParents = lo.PickByKeys(rawInventory.Groups, maps.Keys(group.DirectParents)) } for _, host := range rawInventory.Hosts { host.Groups = lo.PickByKeys(rawInventory.Groups, maps.Keys(host.Groups)) host.DirectGroups = lo.PickByKeys(rawInventory.Groups, maps.Keys(host.DirectGroups)) } inventory.Groups = rawInventory.Groups inventory.Hosts = rawInventory.Hosts return nil } aini-1.6.0/marshal_test.go000066400000000000000000000022001445517230100154470ustar00rootroot00000000000000package aini import ( _ "embed" "encoding/json" "testing" "github.com/stretchr/testify/assert" ) const minMarshalInventory = `[Animals] ET [Animals:children] Cats [Cats] Lion ` //go:embed marshal_test_inventory.json var minMarshalJSON string func TestMarshalJSON(t *testing.T) { v, err := ParseString(minMarshalInventory) assert.Nil(t, err) j, err := json.MarshalIndent(v, "", " ") assert.Nil(t, err) assert.Equal(t, minMarshalJSON, string(j)) t.Run("unmarshal", func(t *testing.T) { var v2 InventoryData assert.Nil(t, json.Unmarshal(j, &v2)) assert.Equal(t, v.Hosts["Lion"], v2.Hosts["Lion"]) assert.Equal(t, v.Groups["Cats"], v2.Groups["Cats"]) }) } func TestMarshalWithVars(t *testing.T) { v, err := ParseFile("test_data/inventory") assert.Nil(t, err) v.HostsToLower() v.GroupsToLower() v.AddVarsLowerCased("test_data") j, err := json.MarshalIndent(v, "", " ") assert.Nil(t, err) t.Run("unmarshal", func(t *testing.T) { var v2 InventoryData assert.Nil(t, json.Unmarshal(j, &v2)) assert.Equal(t, v.Hosts["host1"], v2.Hosts["host1"]) assert.Equal(t, v.Groups["tomcat"], v2.Groups["tomcat"]) }) } aini-1.6.0/marshal_test_inventory.json000066400000000000000000000052611445517230100201420ustar00rootroot00000000000000{ "Groups": { "Animals": { "Name": "Animals", "Vars": {}, "InventoryVars": {}, "FileVars": {}, "AllInventoryVars": {}, "AllFileVars": {}, "Hosts": { "ET": null, "Lion": null }, "Children": { "Cats": null }, "Parents": { "all": null }, "DirectParents": { "all": null } }, "Cats": { "Name": "Cats", "Vars": {}, "InventoryVars": {}, "FileVars": {}, "AllInventoryVars": {}, "AllFileVars": {}, "Hosts": { "Lion": null }, "Children": {}, "Parents": { "Animals": null, "all": null }, "DirectParents": { "Animals": null, "all": null } }, "all": { "Name": "all", "Vars": {}, "InventoryVars": {}, "FileVars": {}, "AllInventoryVars": {}, "AllFileVars": {}, "Hosts": { "ET": null, "Lion": null }, "Children": { "Animals": null, "Cats": null, "ungrouped": null }, "Parents": {}, "DirectParents": { "all": null } }, "ungrouped": { "Name": "ungrouped", "Vars": {}, "InventoryVars": {}, "FileVars": {}, "AllInventoryVars": {}, "AllFileVars": {}, "Hosts": {}, "Children": {}, "Parents": { "all": null }, "DirectParents": { "all": null } } }, "Hosts": { "ET": { "Name": "ET", "Port": 22, "Vars": {}, "InventoryVars": {}, "FileVars": {}, "Groups": { "Animals": null, "all": null }, "DirectGroups": { "Animals": null } }, "Lion": { "Name": "Lion", "Port": 22, "Vars": {}, "InventoryVars": {}, "FileVars": {}, "Groups": { "Animals": null, "Cats": null, "all": null }, "DirectGroups": { "Cats": null } } } }aini-1.6.0/match.go000066400000000000000000000042141445517230100140640ustar00rootroot00000000000000package aini import "path" // MatchHosts looks for hosts that match the pattern func (inventory *InventoryData) MatchHosts(pattern string) (map[string]*Host, error) { return MatchHosts(inventory.Hosts, pattern) } // MatchHosts looks for hosts that match the pattern func (group *Group) MatchHosts(pattern string) (map[string]*Host, error) { return MatchHosts(group.Hosts, pattern) } // MatchHosts looks for hosts that match the pattern func MatchHosts(hosts map[string]*Host, pattern string) (map[string]*Host, error) { matchedHosts := make(map[string]*Host) for _, host := range hosts { m, err := path.Match(pattern, host.Name) if err != nil { return nil, err } if m { matchedHosts[host.Name] = host } } return matchedHosts, nil } // MatchGroups looks for groups that match the pattern func (inventory *InventoryData) MatchGroups(pattern string) (map[string]*Group, error) { return MatchGroups(inventory.Groups, pattern) } // MatchGroups looks for groups that match the pattern func (host *Host) MatchGroups(pattern string) (map[string]*Group, error) { return MatchGroups(host.Groups, pattern) } // MatchGroups looks for groups that match the pattern func MatchGroups(groups map[string]*Group, pattern string) (map[string]*Group, error) { matchedGroups := make(map[string]*Group) for _, group := range groups { m, err := path.Match(pattern, group.Name) if err != nil { return nil, err } if m { matchedGroups[group.Name] = group } } return matchedGroups, nil } // MatchVars looks for vars that match the pattern func (group *Group) MatchVars(pattern string) (map[string]string, error) { return MatchVars(group.Vars, pattern) } // MatchVars looks for vars that match the pattern func (host *Host) MatchVars(pattern string) (map[string]string, error) { return MatchVars(host.Vars, pattern) } // MatchVars looks for vars that match the pattern func MatchVars(vars map[string]string, pattern string) (map[string]string, error) { matchedVars := make(map[string]string) for k, v := range vars { m, err := path.Match(pattern, v) if err != nil { return nil, err } if m { matchedVars[k] = v } } return matchedVars, nil } aini-1.6.0/match_test.go000066400000000000000000000035571445517230100151340ustar00rootroot00000000000000package aini import ( "testing" "github.com/stretchr/testify/assert" ) func TestGroupsMatching(t *testing.T) { v := parseString(t, ` host1 host2 [myGroup1] host1 [myGroup2] host1 [groupForCats] host1 `) groups, err := v.MatchGroups("*Group*") assert.Nil(t, err) assert.Contains(t, groups, "myGroup1") assert.Contains(t, groups, "myGroup2") assert.Len(t, groups, 2) groups, err = v.Hosts["host1"].MatchGroups("*Group*") assert.Nil(t, err) assert.Contains(t, groups, "myGroup1") assert.Contains(t, groups, "myGroup2") assert.Len(t, groups, 2) } func TestHostsMatching(t *testing.T) { v := parseString(t, ` myHost1 otherHost2 [group1] myHost1 [group2] myHost1 myHost2 `) hosts, err := v.MatchHosts("my*") assert.Nil(t, err) assert.Contains(t, hosts, "myHost1") assert.Contains(t, hosts, "myHost2") assert.Len(t, hosts, 2) hosts, err = v.Groups["group1"].MatchHosts("*my*") assert.Nil(t, err) assert.Contains(t, hosts, "myHost1") assert.Len(t, hosts, 1) hosts, err = v.Groups["group2"].MatchHosts("*my*") assert.Nil(t, err) assert.Contains(t, hosts, "myHost1") assert.Contains(t, hosts, "myHost2") assert.Len(t, hosts, 2) } func TestVarsMatching(t *testing.T) { v := parseString(t, ` host1 myHostVar=myHostVarValue otherHostVar=otherHostVarValue [group1] host1 [group1:vars] myGroupVar=myGroupVarValue otherGroupVar=otherGroupVarValue `) group := v.Groups["group1"] vars, err := group.MatchVars("my*") assert.Nil(t, err) assert.Contains(t, vars, "myGroupVar") assert.Len(t, vars, 1) assert.Equal(t, "myGroupVarValue", vars["myGroupVar"]) host := v.Hosts["host1"] vars, err = host.MatchVars("my*") assert.Nil(t, err) assert.Contains(t, vars, "myHostVar") assert.Contains(t, vars, "myGroupVar") assert.Len(t, vars, 2) assert.Equal(t, "myHostVarValue", vars["myHostVar"]) assert.Equal(t, "myGroupVarValue", vars["myGroupVar"]) } aini-1.6.0/ordered.go000066400000000000000000000045421445517230100144200ustar00rootroot00000000000000package aini import ( "path" ) // MatchGroupsOrdered looks for groups that match the pattern // The result is a sorted array, where lower indexes corespond to more specific groups func (host *Host) MatchGroupsOrdered(pattern string) ([]*Group, error) { matchedGroups := make([]*Group, 0) groups := host.ListGroupsOrdered() for _, group := range groups { m, err := path.Match(pattern, group.Name) if err != nil { return nil, err } if m { matchedGroups = append(matchedGroups, group) } } return matchedGroups, nil } // MatchGroupsOrdered looks for groups that match the pattern // The result is a sorted array, where lower indexes corespond to more specific groups func (group *Group) MatchGroupsOrdered(pattern string) ([]*Group, error) { matchedGroups := make([]*Group, 0) groups := group.ListParentGroupsOrdered() for _, group := range groups { m, err := path.Match(pattern, group.Name) if err != nil { return nil, err } if m { matchedGroups = append(matchedGroups, group) } } return matchedGroups, nil } // ListGroupsOrdered returns all ancestor groups of a given host in level order func (host *Host) ListGroupsOrdered() []*Group { return listAncestorsOrdered(host.DirectGroups, nil, true) } // ListParentGroupsOrdered returns all ancestor groups of a given group in level order func (group *Group) ListParentGroupsOrdered() []*Group { visited := map[string]struct{}{group.Name: {}} return listAncestorsOrdered(group.DirectParents, visited, group.Name != "all") } // listAncestorsOrdered returns all ancestor groups of a given group map in level order func listAncestorsOrdered(groups map[string]*Group, visited map[string]struct{}, appendAll bool) []*Group { result := make([]*Group, 0) if visited == nil { visited = map[string]struct{}{} } var allGroup *Group for queue := GroupMapListValues(groups); len(queue) > 0; func() { copy(queue, queue[1:]) queue = queue[:len(queue)-1] }() { group := queue[0] // The all group should always be the last one if group.Name == "all" { allGroup = group continue } if _, ok := visited[group.Name]; ok { continue } visited[group.Name] = struct{}{} parentList := GroupMapListValues(group.DirectParents) result = append(result, group) queue = append(queue, parentList...) } if allGroup != nil && appendAll { result = append(result, allGroup) } return result } aini-1.6.0/ordered_test.go000066400000000000000000000030431445517230100154520ustar00rootroot00000000000000package aini import ( "testing" "github.com/stretchr/testify/assert" ) func TestListAncestorsOrdered(t *testing.T) { v := parseString(t, ` host1 [notMyGroup3] [myGroup2] [myGroup1] host1 [myGroup2:children] myGroup1 [notMyGroup3:children] myGroup2 `) host1 := v.Hosts["host1"] assert.NotNil(t, host1) assert.Len(t, host1.Groups, 4) groups := host1.ListGroupsOrdered() assert.Len(t, groups, 4) assert.Equal(t, groups[0].Name, "myGroup1") assert.Equal(t, groups[1].Name, "myGroup2") assert.Equal(t, groups[2].Name, "notMyGroup3") assert.Equal(t, groups[3].Name, "all") group1 := v.Groups["myGroup1"] assert.NotNil(t, group1) groups = group1.ListParentGroupsOrdered() assert.NotNil(t, groups) assert.Len(t, groups, 3) assert.Equal(t, groups[0].Name, "myGroup2") assert.Equal(t, groups[1].Name, "notMyGroup3") assert.Equal(t, groups[2].Name, "all") } func TestMatchGroupsOrdered(t *testing.T) { v := parseString(t, ` host1 [notMyGroup3] [myGroup2] [myGroup1] host1 [myGroup2:children] myGroup1 [notMyGroup3:children] myGroup2 `) host1 := v.Hosts["host1"] assert.NotNil(t, host1) assert.Len(t, host1.Groups, 4) groups, err := host1.MatchGroupsOrdered("my*") assert.Nil(t, err) assert.Len(t, groups, 2) assert.Equal(t, groups[0].Name, "myGroup1") assert.Equal(t, groups[1].Name, "myGroup2") group1 := v.Groups["myGroup1"] assert.NotNil(t, group1) groups, err = group1.MatchGroupsOrdered("my*") assert.Nil(t, err) assert.NotNil(t, groups) assert.Len(t, groups, 1) assert.Equal(t, groups[0].Name, "myGroup2") } aini-1.6.0/parser.go000066400000000000000000000155431445517230100142730ustar00rootroot00000000000000package aini import ( "bufio" "fmt" "math" "regexp" "strconv" "strings" "github.com/google/shlex" ) // state enum type state int const ( hostsState state = 0 childrenState state = 1 varsState state = 2 ) func getState(str string) (state, bool) { var result state var ok bool = true if str == "" || str == "hosts" { result = hostsState } else if str == "children" { result = childrenState } else if str == "vars" { result = varsState } else { ok = false } return result, ok } // state enum end // parser performs parsing of inventory file from some Reader func (inventory *InventoryData) parse(reader *bufio.Reader) error { // This regexp is copy-pasted from ansible sources sectionRegex := regexp.MustCompile(`^\[([^:\]\s]+)(?::(\w+))?\]\s*(?:\#.*)?$`) scanner := bufio.NewScanner(reader) inventory.Groups = make(map[string]*Group) inventory.Hosts = make(map[string]*Host) activeState := hostsState activeGroup := inventory.getOrCreateGroup("ungrouped") for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") || line == "" { continue } matches := sectionRegex.FindAllStringSubmatch(line, -1) if matches != nil { activeGroup = inventory.getOrCreateGroup(matches[0][1]) var ok bool if activeState, ok = getState(matches[0][2]); !ok { return fmt.Errorf("section [%s] has unknown type: %s", line, matches[0][2]) } continue } else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { return fmt.Errorf("invalid section entry: '%s'. Make sure that there are no spaces or other characters in the section entry", line) } if activeState == hostsState { hosts, err := inventory.getHosts(line, activeGroup) if err != nil { return err } for _, host := range hosts { host.DirectGroups[activeGroup.Name] = activeGroup inventory.Hosts[host.Name] = host if activeGroup.Name != "ungrouped" { delete(host.DirectGroups, "ungrouped") } } } if activeState == childrenState { parsed, err := shlex.Split(line) if err != nil { return err } groupName := parsed[0] newGroup := inventory.getOrCreateGroup(groupName) newGroup.DirectParents[activeGroup.Name] = activeGroup inventory.Groups[line] = newGroup } if activeState == varsState { k, v, err := splitKV(line) if err != nil { return err } activeGroup.InventoryVars[k] = v } } inventory.Groups[activeGroup.Name] = activeGroup return nil } // getHosts parses given "host" line from inventory func (inventory *InventoryData) getHosts(line string, group *Group) (map[string]*Host, error) { parts, err := shlex.Split(line) if err != nil { return nil, err } hostpattern, port, err := getHostPort(parts[0]) if err != nil { return nil, err } hostnames, err := expandHostPattern(hostpattern) if err != nil { return nil, err } result := make(map[string]*Host, len(hostnames)) for _, hostname := range hostnames { params := parts[1:] vars := make(map[string]string, len(params)) for _, param := range params { k, v, err := splitKV(param) if err != nil { return nil, err } vars[k] = v } host := inventory.getOrCreateHost(hostname) host.Port = port host.DirectGroups[group.Name] = group addValues(host.InventoryVars, vars) result[host.Name] = host } return result, nil } // splitKV splits `key=value` into two string: key and value func splitKV(kv string) (string, string, error) { keyval := strings.SplitN(kv, "=", 2) if len(keyval) == 1 { return "", "", fmt.Errorf("bad key=value pair supplied: %s", kv) } return strings.TrimSpace(keyval[0]), strings.TrimSpace(keyval[1]), nil } // getHostPort splits string like `host-[a:b]-c:22` into `host-[a:b]-c` and `22` func getHostPort(str string) (string, int, error) { port := 22 parts := strings.Split(str, ":") if len(parts) == 1 { return str, port, nil } lastPart := parts[len(parts)-1] if strings.Contains(lastPart, "]") { // We are in expand pattern, so no port were specified return str, port, nil } port, err := strconv.Atoi(lastPart) return strings.Join(parts[:len(parts)-1], ":"), port, err } // expandHostPattern turns `host-[a:b]-c` into a flat list of hosts func expandHostPattern(hostpattern string) ([]string, error) { lbrac := strings.Replace(hostpattern, "[", "|", 1) rbrac := strings.Replace(lbrac, "]", "|", 1) parts := strings.Split(rbrac, "|") if len(parts) == 1 { // No pattern detected return []string{hostpattern}, nil } if len(parts) != 3 { return nil, fmt.Errorf("wrong host pattern: %s", hostpattern) } head, nrange, tail := parts[0], parts[1], parts[2] bounds := strings.Split(nrange, ":") if len(bounds) < 2 || len(bounds) > 3 { return nil, fmt.Errorf("wrong host pattern: %s", hostpattern) } var begin, end []rune var step = 1 if len(bounds) == 3 { step, _ = strconv.Atoi(bounds[2]) } end = []rune(bounds[1]) if bounds[0] == "" { if isRunesNumber(end) { format := fmt.Sprintf("%%0%dd", len(end)) begin = []rune(fmt.Sprintf(format, 0)) } else { return nil, fmt.Errorf("skipping range start in not allowed with alphabetical range: %s", hostpattern) } } else { begin = []rune(bounds[0]) } var chars []int isNumberRange := false if isRunesNumber(begin) && isRunesNumber(end) { chars = makeRange(runesToInt(begin), runesToInt(end), step) isNumberRange = true } else if !isRunesNumber(begin) && !isRunesNumber(end) && len(begin) == 1 && len(end) == 1 { dict := append(makeRange('a', 'z', 1), makeRange('A', 'Z', 1)...) chars = makeRange( find(dict, int(begin[0])), find(dict, int(end[0])), step, ) for i, c := range chars { chars[i] = dict[c] } } if len(chars) == 0 { return nil, fmt.Errorf("bad range specified: %s", nrange) } var hosts []string var format string if isNumberRange { format = fmt.Sprintf("%%s%%0%dd%%s", len(begin)) } else { format = "%s%c%s" } for _, c := range chars { hosts = append(hosts, fmt.Sprintf(format, head, c, tail)) } var result []string for _, hostpattern := range hosts { newHosts, err := expandHostPattern(hostpattern) if err != nil { return nil, err } result = append(result, newHosts...) } return result, nil } func isRunesNumber(runes []rune) bool { for _, rune := range runes { if rune < '0' || rune > '9' { return false } } return true } // runesToInt turn runes into corresponding number, ex. '7' -> 7 // should be called only on "number" runes! (see `isRunesNumber` function) func runesToInt(runes []rune) int { result := 0 for i, rune := range runes { result += int((rune - '0')) * int(math.Pow10(len(runes)-1-i)) } return result } func makeRange(start, end, step int) []int { s := make([]int, 0, 1+(end-start)/step) for start <= end { s = append(s, start) start += step } return s } func find(a []int, x int) int { for i, n := range a { if x == n { return i } } return len(a) } aini-1.6.0/test_data/000077500000000000000000000000001445517230100144105ustar00rootroot00000000000000aini-1.6.0/test_data/group_vars/000077500000000000000000000000001445517230100165775ustar00rootroot00000000000000aini-1.6.0/test_data/group_vars/empty/000077500000000000000000000000001445517230100177355ustar00rootroot00000000000000aini-1.6.0/test_data/group_vars/empty/.gitkeep000066400000000000000000000000001445517230100213540ustar00rootroot00000000000000aini-1.6.0/test_data/group_vars/nginx.yml000066400000000000000000000001541445517230100204450ustar00rootroot00000000000000--- nginx_int_var: 1 nginx_string_var: string nginx_bool_var: true nginx_object_var: this: is: object aini-1.6.0/test_data/group_vars/tomcat.yml000066400000000000000000000001361445517230100206110ustar00rootroot00000000000000--- # File name's case doesn't match group name's case in inventory tomcat_string_var: string aini-1.6.0/test_data/group_vars/web/000077500000000000000000000000001445517230100173545ustar00rootroot00000000000000aini-1.6.0/test_data/group_vars/web/any_vars.yml000066400000000000000000000002421445517230100217170ustar00rootroot00000000000000--- # This variable will be overwritten since the file is earlier in lexical order web_int_var: 0 web_string_var: string1 web_object_var: this: is: object? aini-1.6.0/test_data/group_vars/web/junk_file.txt000066400000000000000000000000351445517230100220610ustar00rootroot00000000000000This file should not be read aini-1.6.0/test_data/group_vars/web/some_vars.yml000066400000000000000000000001211445517230100220670ustar00rootroot00000000000000--- web_int_var: 1 web_string_var: string web_object_var: this: is: object aini-1.6.0/test_data/host_vars/000077500000000000000000000000001445517230100164205ustar00rootroot00000000000000aini-1.6.0/test_data/host_vars/empty/000077500000000000000000000000001445517230100175565ustar00rootroot00000000000000aini-1.6.0/test_data/host_vars/empty/.gitkeep000066400000000000000000000000001445517230100211750ustar00rootroot00000000000000aini-1.6.0/test_data/host_vars/host1.yml000066400000000000000000000001271445517230100202010ustar00rootroot00000000000000--- host1_int_var: 1 host1_string_var: string host1_object_var: this: is: object aini-1.6.0/test_data/host_vars/host2/000077500000000000000000000000001445517230100174575ustar00rootroot00000000000000aini-1.6.0/test_data/host_vars/host2/any_vars.yml000066400000000000000000000002501445517230100220210ustar00rootroot00000000000000--- # This variable will be overwritten since the file is earlier in lexical order host2_int_var: 0 host2_string_var: string1 host2_object_var: this: is: object? aini-1.6.0/test_data/host_vars/host2/junk_file.txt000066400000000000000000000000351445517230100221640ustar00rootroot00000000000000This file should not be read aini-1.6.0/test_data/host_vars/host2/some_file.yml000066400000000000000000000001271445517230100221440ustar00rootroot00000000000000--- host2_int_var: 1 host2_string_var: string host2_object_var: this: is: object aini-1.6.0/test_data/host_vars/host7.yml000066400000000000000000000001351445517230100202060ustar00rootroot00000000000000--- # File name's case doesn't match group name's case in inventory host7_string_var: string aini-1.6.0/test_data/inventory000066400000000000000000000004271445517230100163730ustar00rootroot00000000000000host5 [web:children] nginx apache [web:vars] web_string_var=should be overwritten web_inventory_string_var=present [web] host1 host2 [nginx] host1 host1_string_var="should be overwritten" host1_inventory_string_var="present" host3 host4 [apache] host5 host6 [TomCat] Host7 aini-1.6.0/vars.go000066400000000000000000000121331445517230100137420ustar00rootroot00000000000000package aini import ( "encoding/json" "fmt" "io/fs" "io/ioutil" "os" "path/filepath" "strconv" "strings" "gopkg.in/yaml.v3" ) // AddVars take a path that contains group_vars and host_vars directories // and adds these variables to the InventoryData func (inventory *InventoryData) AddVars(path string) error { return inventory.doAddVars(path, false) } // AddVarsLowerCased does the same as AddVars, but converts hostnames and groups name to lowercase. // Use this function if you've executed `inventory.HostsToLower` or `inventory.GroupsToLower` func (inventory *InventoryData) AddVarsLowerCased(path string) error { return inventory.doAddVars(path, true) } func (inventory *InventoryData) doAddVars(path string, lowercased bool) error { _, err := os.Stat(path) if err != nil { return err } walk(path, "group_vars", inventory.getGroupsMap(), lowercased) walk(path, "host_vars", inventory.getHostsMap(), lowercased) inventory.reconcileVars() return nil } type fileVarsGetter interface { getFileVars() map[string]string } func (host *Host) getFileVars() map[string]string { return host.FileVars } func (group *Group) getFileVars() map[string]string { return group.FileVars } func (inventory InventoryData) getHostsMap() map[string]fileVarsGetter { result := make(map[string]fileVarsGetter, len(inventory.Hosts)) for k, v := range inventory.Hosts { result[k] = v } return result } func (inventory InventoryData) getGroupsMap() map[string]fileVarsGetter { result := make(map[string]fileVarsGetter, len(inventory.Groups)) for k, v := range inventory.Groups { result[k] = v } return result } func walk(root string, subdir string, m map[string]fileVarsGetter, lowercased bool) error { path := filepath.Join(root, subdir) _, err := os.Stat(path) // If the dir doesn't exist we can just skip it if err != nil { return nil } f := getWalkerFn(path, m, lowercased) return filepath.WalkDir(path, f) } func getWalkerFn(root string, m map[string]fileVarsGetter, lowercased bool) fs.WalkDirFunc { var currentVars map[string]string return func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if filepath.Dir(path) == root { filename := filepath.Base(path) ext := filepath.Ext(path) itemName := strings.TrimSuffix(filename, ext) if lowercased { itemName = strings.ToLower(itemName) } if currentItem, ok := m[itemName]; ok { currentVars = currentItem.getFileVars() } else { return nil } } if d.IsDir() { return nil } return addVarsFromFile(currentVars, path) } } func addVarsFromFile(currentVars map[string]string, path string) error { if currentVars == nil { // Group or Host doesn't exist in the inventory, ignoring return nil } ext := filepath.Ext(path) if ext != ".yaml" && ext != ".yml" { return nil } f, err := ioutil.ReadFile(path) if err != nil { return err } vars := make(map[string]interface{}) err = yaml.Unmarshal(f, &vars) if err != nil { return err } for k, v := range vars { switch v := v.(type) { case string: currentVars[k] = v case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: currentVars[k] = fmt.Sprint(v) case bool: currentVars[k] = strconv.FormatBool(v) default: data, err := json.Marshal(v) if err != nil { return err } currentVars[k] = string(data) } } return nil } func (inventory *InventoryData) reconcileVars() { /* Priority of variables is defined here: https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#understanding-variable-precedence Distilled list looks like this: 1. inventory file group vars 2. group_vars/* 3. inventory file host vars 4. inventory host_vars/* */ for _, group := range inventory.Groups { group.AllInventoryVars = nil group.AllFileVars = nil } for _, group := range inventory.Groups { group.Vars = make(map[string]string) group.populateInventoryVars() group.populateFileVars() // At this point we already "populated" all parent's inventory and file vars // So it's fine to build Vars right away, without needing the second pass group.Vars = copyStringMap(group.AllInventoryVars) addValues(group.Vars, group.AllFileVars) } for _, host := range inventory.Hosts { host.Vars = make(map[string]string) for _, group := range GroupMapListValues(host.DirectGroups) { addValues(host.Vars, group.Vars) } addValues(host.Vars, host.InventoryVars) addValues(host.Vars, host.FileVars) } } func (group *Group) populateInventoryVars() { if group.AllInventoryVars != nil { return } group.AllInventoryVars = make(map[string]string) for _, parent := range GroupMapListValues(group.DirectParents) { parent.populateInventoryVars() addValues(group.AllInventoryVars, parent.AllInventoryVars) } addValues(group.AllInventoryVars, group.InventoryVars) } func (group *Group) populateFileVars() { if group.AllFileVars != nil { return } group.AllFileVars = make(map[string]string) for _, parent := range GroupMapListValues(group.DirectParents) { parent.populateFileVars() addValues(group.AllFileVars, parent.AllFileVars) } addValues(group.AllFileVars, group.FileVars) } aini-1.6.0/vars_test.go000066400000000000000000000041731445517230100150060ustar00rootroot00000000000000package aini import ( "testing" "github.com/stretchr/testify/assert" ) func TestAddVars(t *testing.T) { v, err := ParseFile("test_data/inventory") assert.Nil(t, err) assert.Equal(t, "present", v.Groups["web"].Vars["web_inventory_string_var"]) assert.Equal(t, "should be overwritten", v.Groups["web"].Vars["web_string_var"]) assert.Equal(t, "present", v.Hosts["host1"].Vars["host1_inventory_string_var"]) assert.Equal(t, "should be overwritten", v.Hosts["host1"].Vars["host1_string_var"]) err = v.AddVars("test_data") assert.Nil(t, err) assert.Equal(t, "1", v.Groups["web"].Vars["web_int_var"]) assert.Equal(t, "string", v.Groups["web"].Vars["web_string_var"]) assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Groups["web"].Vars["web_object_var"]) assert.Equal(t, "present", v.Groups["web"].Vars["web_inventory_string_var"]) assert.Equal(t, "1", v.Groups["nginx"].Vars["nginx_int_var"]) assert.Equal(t, "string", v.Groups["nginx"].Vars["nginx_string_var"]) assert.Equal(t, "true", v.Groups["nginx"].Vars["nginx_bool_var"]) assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Groups["nginx"].Vars["nginx_object_var"]) assert.Equal(t, "1", v.Hosts["host1"].Vars["host1_int_var"]) assert.Equal(t, "string", v.Hosts["host1"].Vars["host1_string_var"]) assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Hosts["host1"].Vars["host1_object_var"]) assert.Equal(t, "present", v.Hosts["host1"].Vars["host1_inventory_string_var"]) assert.Equal(t, "1", v.Hosts["host2"].Vars["host2_int_var"]) assert.Equal(t, "string", v.Hosts["host2"].Vars["host2_string_var"]) assert.Equal(t, "{\"this\":{\"is\":\"object\"}}", v.Hosts["host2"].Vars["host2_object_var"]) assert.NotContains(t, v.Groups, "tomcat") assert.NotContains(t, v.Hosts, "host7") } func TestAddVarsLowerCased(t *testing.T) { v, err := ParseFile("test_data/inventory") assert.Nil(t, err) v.HostsToLower() v.GroupsToLower() v.AddVarsLowerCased("test_data") assert.Contains(t, v.Groups, "tomcat") assert.Contains(t, v.Hosts, "host7") assert.Equal(t, "string", v.Groups["tomcat"].Vars["tomcat_string_var"]) assert.Equal(t, "string", v.Hosts["host7"].Vars["host7_string_var"]) }