{"action":"created","comment":{"url":"https://api.github.com/repos/purpleidea/mgmt/pulls/comments/176905721","pull_request_review_id":106694917,"id":176905721,"diff_hunk":"@@ -0,0 +1,849 @@\n+// Mgmt\n+// Copyright (C) 2013-2018+ James Shubin and the project contributors\n+// Written by James Shubin <james@shubin.ca> and the project contributors\n+//\n+// This program is free software: you can redistribute it and/or modify\n+// it under the terms of the GNU General Public License as published by\n+// the Free Software Foundation, either version 3 of the License, or\n+// (at your option) any later version.\n+//\n+// This program is distributed in the hope that it will be useful,\n+// but WITHOUT ANY WARRANTY; without even the implied warranty of\n+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n+// GNU General Public License for more details.\n+//\n+// You should have received a copy of the GNU General Public License\n+// along with this program.  If not, see <http://www.gnu.org/licenses/>.\n+\n+package resources\n+\n+import (\n+\t\"bytes\"\n+\t\"fmt\"\n+\t\"io/ioutil\"\n+\t\"log\"\n+\t\"net\"\n+\t\"os\"\n+\t\"path\"\n+\t\"sort\"\n+\t\"strings\"\n+\t\"sync\"\n+\t\"syscall\"\n+\n+\t\"golang.org/x/sys/unix\"\n+\n+\t\"github.com/purpleidea/mgmt/recwatch\"\n+\n+\tmultierr \"github.com/hashicorp/go-multierror\"\n+\terrwrap \"github.com/pkg/errors\"\n+\t// XXX: Do NOT use subscribe methods from this lib, as they are racey and\n+\t// do not clean up spawned goroutines. Should be replaced when a suitable\n+\t// alternative is available.\n+\t\"github.com/vishvananda/netlink\"\n+)\n+\n+func init() {\n+\tRegisterResource(\"net\", func() Res { return &NetRes{} })\n+}\n+\n+const (\n+\t// ifaceUp is the up (on) interface state.\n+\tifaceUp = \"up\"\n+\t// ifaceDown is the down (off) interface state.\n+\tifaceDown = \"down\"\n+\n+\t// netlink multicast groups to watch for events\n+\trtmGrpLink        = 0x1   // interface create/delete/up/down\n+\trtmGrpIPv4IfAddr  = 0x10  // add/delete IPv4 addresses\n+\trtmGrpIPv6IfAddr  = 0x100 // add/delete IPv6 addresses\n+\trtmGrpIPv4IfRoute = 0x40  // add delete routes\n+\n+\t// ip routing protocols for used for netlink route messages\n+\trtProtoKernel = 2 // kernel\n+\trtProtoStatic = 4 // static\n+\n+\t// networkdUnitFileDir is the location of networkd unit files which define\n+\t// the systemd network connections.\n+\tnetworkdUnitFileDir = \"/etc/systemd/network/\"\n+\t// IfacePrefix is the prefix used to identify unit files for managed links.\n+\tIfacePrefix = \"mgmt-\"\n+\t// networkdUnitFileExt is the file extension for networkd unit files.\n+\tnetworkdUnitFileExt = \".network\"\n+)\n+\n+// NetRes is an systemd-networkd resource.\n+type NetRes struct {\n+\tBaseRes `yaml:\",inline\"`\n+\tState   string   `yaml:\"state\"`   // up, down, or empty\n+\tAddrs   []string `yaml:\"addrs\"`   // list of addresses in cidr format\n+\tGateway string   `yaml:\"gateway\"` // gateway address\n+\n+\tiface        *iface // a struct containing the net.Interface and netlink.Link\n+\tunitFilePath string // the interface unit file path\n+\t// XXX: replace TempDir with VarDir\n+\ttempDir string // temporary directory for storing the pipe socket file\n+}\n+\n+// nlChanStruct defines the channel used to send netlink messages and errors\n+// to the event processing loop in Watch.\n+type nlChanStruct struct {\n+\tmsg []syscall.NetlinkMessage\n+\terr error\n+}\n+\n+// iface wraps net.Interface to add additional methods.\n+type iface struct {\n+\t*net.Interface\n+\tlink netlink.Link\n+}\n+\n+// Default returns some sensible defaults for this resource.\n+func (obj *NetRes) Default() Res {\n+\treturn &NetRes{\n+\t\tBaseRes: BaseRes{\n+\t\t\tMetaParams: DefaultMetaParams, // force a default\n+\t\t},\n+\t}\n+}\n+\n+// Validate if the params passed in are valid data.\n+func (obj *NetRes) Validate() error {\n+\t// validate state\n+\tif obj.State != ifaceUp && obj.State != ifaceDown && obj.State != \"\" {\n+\t\treturn fmt.Errorf(\"state must be up down or empty\")\n+\t}\n+\n+\t// validate network address input\n+\tif (obj.Addrs == nil) != (obj.Gateway == \"\") {\n+\t\treturn fmt.Errorf(\"addrs and gateway must both be set or both be empty\")\n+\t}\n+\tif obj.Addrs != nil {\n+\t\tfor _, addr := range obj.Addrs {\n+\t\t\tif _, _, err := net.ParseCIDR(addr); err != nil {\n+\t\t\t\treturn errwrap.Wrapf(err, \"error parsing address: %s\", addr)\n+\t\t\t}\n+\t\t}\n+\t}\n+\tif obj.Gateway != \"\" {\n+\t\tif g := net.ParseIP(obj.Gateway); g == nil {\n+\t\t\treturn fmt.Errorf(\"error parsing gateway: %s\", obj.Gateway)\n+\t\t}\n+\t}\n+\n+\t// validate the interface name\n+\t_, err := net.InterfaceByName(obj.GetName())\n+\tif err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error finding interface: %s\", obj.GetName())\n+\t}\n+\n+\treturn obj.BaseRes.Validate()\n+}\n+\n+// Init runs some startup code for this resource.\n+func (obj *NetRes) Init() error {\n+\tvar err error\n+\t// tmp directory for pipe socket\n+\t// XXX: Replace with obj.VarDir\n+\tif obj.tempDir, err = ioutil.TempDir(\"\", \"pipe\"); err != nil {\n+\t\treturn errwrap.Wrapf(err, \"could not get TempDir\")\n+\t}\n+\n+\t// store the network interface in the struct\n+\tobj.iface = &iface{}\n+\tif obj.iface.Interface, err = net.InterfaceByName(obj.GetName()); err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error finding interface: %s\", obj.GetName())\n+\t}\n+\t// store the netlink link to use as interface input in netlink functions\n+\tif obj.iface.link, err = netlink.LinkByName(obj.GetName()); err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error finding link: %s\", obj.GetName())\n+\t}\n+\n+\t// build the path to the networkd configuration file\n+\tobj.unitFilePath = networkdUnitFileDir + IfacePrefix + obj.GetName() + networkdUnitFileExt\n+\n+\treturn obj.BaseRes.Init()\n+}\n+\n+// Watch listens for events from the specified interface via a netlink socket.\n+// XXX: currently gets events from ALL interfaces, would be nice to reject\n+// events from other interfaces.\n+func (obj *NetRes) Watch() error {\n+\t// waitgroup for netlink receive goroutine\n+\twg := &sync.WaitGroup{}\n+\tdefer wg.Wait()\n+\n+\t// channel for netlink messages\n+\tnlChan := make(chan *nlChanStruct) // closed from goroutine\n+\n+\t// create a pipe socket to unblock unix.Select when we close\n+\tpipe, err := unix.Socket(unix.AF_UNIX, unix.SOCK_RAW, unix.PROT_NONE)\n+\tif err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error creating pipe socket\")\n+\t}\n+\t// bind the pipe to a file\n+\tif err = unix.Bind(pipe, &unix.SockaddrUnix{\n+\t\tName: path.Join(obj.tempDir, \"pipe.sock\"),\n+\t}); err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error binding pipe socket\")\n+\t} // closed when the goroutine returns to avoid race with sendto below\n+\n+\t// unblock select if it didn't catch the interrupt\n+\tdefer unix.Sendto(pipe, nil, 0, &unix.SockaddrUnix{\n+\t\tName: path.Join(obj.tempDir, \"pipe.sock\"),\n+\t})\n+\n+\t// make a netlink socket file descriptor\n+\tnlSock, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW, unix.NETLINK_ROUTE)\n+\tif err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error creating netlink socket\")\n+\t}\n+\t// bind to the socket and add add the netlink groups we need to get events\n+\tif err := unix.Bind(nlSock, &unix.SockaddrNetlink{\n+\t\tFamily: unix.AF_NETLINK,\n+\t\tGroups: rtmGrpLink | rtmGrpIPv4IfAddr | rtmGrpIPv4IfRoute | rtmGrpIPv6IfAddr, // 0x1 | 0x10 | 0x40 | 0x100\n+\t}); err != nil {\n+\t\treturn errwrap.Wrapf(err, \"error binding netlink socket\")\n+\t}\n+\tdefer unix.Close(nlSock)\n+\tdefer unix.Shutdown(nlSock, unix.SHUT_RDWR) // probably unnecessary, but can't hurt\n+\n+\t// watch the systemd-networkd configuration file\n+\trecWatcher, err := recwatch.NewRecWatcher(obj.unitFilePath, false)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\t// close the recwatcher when we're done\n+\tdefer recWatcher.Close()\n+\n+\t// channel to unblock selects in goroutine\n+\tcloseChan := make(chan struct{})\n+\tdefer close(closeChan)\n+\n+\twg.Add(1)\n+\tgo func() {\n+\t\tdefer wg.Done()\n+\t\tdefer unix.Close(pipe)\n+\t\tdefer close(nlChan)\n+\t\tfor {\n+\t\t\t// At the beginning of every loop, it is necessary to recreate the\n+\t\t\t// file descriptor set to use as the read argument for select.\n+\t\t\tfdSet := &unix.FdSet{}\n+\t\t\tfdSetAdd(fdSet, pipe)\n+\t\t\tfdSetAdd(fdSet, nlSock)\n+\t\t\t// Select will return when any fd in fdSet (nlChan and pipe) is\n+\t\t\t// ready to read. The first argument (nfds) is set to the highest\n+\t\t\t// numbered file descriptor + 1. This function also changes fdSet\n+\t\t\t// to reflect which fd caused it to return, which is why it needs\n+\t\t\t// to be re-initialized at the top of each loop.\n+\t\t\t_, err := unix.Select(nlSock+1, fdSet, nil, nil, nil)\n+\t\t\tif err != nil {\n+\t\t\t\t// if a system interrupt is caught\n+\t\t\t\tif err == unix.EINTR { // signal interrupt\n+\t\t\t\t\treturn\n+\t\t\t\t}\n+\t\t\t\tselect {\n+\t\t\t\tcase nlChan <- &nlChanStruct{\n+\t\t\t\t\terr: errwrap.Wrapf(err, \"error selecting on fd\"),\n+\t\t\t\t}:\n+\t\t\t\tcase <-closeChan:\n+\t\t\t\t}\n+\t\t\t\treturn\n+\t\t\t}\n+\t\t\t// receive the message from the netlink socket into b\n+\t\t\tb := make([]byte, os.Getpagesize())\n+\t\t\tn, _, err := unix.Recvfrom(nlSock, b, unix.MSG_DONTWAIT) // non-blocking receive\n+\t\t\tif err != nil {\n+\t\t\t\t// if nlSock is closed\n+\t\t\t\tif err == unix.EBADF { // bad file descriptor\n+\t\t\t\t\treturn\n+\t\t\t\t}\n+\t\t\t\tselect {\n+\t\t\t\tcase nlChan <- &nlChanStruct{\n+\t\t\t\t\terr: errwrap.Wrapf(err, \"error receiving messages\"),\n+\t\t\t\t}:\n+\t\t\t\tcase <-closeChan:\n+\t\t\t\t}\n+\t\t\t\treturn\n+\t\t\t}\n+\t\t\t// if we didn't get enough bytes for a header, something went wrong\n+\t\t\tif n < unix.NLMSG_HDRLEN {\n+\t\t\t\tselect {\n+\t\t\t\tcase nlChan <- &nlChanStruct{\n+\t\t\t\t\terr: fmt.Errorf(\"received short header\"),\n+\t\t\t\t}:\n+\t\t\t\tcase <-closeChan:\n+\t\t\t\t}\n+\t\t\t\treturn\n+\t\t\t}\n+\t\t\tb = b[:n] // truncate b to message length\n+\t\t\t// use syscall to parse, as func does not exist in x/sys/unix\n+\t\t\tmsgs, err := syscall.ParseNetlinkMessage(b)\n+\t\t\tif err != nil {\n+\t\t\t\tselect {\n+\t\t\t\tcase nlChan <- &nlChanStruct{\n+\t\t\t\t\terr: errwrap.Wrapf(err, \"error parsing netlink message\"),\n+\t\t\t\t}:\n+\t\t\t\tcase <-closeChan:\n+\t\t\t\t\treturn\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tselect {\n+\t\t\tcase nlChan <- &nlChanStruct{\n+\t\t\t\tmsg: msgs,\n+\t\t\t}:\n+\t\t\tcase <-closeChan:\n+\t\t\t\treturn\n+\t\t\t}\n+\t\t}\n+\t}()\n+\n+\t// notify engine that we're running\n+\tif err := obj.Running(); err != nil {\n+\t\treturn err // bubble up a NACK...\n+\t}\n+\n+\tvar exit *error\n+\tvar send bool\n+\tvar done bool\n+\n+\tfor {\n+\t\tselect {\n+\t\tcase s, ok := <-nlChan:\n+\t\t\tif !ok {\n+\t\t\t\tif done {\n+\t\t\t\t\treturn nil\n+\t\t\t\t}\n+\t\t\t\tdone = true\n+\t\t\t\tcontinue\n+\t\t\t}\n+\t\t\tif err := s.err; err != nil {\n+\t\t\t\treturn errwrap.Wrapf(s.err, \"unknown netlink error\")\n+\t\t\t}\n+\t\t\tif obj.debug {\n+\t\t\t\tlog.Printf(\"%s: Event: %+v\", obj, s.msg)\n+\t\t\t}\n+\n+\t\t\tsend = true\n+\t\t\tobj.StateOK(false)\n+\n+\t\tcase event, ok := <-recWatcher.Events():\n+\t\t\tif !ok {\n+\t\t\t\tif done {\n+\t\t\t\t\treturn nil\n+\t\t\t\t}\n+\t\t\t\tdone = true\n+\t\t\t\tcontinue\n+\t\t\t}\n+\t\t\tif err := event.Error; err != nil {\n+\t\t\t\treturn errwrap.Wrapf(err, \"unknown recwatcher error\")\n+\t\t\t}\n+\t\t\tif obj.debug {\n+\t\t\t\tlog.Printf(\"%s: Event(%s): %v\", obj, event.Body.Name, event.Body.Op)\n+\t\t\t}\n+\n+\t\t\tsend = true\n+\t\t\tobj.StateOK(false) // dirty\n+\n+\t\tcase event := <-obj.Events():\n+\t\t\tif exit, send = obj.ReadEvent(event); exit != nil {\n+\t\t\t\treturn *exit // exit\n+\t\t\t}\n+\t\t}\n+\n+\t\t// do all our event sending all together to avoid duplicate msgs\n+\t\tif send {\n+\t\t\tsend = false\n+\t\t\tobj.Event()\n+\t\t}\n+\t}\n+}\n+\n+// ifaceCheckApply checks the state of the network device and brings it up or\n+// down as necessary.\n+func (obj *NetRes) ifaceCheckApply(apply bool) (bool, error) {\n+\t// check the interface state\n+\tstate, err := obj.iface.state()\n+\tif err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error checking %s state\", obj.GetName())\n+\t}\n+\t// if up is unspecified\n+\tif obj.State == \"\" {\n+\t\tobj.State = state // send the state before returning\n+\t\treturn true, nil\n+\t}\n+\t// if the state is correct, we're done\n+\tif obj.State == state {\n+\t\treturn true, nil\n+\t}\n+\n+\t// end of state checking\n+\tif !apply {\n+\t\treturn false, nil\n+\t}\n+\tlog.Printf(\"%s: ifaceCheckApply(%t)\", obj, apply)\n+\n+\t// ip link set up/down\n+\tif err := obj.iface.linkUpDown(obj.State); err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error setting %s up or down\", obj.GetName())\n+\t}\n+\n+\treturn false, nil\n+}\n+\n+// addrCheckApply checks if the interface has the correct addresses and then\n+// adds/deletes addresses as necessary.\n+func (obj *NetRes) addrCheckApply(apply bool) (bool, error) {\n+\t// get the link's addresses\n+\tifaceAddrs, err := obj.iface.getAddrs()\n+\tif err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error getting addresses from %s\", obj.GetName())\n+\t}\n+\t// if state is not defined\n+\tif obj.Addrs == nil {\n+\t\t// send addrs\n+\t\tobj.Addrs = ifaceAddrs\n+\t\treturn true, nil\n+\t}\n+\t// check if all addrs have a kernel route needed for first hop\n+\tkernelOK, err := obj.iface.kernelCheck(obj.Addrs)\n+\tif err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error checking kernel routes\")\n+\t}\n+\t// if the kernel routes are intact and the addrs match, we're done\n+\tif kernelOK && strSliceCompare(obj.Addrs, ifaceAddrs) {\n+\t\treturn true, nil\n+\t}\n+\n+\t// end of state checking\n+\tif !apply {\n+\t\treturn false, nil\n+\t}\n+\tlog.Printf(\"%s: addrCheckApply(%t)\", obj, apply)\n+\n+\t// check each address and delete the ones that aren't in the definition\n+\tif err := obj.iface.addrApplyDelete(obj.Addrs); err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error checking or deleting addresses\")\n+\t}\n+\t// check each address and add the ones that are defined but do not exist\n+\tif err := obj.iface.addrApplyAdd(obj.Addrs); err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error checking or adding addresses\")\n+\t}\n+\t// make sure all the addrs have the appropriate kernel routes\n+\tif err := obj.iface.kernelApply(obj.Addrs); err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error adding kernel routes\")\n+\t}\n+\n+\treturn false, nil\n+}\n+\n+// gatewayCheckApply checks if the interface has the correct default gateway\n+// and adds/deletes routes as necessary.\n+func (obj *NetRes) gatewayCheckApply(apply bool) (bool, error) {\n+\t// get all routes from the interface\n+\troutes, err := netlink.RouteList(obj.iface.link, netlink.FAMILY_V4)\n+\tif err != nil {\n+\t\treturn false, errwrap.Wrapf(err, \"error getting default routes\")\n+\t}\n+\t// add default routes to a slice\n+\tdefRoutes := []netlink.Route{}\n+\tfor _, route := range routes {\n+\t\tif route.Dst == nil { // route is default\n+\t\t\tdefRoutes = append(defRoutes, route)\n+\t\t}\n+\t}\n+\t// if the gateway is already set, we're done\n+\tif len(defRoutes) == 1 && defRoutes[0].Gw.String() == obj.Gateway {\n+\t\treturn true, nil\n+\t}\n+\t// if no gateway was defined\n+\tif obj.Gateway == \"\" {\n+\t\t// send the gateway if there is one\n+\t\tif len(defRoutes) == 1 {\n+\t\t\tobj.Gateway = defRoutes[0].Gw.String()\n+\t\t}\n+\t\treturn true, nil\n+\t}\n+\n+\t// end of state checking\n+\tif !apply {\n+\t\treturn false, nil\n+\t}\n+\tlog.Printf(\"%s: gatewayCheckApply(%t)\", obj, apply)\n+\n+\t// delete all but one default route\n+\tfor n, route := range defRoutes {","path":"resources/net.go","position":474,"original_position":474,"commit_id":"356f5c0921c95f188b922d9d235aa640b7e48e4b","original_commit_id":"356f5c0921c95f188b922d9d235aa640b7e48e4b","user":{"login":"purpleidea","id":135091,"avatar_url":"https://avatars0.githubusercontent.com/u/135091?v=4","gravatar_id":"","url":"https://api.github.com/users/purpleidea","html_url":"https://github.com/purpleidea","followers_url":"https://api.github.com/users/purpleidea/followers","following_url":"https://api.github.com/users/purpleidea/following{/other_user}","gists_url":"https://api.github.com/users/purpleidea/gists{/gist_id}","starred_url":"https://api.github.com/users/purpleidea/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/purpleidea/subscriptions","organizations_url":"https://api.github.com/users/purpleidea/orgs","repos_url":"https://api.github.com/users/purpleidea/repos","events_url":"https://api.github.com/users/purpleidea/events{/privacy}","received_events_url":"https://api.github.com/users/purpleidea/received_events","type":"User","site_admin":false},"body":"`for i:=1` might work better here...","created_at":"2018-03-24T10:34:00Z","updated_at":"2018-03-24T10:43:14Z","html_url":"https://github.com/purpleidea/mgmt/pull/371#discussion_r176905721","pull_request_url":"https://api.github.com/repos/purpleidea/mgmt/pulls/371","author_association":"OWNER","_links":{"self":{"href":"https://api.github.com/repos/purpleidea/mgmt/pulls/comments/176905721"},"html":{"href":"https://github.com/purpleidea/mgmt/pull/371#discussion_r176905721"},"pull_request":{"href":"https://api.github.com/repos/purpleidea/mgmt/pulls/371"}}},"pull_request":{"url":"https://api.github.com/repos/purpleidea/mgmt/pulls/371","id":174198386,"html_url":"https://github.com/purpleidea/mgmt/pull/371","diff_url":"https://github.com/purpleidea/mgmt/pull/371.diff","patch_url":"https://github.com/purpleidea/mgmt/pull/371.patch","issue_url":"https://api.github.com/repos/purpleidea/mgmt/issues/371","number":371,"state":"open","locked":false,"title":"resources: Add net resource based on netlink","user":{"login":"jonathangold","id":30417125,"avatar_url":"https://avatars3.githubusercontent.com/u/30417125?v=4","gravatar_id":"","url":"https://api.github.com/users/jonathangold","html_url":"https://github.com/jonathangold","followers_url":"https://api.github.com/users/jonathangold/followers","following_url":"https://api.github.com/users/jonathangold/following{/other_user}","gists_url":"https://api.github.com/users/jonathangold/gists{/gist_id}","starred_url":"https://api.github.com/users/jonathangold/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jonathangold/subscriptions","organizations_url":"https://api.github.com/users/jonathangold/orgs","repos_url":"https://api.github.com/users/jonathangold/repos","events_url":"https://api.github.com/users/jonathangold/events{/privacy}","received_events_url":"https://api.github.com/users/jonathangold/received_events","type":"User","site_admin":false},"body":"This is very much a work in progress. Let's discuss before you review anything carefully. I haven't yet combined this with the systemd code, because I really like the netlink based ipCheckApply, and I want to discuss with you which parts to keep from each.","created_at":"2018-03-11T01:07:14Z","updated_at":"2018-03-24T10:43:14Z","closed_at":null,"merged_at":null,"merge_commit_sha":"8cb8a03e7ff8367fc6110496536f7e94c50a2823","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/purpleidea/mgmt/pulls/371/commits","review_comments_url":"https://api.github.com/repos/purpleidea/mgmt/pulls/371/comments","review_comment_url":"https://api.github.com/repos/purpleidea/mgmt/pulls/comments{/number}","comments_url":"https://api.github.com/repos/purpleidea/mgmt/issues/371/comments","statuses_url":"https://api.github.com/repos/purpleidea/mgmt/statuses/356f5c0921c95f188b922d9d235aa640b7e48e4b","head":{"label":"jonathangold:netlink","ref":"netlink","sha":"356f5c0921c95f188b922d9d235aa640b7e48e4b","user":{"login":"jonathangold","id":30417125,"avatar_url":"https://avatars3.githubusercontent.com/u/30417125?v=4","gravatar_id":"","url":"https://api.github.com/users/jonathangold","html_url":"https://github.com/jonathangold","followers_url":"https://api.github.com/users/jonathangold/followers","following_url":"https://api.github.com/users/jonathangold/following{/other_user}","gists_url":"https://api.github.com/users/jonathangold/gists{/gist_id}","starred_url":"https://api.github.com/users/jonathangold/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jonathangold/subscriptions","organizations_url":"https://api.github.com/users/jonathangold/orgs","repos_url":"https://api.github.com/users/jonathangold/repos","events_url":"https://api.github.com/users/jonathangold/events{/privacy}","received_events_url":"https://api.github.com/users/jonathangold/received_events","type":"User","site_admin":false},"repo":{"id":102772903,"name":"mgmt","full_name":"jonathangold/mgmt","owner":{"login":"jonathangold","id":30417125,"avatar_url":"https://avatars3.githubusercontent.com/u/30417125?v=4","gravatar_id":"","url":"https://api.github.com/users/jonathangold","html_url":"https://github.com/jonathangold","followers_url":"https://api.github.com/users/jonathangold/followers","following_url":"https://api.github.com/users/jonathangold/following{/other_user}","gists_url":"https://api.github.com/users/jonathangold/gists{/gist_id}","starred_url":"https://api.github.com/users/jonathangold/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jonathangold/subscriptions","organizations_url":"https://api.github.com/users/jonathangold/orgs","repos_url":"https://api.github.com/users/jonathangold/repos","events_url":"https://api.github.com/users/jonathangold/events{/privacy}","received_events_url":"https://api.github.com/users/jonathangold/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/jonathangold/mgmt","description":"Next generation distributed, event-driven, parallel config management!","fork":true,"url":"https://api.github.com/repos/jonathangold/mgmt","forks_url":"https://api.github.com/repos/jonathangold/mgmt/forks","keys_url":"https://api.github.com/repos/jonathangold/mgmt/keys{/key_id}","collaborators_url":"https://api.github.com/repos/jonathangold/mgmt/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/jonathangold/mgmt/teams","hooks_url":"https://api.github.com/repos/jonathangold/mgmt/hooks","issue_events_url":"https://api.github.com/repos/jonathangold/mgmt/issues/events{/number}","events_url":"https://api.github.com/repos/jonathangold/mgmt/events","assignees_url":"https://api.github.com/repos/jonathangold/mgmt/assignees{/user}","branches_url":"https://api.github.com/repos/jonathangold/mgmt/branches{/branch}","tags_url":"https://api.github.com/repos/jonathangold/mgmt/tags","blobs_url":"https://api.github.com/repos/jonathangold/mgmt/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/jonathangold/mgmt/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/jonathangold/mgmt/git/refs{/sha}","trees_url":"https://api.github.com/repos/jonathangold/mgmt/git/trees{/sha}","statuses_url":"https://api.github.com/repos/jonathangold/mgmt/statuses/{sha}","languages_url":"https://api.github.com/repos/jonathangold/mgmt/languages","stargazers_url":"https://api.github.com/repos/jonathangold/mgmt/stargazers","contributors_url":"https://api.github.com/repos/jonathangold/mgmt/contributors","subscribers_url":"https://api.github.com/repos/jonathangold/mgmt/subscribers","subscription_url":"https://api.github.com/repos/jonathangold/mgmt/subscription","commits_url":"https://api.github.com/repos/jonathangold/mgmt/commits{/sha}","git_commits_url":"https://api.github.com/repos/jonathangold/mgmt/git/commits{/sha}","comments_url":"https://api.github.com/repos/jonathangold/mgmt/comments{/number}","issue_comment_url":"https://api.github.com/repos/jonathangold/mgmt/issues/comments{/number}","contents_url":"https://api.github.com/repos/jonathangold/mgmt/contents/{+path}","compare_url":"https://api.github.com/repos/jonathangold/mgmt/compare/{base}...{head}","merges_url":"https://api.github.com/repos/jonathangold/mgmt/merges","archive_url":"https://api.github.com/repos/jonathangold/mgmt/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/jonathangold/mgmt/downloads","issues_url":"https://api.github.com/repos/jonathangold/mgmt/issues{/number}","pulls_url":"https://api.github.com/repos/jonathangold/mgmt/pulls{/number}","milestones_url":"https://api.github.com/repos/jonathangold/mgmt/milestones{/number}","notifications_url":"https://api.github.com/repos/jonathangold/mgmt/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/jonathangold/mgmt/labels{/name}","releases_url":"https://api.github.com/repos/jonathangold/mgmt/releases{/id}","deployments_url":"https://api.github.com/repos/jonathangold/mgmt/deployments","created_at":"2017-09-07T18:39:11Z","updated_at":"2017-09-07T18:39:13Z","pushed_at":"2018-03-24T09:40:46Z","git_url":"git://github.com/jonathangold/mgmt.git","ssh_url":"git@github.com:jonathangold/mgmt.git","clone_url":"https://github.com/jonathangold/mgmt.git","svn_url":"https://github.com/jonathangold/mgmt","homepage":"","size":2586,"stargazers_count":0,"watchers_count":0,"language":"Go","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"open_issues_count":0,"license":null,"forks":0,"open_issues":0,"watchers":0,"default_branch":"master"}},"base":{"label":"purpleidea:master","ref":"master","sha":"ddefb4e98778d4568a96754df52e3a6a494f2ad3","user":{"login":"purpleidea","id":135091,"avatar_url":"https://avatars0.githubusercontent.com/u/135091?v=4","gravatar_id":"","url":"https://api.github.com/users/purpleidea","html_url":"https://github.com/purpleidea","followers_url":"https://api.github.com/users/purpleidea/followers","following_url":"https://api.github.com/users/purpleidea/following{/other_user}","gists_url":"https://api.github.com/users/purpleidea/gists{/gist_id}","starred_url":"https://api.github.com/users/purpleidea/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/purpleidea/subscriptions","organizations_url":"https://api.github.com/users/purpleidea/orgs","repos_url":"https://api.github.com/users/purpleidea/repos","events_url":"https://api.github.com/users/purpleidea/events{/privacy}","received_events_url":"https://api.github.com/users/purpleidea/received_events","type":"User","site_admin":false},"repo":{"id":43111018,"name":"mgmt","full_name":"purpleidea/mgmt","owner":{"login":"purpleidea","id":135091,"avatar_url":"https://avatars0.githubusercontent.com/u/135091?v=4","gravatar_id":"","url":"https://api.github.com/users/purpleidea","html_url":"https://github.com/purpleidea","followers_url":"https://api.github.com/users/purpleidea/followers","following_url":"https://api.github.com/users/purpleidea/following{/other_user}","gists_url":"https://api.github.com/users/purpleidea/gists{/gist_id}","starred_url":"https://api.github.com/users/purpleidea/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/purpleidea/subscriptions","organizations_url":"https://api.github.com/users/purpleidea/orgs","repos_url":"https://api.github.com/users/purpleidea/repos","events_url":"https://api.github.com/users/purpleidea/events{/privacy}","received_events_url":"https://api.github.com/users/purpleidea/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/purpleidea/mgmt","description":"Next generation distributed, event-driven, parallel config management!","fork":false,"url":"https://api.github.com/repos/purpleidea/mgmt","forks_url":"https://api.github.com/repos/purpleidea/mgmt/forks","keys_url":"https://api.github.com/repos/purpleidea/mgmt/keys{/key_id}","collaborators_url":"https://api.github.com/repos/purpleidea/mgmt/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/purpleidea/mgmt/teams","hooks_url":"https://api.github.com/repos/purpleidea/mgmt/hooks","issue_events_url":"https://api.github.com/repos/purpleidea/mgmt/issues/events{/number}","events_url":"https://api.github.com/repos/purpleidea/mgmt/events","assignees_url":"https://api.github.com/repos/purpleidea/mgmt/assignees{/user}","branches_url":"https://api.github.com/repos/purpleidea/mgmt/branches{/branch}","tags_url":"https://api.github.com/repos/purpleidea/mgmt/tags","blobs_url":"https://api.github.com/repos/purpleidea/mgmt/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/purpleidea/mgmt/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/purpleidea/mgmt/git/refs{/sha}","trees_url":"https://api.github.com/repos/purpleidea/mgmt/git/trees{/sha}","statuses_url":"https://api.github.com/repos/purpleidea/mgmt/statuses/{sha}","languages_url":"https://api.github.com/repos/purpleidea/mgmt/languages","stargazers_url":"https://api.github.com/repos/purpleidea/mgmt/stargazers","contributors_url":"https://api.github.com/repos/purpleidea/mgmt/contributors","subscribers_url":"https://api.github.com/repos/purpleidea/mgmt/subscribers","subscription_url":"https://api.github.com/repos/purpleidea/mgmt/subscription","commits_url":"https://api.github.com/repos/purpleidea/mgmt/commits{/sha}","git_commits_url":"https://api.github.com/repos/purpleidea/mgmt/git/commits{/sha}","comments_url":"https://api.github.com/repos/purpleidea/mgmt/comments{/number}","issue_comment_url":"https://api.github.com/repos/purpleidea/mgmt/issues/comments{/number}","contents_url":"https://api.github.com/repos/purpleidea/mgmt/contents/{+path}","compare_url":"https://api.github.com/repos/purpleidea/mgmt/compare/{base}...{head}","merges_url":"https://api.github.com/repos/purpleidea/mgmt/merges","archive_url":"https://api.github.com/repos/purpleidea/mgmt/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/purpleidea/mgmt/downloads","issues_url":"https://api.github.com/repos/purpleidea/mgmt/issues{/number}","pulls_url":"https://api.github.com/repos/purpleidea/mgmt/pulls{/number}","milestones_url":"https://api.github.com/repos/purpleidea/mgmt/milestones{/number}","notifications_url":"https://api.github.com/repos/purpleidea/mgmt/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/purpleidea/mgmt/labels{/name}","releases_url":"https://api.github.com/repos/purpleidea/mgmt/releases{/id}","deployments_url":"https://api.github.com/repos/purpleidea/mgmt/deployments","created_at":"2015-09-25T05:15:41Z","updated_at":"2018-03-22T06:14:27Z","pushed_at":"2018-03-24T09:40:48Z","git_url":"git://github.com/purpleidea/mgmt.git","ssh_url":"git@github.com:purpleidea/mgmt.git","clone_url":"https://github.com/purpleidea/mgmt.git","svn_url":"https://github.com/purpleidea/mgmt","homepage":"https://purpleidea.com/tags/mgmtconfig/","size":2499,"stargazers_count":1233,"watchers_count":1233,"language":"Go","has_issues":true,"has_projects":false,"has_downloads":true,"has_wiki":false,"has_pages":false,"forks_count":158,"mirror_url":null,"archived":false,"open_issues_count":67,"license":{"key":"other","name":"Other","spdx_id":null,"url":null},"forks":158,"open_issues":67,"watchers":1233,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/purpleidea/mgmt/pulls/371"},"html":{"href":"https://github.com/purpleidea/mgmt/pull/371"},"issue":{"href":"https://api.github.com/repos/purpleidea/mgmt/issues/371"},"comments":{"href":"https://api.github.com/repos/purpleidea/mgmt/issues/371/comments"},"review_comments":{"href":"https://api.github.com/repos/purpleidea/mgmt/pulls/371/comments"},"review_comment":{"href":"https://api.github.com/repos/purpleidea/mgmt/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/purpleidea/mgmt/pulls/371/commits"},"statuses":{"href":"https://api.github.com/repos/purpleidea/mgmt/statuses/356f5c0921c95f188b922d9d235aa640b7e48e4b"}},"author_association":"CONTRIBUTOR"}}
{"actor":{"display_login":"purpleidea"}}