server.garden privileged automation agent (mirror of https://git.sequentialread.com/forest/rootsystem)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

984 lines
31 KiB

package automation
import (
"bufio"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"html"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
errors "git.sequentialread.com/forest/pkg-errors"
"git.sequentialread.com/forest/rootsystem/configuration"
)
type TerraformShow struct {
PlannedValues TerraformShowPlannedValues `json:"planned_values"`
Configuraton TerraformShowConfiguration `json:"configuration"`
ResourceChanges []*TerraformShowResourceChange `json:"resource_changes"`
}
type TerraformShowPlannedValues struct {
RootModule *TerraformShowPlannedValuesRoot `json:"root_module"`
}
type TerraformShowPlannedValuesRoot struct {
ChildModules []*TerraformShowModule `json:"child_modules"`
}
type TerraformShowConfiguration struct {
RootModule TerraformShowRootModule `json:"root_module"`
}
type TerraformShowRootModule struct {
ModuleCalls map[string]*TerraformShowModuleCall `json:"module_calls"`
}
type TerraformShowModule struct {
Resources []*TerraformShowResource `json:"resources"`
Address string `json:"address"`
}
type TerraformShowResource struct {
Address string `json:"address"`
Type string `json:"type"`
Name string `json:"name"`
Index int `json:"index"`
Values map[string]interface{} `json:"values"`
}
type ResourceChangeFilter struct {
ResourceType string
Action string
NotAction string
}
type TerraformShowResourceChange struct {
TerraformShowResource
ModuleAddress string `json:"module_address"`
Change TerraformShowChangeSpec `json:"change"`
}
type TerraformShowChangeSpec struct {
Actions []string `json:"actions"`
}
type TerraformShowModuleCall struct {
Expressions map[string]*TerraformShowExpression `json:"expressions"`
Module *TerraformShowModule `json:"module"`
}
type TerraformShowExpression struct {
References []string `json:"references"`
}
type SimplifiedTerraformStatus struct {
Modules map[string]*SimplifiedTerraformModule
Variables map[string]string
Connections []SimplifiedTerraformConnection
}
type SimplifiedTerraformModule struct {
DisplayName string
IsAnsible bool
Resources []*SimplifiedTerraformResource
}
type SimplifiedTerraformResource struct {
ResourceType string
DisplayName string
Plan string
State string
Progress int
ProgressTotal int
}
type SimplifiedTerraformConnection struct {
From string
To string
DisplayName string
}
type AnsibleTaskResult struct {
Name string
Success bool
Skipped bool
Mode string
Role string
Changed bool
}
type TerraformApplyResult struct {
Error error
Log string
Complete bool
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(
config *configuration.Configuration,
workingDirectory string,
terraformProject string,
) (string, chan TerraformApplyResult, error) {
terraformDirectory := filepath.Join(workingDirectory, terraformProject)
// Under normal conditions you would not init every time. But I ran into some issues and decided doing this
// every time was the best course of action.
exitCode, initStdout, initStderr, err := shellExec(terraformDirectory, "terraform", "init")
err = errorFromShellExecResult("terraform init", exitCode, initStdout, initStderr, err)
if err != nil {
return "", nil, err
}
// Convenience function so we can plan multiple times if needed
doPlan := func(terraformDirectory string) (*SimplifiedTerraformStatus, *TerraformShow, string, error) {
exitCode, planStdout, planStderr, err := shellExec(terraformDirectory, "terraform", "plan", "-out", configuration.TERRAFORM_PLAN_FILE_NAME)
err = errorFromShellExecResult("terraform plan", exitCode, planStdout, planStderr, err)
if err != nil {
return nil, nil, "", err
}
exitCode, tfJson, showJsonStderr, err := shellExec(terraformDirectory, "terraform", "show", "-json", configuration.TERRAFORM_PLAN_FILE_NAME)
err = errorFromShellExecResult("terraform show", exitCode, tfJson, showJsonStderr, err)
if err != nil {
return nil, nil, "", err
}
//log.Println(string(tfJson))
var tfShow TerraformShow
err = json.Unmarshal([]byte(tfJson), &tfShow)
if err != nil {
return nil, nil, "", errors.Wrap(err, "can't TerraformPlanAndApply because can't json.Unmarshal the output of `terraform show`")
}
// json, err := json.MarshalIndent(tfShow, "", " ")
// if err != nil {
// return nil, nil, "", errors.Wrap(err, "can't GenerateTerraformPlan because can't json.Marshal the output of `terraform show`")
// }
// log.Println(string(json))
// Copy some values over from PlannedValues to ResourceChanges for convenience access later
changedResources := map[string]*TerraformShowResource{}
for _, module := range tfShow.PlannedValues.RootModule.ChildModules {
for _, resource := range module.Resources {
changedResources[resource.Address] = resource
}
}
for _, changedResource := range tfShow.ResourceChanges {
resourceWithValues := changedResources[changedResource.Address]
changedResource.Index = resourceWithValues.Index
changedResource.Type = resourceWithValues.Type
changedResource.Name = resourceWithValues.Name
changedResource.Values = resourceWithValues.Values
}
simpleStatus, err := makeSimplifiedTerraformStatus(config, workingDirectory, tfShow)
if err != nil {
return nil, nil, "", errors.Wrap(err, "can't TerraformPlanAndApply because can't makeSimplifiedTerraformStatus")
}
// json, err := json.MarshalIndent(simpleStatus, "", " ")
// if err != nil {
// return nil, errors.Wrap(err, "can't GenerateTerraformPlan because can't json.Marshal the simpleStatus")
// }
// log.Println(string(json))
return &simpleStatus, &tfShow, string(planStdout), nil
}
simpleStatus, tfShow, planStdout, err := doPlan(terraformDirectory)
if err != nil {
return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't doPlan()")
}
// After plan but before apply, check over the plan and fix any known issues with cloud providers
stateModified := false
// DigitalOcean
// TODO remove/import any orphaned server.garden tagged instances?
mod, err := handleDigitalOceanSSHKeyAlreadyExists(config, workingDirectory, terraformDirectory, tfShow)
if mod {
stateModified = true
}
// Gandi.net
//
mod, err = handleGandiLiveDNSRecordAlreadyExists(config, workingDirectory, terraformDirectory, tfShow)
if mod {
stateModified = true
}
// After we try to fix any known issues, we may have to plan again if the terraform state was changed
// for example, with terraform import or state commands.
if stateModified {
simpleStatus, tfShow, planStdout, err = doPlan(terraformDirectory)
if err != nil {
return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't doPlan()")
}
}
svg, err := makeSVGFromSimpleStatus(simpleStatus)
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 {
err := linkAnsibleWrapperToModule(strings.TrimPrefix(moduleName, "module."), workingDirectory, terraformProject, true)
if err != nil {
return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't linkAnsibleWrapperToModule")
}
}
}
process := exec.Command("terraform", "apply", "-auto-approve", configuration.TERRAFORM_PLAN_FILE_NAME)
process.Dir = terraformDirectory
logLinesChannel := make(chan string)
stdoutPipe, err := process.StdoutPipe()
if err != nil {
return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't process.StdoutPipe() terraform apply process")
}
stderrPipe, err := process.StderrPipe()
if err != nil {
return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't process.StderrPipe() terraform apply process")
}
err = process.Start()
if err != nil {
return "", nil, errors.Wrap(err, "can't TerraformPlanAndApply because can't process.Start() terraform apply process")
}
scanAllOutput := func(prefix string, reader io.ReadCloser) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
logLinesChannel <- fmt.Sprintf("%s%s", prefix, scanner.Text())
}
}
go scanAllOutput("stdout: ", stdoutPipe)
go scanAllOutput("stderr: ", stderrPipe)
toReturn := make(chan TerraformApplyResult)
logSoFar := strings.Join(
[]string{
fmt.Sprintf("\n(%s) $ terraform init\n", terraformDirectory),
string(initStdout),
fmt.Sprintf("\n(%s) $ terraform plan -out %s\n", terraformDirectory, configuration.TERRAFORM_PLAN_FILE_NAME),
planStdout,
fmt.Sprintf("\n(%s) $ terraform apply -auto-approve %s\n", terraformDirectory, configuration.TERRAFORM_PLAN_FILE_NAME),
},
"\n",
)
go monitorTerraformApplyProgress(workingDirectory, terraformProject, simpleStatus, process, logSoFar, logLinesChannel, toReturn)
return svg, toReturn, nil
}
func monitorTerraformApplyProgress(
workingDirectory string,
terraformProject string,
simpleStatus *SimplifiedTerraformStatus,
applyProcess *exec.Cmd,
logSoFar string,
logLinesChannel chan string,
outputChannel chan TerraformApplyResult,
) {
//ansibleModules := map[string]bool
logLines := []string{}
terraformIsRunning := true
terraformDirectory := filepath.Join(workingDirectory, terraformProject)
// https://github.com/acarl005/stripansi/blob/master/stripansi.go
ansiEscapeRegex := regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
go (func() {
terraformApplyLogRegex := regexp.MustCompile(`^stdout: (([a-zA-Z0-9_-]+\.)+[a-zA-Z0-9_-]+)(\[([0-9]+)\])?( \([a-zA-Z0-9_-]+\))?: (.*)$`)
for logLine := range logLinesChannel {
logLines = append(logLines, logLine)
lineWithoutAnsiEscapes := ansiEscapeRegex.ReplaceAllString(logLine, "")
matches := terraformApplyLogRegex.FindStringSubmatch(lineWithoutAnsiEscapes)
if matches != nil {
address := matches[1]
//index := matches[4]
message := matches[6]
// address = strings.Trim(address, ".")
// address = regexp.MustCompile(`\[[0-9]+\]$`).ReplaceAllString(address, "")
foundModule := false
for moduleName, module := range simpleStatus.Modules {
if strings.HasPrefix(address, moduleName) {
foundModule = true
if module.IsAnsible {
if strings.HasPrefix(message, "Creating...") || strings.HasPrefix(message, "Modifying...") {
//ansibleModules[moduleName] = true
//fmt.Printf("__INCLUDE_ANSIBLE__%s\n", module.DisplayName)
logLines = append(logLines, fmt.Sprintf("__INCLUDE_ANSIBLE__%s", module.DisplayName))
}
} else {
addressInsideModule := strings.TrimPrefix(address, moduleName)
addressInsideModule = strings.Trim(addressInsideModule, ".")
foundResource := false
for _, resource := range module.Resources {
if addressInsideModule == resource.DisplayName {
foundResource = true
// https://github.com/hashicorp/terraform/blob/5d0b75df7ae65e9f6a8314961c253ca1085c6534/command/hook_ui.go#L78
// https://github.com/hashicorp/terraform/blob/5d0b75df7ae65e9f6a8314961c253ca1085c6534/command/hook_ui.go#L243
if strings.HasPrefix(message, "Creating...") {
resource.State = "creating"
}
if strings.HasPrefix(message, "Creation complete") {
resource.State = "ok"
}
if strings.HasPrefix(message, "Modifying...") {
resource.State = "modifying"
}
if strings.HasPrefix(message, "Modifications complete") {
resource.State = "ok"
}
if strings.HasPrefix(message, "Destroying...") {
resource.State = "destroying"
}
if strings.HasPrefix(message, "Destruction complete") {
resource.State = "destroyed"
}
//fmt.Printf("non-ansible: %s %s %s %s\n", moduleName, resource.DisplayName, resource.State, message)
}
}
if !foundResource {
//fmt.Printf("!foundResource %s %s: %s\n", address, addressInsideModule, message)
}
}
}
}
if !foundModule {
//fmt.Printf("!foundModule %s: %s\n", address, message)
}
} else {
//fmt.Printf("NOT PARSE %s\n", lineWithoutAnsiEscapes)
}
}
})()
joinAnsibleLogsAndUpdateAnsibleStatus := func() string {
processedLogLines := []string{}
for _, line := range logLines {
if strings.HasPrefix(line, "__INCLUDE_ANSIBLE__") {
module := strings.TrimPrefix(line, "__INCLUDE_ANSIBLE__")
logBytes, err := ioutil.ReadFile(filepath.Join(terraformDirectory, "modules", module, "ansible.log"))
if err == nil {
processedLogLines = append(processedLogLines, string(logBytes))
} else {
//fmt.Printf("ansible.log: %s\n", err)
}
jsonBytes, err := ioutil.ReadFile(filepath.Join(terraformDirectory, "modules", module, "ansible-log.json"))
if err == nil {
var ansibleLog []AnsibleTaskResult
err = json.Unmarshal(jsonBytes, &ansibleLog)
if err == nil {
module, has := simpleStatus.Modules[fmt.Sprintf("module.%s", module)]
if has {
ansibleRoles := map[string]int{}
for _, ansibleResult := range ansibleLog {
if ansibleResult.Role != "" && ansibleResult.Success {
ansibleRoles[ansibleResult.Role] += 1
}
}
for _, resource := range module.Resources {
resource.Progress = ansibleRoles[resource.DisplayName]
if resource.Progress < resource.ProgressTotal {
resource.State = "creating"
} else {
resource.State = "ok"
}
// fmt.Printf(
// "ansible %s %s %s (%d/%d)\n",
// fmt.Sprintf("module.%s", module.DisplayName), resource.DisplayName, resource.State, resource.Progress, resource.ProgressTotal,
// )
}
} else {
//fmt.Printf("module %s not found\n", fmt.Sprintf("module.%s", module))
}
}
} else {
//fmt.Printf("ansible-log.json: %s\n", err)
}
} else {
processedLogLines = append(processedLogLines, line)
}
}
return fmt.Sprintf("%s%s", logSoFar, strings.Join(processedLogLines, "\n"))
}
go (func() {
for terraformIsRunning {
time.Sleep(time.Second * configuration.TERRAFORM_APPLY_STATUS_UPDATE_INTERVAL_SECONDS)
if terraformIsRunning {
log := joinAnsibleLogsAndUpdateAnsibleStatus()
outputChannel <- TerraformApplyResult{
Error: nil,
Log: log,
Status: simpleStatus,
}
}
}
})()
err := applyProcess.Wait()
// once upon a time I got an error
// panic: send on closed channel @ terraformActions.go:204 (logLinesChannel <- blahblah; in scanAllOutput())
// I assumed it was a race condition so I put this sleep here to try to fix it, lets see if it ever happens again...
go (func() {
time.Sleep(time.Millisecond * 100)
close(logLinesChannel)
})()
terraformIsRunning = false
if err == nil && applyProcess.ProcessState.ExitCode() != 0 {
err = fmt.Errorf("terraform apply failed: exit code %d", applyProcess.ProcessState.ExitCode())
}
log := joinAnsibleLogsAndUpdateAnsibleStatus()
outputChannel <- TerraformApplyResult{
Error: err,
Complete: true,
Log: log,
Status: simpleStatus,
}
close(outputChannel)
// cleanUpSymlinksInTerraformProjects
for moduleName, module := range simpleStatus.Modules {
if module.IsAnsible {
linkAnsibleWrapperToModule(strings.TrimPrefix(moduleName, "module."), workingDirectory, terraformProject, false)
rolesFolder := filepath.Join(workingDirectory, configuration.TERRAFORM_MODULES, moduleName, "roles")
os.Remove(rolesFolder)
}
}
}
func makeSimplifiedTerraformStatus(
config *configuration.Configuration,
workingDirectory string,
tfShow TerraformShow,
) (SimplifiedTerraformStatus, error) {
omitFromDiagram := []string{
post_to_object_storage_shell_script,
data_template_file,
}
shouldOmit := func(name string) bool {
toReturn := false
for _, omit := range omitFromDiagram {
if strings.Contains(name, omit) {
toReturn = true
}
}
return toReturn
}
simpleModules := map[string]*SimplifiedTerraformModule{}
for name, module := range tfShow.Configuraton.RootModule.ModuleCalls {
if shouldOmit(name) {
continue
}
resources := []*SimplifiedTerraformResource{}
isAnsible := strings.HasPrefix(name, "ansible-")
if isAnsible {
rolesMap, err := getAnsibleRolesFromModule(name, workingDirectory)
if err != nil {
return SimplifiedTerraformStatus{}, errors.Wrapf(err, "cant getAnsibleRoles(%s) because", name)
}
for roleName, tasks := range rolesMap {
if shouldOmit(roleName) {
continue
}
resource := SimplifiedTerraformResource{
ResourceType: "ansible_role",
DisplayName: roleName,
Plan: "none",
State: "ok",
Progress: 0,
ProgressTotal: len(tasks),
}
resources = append(resources, &resource)
}
} else {
for _, resource := range module.Module.Resources {
if shouldOmit(resource.Address) {
continue
}
resource := SimplifiedTerraformResource{
ResourceType: resource.Type,
DisplayName: resource.Address,
Plan: "none",
State: "ok",
}
resources = append(resources, &resource)
}
}
if len(resources) == 0 {
resource := SimplifiedTerraformResource{
DisplayName: "none",
}
resources = append(resources, &resource)
}
simpleModules[fmt.Sprintf("module.%s", name)] = &SimplifiedTerraformModule{
DisplayName: name,
IsAnsible: isAnsible,
Resources: resources,
}
}
resourceIndexRegexp := regexp.MustCompile(`\[([0-9]+)\]$`)
for _, resourceChange := range tfShow.ResourceChanges {
module, has := simpleModules[resourceChange.ModuleAddress]
if has {
address := strings.TrimPrefix(resourceChange.Address, resourceChange.ModuleAddress)
address = strings.Trim(address, ".")
// TODO it looks like the simplified terraform status wraps up repeated resources into one
// and it takes the "Plan" and "State" from whichever was the last one
// Is that ok? do we need to change that ?
//indexMatches = resourceIndexRegexp.FindStringSubmatch(address)
address = resourceIndexRegexp.ReplaceAllString(address, "")
if shouldOmit(address) {
continue
}
foundResource := false
for _, resource := range module.Resources {
if resource.DisplayName == address || module.IsAnsible {
foundResource = true
create := false
delete := false
update := false
for _, action := range resourceChange.Change.Actions {
if action == "create" {
create = true
}
if action == "delete" {
delete = true
}
if action == "update" {
update = true
}
}
if create && delete {
resource.Plan = "recreate"
resource.State = "tainted"
} else if create {
resource.Plan = "create"
resource.State = "planned"
} else if delete {
resource.Plan = "delete"
resource.State = "tainted"
} else if update {
resource.Plan = "update"
resource.State = "tainted"
}
//fmt.Printf("init: %s %s %s %s\n", resourceChange.ModuleAddress, address, strings.Join(resourceChange.Change.Actions, ","), resource.State)
}
}
if !foundResource {
err := fmt.Errorf("no resource \"%s\" found in module \"%s\" when processing a resource change", address, resourceChange.ModuleAddress)
return SimplifiedTerraformStatus{}, err
}
} else {
err := fmt.Errorf("no module \"%s\" found when processing a resource change", resourceChange.ModuleAddress)
return SimplifiedTerraformStatus{}, err
}
}
simpleConnectionsMap := map[string]*SimplifiedTerraformConnection{}
for moduleName, module := range tfShow.Configuraton.RootModule.ModuleCalls {
if shouldOmit(moduleName) {
continue
}
for expressionName, expression := range module.Expressions {
if shouldOmit(expressionName) {
continue
}
for _, reference := range expression.References {
if shouldOmit(reference) {
continue
}
refSlice := strings.Split(reference, ".")
if len(refSlice) > 1 {
reference = strings.Join(refSlice[:2], ".")
simpleConnectionsMap[fmt.Sprintf("%s%s%s", reference, moduleName, expressionName)] = &SimplifiedTerraformConnection{
From: reference,
To: fmt.Sprintf("module.%s", moduleName),
DisplayName: expressionName,
}
}
}
}
}
simpleConnections := []SimplifiedTerraformConnection{}
for _, simpleConnection := range simpleConnectionsMap {
simpleConnections = append(simpleConnections, *simpleConnection)
}
simpleVariables := map[string]string{}
simpleVariables[ssh_public_keys] = "<secure shell public keys>"
simpleVariables[ssh_private_key_filepath] = ssh_private_key_filepath_value
for variableName, value := range config.Terraform.Variables {
if shouldOmit(variableName) {
continue
}
simpleVariables[variableName] = value
}
return SimplifiedTerraformStatus{
Modules: simpleModules,
Connections: simpleConnections,
Variables: simpleVariables,
}, nil
}
func makeSVGFromSimpleStatus(simpleStatus *SimplifiedTerraformStatus) (string, error) {
moduleDots := []string{}
for moduleName, module := range simpleStatus.Modules {
resourceDots := []string{}
for _, resource := range module.Resources {
id := fmt.Sprintf(`%s_%s`, moduleName, resource.DisplayName)
id = strings.ReplaceAll(strings.ReplaceAll(id, ".", "_"), "-", "_")
resourceStyle := `, margin = 0.1, shape = "box3d"`
if resource.DisplayName == "none" {
resourceStyle = ""
}
tooltip := ""
if !strings.HasPrefix(moduleName, "module.ansible-") && resource.DisplayName != "none" {
tooltip = fmt.Sprintf(`, tooltip = "%s.%s"`, moduleName, resource.DisplayName)
}
resourceDot := fmt.Sprintf(`
"%s" [label = "%s"%s%s]`,
id, padStringForDot(resource.DisplayName), tooltip, resourceStyle,
)
resourceDots = append(resourceDots, resourceDot)
}
i := 0
for _, resource := range module.Resources {
if i != 0 {
id0 := fmt.Sprintf(`%s_%s`, moduleName, module.Resources[i-1].DisplayName)
id0 = strings.ReplaceAll(strings.ReplaceAll(id0, ".", "_"), "-", "_")
id1 := fmt.Sprintf(`%s_%s`, moduleName, resource.DisplayName)
id1 = strings.ReplaceAll(strings.ReplaceAll(id1, ".", "_"), "-", "_")
resourceDot := fmt.Sprintf(`
"%s" -> "%s" [style=invis]`, id0, id1,
)
resourceDots = append(resourceDots, resourceDot)
}
i++
}
moduleDot := fmt.Sprintf(`
subgraph cluster_%s {
bgcolor = lightgrey;
tooltip = "%s";
label = "%s";
%s
}`,
strings.ReplaceAll(strings.ReplaceAll(moduleName, ".", "_"), "-", "_"),
moduleName,
padStringForDot(moduleName),
strings.Join(resourceDots, ""),
)
//fmt.Println(moduleName)
moduleDots = append(moduleDots, moduleDot)
}
variableDots := []string{}
for variableName, value := range simpleStatus.Variables {
variableDot := fmt.Sprintf(`
"var_%s" [label = "%s", tooltip = "%s", margin = 0.1, shape = "note"];`,
strings.ReplaceAll(variableName, "-", "_"), padStringForDot(value), fmt.Sprintf("var.%s", variableName),
)
variableDots = append(variableDots, variableDot)
}
edgeMap := map[string]bool{}
connectionDots := []string{}
for _, connection := range simpleStatus.Connections {
tailLocation := fmt.Sprintf(
"ltail=\"cluster_%s\", ",
strings.ReplaceAll(strings.ReplaceAll(connection.From, ".", "_"), "-", "_"),
)
headLocation := fmt.Sprintf(
"lhead=\"cluster_%s\", ",
strings.ReplaceAll(strings.ReplaceAll(connection.To, ".", "_"), "-", "_"),
)
from := connection.From
if strings.HasPrefix(connection.From, "var") || strings.HasPrefix(connection.From, "data.terraform_remote_state") {
tailLocation = ""
} else {
fromResources := simpleStatus.Modules[connection.From].Resources
from = fmt.Sprintf("%s_%s", connection.From, fromResources[len(fromResources)-1].DisplayName)
}
toResources := simpleStatus.Modules[connection.To].Resources
to := fmt.Sprintf("%s_%s", connection.To, toResources[0].DisplayName)
from = strings.ReplaceAll(strings.ReplaceAll(from, ".", "_"), "-", "_")
to = strings.ReplaceAll(strings.ReplaceAll(to, ".", "_"), "-", "_")
paramName := strings.ReplaceAll(strings.ReplaceAll(connection.DisplayName, ".", "_"), "-", "_")
sourceEdge := fmt.Sprintf(`
"%s" -> "param_%s" [%sarrowhead=none];`,
from, paramName, tailLocation,
)
if !edgeMap[sourceEdge] {
edgeMap[sourceEdge] = true
} else {
sourceEdge = ""
}
destEdge := fmt.Sprintf(`
"param_%s" -> "%s" [%s];`,
paramName, to, headLocation,
)
if !edgeMap[destEdge] {
edgeMap[destEdge] = true
} else {
destEdge = ""
}
connectionDot := fmt.Sprintf(`
"param_%s" [label = "%s", shape = "invhouse", style=filled, color=lightgrey]; %s %s`,
paramName, connection.DisplayName, sourceEdge, destEdge,
)
connectionDots = append(connectionDots, connectionDot)
}
dot := fmt.Sprintf(`
digraph {
compound = "true"
newrank = "true"
ranksep = 0.1;
%s
%s
%s
}`,
strings.Join(moduleDots, ""),
strings.Join(variableDots, ""),
strings.Join(connectionDots, ""),
)
exitCode, dotStdout, dotStderr, err := shellExecInputPipe(".", &dot, "dot", "-Tsvg")
err = errorFromShellExecResult("dot -Tsvg", exitCode, dotStdout, dotStderr, err)
if err != nil {
return "", err
}
svgString := string(dotStdout)
// the svg output from dot has html comments that contain metadata preceeding each entity.
// for example:
// <!-- param_ssh_private_key_filepath -->
// <g id="node16" class="node">
// 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*(.+)\s*-->\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(`<title>[^<]*</title>`)
// 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
}
func linkAnsibleWrapperToModule(moduleName, workingDirectory, terraformProject string, createLink bool) error {
ansibleDirectory := filepath.Join(workingDirectory, terraformProject, "modules", moduleName)
for _, toLink := range configuration.GET_ANSIBLE_WRAPPER_FILES() {
inModule := filepath.Join(ansibleDirectory, toLink)
inAnsibleWrapper := filepath.Join(workingDirectory, configuration.ANSIBLE_WRAPPER_PATH, toLink)
os.Remove(inModule)
if createLink {
err := os.Symlink(inAnsibleWrapper, inModule)
if err != nil {
return errors.Wrapf(err, "could not create symbolic link for ansible wrapper")
}
}
}
return nil
}
func getAnsibleRolesFromModule(moduleName, workingDirectory string) (map[string][]string, error) {
ansibleDirectory := filepath.Join(workingDirectory, configuration.TERRAFORM_MODULES, moduleName)
rolesFolder := filepath.Join(ansibleDirectory, "roles")
os.Remove(rolesFolder)
err := os.Symlink(
filepath.Join(workingDirectory, configuration.ANSIBLE_ROLES),
rolesFolder,
)
if err != nil {
return nil, errors.Wrapf(err, "could not create symbolic link to ansible roles folder")
}
exitCode, ansibleStdout, ansibleStderr, err := shellExec(ansibleDirectory, "ansible-playbook", "--list-tasks", configuration.ANSIBLE_PLAYBOOK_FILE_NAME)
err = errorFromShellExecResult("ansible-playbook --list-tasks", exitCode, ansibleStdout, ansibleStderr, err)
if err != nil {
return nil, err
}
// ansibleStdout looks like:
// playbook: provision.yml
//
// play #1 (localhost): This is a hello-world example TAGS: []
// tasks:
// test-role : Create a file called '/tmp/testfile.txt' with the content 'hello world'. TAGS: []
// .....
matchTask := regexp.MustCompile(`\n\s+(( *[^ :]+)+) : (.*?)\s*TAGS: \[.+`)
taskMatches := matchTask.FindAllStringSubmatch(string(ansibleStdout), -1)
ansibleRoles := map[string][]string{}
for _, match := range taskMatches {
role := match[1]
task := match[3]
if _, has := ansibleRoles[role]; !has {
ansibleRoles[role] = []string{}
}
ansibleRoles[role] = append(ansibleRoles[role], task)
}
return ansibleRoles, nil
}
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
}