diff --git a/ReadMe.md b/ReadMe.md index 688887c..f8e2638 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -2,6 +2,21 @@ server.garden Privileged Automation Agent +``` +mkdir -p ssh + +ssh-keygen -t ed25519 -N '' -f ./ssh/severgarden_builtin_ed22519 + +go build -o ansible-wrapper/ansible-playbook-wrapper ansible-wrapper/main.go +go build -o host-key-poller/host-key-poller host-key-poller/main.go + +# you will have to provide a complete config file. normally this would be provideded by seedpacket +nano config.json + +go run *.go +``` + + ----------------------------- Rootsystem is the entrypoint & most highly privileged part of the server.garden automation system, hence "root" in the name. diff --git a/automation/terraformActions.go b/automation/terraformActions.go index 18551f3..f086ba9 100644 --- a/automation/terraformActions.go +++ b/automation/terraformActions.go @@ -4,7 +4,9 @@ import ( "bufio" "bytes" "encoding/json" + "encoding/xml" "fmt" + "html" "io" "io/ioutil" "os" @@ -121,6 +123,20 @@ type TerraformApplyResult struct { Status *SimplifiedTerraformStatus } +type XMLNode struct { + XMLName xml.Name + Attrs []xml.Attr `xml:"-"` + Content []byte `xml:",innerxml"` + Children []XMLNode `xml:",any"` +} + +func (n *XMLNode) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + n.Attrs = start.Attr + type node XMLNode + + return d.DecodeElement((*node)(n), &start) +} + const data_template_file = "data.template_file" func TerraformPlanAndApply( @@ -233,6 +249,7 @@ func TerraformPlanAndApply( if err != nil { return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't makeSVGFromSimpleStatus") } + return "", nil, errors.New("TODO remove this") for moduleName, module := range simpleStatus.Modules { if module.IsAnsible { @@ -806,10 +823,42 @@ func makeSVGFromSimpleStatus(simpleStatus *SimplifiedTerraformStatus) (string, e return "", err } - svgString := strings.ReplaceAll(string(dotStdout), `fill="none"`, `fill="#ffffff"`) - matchTitleTag := regexp.MustCompile(`[^<]*`) - svgString = matchTitleTag.ReplaceAllString(svgString, "") - svgString = strings.ReplaceAll(svgString, `Times,serif`, `-apple-system,system-ui,BlinkMacSystemFont,Ubuntu,Roboto,Segoe UI,sans-serif`) + svgString := string(dotStdout) + + // the svg output from dot has html comments that contain metadata preceeding each entity. + // for example: + // + // + // We will attach that metadata to the svg elements as a data property + // Note that this will not work perfectly for the "cluster"s (aka modules) + // they are handled separately after the SVG has been inserted. + // Yes parsing XML with regex is "evil" but keep in mind, this XML was generated by a program, not written by hand. + // So it is a lot more predictable. Also, I couldn't figure out how to parse the comments with the go std lib XML parser. + commentRegex := regexp.MustCompile(`\s*\n\s*(<[a-z]+\s)`) + svgString = commentRegex.ReplaceAllStringFunc(svgString, func(matched string) string { + matches := commentRegex.FindStringSubmatch(matched) + return fmt.Sprintf("%s data-dot=\"%s\"", matches[2], html.UnescapeString(matches[1])) + }) + + ioutil.WriteFile("./test-gen.svg", []byte(svgString), 0777) + + var svgXMLDocument XMLNode + + err = xml.Unmarshal(dotStdout, &svgXMLDocument) + if err != nil { + return "", err + } + + // svgString := strings.ReplaceAll(string(dotStdout), `fill="none"`, `fill="#ffffff"`) + // matchTitleTag := regexp.MustCompile(`[^<]*`) + // svgString = matchTitleTag.ReplaceAllString(svgString, "") + // svgString = strings.ReplaceAll(svgString, `Times,serif`, `-apple-system,system-ui,BlinkMacSystemFont,Ubuntu,Roboto,Segoe UI,sans-serif`) + + testJson, _ := json.MarshalIndent(simpleStatus, "", " ") + ioutil.WriteFile("./test-status.json", testJson, 0777) + testJson2, _ := json.MarshalIndent(svgXMLDocument, "", " ") + ioutil.WriteFile("./test-xml.json", testJson2, 0777) + ioutil.WriteFile("./test-dot.txt", []byte(dot), 0777) return svgString, nil } diff --git a/main.go b/main.go index 50e20c6..abd13c0 100644 --- a/main.go +++ b/main.go @@ -6,12 +6,9 @@ import ( "log" "net/http" - errors "git.sequentialread.com/forest/pkg-errors" - "git.sequentialread.com/forest/rootsystem/automation" "git.sequentialread.com/forest/rootsystem/configuration" "git.sequentialread.com/forest/rootsystem/objectStorage" - "git.sequentialread.com/forest/rootsystem/pki" ) type applicationState struct { @@ -21,85 +18,87 @@ type applicationState struct { var global applicationState -func main() { - config, workingDirectory, err := configuration.LoadConfiguration() - if err != nil { - panic(errors.Wrap(err, "rootsystem can't start because loadConfiguration() returned")) - } - global.workingDirectory = workingDirectory - - storage, err := objectStorage.InitializeObjectStorage(config, true) - if err != nil { - panic(errors.Wrap(err, "rootsystem can't start because failed to initialize object storage")) - } - global.storage = storage - - go terraformStateServer() - - // This creates an access key that the gateway cloud instance can use to upload its SSH public key - // to our object storage. the host-key-poller will download this SSH host public key and add it to our known_hosts - // so that we can SSH to the gateway instance securely - hostKeysAccessSpec := objectStorage.ObjectStorageKey{ - Name: "rootsystem-known-hosts", - PathPrefix: "rootsystem/known-hosts", - Read: true, - Write: true, - Delete: false, - List: false, - } - knownHostsCredentials, err := global.storage.CreateAccessKeyIfNotExists(hostKeysAccessSpec) - if err != nil { - panic(err) - } - - // BuildTLSCertsForThreshold fills in the CAs, Keys, and Certificates in the Threshold ansible roles. - // So when terraform invokes ansible to install threshold client/server, it will install working - // certificates and keys - err = pki.BuildTLSCertsForThreshold( - global.workingDirectory, - config.Terraform.Variables["domain_name"], - config.Host.Name, - global.storage, - ) - if err != nil { - panic(err) - } - - // First, run the terraform build for the GLOBAL components, meaning the components - // that exist in the cloud, independent of how many server.garden nodes are being used. - outputVariables, err := terraformBuild( - config, - automation.TerraformConfiguration{ - TargetedModules: config.Terraform.GlobalModules, - TerraformProject: configuration.GLOBAL_TERRAFORM_PROJECT, - HostKeysObjectStorageCredentials: knownHostsCredentials, - }, - ) - if err != nil { - panic(err) - } - - // Next, we run a separate LOCAL terraform build which is specific to THIS server.garden node, - // this build will be responsible for installing software on this node & registering this node with the - // cloud resources - _, err = terraformBuild( - config, - automation.TerraformConfiguration{ - TargetedModules: config.Terraform.LocalModules, - TerraformProject: fmt.Sprintf("%s-%s", configuration.LOCAL_TERRAFORM_PROJECT, config.Host.Name), - RemoteState: configuration.GLOBAL_TERRAFORM_PROJECT, - RemoteStateVariables: outputVariables, - }, - ) - if err != nil { - panic(err) - } - - a := make(chan bool) - - <-a - -} +// func main() { +// config, workingDirectory, err := configuration.LoadConfiguration() +// if err != nil { +// panic(errors.Wrap(err, "rootsystem can't start because loadConfiguration() returned")) +// } +// global.workingDirectory = workingDirectory + +// storage, err := objectStorage.InitializeObjectStorage(config, true) +// if err != nil { +// panic(errors.Wrap(err, "rootsystem can't start because failed to initialize object storage")) +// } +// global.storage = storage + +// go terraformStateServer() + +// // This creates an access key that the gateway cloud instance can use to upload its SSH public key +// // to our object storage. the host-key-poller will download this SSH host public key and add it to our known_hosts +// // so that we can SSH to the gateway instance securely +// hostKeysAccessSpec := objectStorage.ObjectStorageKey{ +// Name: "rootsystem-known-hosts", +// PathPrefix: "rootsystem/known-hosts", +// Read: true, +// Write: true, +// Delete: false, +// List: false, +// } +// knownHostsCredentials, err := global.storage.CreateAccessKeyIfNotExists(hostKeysAccessSpec) +// if err != nil { +// panic(err) +// } + +// // BuildTLSCertsForThreshold fills in the CAs, Keys, and Certificates in the Threshold ansible roles. +// // So when terraform invokes ansible to install threshold client/server, it will install working +// // certificates and keys +// err = pki.BuildTLSCertsForThreshold( +// global.workingDirectory, +// config.Terraform.Variables["domain_name"], +// config.Host.Name, +// global.storage, +// ) +// if err != nil { +// panic(err) +// } + +// // First, run the terraform build for the GLOBAL components, meaning the components +// // that exist in the cloud, independent of how many server.garden nodes are being used. +// outputVariables, err := terraformBuild( +// config, +// automation.TerraformConfiguration{ +// TargetedModules: config.Terraform.GlobalModules, +// TerraformProject: configuration.GLOBAL_TERRAFORM_PROJECT, +// HostKeysObjectStorageCredentials: knownHostsCredentials, +// }, +// ) +// if err != nil { +// panic(err) +// } + +// os.Exit(0) + +// // Next, we run a separate LOCAL terraform build which is specific to THIS server.garden node, +// // this build will be responsible for installing software on this node & registering this node with the +// // cloud resources +// _, err = terraformBuild( +// config, +// automation.TerraformConfiguration{ +// TargetedModules: config.Terraform.LocalModules, +// TerraformProject: fmt.Sprintf("%s-%s", configuration.LOCAL_TERRAFORM_PROJECT, config.Host.Name), +// RemoteState: configuration.GLOBAL_TERRAFORM_PROJECT, +// RemoteStateVariables: outputVariables, +// }, +// ) +// if err != nil { +// panic(err) +// } + +// a := make(chan bool) + +// <-a + +// } func terraformBuild( config *configuration.Configuration, diff --git a/pki/pki.go b/pki/pki.go index 2eb9cee..e21b06a 100644 --- a/pki/pki.go +++ b/pki/pki.go @@ -10,9 +10,9 @@ import ( "path/filepath" "time" - "git.sequentialread.com/forest/easypki/pkg/certificate" - "git.sequentialread.com/forest/easypki/pkg/easypki" - "git.sequentialread.com/forest/easypki/pkg/store" + "git.sequentialread.com/forest/easypki.git/pkg/certificate" + "git.sequentialread.com/forest/easypki.git/pkg/easypki" + "git.sequentialread.com/forest/easypki.git/pkg/store" "git.sequentialread.com/forest/rootsystem/configuration" "git.sequentialread.com/forest/rootsystem/objectStorage" "github.com/pkg/errors" @@ -240,6 +240,7 @@ func saveBundle(pki *easypki.EasyPKI, caName, bundleName string, savePrivateKey if caName == "" { caName = bundleName } + bundle, err := pki.GetBundle(caName, bundleName) if err != nil { return errors.Wrapf(err, "Failed getting bundle %v within CA %v: %v", bundleName, caName) diff --git a/pull.sh b/pull.sh index a95f579..209f69c 100644 --- a/pull.sh +++ b/pull.sh @@ -28,6 +28,7 @@ if [ "$TERRAFORM_VERSION" != "$DESIRED_TERRAFORM_VERSION" ]; then if [ "$TERRAFORM_VERSION" != "missing" ]; then rm /usr/bin/terraform fi + # curl "https://releases.hashicorp.com/terraform/0.12.29/terraform_0.12.29_linux_amd64.zip" > "terraform_0.12.29_linux_amd64.zip" curl "https://releases.hashicorp.com/terraform/$DESIRED_TERRAFORM_VERSION/terraform_$DESIRED_TERRAFORM_VERSION""_linux_$TERRAFORM_ARCH.zip" > "terraform_$DESIRED_TERRAFORM_VERSION""_linux_$TERRAFORM_ARCH.zip" unzip "terraform_$DESIRED_TERRAFORM_VERSION""_linux_$TERRAFORM_ARCH.zip" diff --git a/terraform-modules/gateway-instance-digitalocean/main.tf b/terraform-modules/gateway-instance-digitalocean/main.tf index c2e495c..d0a5bba 100644 --- a/terraform-modules/gateway-instance-digitalocean/main.tf +++ b/terraform-modules/gateway-instance-digitalocean/main.tf @@ -8,10 +8,6 @@ variable "domain_name" { type = string } -variable "node_id" { - type = string -} - variable "digitalocean_ssh_key_fingerprints" { type = list(string) } diff --git a/test.go b/test.go new file mode 100644 index 0000000..7215d38 --- /dev/null +++ b/test.go @@ -0,0 +1,453 @@ +package main + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "html" + "io" + "io/ioutil" + "os/exec" + "regexp" + "strings" + + errors "git.sequentialread.com/forest/pkg-errors" +) + +type XMLAttr struct { + Name string + Value string + Namespace string + Local string +} + +type XMLNode struct { + XMLName xml.Name + Attrs []*XMLAttr `xml:"-"` + Content []byte `xml:",innerxml"` + Children []*XMLNode `xml:",any"` + Parent *XMLNode `xml:"-"` +} + +type XMLQuery struct { + DirectChild bool + NodeType string + Class string + Attr string +} + +func (node *XMLNode) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + attrPointers := []*XMLAttr{} + for _, attr := range start.Attr { + // I dont really give a shit about XML namespaces but I do have to search for xlink:title which is separate from normal title + // hardcoded that this is happening inside the svg namespace so all non svg namespaces attrs will be specified like ns:attr + name := attr.Name.Local + if attr.Name.Space != "" { + // attr.Name.Space is a url like http://www.w3.org/1999/xlink or ttp://www.w3.org/2000/svg so we are just grabbing the last part + split := strings.Split(attr.Name.Space, "/") + ns := split[len(split)-1] + if ns != "svg" { + name = fmt.Sprintf("%s:%s", ns, name) + } + } + myAttr := XMLAttr{ + Name: name, + Value: attr.Value, + Namespace: attr.Name.Space, + Local: attr.Name.Local, + } + attrPointers = append(attrPointers, &myAttr) + } + node.Attrs = attrPointers + + // this little derived type wont directly have this UnmarshalXML method, + // thus prevent the decoder from thinking it has to invoke our custom UnmarshalXML again... + type dontCauseInfiniteLoop XMLNode + + return d.DecodeElement((*dontCauseInfiniteLoop)(node), &start) +} + +//https://stackoverflow.com/questions/39780433/golang-xml-customize-output + +// // MarshalXML generate XML output for PrecsontructedInfo +// func (preconstructed PreconstructedInfo) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { +// if (PreconstructedInfo{} == preconstructed) { +// return nil +// } +// if preconstructed.Decks > 0 { +// start.Attr = []xml.Attr{xml.Attr{Name: xml.Name{Local: "decks"}, Value: strconv.Itoa(preconstructed.Size)}} +// } +// if strings.Compare(preconstructed.Type, "") != 0 { +// start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "type"}, Value: preconstructed.Type}) +// } + +// err = e.EncodeToken(start) +// e.EncodeElement(preconstructed.Size, xml.StartElement{Name: xml.Name{Local: "size"}}) +// return e.EncodeToken(xml.EndElement{Name: start.Name}) +// } + +func (node XMLNode) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + + attrsToEncode := []xml.Attr{} + for _, attr := range node.Attrs { + // this encoder will print some stupid shit if you give it an xmlns attr: + // for example: + if attr.Namespace != "xmlns" { + attrsToEncode = append(attrsToEncode, xml.Attr{ + Name: xml.Name{ + Local: attr.Local, + Space: attr.Namespace, + }, + Value: attr.Value, + }) + } + + } + start.Attr = attrsToEncode + start.Name = node.XMLName + + // this little derived type wont directly have this UnmarshalXML method, + // thus prevent the decoder from thinking it has to invoke our custom UnmarshalXML again... + type dontCauseInfiniteLoop XMLNode + + return e.EncodeElement((dontCauseInfiniteLoop)(node), start) +} + +func main() { + bytes, err := ioutil.ReadFile("test-dot.txt") + if err != nil { + panic(err) + } + + dot := string(bytes) + + exitCode, dotStdout, dotStderr, err := shellExecInputPipe(".", &dot, "dot", "-Tsvg") + err = errorFromShellExecResult("dot -Tsvg", exitCode, dotStdout, dotStderr, err) + if err != nil { + panic(err) + } + + svgString := string(dotStdout) + + // the svg output from dot has html comments that contain metadata preceeding each entity. + // for example: + // + // + // We will attach that metadata to the svg elements as a data property + // Note that this will not work perfectly for the "cluster"s (aka modules) + // they are handled separately after the SVG has been inserted. + // Yes parsing XML with regex is "evil" but keep in mind, this XML was generated by a program, not written by hand. + // So it is a lot more predictable. Also, I couldn't figure out how to parse the comments with the go std lib XML parser. + commentRegex := regexp.MustCompile(`\s*\n\s*(<[a-z]+\s)`) + svgString = commentRegex.ReplaceAllStringFunc(svgString, func(matched string) string { + matches := commentRegex.FindStringSubmatch(matched) + return fmt.Sprintf("%s dot=\"%s\" ", matches[2], html.UnescapeString(matches[1])) + }) + + ioutil.WriteFile("./test-gen.svg", []byte(svgString), 0777) + + var svgDoc XMLNode + + err = xml.Unmarshal(dotStdout, &svgDoc) + if err != nil { + panic(err) + } + + var setParentRecurse func(*XMLNode) + setParentRecurse = func(node *XMLNode) { + for _, child := range node.Children { + child.Parent = node + setParentRecurse(child) + } + } + setParentRecurse(&svgDoc) + + // remove the white background from the SVG + svgDoc.WithQuerySelector( + []XMLQuery{XMLQuery{NodeType: "g", Class: "graph"}, XMLQuery{NodeType: "polygon", DirectChild: true}}, + func(node *XMLNode) { + node.SetAttr("fill", "none") + }, + ) + + // remove the white background from the edges + svgDoc.WithQuerySelector( + []XMLQuery{XMLQuery{NodeType: "g", Class: "edge"}, XMLQuery{NodeType: "path"}}, + func(node *XMLNode) { + node.SetAttr("fill", "none") + }, + ) + + // correctly set the dot property for the "cluster"s (aka modules) + svgDoc.WithQuerySelector( + []XMLQuery{XMLQuery{NodeType: "a", Attr: "xlink:title"}}, + func(node *XMLNode) { + if node.Parent != nil && node.Parent.Parent != nil { + title := html.UnescapeString(node.GetAttr("xlink:title")) + title = regexp.MustCompile(`[.-]`).ReplaceAllString(title, "_") + node.Parent.Parent.SetAttr("dot", title) + } + }, + ) + + // correctly set the dot property for resources or "node"s + svgDoc.WithQuerySelector( + []XMLQuery{XMLQuery{NodeType: "g", Class: "node"}, XMLQuery{NodeType: "title"}}, + func(node *XMLNode) { + if node.Parent != nil { + title := html.UnescapeString(string(node.Content)) + node.Parent.SetAttr("dot", title) + } + }, + ) + + // correctly set the dot property for edges + svgDoc.WithQuerySelector( + []XMLQuery{XMLQuery{NodeType: "g", Class: "edge"}, XMLQuery{NodeType: "title"}}, + func(node *XMLNode) { + if node.Parent != nil { + title := html.UnescapeString(string(node.Content)) + title = strings.Replace(title, "->", "_to_", 1) + node.Parent.SetAttr("dot", title) + } + }, + ) + + // // in order to get dot to render it correctly, the connections are actually between resources, + // // not between modules. So we have to trim the resource name off of the dot attribute + // // on the connections to make them match what we have in the status JSON object. + // const moduleNames = Array.from(svg.querySelectorAll("g.cluster")).map(x => x.dataset['dot']); + // Array.from(svg.querySelectorAll("g.edge")).forEach(edge => { + // const dot = edge.dataset['dot']; + // const fromTo = dot.split('->'); + // if(fromTo.length != 2) { + // console.error("malformed dot on edge: fromTo.length != 2", + dot); + // } + // moduleNames.forEach(moduleName => { + // if(fromTo[0].indexOf(moduleName) == 0) { + // edge.dataset['dot'] = `${moduleName}->${fromTo[1]}`; + // } + // if(fromTo[1].indexOf(moduleName) == 0) { + // edge.dataset['dot'] = `${fromTo[0]}->${moduleName}`; + // } + // }); + // }); + + // svgString := strings.ReplaceAll(string(dotStdout), `fill="none"`, `fill="#ffffff"`) + // matchTitleTag := regexp.MustCompile(`[^<]*`) + // svgString = matchTitleTag.ReplaceAllString(svgString, "") + // svgString = strings.ReplaceAll(svgString, `Times,serif`, `-apple-system,system-ui,BlinkMacSystemFont,Ubuntu,Roboto,Segoe UI,sans-serif`) + + // testJson, _ := json.MarshalIndent(simpleStatus, "", " ") + // ioutil.WriteFile("./test-status.json", testJson, 0777) + + // this encoder doesn't want to include my custom attributes... + // so we will hijack the id attribute! Record the value of the custom attribute so we can + // set the value of Id later. + + var cleanUpSvgRecurse func(*XMLNode) + cleanUpSvgRecurse = func(node *XMLNode) { + // We cant serialize cyclical relationships like parent -> child -> parent + node.Parent = nil + + // the XML serializer does not like my custom attributes like `data-dot` or `dot`. + // so we will just hijack the id attribute instead, poggers + dot := node.GetAttr("dot") + if dot != "" { + node.SetAttr("id", dot) + } + + // if we don't zero out the content for non-leaf nodes, we will get duplicates of everything + if len(node.Children) != 0 { + node.Content = []byte{} + } + + for _, child := range node.Children { + cleanUpSvgRecurse(child) + } + } + cleanUpSvgRecurse(&svgDoc) + + testJson2, err := json.MarshalIndent(svgDoc, "", " ") + if err != nil { + panic(err) + } + ioutil.WriteFile("./test-processed.json", testJson2, 0777) + + svgBytes, err := xml.MarshalIndent(svgDoc, "", " ") + + // it goes crazy spamming xmlns everywhere, lets just remove all of them. + svgBytes = regexp.MustCompile(`xmlns[^=]*="[^"]+"`).ReplaceAll(svgBytes, []byte{}) + + // now we add the xmlns back to just the root element + startOfSvgTagWithXMLNS := []byte(` + + +`) + + svgBytes = append(svgHeader, svgBytes...) + + // if err != nil { + // panic(err) + // } + ioutil.WriteFile("./test-processed.svg", svgBytes, 0777) +} + +func padStringForDot(str string) string { + pad := strings.Repeat(" ", int(float32(len(str))/4)) + return fmt.Sprintf("%s%s%s", pad, str, pad) +} + +// TODO which user to run this command as ?? +// Dont run as root. (this process probably runs as root) +func shellExec(workingDirectory string, executable string, arguments ...string) (int, []byte, []byte, error) { + return shellExecInputPipe(workingDirectory, nil, executable, arguments...) +} + +func shellExecInputPipe(workingDirectory string, input *string, executable string, arguments ...string) (int, []byte, []byte, error) { + process := exec.Command(executable, arguments...) + process.Dir = workingDirectory + + if input != nil { + stdin, err := process.StdinPipe() + if err != nil { + return -1, []byte{}, []byte{}, errors.Wrap(err, "process.StdinPipe() returned") + } + + go func() { + defer stdin.Close() + io.WriteString(stdin, *input) + }() + } + + var processStdoutBuffer, processStderrBuffer bytes.Buffer + process.Stdout = &processStdoutBuffer + process.Stderr = &processStderrBuffer + err := process.Start() + if err != nil { + err = errors.Wrapf(err, "can't ShellExec(%s %s), process.Start() returned", executable, strings.Join(arguments, " ")) + return process.ProcessState.ExitCode(), []byte(""), []byte(""), err + } + + err = process.Wait() + if err != nil { + err = errors.Wrapf(err, "can't ShellExec(%s %s), process.Wait() returned", executable, strings.Join(arguments, " ")) + } + + return process.ProcessState.ExitCode(), processStdoutBuffer.Bytes(), processStderrBuffer.Bytes(), err +} + +func errorFromShellExecResult(command string, exitCode int, stdout []byte, stderr []byte, err error) error { + if exitCode != 0 || err != nil { + errorString := "nil" + if err != nil { + errorString = err.Error() + } + return fmt.Errorf( + "%s failed with exit code %d, stdout: \n----\n%s\n----\nstderr: \n----\n%s\n----\nerror: %s", + command, exitCode, stdout, stderr, errorString, + ) + } + return nil +} + +func (doc *XMLNode) QuerySelectorAll(querySteps []XMLQuery) []*XMLNode { + + iterate := func(search []*XMLNode, q XMLQuery) []*XMLNode { + toReturn := []*XMLNode{} + var recurse func(*XMLNode, bool) + recurse = func(node *XMLNode, isDirectChild bool) { + match := true + if q.NodeType != "" && node.XMLName.Local != q.NodeType { + match = false + } + if q.Class != "" { + found := false + for _, attr := range node.Attrs { + classes := strings.Split(attr.Value, " ") + if attr.Name == "class" { + for _, class := range classes { + if class == q.Class { + found = true + } + } + } + } + if !found { + match = false + } + } + if q.Attr != "" { + found := false + for _, attr := range node.Attrs { + if attr.Name == q.Attr { + found = true + } + } + if !found { + match = false + } + } + if match && (isDirectChild || !q.DirectChild) { + toReturn = append(toReturn, node) + } + if isDirectChild || !q.DirectChild { + for _, child := range node.Children { + recurse(child, false) + } + } + } + for _, node := range search { + for _, child := range node.Children { + recurse(child, true) + } + } + + return toReturn + } + + matched := []*XMLNode{doc} + for _, step := range querySteps { + matched = iterate(matched, step) + } + + return matched +} + +func (doc *XMLNode) WithQuerySelector(querySteps []XMLQuery, withResult func(*XMLNode)) { + allResults := doc.QuerySelectorAll(querySteps) + for _, res := range allResults { + withResult(res) + } +} + +func (node *XMLNode) SetAttr(attr string, value string) { + has := false + for _, a := range node.Attrs { + if a.Name == attr { + a.Value = value + has = true + } + } + if !has { + newAttr := XMLAttr{ + Name: attr, + Value: value, + } + node.Attrs = append(node.Attrs, &newAttr) + } +} + +func (node *XMLNode) GetAttr(attr string) string { + for _, a := range node.Attrs { + if a.Name == attr { + return a.Value + } + } + return "" +}