diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1853468 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - uses: actions/setup-go@v5 + with: + go-version: '1.22.2' + cache: false + - run: | + make build + build/terramaid -w test/ + cat Terramaid.md diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7a4bcda..e1a77aa 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,8 +9,7 @@ before: builds: - env: - CGO_ENABLED=0 - ldflags: - - -s -w + ldflags: -s -w -X main.version={{ .Version }} goos: - linux - windows diff --git a/Brewfile b/Brewfile index f8415df..6e603c3 100644 --- a/Brewfile +++ b/Brewfile @@ -1 +1,2 @@ -brew "go" \ No newline at end of file +brew "go" +brew "gofumpt" diff --git a/Makefile b/Makefile index e5811d6..ce15041 100644 --- a/Makefile +++ b/Makefile @@ -2,22 +2,30 @@ BINARY_NAME=terramaid VERSION=v1 GO=go +default: help + +help: ## List Makefile targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + all: build -build: +fmt: ## Format Go files + gofumpt -w . + +build: ## Build Terramaid $(GO) build -ldflags="-s -w" -o build/$(BINARY_NAME) main.go -install: +install: ## Install dependencies $(GO) install ./...@latest -clean: +clean: ## Clean up build artifacts $(GO) clean rm ./build/$(BINARY_NAME) -run: build +run: build ## Run Terramaid ./build/$(BINARY_NAME) -docs: build +docs: build ## Generate documentation ./build/$(BINARY_NAME) docs -.PHONY: all build install clean run \ No newline at end of file +.PHONY: all build install clean run fmt help diff --git a/README.md b/README.md index f5a4f24..849a957 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,19 @@ Terramaid transforms your Terraform resources and plans into visually appealing ### Output ```mermaid -flowchart TD; - subgraph Terraform - aws_db_instance.dev_example_db_instance["aws_db_instance.dev_example_db_instance"] - aws_instance.dev_example_instance["aws_instance.dev_example_instance"] - aws_s3_bucket.dev_logs_bucket["aws_s3_bucket.dev_logs_bucket"] - aws_s3_bucket.dev_test_bucket["aws_s3_bucket.dev_test_bucket"] - aws_s3_bucket_policy.dev_logs_bucket_policy["aws_s3_bucket_policy.dev_logs_bucket_policy"] - aws_s3_bucket_policy.dev_test_bucket_policy["aws_s3_bucket_policy.dev_test_bucket_policy"] - aws_s3_bucket_policy.dev_logs_bucket_policy --> aws_s3_bucket.dev_logs_bucket - aws_s3_bucket_policy.dev_test_bucket_policy --> aws_s3_bucket.dev_test_bucket - end +flowchart TD + subgraph Hashicorp + subgraph Terraform + aws_db_instance.example_db["aws_db_instance.example_db"] + aws_instance.example_instance["aws_instance.example_instance"] + aws_s3_bucket.logs["aws_s3_bucket.logs"] + aws_s3_bucket.test["aws_s3_bucket.test"] + aws_s3_bucket_policy.logs_policy["aws_s3_bucket_policy.logs_policy"] + aws_s3_bucket_policy.test_policy["aws_s3_bucket_policy.test_policy"] + aws_s3_bucket_policy.logs_policy --> aws_s3_bucket.logs + aws_s3_bucket_policy.test_policy --> aws_s3_bucket.test + end + end ``` ## Installation @@ -56,6 +58,41 @@ cd terramaid make build ``` +### Usage + +`terramaid` can be configured using CLI parameters and environment variables. + +> [!NOTE] +> CLI parameters take precedence over environment variables. + +The following configuration options are available: + +```sh +> terramaid -h +A utility for generating Mermaid diagrams from Terraform + +Usage: + terramaid [flags] + terramaid [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + help Help about any command + version Print the CLI version + +Flags: + -r, --direction string Specify the direction of the flowchart (env: TERRAMAID_DIRECTION) (default "TD") + -h, --help help for terramaid + -o, --output string Output file for Mermaid diagram (env: TERRAMAID_OUTPUT) (default "Terramaid.md") + -s, --subgraph-name string Specify the subgraph name of the flowchart (env: TERRAMAID_SUBGRAPH_NAME) (default "Terraform") + -b, --tf-binary string Path to Terraform binary (env: TERRAMAID_TF_BINARY) + -d, --tf-dir string Path to Terraform directory (env: TERRAMAID_TF_DIR) (default ".") + -p, --tf-plan string Path to Terraform plan file (env: TERRAMAID_TF_PLAN) + -w, --working-wir string Working directory for Terraform (env: TERRAMAID_WORKING_DIR) (default ".") + +Use "terramaid [command] --help" for more information about a command. +``` + ### Docker Image Run the following command to utilize the Terramaid Docker image: diff --git a/cmd/docs.go b/cmd/docs.go index 6d3c1fe..832dff5 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -1,24 +1,25 @@ package cmd import ( - "log" - "github.com/spf13/cobra" "github.com/spf13/cobra/doc" ) -var docsCmd = &cobra.Command{ - Use: "docs", - Short: "Generate documentation for the CLI", - SilenceUsage: true, - Run: func(cmd *cobra.Command, args []string) { - err := doc.GenMarkdownTree(RootCmd, "./docs") - if err != nil { - log.Fatal(err) - } - }, -} +func docsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "docs", + Short: "Generate documentation for the CLI", + SilenceUsage: true, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + err := doc.GenMarkdownTree(cmd.Root(), "./docs") + if err != nil { + return err + } + + return nil + }, + } -func init() { - RootCmd.AddCommand(docsCmd) + return cmd } diff --git a/cmd/root.go b/cmd/root.go index 909cdf0..8ca8bfc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,70 +2,117 @@ package cmd import ( "fmt" + "log" "os" "os/exec" "github.com/RoseSecurity/terramaid/internal" + "github.com/RoseSecurity/terramaid/pkg/utils" + "github.com/caarlos0/env/v11" "github.com/spf13/cobra" ) -var ( - workingDir string - tfDir string - tfPlan string - tfBinary string - output string - direction string - subgraphName string -) +var Version = "0.0.1" + +type options struct { + WorkingDir string `env:"WORKING_DIR" envDefault:"."` + TFDir string `env:"TF_DIR" envDefault:"."` + TFPlan string `env:"TF_PLAN"` + TFBinary string `env:"TF_BINARY"` + Output string `env:"OUTPUT" envDefault:"Terramaid.md"` + Direction string `env:"DIRECTION" envDefault:"TD"` + SubgraphName string `env:"SUBGRAPH_NAME" envDefault:"Terraform"` + ChartType string `env:"CHART_TYPE" envDefault:"flowchart"` +} + +func TerramaidCmd() *cobra.Command { + options := &options{} + + // Parse Envs + if err := env.ParseWithOptions(options, env.Options{Prefix: "TERRAMAID_"}); err != nil { + log.Fatalf("error parsing envs: %s", err.Error()) + } + + cmd := &cobra.Command{ + Use: "terramaid", + Short: "A utility for generating Mermaid diagrams from Terraform", + SilenceUsage: true, + SilenceErrors: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if options.TFDir != "" && !utils.DirExists(options.TFDir) { + return fmt.Errorf("Terraform directory \"%s\" does not exist", options.TFDir) + } + + if options.TFDir != "" && !utils.TerraformFilesExist(options.TFDir) { + return fmt.Errorf("Terraform files do not exist in directory \"%s\"", options.TFDir) + } + + if options.WorkingDir != "" && !utils.DirExists(options.WorkingDir) { + return fmt.Errorf("Working directory \"%s\" does not exist", options.WorkingDir) + } + + if options.TFPlan != "" && !utils.DirExists(options.TFPlan) { + return fmt.Errorf("Terraform planfile \"%s\" does not exist", options.TFPlan) + } -var RootCmd = &cobra.Command{ - Use: "terramaid", - Short: "A utility for generating Mermaid diagrams from Terraform", - SilenceUsage: true, - Run: func(cmd *cobra.Command, args []string) { - var err error - if tfBinary == "" { - tfBinary, err = exec.LookPath("terraform") + if options.TFBinary == "" { + tfBinary, err := exec.LookPath("terraform") + if err != nil { + return fmt.Errorf("error finding Terraform binary: %w", err) + } + + options.TFBinary = tfBinary + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + graph, err := internal.ParseTerraform(options.WorkingDir, options.TFBinary, options.TFPlan) if err != nil { - fmt.Printf("Error finding Terraform binary: %v\n", err) - os.Exit(1) + return fmt.Errorf("error parsing Terraform: %w", err) + } + + // Convert the graph to a Mermaid diagram + var mermaidDiagram string + switch options.ChartType { + case "flowchart": + mermaidDiagram, err = internal.ConvertToMermaidFlowchart(graph, options.Direction, options.SubgraphName) + if err != nil { + return fmt.Errorf("error converting to Mermaid flowchart: %w", err) + } + default: + return fmt.Errorf("unsupported chart type: %s", options.ChartType) + } + + // Write the Mermaid diagram to the specified output file + if err := os.WriteFile(options.Output, []byte(mermaidDiagram), 0o644); err != nil { + return fmt.Errorf("error writing to file: %w", err) } - } - - graph, err := internal.ParseTerraform(workingDir, tfBinary, tfPlan) - if err != nil { - fmt.Printf("Error parsing Terraform: %v\n", err) - os.Exit(1) - } - - // Convert the graph to a Mermaid diagram - mermaidDiagram, err := internal.ConvertToMermaid(graph, direction, subgraphName) - if err != nil { - fmt.Printf("Error converting to Mermaid: %v\n", err) - os.Exit(1) - } - - // Write the Mermaid diagram to the specified output file - err = os.WriteFile(output, []byte(mermaidDiagram), 0644) - if err != nil { - fmt.Printf("Error writing to file: %v\n", err) - os.Exit(1) - } - fmt.Printf("Mermaid diagram successfully written to %s\n", output) - }, + + fmt.Printf("Mermaid diagram successfully written to %s\n", options.Output) + + return nil + }, + } + + cmd.Flags().StringVarP(&options.Output, "output", "o", options.Output, "Output file for Mermaid diagram (env: TERRAMAID_OUTPUT)") + cmd.Flags().StringVarP(&options.Direction, "direction", "r", options.Direction, "Specify the direction of the diagram (env: TERRAMAID_DIRECTION)") + cmd.Flags().StringVarP(&options.SubgraphName, "subgraph-name", "s", options.SubgraphName, "Specify the subgraph name of the diagram (env: TERRAMAID_SUBGRAPH_NAME)") + cmd.Flags().StringVarP(&options.ChartType, "chart-type", "c", options.ChartType, "Specify the type of Mermaid chart to generate (env: TERRAMAID_CHART_TYPE)") + cmd.Flags().StringVarP(&options.TFDir, "tf-dir", "d", options.TFDir, "Path to Terraform directory (env: TERRAMAID_TF_DIR)") + cmd.Flags().StringVarP(&options.TFPlan, "tf-plan", "p", options.TFPlan, "Path to Terraform plan file (env: TERRAMAID_TF_PLAN)") + cmd.Flags().StringVarP(&options.TFBinary, "tf-binary", "b", options.TFBinary, "Path to Terraform binary (env: TERRAMAID_TF_BINARY)") + cmd.Flags().StringVarP(&options.WorkingDir, "working-dir", "w", options.WorkingDir, "Working directory for Terraform (env: TERRAMAID_WORKING_DIR)") + + cmd.AddCommand(docsCmd(), versionCmd()) + + return cmd } func Execute() error { - return RootCmd.Execute() -} + if err := TerramaidCmd().Execute(); err != nil { + return err + } -func init() { - RootCmd.Flags().StringVarP(&output, "output", "o", "Terramaid.md", "Output file for Mermaid diagram") - RootCmd.Flags().StringVarP(&direction, "direction", "r", "TD", "Specify the direction of the flowchart") - RootCmd.Flags().StringVarP(&subgraphName, "subgraphName", "s", "Terraform", "Specify the subgraph name of the flowchart") - RootCmd.Flags().StringVarP(&tfDir, "tfDir", "d", ".", "Path to Terraform directory") - RootCmd.Flags().StringVarP(&tfPlan, "tfPlan", "p", "", "Path to Terraform plan file") - RootCmd.Flags().StringVarP(&tfBinary, "tfBinary", "b", "", "Path to Terraform binary") - RootCmd.Flags().StringVarP(&workingDir, "workingDir", "w", ".", "Working directory for Terraform") + return nil } diff --git a/cmd/version.go b/cmd/version.go index 635443c..2dcf71e 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -11,28 +11,30 @@ import ( "github.com/spf13/cobra" ) -var Version = "1.6.3" - type Release struct { TagName string `json:"tag_name"` } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the CLI version", - Long: `This command prints the CLI version`, - Example: "terramaid version", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("terramaid: " + Version) - latestReleaseTag, err := latestRelease() - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(Version, "v") - if latestRelease != currentRelease { - updateTerramaid(latestRelease) +func versionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Print the CLI version", + Long: `This command prints the CLI version`, + Example: "terramaid version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("terramaid: " + Version) + latestReleaseTag, err := latestRelease() + if err == nil && latestReleaseTag != "" { + latestRelease := strings.TrimPrefix(latestReleaseTag, "v") + currentRelease := strings.TrimPrefix(Version, "v") + if latestRelease != currentRelease { + updateTerramaid(latestRelease) + } } - } - }, + }, + } + + return cmd } func latestRelease() (string, error) { @@ -60,7 +62,3 @@ func updateTerramaid(latestVersion string) { c1.Println(fmt.Sprintf("\nYour version of Terramaid is out of date. The latest version is %s\n\n", latestVersion)) } - -func init() { - RootCmd.AddCommand(versionCmd) -} diff --git a/docs/terramaid_completion.md b/docs/terramaid_completion.md index f4b024c..69a66b8 100644 --- a/docs/terramaid_completion.md +++ b/docs/terramaid_completion.md @@ -22,4 +22,4 @@ See each sub-command's help for details on how to use the generated script. * [terramaid completion powershell](terramaid_completion_powershell.md) - Generate the autocompletion script for powershell * [terramaid completion zsh](terramaid_completion_zsh.md) - Generate the autocompletion script for zsh -###### Auto generated by spf13/cobra on 4-Jul-2024 +###### Auto generated by spf13/cobra on 3-Aug-2024 diff --git a/docs/terramaid_completion_bash.md b/docs/terramaid_completion_bash.md index 161af1b..1c50862 100644 --- a/docs/terramaid_completion_bash.md +++ b/docs/terramaid_completion_bash.md @@ -41,4 +41,4 @@ terramaid completion bash * [terramaid completion](terramaid_completion.md) - Generate the autocompletion script for the specified shell -###### Auto generated by spf13/cobra on 4-Jul-2024 +###### Auto generated by spf13/cobra on 3-Aug-2024 diff --git a/docs/terramaid_completion_fish.md b/docs/terramaid_completion_fish.md index b52faed..9aa518b 100644 --- a/docs/terramaid_completion_fish.md +++ b/docs/terramaid_completion_fish.md @@ -32,4 +32,4 @@ terramaid completion fish [flags] * [terramaid completion](terramaid_completion.md) - Generate the autocompletion script for the specified shell -###### Auto generated by spf13/cobra on 4-Jul-2024 +###### Auto generated by spf13/cobra on 3-Aug-2024 diff --git a/docs/terramaid_completion_powershell.md b/docs/terramaid_completion_powershell.md index b059e83..352574e 100644 --- a/docs/terramaid_completion_powershell.md +++ b/docs/terramaid_completion_powershell.md @@ -29,4 +29,4 @@ terramaid completion powershell [flags] * [terramaid completion](terramaid_completion.md) - Generate the autocompletion script for the specified shell -###### Auto generated by spf13/cobra on 4-Jul-2024 +###### Auto generated by spf13/cobra on 3-Aug-2024 diff --git a/docs/terramaid_completion_zsh.md b/docs/terramaid_completion_zsh.md index 204bc61..d2f328c 100644 --- a/docs/terramaid_completion_zsh.md +++ b/docs/terramaid_completion_zsh.md @@ -43,4 +43,4 @@ terramaid completion zsh [flags] * [terramaid completion](terramaid_completion.md) - Generate the autocompletion script for the specified shell -###### Auto generated by spf13/cobra on 4-Jul-2024 +###### Auto generated by spf13/cobra on 3-Aug-2024 diff --git a/docs/terramaid_version.md b/docs/terramaid_version.md index e791687..716a48a 100644 --- a/docs/terramaid_version.md +++ b/docs/terramaid_version.md @@ -26,4 +26,4 @@ terramaid version * [terramaid](terramaid.md) - A utility for generating Mermaid diagrams from Terraform -###### Auto generated by spf13/cobra on 4-Jul-2024 +###### Auto generated by spf13/cobra on 3-Aug-2024 diff --git a/go.mod b/go.mod index 2c6fdc7..9a53396 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.2 require ( github.com/awalterschulze/gographviz v2.0.3+incompatible + github.com/caarlos0/env/v11 v11.1.0 github.com/fatih/color v1.17.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 438dffe..d71ef24 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= +github.com/caarlos0/env/v11 v11.1.0 h1:a5qZqieE9ZfzdvbbdhTalRrHT5vu/4V1/ad1Ka6frhI= +github.com/caarlos0/env/v11 v11.1.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= diff --git a/internal/flowchart.go b/internal/flowchart.go new file mode 100644 index 0000000..a0f5375 --- /dev/null +++ b/internal/flowchart.go @@ -0,0 +1,138 @@ +package internal + +import ( + "fmt" + "regexp" + "strings" + + "github.com/awalterschulze/gographviz" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type Node struct { + ID string + Label string +} + +type Edge struct { + From string + To string +} + +type Graph struct { + Nodes []Node + Edges []Edge +} + +// Removes unnecessary parts from the label +func CleanLabel(label string) string { + re := regexp.MustCompile(`\s*\(expand\)|\s*\(close\)|\[root\]\s*|"`) + return re.ReplaceAllString(label, "") +} + +// Removes unnecessary parts from the ID +func CleanID(id string) string { + re := regexp.MustCompile(`\s*\(expand\)|\s*\(close\)|\[root\]\s*|"`) + return re.ReplaceAllString(id, "") +} + +// Extracts the provider for separate subgraph +func ExtractProvider(label string) string { + if strings.Contains(label, "provider") { + parts := strings.Split(label, "/") + if len(parts) > 2 { + return parts[len(parts)-2] + } + } + return "" +} + +// Transforms the parsed graph into cleaned nodes and edges +func TransformGraph(graph *gographviz.Graph) Graph { + nodes := []Node{} + edges := []Edge{} + + for _, node := range graph.Nodes.Nodes { + cleanedID := CleanID(node.Name) + cleanedLabel := CleanLabel(node.Attrs["label"]) + if cleanedLabel != "" && !strings.Contains(cleanedLabel, "provider") { + nodes = append(nodes, Node{ID: cleanedID, Label: cleanedLabel}) + } + } + + for _, edge := range graph.Edges.Edges { + fromLabel := CleanLabel(graph.Nodes.Lookup[edge.Src].Attrs["label"]) + toLabel := CleanLabel(graph.Nodes.Lookup[edge.Dst].Attrs["label"]) + if fromLabel != "" && toLabel != "" && !strings.Contains(fromLabel, "provider") && !strings.Contains(toLabel, "provider") { + edges = append(edges, Edge{From: CleanID(edge.Src), To: CleanID(edge.Dst)}) + } + } + + return Graph{Nodes: nodes, Edges: edges} +} + +// Converts a gographviz graph to a Mermaid.js compatible string. +// It accepts a graph, direction, and an optional subgraph name. +func ConvertToMermaidFlowchart(graph *gographviz.Graph, direction string, subgraphName string) (string, error) { + var sb strings.Builder + + // Capitalize the provider name + caser := cases.Title(language.English) + // Validate the direction of the flowchart. Valid options are: TB, TD, BT, RL, LR + validDirections := map[string]bool{ + "TB": true, "TD": true, "BT": true, "RL": true, "LR": true, + } + if !validDirections[direction] { + return "", fmt.Errorf("invalid direction %s: valid options are: TB, TD, BT, RL, LR", direction) + } + // Start Mermaid graph definition + sb.WriteString("```mermaid\n") + sb.WriteString("flowchart " + direction + "\n") + + // Add subgraph for providers + providerSubgraphs := make(map[string]bool) + for _, n := range graph.Nodes.Nodes { + label := n.Attrs["label"] + provider := ExtractProvider(label) + if provider != "" && !providerSubgraphs[provider] { + sb.WriteString(fmt.Sprintf("\tsubgraph %s\n", caser.String(provider))) + providerSubgraphs[provider] = true + } + } + + if subgraphName != "" { + sb.WriteString(fmt.Sprintf("\tsubgraph %s\n", subgraphName)) + } + + // Iterate over nodes to add them to the Mermaid graph + for _, n := range graph.Nodes.Nodes { + label := CleanLabel(n.Attrs["label"]) + nodeName := CleanID(n.Name) + if label != "" && nodeName != "" && !strings.Contains(label, "provider") { + sb.WriteString(fmt.Sprintf("\t\t%s[\"%s\"]\n", nodeName, label)) + } + } + + // Iterate over edges to add them to the Mermaid graph + for _, edge := range graph.Edges.Edges { + srcLabel := CleanLabel(graph.Nodes.Lookup[edge.Src].Attrs["label"]) + dstLabel := CleanLabel(graph.Nodes.Lookup[edge.Dst].Attrs["label"]) + srcName := CleanID(edge.Src) + dstName := CleanID(edge.Dst) + if srcLabel != "" && dstLabel != "" && !strings.Contains(srcLabel, "provider") && !strings.Contains(dstLabel, "provider") { + sb.WriteString(fmt.Sprintf("\t\t%s --> %s\n", srcName, dstName)) + } + } + + // Close all open subgraphs + for range providerSubgraphs { + sb.WriteString("\tend\n") + } + if subgraphName != "" { + sb.WriteString("\tend\n") + } + + sb.WriteString("```\n") + return sb.String(), nil +} diff --git a/internal/graph.go b/internal/graph.go deleted file mode 100644 index 3b08a63..0000000 --- a/internal/graph.go +++ /dev/null @@ -1,50 +0,0 @@ -package internal - -import ( - "fmt" - "strings" - - "github.com/awalterschulze/gographviz" -) - -// ConvertToMermaid converts a gographviz graph to a Mermaid.js compatible string. -// It accepts a graph, direction, and an optional subgraph name. -func ConvertToMermaid(graph *gographviz.Graph, direction string, subgraphName string) (string, error) { - var sb strings.Builder - - // Validate the direction of the flowchart. Valid options are: TB, TD, BT, RL, LR - validDirections := map[string]bool{ - "TB": true, "TD": true, "BT": true, "RL": true, "LR": true, - } - if !validDirections[direction] { - return "", fmt.Errorf("invalid direction %s: valid options are: TB, TD, BT, RL, LR", direction) - } - - // Start Mermaid graph definition - sb.WriteString("```mermaid\n") - sb.WriteString("flowchart " + direction + ";\n") - if subgraphName != "" { - sb.WriteString(fmt.Sprintf("\tsubgraph %s\n", subgraphName)) - } - - // Iterate over nodes to add them to the Mermaid graph - for _, node := range graph.Nodes.Nodes { - label := strings.Trim(node.Attrs["label"], "\"") - nodeName := strings.Trim(node.Name, "\"") - sb.WriteString(fmt.Sprintf(" %s[\"%s\"]\n", nodeName, label)) - } - - // Iterate over edges to add them to the Mermaid graph - for _, edge := range graph.Edges.Edges { - srcName := strings.Trim(edge.Src, "\"") - dstName := strings.Trim(edge.Dst, "\"") - sb.WriteString(fmt.Sprintf(" %s --> %s\n", srcName, dstName)) - } - - if subgraphName != "" { - sb.WriteString("\tend\n") - } - sb.WriteString("```\n") - - return sb.String(), nil -} diff --git a/internal/parse.go b/internal/parse.go index d3db3bb..f87cde2 100644 --- a/internal/parse.go +++ b/internal/parse.go @@ -2,11 +2,20 @@ package internal import ( "context" + "fmt" "github.com/awalterschulze/gographviz" "github.com/hashicorp/terraform-exec/tfexec" ) +const emptyGraph = `digraph G { + rankdir = "RL"; + node [shape = rect, fontname = "sans-serif"]; + /* This configuration does not contain any resources. */ + /* For a more detailed graph, try: terraform graph -type=plan */ +} +` + // ParseTerraform parses the Terraform plan and returns the generated graph func ParseTerraform(workingDir, tfPath, planFile string) (*gographviz.Graph, error) { ctx := context.Background() @@ -15,31 +24,33 @@ func ParseTerraform(workingDir, tfPath, planFile string) (*gographviz.Graph, err return nil, err } - err = tf.Init(ctx, tfexec.Upgrade(true)) - if err != nil { + if err := tf.Init(ctx, tfexec.Upgrade(true)); err != nil { return nil, err } - var output string - // Graph Terraform resources + opts := &tfexec.GraphPlanOption{} + if planFile != "" { - output, err = tf.Graph(ctx, tfexec.GraphPlan(planFile)) - } else { - output, err = tf.Graph(ctx) + opts = tfexec.GraphPlan(planFile) } + output, err := tf.Graph(ctx, opts) if err != nil { return nil, err } + if output == emptyGraph { + return nil, fmt.Errorf("no output from terraform graph") + } + // Parse the DOT output - dot := string(output) - graphAst, err := gographviz.ParseString(dot) + graphAst, err := gographviz.ParseString(string(output)) if err != nil { return nil, err } graph := gographviz.NewGraph() + if err := gographviz.Analyse(graphAst, graph); err != nil { return nil, err } diff --git a/main.go b/main.go index fe044c1..2cbd10e 100644 --- a/main.go +++ b/main.go @@ -5,9 +5,12 @@ import ( u "github.com/RoseSecurity/terramaid/pkg/utils" ) +var version string + func main() { - err := cmd.Execute() - if err != nil { + cmd.Version = version + + if err := cmd.Execute(); err != nil { u.LogErrorAndExit(err) } } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..1b3bae5 --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,39 @@ +package utils + +import ( + "os" + "path/filepath" +) + +// Check if a directory exists +func DirExists(dir string) bool { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return false + } + + return true +} + +// Check if Terraform files exist in a directory +func TerraformFilesExist(dir string) bool { + validExtensions := []string{".tf", ".tf.json", ".tftest.hcl", ".tftest.json", "terraform.tfvars", "terraform.tfvars.json"} + + found := false + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + for _, ext := range validExtensions { + if filepath.Ext(path) == ext || filepath.Base(path) == ext { + found = true + return filepath.SkipDir + } + } + return nil + }) + if err != nil { + return false + } + + return found +} diff --git a/test/tfplan b/test/tfplan deleted file mode 100644 index d21a1c6..0000000 Binary files a/test/tfplan and /dev/null differ