rootsystem/automation/dockerCompose.go

710 lines
22 KiB
Go
Raw Permalink Normal View History

package automation
import (
"encoding/json"
2020-10-01 20:51:46 +00:00
"fmt"
"io/ioutil"
2020-10-03 04:07:47 +00:00
"log"
2020-10-01 20:51:46 +00:00
"os"
2020-10-03 04:07:47 +00:00
"os/exec"
2020-10-01 20:51:46 +00:00
"path"
2020-10-03 05:15:40 +00:00
"regexp"
2020-10-01 20:51:46 +00:00
"strings"
2020-10-03 04:07:47 +00:00
"time"
2020-10-01 20:51:46 +00:00
errors "git.sequentialread.com/forest/pkg-errors"
"git.sequentialread.com/forest/rootsystem/configuration"
2020-10-01 20:51:46 +00:00
composeloader "github.com/docker/cli/cli/compose/loader"
composeschema "github.com/docker/cli/cli/compose/schema"
composetypes "github.com/docker/cli/cli/compose/types"
)
func DockerComposeUp(
config *configuration.Configuration,
workingDirectory string,
) ([]byte, chan TerraformApplyResult, error) {
existingModuleFolders := map[string]bool{}
2020-10-01 20:51:46 +00:00
applicationModulesMap := map[string]*composetypes.ConfigDetails{}
applicationModulesPath := path.Join(workingDirectory, configuration.APPLICATION_MODULES_PATH)
fileInfos, err := ioutil.ReadDir(applicationModulesPath)
if err != nil {
return nil, nil, errors.Wrap(err, "can't list application modules directory")
}
for _, fileInfo := range fileInfos {
if fileInfo.IsDir() {
existingModuleFolders[fileInfo.Name()] = true
2020-10-01 20:51:46 +00:00
}
}
for _, moduleName := range config.ApplicationModules {
_, has := existingModuleFolders[moduleName]
if !has {
2020-10-01 20:51:46 +00:00
return nil, nil, errors.New(fmt.Sprintf("unknown application module '%s'", moduleName))
}
workingDir := path.Join(applicationModulesPath, moduleName)
dockerComposePath := path.Join(workingDir, "docker-compose.yml")
2020-10-01 20:51:46 +00:00
fileInfo, err := os.Stat(dockerComposePath)
if err != nil || fileInfo.IsDir() {
return nil, nil, errors.New(
fmt.Sprintf("application module '%s' doesn't contain a docker-compose.yml file", moduleName),
)
}
bytes, err := ioutil.ReadFile(dockerComposePath)
if err != nil {
return nil, nil, errors.Wrap(err, "can't read application module compose file")
}
config, err := composeloader.ParseYAML(bytes)
if err != nil {
return nil, nil, errors.New(
fmt.Sprintf("can't ParseYAML(docker-compose.yml) in application module '%s'", moduleName),
)
}
configDetails := composetypes.ConfigDetails{
WorkingDir: workingDir,
Version: composeschema.Version(config),
ConfigFiles: []composetypes.ConfigFile{
composetypes.ConfigFile{
Filename: dockerComposePath,
Config: config,
},
2020-10-01 20:51:46 +00:00
},
}
applicationModulesMap[moduleName] = &configDetails
2020-10-01 20:51:46 +00:00
// TODO set details.Environment ?
}
configs := map[string]*composetypes.Config{}
moduleByNetwork := map[string]string{}
2020-10-03 04:07:47 +00:00
connectionsMap := map[string]map[string]SimplifiedTerraformConnection{}
2020-10-01 20:51:46 +00:00
for _, moduleName := range config.ApplicationModules {
dockerCompose, err := composeloader.Load(*(applicationModulesMap[moduleName]))
2020-10-01 20:51:46 +00:00
if err != nil {
return nil, nil, errors.Wrapf(
err, "can't composeloader.Load(docker-compose.yml) for application module '%s'", moduleName,
2020-10-01 20:51:46 +00:00
)
}
configs[moduleName] = dockerCompose
2020-10-01 20:51:46 +00:00
createsDefaultNetwork := false
for _, service := range dockerCompose.Services {
2020-10-01 20:51:46 +00:00
for networkName := range service.Networks {
if networkName == "default" {
createsDefaultNetwork = true
}
}
if len(service.Networks) == 0 {
createsDefaultNetwork = true
}
}
if createsDefaultNetwork {
moduleByNetwork[fmt.Sprintf("%s_default", moduleName)] = moduleName
}
for networkId, network := range dockerCompose.Networks {
2020-10-01 20:51:46 +00:00
if !network.External.External {
networkName := fmt.Sprintf("%s_%s", moduleName, networkId)
if network.Name != "" {
networkName = network.Name
}
moduleByNetwork[networkName] = moduleName
}
}
// bytes2, _ := json.MarshalIndent(dockerCompose, "", " ")
// log.Printf("\ndockerCompose %s: %s\n\n", moduleName, string(bytes2))
2020-10-01 20:51:46 +00:00
}
bytes1, _ := json.MarshalIndent(moduleByNetwork, "", " ")
log.Printf("\nmoduleByNetwork: %s\n\n", string(bytes1))
2020-10-01 20:51:46 +00:00
for _, moduleName := range config.ApplicationModules {
config := configs[moduleName]
for _, network := range config.Networks {
dependsOnModule := moduleByNetwork[network.Name]
if network.External.External && dependsOnModule != "" {
2020-10-03 04:07:47 +00:00
if connectionsMap[moduleName] == nil {
connectionsMap[moduleName] = map[string]SimplifiedTerraformConnection{}
}
connectionsMap[moduleName][dependsOnModule] = SimplifiedTerraformConnection{
From: dependsOnModule,
To: moduleName,
DisplayName: network.Name,
}
2020-10-01 20:51:46 +00:00
}
}
//TODO external volumes as well..?
//TODO depends_on.. ?
2020-10-03 04:07:47 +00:00
//TODO dis-allow links? other validation?
}
sortedApplicationModulesMap := map[string]bool{}
sortedApplicationModules := []string{}
iterations := 0
for len(sortedApplicationModules) < len(config.ApplicationModules) && iterations < 100 {
iterations++
for _, moduleName := range config.ApplicationModules {
if !sortedApplicationModulesMap[moduleName] {
allDependenciesMet := true
2020-10-03 04:07:47 +00:00
dependencies := connectionsMap[moduleName]
if dependencies != nil {
2020-10-03 04:07:47 +00:00
for dependsOnModule := range dependencies {
if !sortedApplicationModulesMap[dependsOnModule] {
allDependenciesMet = false
}
}
}
if allDependenciesMet {
sortedApplicationModulesMap[moduleName] = true
sortedApplicationModules = append(sortedApplicationModules, moduleName)
}
}
}
2020-10-01 20:51:46 +00:00
}
if len(sortedApplicationModules) < len(config.ApplicationModules) {
return nil, nil, errors.New(
"circular dependency detected: can't sort application modules in dependency order",
)
}
2020-10-03 04:07:47 +00:00
connections := []SimplifiedTerraformConnection{}
for _, dependsOn := range connectionsMap {
for _, connection := range dependsOn {
connections = append(connections, connection)
}
}
simpleStatus, err := dockerComposePlan(configs, applicationModulesMap, connections)
if err != nil {
return nil, nil, errors.Wrap(err, "can't create docker-compose plan")
}
svgBytes, err := makeSVGFromSimpleStatus(simpleStatus)
//svg, err := makeSVGFromDockerCompose(moduleDependencies, applicationModulesMap, configs)
if err != nil {
return nil, nil, errors.Wrap(err, "can't create svg diagram from docker-compose plan document")
}
outputChannel := make(chan TerraformApplyResult)
go (func() {
logLinesChannel := make(chan string)
logLines := []string{}
dockerComposeIsRunning := true
go (func() {
for logLine := range logLinesChannel {
logLines = append(logLines, logLine)
2020-10-03 05:15:40 +00:00
split := strings.Split(logLine, " ... ")
if len(split) == 2 {
leftSide := strings.Split(strings.TrimSpace(split[0]), " ")
status := split[1]
if len(leftSide) == 2 {
action := leftSide[0]
containerName := leftSide[1]
for moduleName, _ := range configs {
moduleNameUnderscore := fmt.Sprintf("%s_", moduleName)
if strings.HasPrefix(containerName, moduleNameUnderscore) {
serviceName := strings.TrimPrefix(containerName, moduleNameUnderscore)
serviceName = regexp.MustCompile("_[0-9]$").ReplaceAllString(serviceName, "")
if simpleStatus.Modules[moduleName] != nil {
for _, service := range simpleStatus.Modules[moduleName].Resources {
if service.DisplayName == serviceName {
if status == "done" {
service.State = "ok"
} else if action == "Recreating" {
service.State = "modifying"
} else if action == "Creating" {
service.State = "creating"
}
}
}
}
}
}
}
}
2020-10-03 04:07:47 +00:00
}
})()
abort := func(err error) {
dockerComposeIsRunning = false
outputChannel <- TerraformApplyResult{
Error: err,
Complete: true,
Success: false,
Log: strings.Join(logLines, "\n"),
Status: simpleStatus,
}
close(outputChannel)
}
go (func() {
for dockerComposeIsRunning {
time.Sleep(time.Second * configuration.TERRAFORM_APPLY_STATUS_UPDATE_INTERVAL_SECONDS)
if dockerComposeIsRunning {
outputChannel <- TerraformApplyResult{
Error: nil,
Log: strings.Join(logLines, "\n"),
Status: simpleStatus,
}
}
}
})()
dockerComposeSuccess := true
for _, moduleName := range sortedApplicationModules {
// TODO handle panics in here with abort
details := applicationModulesMap[moduleName]
2020-10-03 05:15:40 +00:00
process := exec.Command("docker-compose", "--no-ansi", "up", "-d", "--remove-orphans")
logLinesChannel <- fmt.Sprintf("\n(%s) $ docker-compose --no-ansi up -d --remove-orphans\n", details.WorkingDir)
2020-10-03 04:07:47 +00:00
process.Dir = details.WorkingDir
stdoutPipe, err := process.StdoutPipe()
if err != nil {
abort(errors.Wrap(err, "can't DockerComposeUp because can't process.StdoutPipe() docker-compose process"))
return
}
stderrPipe, err := process.StderrPipe()
if err != nil {
abort(errors.Wrap(err, "can't DockerComposeUp because can't process.StderrPipe() docker-compose process"))
return
}
err = process.Start()
if err != nil {
abort(errors.Wrap(err, "can't DockerComposeUp because can't process.Start() docker-compose process"))
return
}
go scanAllOutput(stdoutPipe, logLinesChannel)
go scanAllOutput(stderrPipe, logLinesChannel)
err = process.Wait()
_, isExitError := err.(*exec.ExitError)
if err != nil && !isExitError {
abort(errors.Wrap(err, "can't DockerComposeUp, error occurred while waiting for docker-compose to finish"))
return
}
if process.ProcessState.ExitCode() != 0 {
dockerComposeSuccess = false
break
}
}
dockerComposeIsRunning = false
if !dockerComposeSuccess {
for _, module := range simpleStatus.Modules {
for _, resource := range module.Resources {
if resource.Plan != "none" {
resource.State = "error"
}
}
}
}
2020-10-03 05:15:40 +00:00
time.Sleep(time.Millisecond * 100)
close(logLinesChannel)
2020-10-03 04:07:47 +00:00
outputChannel <- TerraformApplyResult{
Error: nil,
Complete: true,
Success: dockerComposeSuccess,
Log: strings.Join(logLines, "\n"),
Status: simpleStatus,
}
close(outputChannel)
})()
return svgBytes, outputChannel, nil
2020-10-01 20:51:46 +00:00
}
2020-10-03 04:07:47 +00:00
func dockerComposePlan(
configs map[string]*composetypes.Config,
2020-10-03 04:07:47 +00:00
configDetails map[string]*composetypes.ConfigDetails,
connections []SimplifiedTerraformConnection,
) (*SimplifiedTerraformStatus, error) {
2020-10-01 20:51:46 +00:00
tasks := []func() TaskResult{
func() TaskResult {
2020-11-04 07:24:31 +00:00
containers, err := ListDockerContainers()
2020-10-01 20:51:46 +00:00
2020-10-03 04:07:47 +00:00
if err != nil {
return TaskResult{
2020-10-03 04:07:47 +00:00
Name: "list_containers_task",
Err: errors.Wrap(err, "can't list docker containers"),
2020-10-03 04:07:47 +00:00
}
}
return TaskResult{
2020-10-03 04:07:47 +00:00
Name: "list_containers_task",
Result: containers,
Err: err,
}
},
}
for moduleName, details := range configDetails {
// https://stackoverflow.com/questions/26692844/captured-closure-for-loop-variable-in-go
// golang way of doing a closure over the CURRENT VALUE of a variable (not the variable itself)
moduleName := moduleName
details := *details
tasks = append(tasks, func() TaskResult {
taskName := fmt.Sprintf("%s_get_compose_config_hash", moduleName)
2020-10-03 04:07:47 +00:00
exitCode, stdout, stderr, err := shellExec(details.WorkingDir, "docker-compose", "config", "--hash=*")
err = errorFromShellExecResult("docker-compose config --hash=*", exitCode, stdout, stderr, err)
if err != nil {
return TaskResult{
2020-10-03 04:07:47 +00:00
Name: taskName,
Err: err,
}
}
// $ sudo docker-compose config --hash=*
// threshold 5f6deb1fa7dfc76fa0dc04e96d22cb4a2cc05a02060a4a3e77d38b87ff1d7f82
// caddy 95b3516b900089a3d04376b2635bfbde5596ec3769c50f12ce53b65f2d2e62a0
lines := strings.Split(string(stdout), "\n")
configHashByServiceName := map[string]string{}
for _, line := range lines {
elements := strings.Split(line, " ")
if len(elements) == 2 {
configHashByServiceName[elements[0]] = elements[1]
}
}
return TaskResult{
2020-10-03 04:07:47 +00:00
Name: taskName,
Result: configHashByServiceName,
}
})
}
results := DoInParallel(tasks...)
2020-10-03 04:07:47 +00:00
for _, result := range results {
if result.Err != nil {
return nil, errors.Wrapf(result.Err, "can't dockerComposePlan because %s failed", result.Name)
2020-10-01 20:51:46 +00:00
}
}
containers := results["list_containers_task"].Result.([]DockerContainer)
containersByModuleService := map[string]DockerContainer{}
2020-10-03 04:07:47 +00:00
planModules := map[string]*SimplifiedTerraformModule{}
2020-10-03 04:07:47 +00:00
// First we go through all the containers returned by `docker ps -a` api command.
// we fill out the containersByModuleService map, and
// mark to be deleted any docker-compose containers that aren't in the current docker-compose configs.
for _, container := range containers {
containersModuleName := container.Labels["com.docker.compose.project"]
containersServiceName := container.Labels["com.docker.compose.service"]
if containersModuleName == "" {
continue
}
containersConfig, has := configs[containersModuleName]
foundInConfig := false
if has {
if containersServiceName != "" {
containersByModuleService[fmt.Sprintf("%s_%s", containersModuleName, containersServiceName)] = container
}
var containersService composetypes.ServiceConfig
for _, service := range containersConfig.Services {
if service.Name == containersServiceName {
containersService = service
}
}
if containersService.Name != "" {
foundInConfig = true
}
}
if !foundInConfig {
if _, has := planModules[containersModuleName]; !has {
planModules[containersModuleName] = &SimplifiedTerraformModule{
DisplayName: containersModuleName,
Resources: []*SimplifiedTerraformResource{},
}
}
planModules[containersModuleName].Resources = append(
planModules[containersModuleName].Resources,
&SimplifiedTerraformResource{
DisplayName: containersServiceName,
Plan: "delete",
},
)
2020-10-03 04:07:47 +00:00
}
}
2020-10-01 20:51:46 +00:00
2020-10-03 04:07:47 +00:00
// Now we go through the current docker-compose configs and mark any services
// that have no containers as to be created,
// plus mark any that have differing configHash as to be updated
for moduleName, config := range configs {
if _, has := planModules[moduleName]; !has {
planModules[moduleName] = &SimplifiedTerraformModule{
DisplayName: moduleName,
Resources: []*SimplifiedTerraformResource{},
}
}
for _, service := range config.Services {
container := containersByModuleService[fmt.Sprintf("%s_%s", moduleName, service.Name)]
plan := "none"
2020-11-04 07:24:31 +00:00
if container.Id != "" {
2020-10-03 04:07:47 +00:00
existingConfigHash := container.Labels["com.docker.compose.config-hash"]
getConfigHashTaskName := fmt.Sprintf("%s_get_compose_config_hash", moduleName)
newConfigHash := results[getConfigHashTaskName].Result.(map[string]string)[service.Name]
if existingConfigHash != newConfigHash {
plan = "recreate"
}
} else {
plan = "create"
}
planModules[moduleName].Resources = append(
planModules[moduleName].Resources,
&SimplifiedTerraformResource{
DisplayName: service.Name,
Plan: plan,
},
)
2020-10-01 20:51:46 +00:00
}
}
2020-10-03 04:07:47 +00:00
return &SimplifiedTerraformStatus{
Modules: planModules,
Variables: map[string]string{},
Connections: connections,
}, nil
2020-10-03 04:07:47 +00:00
}
func DoInParallel(actions ...func() TaskResult) map[string]TaskResult {
2020-10-03 04:07:47 +00:00
resultsChannel := make(chan TaskResult)
results := map[string]TaskResult{}
log.Printf("do %d actions in parallel", len(actions))
2020-10-03 04:07:47 +00:00
for _, action := range actions {
// this is how you do closures over the VALUE of a variable (not the variable itself) in golang
// https://stackoverflow.com/questions/26692844/captured-closure-for-loop-variable-in-go
action := action
2020-10-03 04:07:47 +00:00
go (func() {
result := action()
resultsChannel <- result
})()
2020-10-01 20:51:46 +00:00
}
2020-10-03 04:07:47 +00:00
for range actions {
result := <-resultsChannel
results[result.Name] = result
log.Printf("task '%s' completed", result.Name)
if result.Err != nil {
break
2020-10-03 04:07:47 +00:00
}
}
return results
}
2020-10-01 20:51:46 +00:00
type TaskResult struct {
2020-10-03 04:07:47 +00:00
Name string
Err error
Result interface{}
}
2020-10-03 04:07:47 +00:00
// func makeSVGFromDockerCompose(
// moduleDependencies map[string][]string,
// configs map[string]*composetypes.Config,
// ) ([]byte, error) {
// moduleDots := []string{}
// for moduleName := range configs {
// serviceDots := []string{}
// config := configs[moduleName]
// for _, service := range config.Services {
// id := fmt.Sprintf(`service_%s_%s`, moduleName, service.Name)
// id = strings.ReplaceAll(strings.ReplaceAll(id, ".", "_"), "-", "_")
// serviceDot := fmt.Sprintf(`
// "%s" [label = "%s", tooltip="%s", margin = 0.1, shape = "box3d]`,
// id, padStringForDot(service.Name), id,
// )
// serviceDots = append(serviceDots, serviceDot)
// }
// i := 0
// for _, service := range config.Services {
// if i != 0 {
// id0 := fmt.Sprintf(`service_%s_%s`, moduleName, config.Services[i-1].Name)
// id0 = strings.ReplaceAll(strings.ReplaceAll(id0, ".", "_"), "-", "_")
// id1 := fmt.Sprintf(`service_%s_%s`, moduleName, service.Name)
// id1 = strings.ReplaceAll(strings.ReplaceAll(id1, ".", "_"), "-", "_")
// serviceDot := fmt.Sprintf(`
// "%s" -> "%s" [style=invis]`, id0, id1,
// )
// serviceDots = append(serviceDots, serviceDot)
// }
// i++
// }
// moduleDot := fmt.Sprintf(`
// subgraph cluster_%s {
// bgcolor = lightgrey;
// tooltip = "%s";
// label = "%s";
// %s
// }`,
// strings.ReplaceAll(strings.ReplaceAll(moduleName, ".", "_"), "-", "_"),
// moduleName,
// padStringForDot(moduleName),
// strings.Join(serviceDots, ""),
// )
// moduleDots = append(moduleDots, moduleDot)
// }
// dependencyDots := []string{}
// for to, fromList := range moduleDependencies {
// for _, from := range fromList {
// fromServiceName := configs[from].Services[len(configs[from].Services)-1].Name
// toServiceName := configs[to].Services[0].Name
// fromService := fmt.Sprintf(`service_%s_%s`, from, fromServiceName)
// toService := fmt.Sprintf(`service_%s_%s`, to, toServiceName)
// dependencyDot := fmt.Sprintf(`
// "%s" -> "%s" [ ltail="cluster_%s", lhead="cluster_%s" ];`,
// strings.ReplaceAll(strings.ReplaceAll(fromService, ".", "_"), "-", "_"),
// strings.ReplaceAll(strings.ReplaceAll(toService, ".", "_"), "-", "_"),
// strings.ReplaceAll(strings.ReplaceAll(from, ".", "_"), "-", "_"),
// strings.ReplaceAll(strings.ReplaceAll(to, ".", "_"), "-", "_"),
// )
// dependencyDots = append(dependencyDots, dependencyDot)
// }
// }
// dot := fmt.Sprintf(`
// digraph {
// compound = "true"
// newrank = "true"
// ranksep = 0.1;
// %s
// %s
// }`,
// strings.Join(moduleDots, ""),
// strings.Join(dependencyDots, ""),
// )
// exitCode, dotStdout, dotStderr, err := shellExecInputPipe(".", &dot, "dot", "-Tsvg")
// err = errorFromShellExecResult("dot -Tsvg", exitCode, dotStdout, dotStderr, err)
// if err != nil {
// return []byte{}, err
// }
// svgBytes, err := modifyDotSVGAsXML(dotStdout, func(svgDoc *XMLNode) error {
// // correctly set the dot property for services
// svgDoc.WithQuerySelector(
// // 'g.node > title'
// []XMLQuery{XMLQuery{NodeType: "g", Class: "node"}, XMLQuery{NodeType: "title", DirectChild: true}},
// func(node *XMLNode) {
// if node.Parent != nil {
// title := html.UnescapeString(string(node.Content))
// node.Parent.SetAttr("data-dot", title)
// if strings.HasPrefix(title, "service_") {
// node.Parent.SetAttr("class", "resource")
// }
// }
// },
// )
// // correctly set the dot property for the "cluster"s (aka modules)
// moduleNames := []string{}
// svgDoc.WithQuerySelector(
// // 'a[xlink:title]'
// []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, "_")
// if node.Parent.Parent.GetAttr("data-dot") == "" {
// moduleNames = append(moduleNames, title)
// node.Parent.Parent.SetAttr("data-dot", title)
// node.Parent.Parent.SetAttr("class", "module")
// }
// }
// },
// )
// // correctly set the dot property for edges
// // 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.
// svgDoc.WithQuerySelector(
// // 'g.edge title'
// []XMLQuery{XMLQuery{NodeType: "g", Class: "edge"}, XMLQuery{NodeType: "title"}},
// func(node *XMLNode) {
// if node.Parent != nil {
// title := html.UnescapeString(string(node.Content))
// fromTo := strings.Split(title, "->")
// if len(fromTo) != 2 {
// fmt.Printf("malformed dot string on svg edge: fromTo.length != 2: %s", title)
// }
// // if this edge starts or ends at a resource inside a module, override the dot string
// // to make it start or end at that module instead.
// for _, moduleName := range moduleNames {
// if strings.HasPrefix(strings.TrimPrefix(fromTo[0], "service_"), moduleName) {
// fromTo[0] = moduleName
// }
// if strings.HasPrefix(strings.TrimPrefix(fromTo[1], "service_"), moduleName) {
// fromTo[1] = moduleName
// }
// }
// node.Parent.SetAttr("data-dot", html.EscapeString(fmt.Sprintf("%s->%s", fromTo[0], fromTo[1])))
// }
// },
// )
// // two last things to do:
// // 1. center each resource horizontally within the bounding box of its parent module
// // because dot is dumb and it just kinda throws them wherever
// // 2. create little create/delete/modify icons next to each changed resource
// // similar to how terraform displays -, +, +/-, or ~ on each line-item in the plan
// for moduleName, config := range configs {
// moduleId := regexp.MustCompile(`[.-]`).ReplaceAllString(moduleName, "_")
// svgDoc.WithQuerySelector(
// []XMLQuery{XMLQuery{Attr: "data-dot", AttrValue: moduleId}},
// func(node *XMLNode) {
// moduleRect := node.GetBoundingBox()
// for _, service := range config.Services {
// resourceId := fmt.Sprintf("%s_%s", moduleId, regexp.MustCompile(`[.-]`).ReplaceAllString(service.Name, "_"))
// svgDoc.WithQuerySelector(
// // '[data-dot=module_dns_gandi_gandi_livedns_record_dns_entries]' for example
// []XMLQuery{XMLQuery{Attr: "data-dot", AttrValue: resourceId}},
// func(node *XMLNode) {
// // TODO pass the plan here
// centerResourceAndAddPlannedAction(moduleRect, node, resourceId, "create")
// },
// )
// }
// },
// )
// }
// return nil
// })
// return svgBytes, err
// }