Repository: diggerhq/digger
Pull request #471 (2023-07-27 12:31:21)

add drift-detect command


Patch:
diff --git a/action.yml b/action.yml
index e13d3376..b150b3e9 100644
--- a/action.yml
+++ b/action.yml
@@ -94,7 +94,7 @@ inputs:
     required: false
     default: 'comments_per_run'
   mode:
-    description: 'manual or otherwise'
+    description: 'manual, drift-detection or otherwise'
     required: false
     default: ''
   command:
@@ -105,6 +105,10 @@ inputs:
     description: 'project name for digger to run in case of manual mode'
     required: false
     default: ''
+  drift-detection-slack-notification-url:
+    description: 'drift-detection slack notification url'
+    required: false
+    default: ''
 
 outputs:
   output:
@@ -211,6 +215,7 @@ runs:
         INPUT_DIGGER_PROJECT: ${{ inputs.project }}
         INPUT_DIGGER_MODE: ${{ inputs.mode }}
         INPUT_DIGGER_COMMAND: ${{ inputs.command }}
+        INPUT_DRIFT_DETECTION_SLACK_NOTIFICATION_URL: ${{ inputs.drift-detection-slack-notification-url }}
       run: |
           cd ${{ github.action_path }}
           go build -o digger ./cmd/digger
@@ -233,6 +238,7 @@ runs:
         INPUT_DIGGER_PROJECT: ${{ inputs.project }}
         INPUT_DIGGER_MODE: ${{ inputs.mode }}
         INPUT_DIGGER_COMMAND: ${{ inputs.command }}
+        INPUT_DRIFT_DETECTION_SLACK_NOTIFICATION_URL: ${{ inputs.drift-detection-slack-notification-url }}
       id: digger
       shell: bash
       run: |
diff --git a/cmd/digger/main.go b/cmd/digger/main.go
index d5ddd059..a315494c 100644
--- a/cmd/digger/main.go
+++ b/cmd/digger/main.go
@@ -119,6 +119,32 @@ func gitHubCI(lock core_locking.Lock, policyChecker core_policy.Checker, reporti
 		if err != nil {
 			reportErrorAndExit(githubActor, fmt.Sprintf("Failed to run commands. %s", err), 8)
 		}
+	} else if runningMode == "drift-detection" {
+
+		for _, projectConfig := range diggerConfig.Projects {
+			if !projectConfig.DriftDetection {
+				continue
+			}
+			workflow := diggerConfig.Workflows[projectConfig.Workflow]
+
+			stateEnvVars, commandEnvVars := configuration.CollectTerraformEnvConfig(workflow.EnvVars)
+
+			projectCommand := models.ProjectCommand{
+				ProjectName:      projectConfig.Name,
+				ProjectDir:       projectConfig.Dir,
+				ProjectWorkspace: projectConfig.Workspace,
+				Terragrunt:       projectConfig.Terragrunt,
+				Commands:         []string{"digger drift-detect"},
+				ApplyStage:       workflow.Apply,
+				PlanStage:        workflow.Plan,
+				CommandEnvVars:   commandEnvVars,
+				StateEnvVars:     stateEnvVars,
+			}
+			err := digger.RunCommandForProject(projectCommand, ghRepository, githubActor, "drift-detect", &githubPrService, policyChecker, nil, currentDir)
+			if err != nil {
+				reportErrorAndExit(githubActor, fmt.Sprintf("Failed to run commands. %s", err), 8)
+			}
+		}
 	} else {
 		parsedGhContext, err := github_models.GetGitHubContext(ghContext)
 		if err != nil {
diff --git a/pkg/configuration/config.go b/pkg/configuration/config.go
index a6c384d6..9977ce5e 100644
--- a/pkg/configuration/config.go
+++ b/pkg/configuration/config.go
@@ -18,6 +18,7 @@ type Project struct {
 	IncludePatterns    []string
 	ExcludePatterns    []string
 	DependencyProjects []string
+	DriftDetection     bool
 }
 
 type Workflow struct {
diff --git a/pkg/configuration/converters.go b/pkg/configuration/converters.go
index ff550b04..269d3810 100644
--- a/pkg/configuration/converters.go
+++ b/pkg/configuration/converters.go
@@ -13,6 +13,10 @@ const defaultWorkflowName = "default"
 func copyProjects(projects []*ProjectYaml) []Project {
 	result := make([]Project, len(projects))
 	for i, p := range projects {
+		driftDetection := true
+		if p.DriftDetection != nil {
+			driftDetection = *p.DriftDetection
+		}
 		item := Project{p.Name,
 			p.Dir,
 			p.Workspace,
@@ -21,6 +25,7 @@ func copyProjects(projects []*ProjectYaml) []Project {
 			p.IncludePatterns,
 			p.ExcludePatterns,
 			p.DependencyProjects,
+			driftDetection,
 		}
 		result[i] = item
 	}
diff --git a/pkg/configuration/yaml.go b/pkg/configuration/yaml.go
index f8e428dc..3b1e4653 100644
--- a/pkg/configuration/yaml.go
+++ b/pkg/configuration/yaml.go
@@ -22,6 +22,7 @@ type ProjectYaml struct {
 	IncludePatterns    []string `yaml:"include_patterns,omitempty"`
 	ExcludePatterns    []string `yaml:"exclude_patterns,omitempty"`
 	DependencyProjects []string `yaml:"depends_on,omitempty"`
+	DriftDetection     *bool    `yaml:"drift_detection,omitempty"`
 }
 
 type WorkflowYaml struct {
diff --git a/pkg/core/execution/execution.go b/pkg/core/execution/execution.go
index cb3f27d4..43012d49 100644
--- a/pkg/core/execution/execution.go
+++ b/pkg/core/execution/execution.go
@@ -17,7 +17,7 @@ import (
 )
 
 type Executor interface {
-	Plan() (bool, string, string, error)
+	Plan() (bool, bool, string, string, error)
 	Apply() (bool, error)
 }
 
@@ -26,17 +26,17 @@ type LockingExecutorWrapper struct {
 	Executor    Executor
 }
 
-func (l LockingExecutorWrapper) Plan() (bool, string, string, error) {
+func (l LockingExecutorWrapper) Plan() (bool, bool, string, string, error) {
 	plan := ""
 	locked, err := l.ProjectLock.Lock()
 	if err != nil {
-		return false, "", "", fmt.Errorf("error locking project: %v", err)
+		return false, false, "", "", fmt.Errorf("error locking project: %v", err)
 	}
 	log.Printf("Lock result: %t\n", locked)
 	if locked {
 		return l.Executor.Plan()
 	} else {
-		return false, plan, "", nil
+		return false, false, plan, "", nil
 	}
 }
 
@@ -97,7 +97,7 @@ type ProjectPathProvider struct {
 }
 
 func (d ProjectPathProvider) PlanFileName() string {
-	return strings.ReplaceAll(d.ProjectNamespace, "/", ":") + "#" + d.ProjectName + ".tfplan"
+	return strings.ReplaceAll(d.ProjectNamespace, "/", "-") + "#" + d.ProjectName + ".tfplan"
 }
 
 func (d ProjectPathProvider) LocalPlanFilePath() string {
@@ -108,9 +108,10 @@ func (d ProjectPathProvider) StoredPlanFilePath() string {
 	return path.Join(d.ProjectNamespace, d.PlanFileName())
 }
 
-func (d DiggerExecutor) Plan() (bool, string, string, error) {
+func (d DiggerExecutor) Plan() (bool, bool, string, string, error) {
 	plan := ""
 	terraformPlanOutput := ""
+	isNonEmptyPlan := false
 	var planSteps []models.Step
 
 	if d.PlanStage != nil {
@@ -129,32 +130,33 @@ func (d DiggerExecutor) Plan() (bool, string, string, error) {
 		if step.Action == "init" {
 			_, _, err := d.TerraformExecutor.Init(step.ExtraArgs, d.StateEnvVars)
 			if err != nil {
-				return false, "", "", fmt.Errorf("error running init: %v", err)
+				return false, false, "", "", fmt.Errorf("error running init: %v", err)
 			}
 		}
 		if step.Action == "plan" {
-			planArgs := []string{"-out", d.PlanPathProvider.PlanFileName()}
+			planArgs := []string{"-out", d.PlanPathProvider.PlanFileName(), "-lock-timeout=3m"}
 			planArgs = append(planArgs, step.ExtraArgs...)
-			isNonEmptyPlan, stdout, stderr, err := d.TerraformExecutor.Plan(planArgs, d.CommandEnvVars)
+			nonEmptyPlan, stdout, stderr, err := d.TerraformExecutor.Plan(planArgs, d.CommandEnvVars)
+			isNonEmptyPlan = nonEmptyPlan
 			if err != nil {
-				return false, "", "", fmt.Errorf("error executing plan: %v", err)
+				return false, false, "", "", fmt.Errorf("error executing plan: %v", err)
 			}
 			if d.PlanStorage != nil {
 				planExists, err := d.PlanStorage.PlanExists(d.PlanPathProvider.StoredPlanFilePath())
 				if err != nil {
-					return false, "", "", fmt.Errorf("error checking if plan exists: %v", err)
+					return false, false, "", "", fmt.Errorf("error checking if plan exists: %v", err)
 				}
 
 				if planExists {
 					err = d.PlanStorage.DeleteStoredPlan(d.PlanPathProvider.StoredPlanFilePath())
 					if err != nil {
-						return false, "", "", fmt.Errorf("error deleting plan: %v", err)
+						return false, false, "", "", fmt.Errorf("error deleting plan: %v", err)
 					}
 				}
 
 				err = d.PlanStorage.StorePlan(d.PlanPathProvider.LocalPlanFilePath(), d.PlanPathProvider.StoredPlanFilePath())
 				if err != nil {
-					return false, "", "", fmt.Errorf("error storing plan: %v", err)
+					return false, false, "", "", fmt.Errorf("error storing plan: %v", err)
 				}
 			}
 			plan = cleanupTerraformPlan(isNonEmptyPlan, err, stdout, stderr)
@@ -176,11 +178,11 @@ func (d DiggerExecutor) Plan() (bool, string, string, error) {
 			log.Printf("Running %v for **%v**\n", step.Value, d.ProjectNamespace+"#"+d.ProjectName)
 			_, _, err := d.CommandRunner.Run(d.ProjectPath, step.Shell, commands)
 			if err != nil {
-				return false, "", "", fmt.Errorf("error running command: %v", err)
+				return false, false, "", "", fmt.Errorf("error running command: %v", err)
 			}
 		}
 	}
-	return true, plan, terraformPlanOutput, nil
+	return true, isNonEmptyPlan, plan, terraformPlanOutput, nil
 }
 
 func (d DiggerExecutor) Apply() (bool, error) {
@@ -216,7 +218,9 @@ func (d DiggerExecutor) Apply() (bool, error) {
 			}
 		}
 		if step.Action == "apply" {
-			stdout, stderr, err := d.TerraformExecutor.Apply(step.ExtraArgs, plansFilename, d.CommandEnvVars)
+			applyArgs := []string{"-lock-timeout=3m"}
+			applyArgs = append(applyArgs, step.ExtraArgs...)
+			stdout, stderr, err := d.TerraformExecutor.Apply(applyArgs, plansFilename, d.CommandEnvVars)
 			applyOutput := cleanupTerraformApply(true, err, stdout, stderr)
 			formatter := utils.GetTerraformOutputAsCollapsibleComment("Apply for <b>" + d.ProjectNamespace + "#" + d.ProjectName + "</b>")
 
diff --git a/pkg/core/terraform/tf.go b/pkg/core/terraform/tf.go
index dda56846..603809b1 100644
--- a/pkg/core/terraform/tf.go
+++ b/pkg/core/terraform/tf.go
@@ -113,7 +113,6 @@ func (tf Terraform) Apply(params []string, plan *string, envs map[string]string)
 	return stdout, stderr, err
 }
 
-// runTerraformCommand
 func (tf Terraform) runTerraformCommand(command string, envs map[string]string, arg ...string) (string, string, int, error) {
 	args := []string{command}
 	args = append(args, arg...)
@@ -144,7 +143,8 @@ func (tf Terraform) runTerraformCommand(command string, envs map[string]string,
 
 	err := cmd.Run()
 
-	if err != nil {
+	// terraform plan can return 2 if there are changes to be applied, so we don't want to fail in that case
+	if err != nil && cmd.ProcessState.ExitCode() != 2 {
 		fmt.Println("Error:", err)
 	}
 
@@ -197,12 +197,12 @@ func (tf Terraform) Plan(params []string, envs map[string]string) (bool, string,
 			return false, "", "", err
 		}
 	}
-	params = append(append(params, "-input=false"), "-no-color")
+	params = append(append(append(params, "-input=false"), "-no-color"), "-detailed-exitcode")
 	stdout, stderr, statusCode, err := tf.runTerraformCommand("plan", envs, params...)
-	if err != nil {
+	if err != nil && statusCode != 2 {
 		return false, "", "", err
 	}
-	return statusCode != 2, stdout, stderr, nil
+	return statusCode == 2, stdout, stderr, nil
 }
 
 func (tf Terraform) Show(params []string, envs map[string]string) (string, string, error) {
diff --git a/pkg/digger/digger.go b/pkg/digger/digger.go
index 31543d01..b8116b25 100644
--- a/pkg/digger/digger.go
+++ b/pkg/digger/digger.go
@@ -1,6 +1,7 @@
 package digger
 
 import (
+	"bytes"
 	"digger/pkg/ci"
 	"digger/pkg/core/execution"
 	core_locking "digger/pkg/core/locking"
@@ -14,9 +15,12 @@ import (
 	"digger/pkg/locking"
 	"digger/pkg/reporting"
 	"digger/pkg/usage"
+	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log"
+	"net/http"
 	"os"
 	"path"
 	"strings"
@@ -155,7 +159,7 @@ func RunCommandsPerProject(
 				if err != nil {
 					return false, false, fmt.Errorf("failed to set PR status. %v", err)
 				}
-				planPerformed, plan, planJsonOutput, err := diggerExecutor.Plan()
+				planPerformed, isNonEmptyPlan, plan, planJsonOutput, err := diggerExecutor.Plan()
 
 				if err != nil {
 					log.Printf("Failed to run digger plan command. %v", err)
@@ -165,7 +169,7 @@ func RunCommandsPerProject(
 					}
 					return false, false, fmt.Errorf("failed to run digger plan command. %v", err)
 				} else if planPerformed {
-					if plan != "" {
+					if isNonEmptyPlan {
 						formatter := utils.GetTerraformOutputAsCollapsibleComment("Plan for <b>" + projectLock.LockId() + "</b>")
 						err = reporter.Report(plan, formatter)
 						if err != nil {
@@ -272,6 +276,12 @@ func RunCommandsPerProject(
 				if err != nil {
 					return false, false, fmt.Errorf("failed to lock project. %v", err)
 				}
+
+			case "digger drift-detect":
+				err := runDriftDetection(projectCommands.ProjectName, requestedBy, eventName, diggerExecutor)
+				if err != nil {
+					return false, false, fmt.Errorf("failed to run drift detection. %v", err)
+				}
 			}
 		}
 	}
@@ -356,7 +366,7 @@ func RunCommandForProject(
 			if err != nil {
 				log.Printf("Failed to send usage report. %v", err)
 			}
-			_, _, planJsonOutput, err := diggerExecutor.Plan()
+			_, _, _, planJsonOutput, err := diggerExecutor.Plan()
 			if err != nil {
 				log.Printf("Failed to run digger plan command. %v", err)
 				return fmt.Errorf("failed to run digger plan command. %v", err)
@@ -382,7 +392,77 @@ func RunCommandForProject(
 				log.Printf("Failed to run digger apply command. %v", err)
 				return fmt.Errorf("failed to run digger apply command. %v", err)
 			}
+		case "digger drift-detect":
+			err = runDriftDetection(commands.ProjectName, requestedBy, eventName, diggerExecutor)
+			if err != nil {
+				return fmt.Errorf("failed to run digger drift-detect command. %v", err)
+			}
+		}
+
+	}
+	return nil
+}
+
+func runDriftDetection(projectName string, requestedBy string, eventName string, diggerExecutor execution.Executor) error {
+	err := usage.SendUsageRecord(requestedBy, eventName, "drift-detect")
+	if err != nil {
+		log.Printf("Failed to send usage report. %v", err)
+	}
+
+	planPerformed, nonEmptyPlan, plan, _, err := diggerExecutor.Plan()
+	if err != nil {
+		log.Printf("Failed to run digger plan command. %v", err)
+		return fmt.Errorf("failed to run digger plan command. %v", err)
+	}
+
+	if planPerformed && nonEmptyPlan {
+		httpClient := &http.Client{}
+		slackNotificationUrl := os.Getenv("INPUT_DRIFT_DETECTION_SLACK_NOTIFICATION_URL")
+
+		if slackNotificationUrl == "" {
+			log.Printf("No INPUT_DRIFT_DETECTION_SLACK_NOTIFICATION_URL set, not sending notification")
+			return fmt.Errorf("no INPUT_DRIFT_DETECTION_SLACK_NOTIFICATION_URL set, not sending notification")
+		}
+
+		type SlackMessage struct {
+			Text string `json:"text"`
+		}
+		slackMessage := SlackMessage{
+			Text: fmt.Sprintf(":bangbang: Drift detected in digger project %v details below: \n```%v```", projectName, plan),
+		}
+
+		jsonData, err := json.Marshal(slackMessage)
+		if err != nil {
+			log.Printf("Failed to marshal slack message. %v", err)
+			return fmt.Errorf("failed to marshal slack message. %v", err)
+		}
+
+		request, err := http.NewRequest("POST", slackNotificationUrl, bytes.NewBuffer(jsonData))
+		if err != nil {
+			log.Printf("Failed to create drift detection request. %v", err)
+			return fmt.Errorf("failed to create drift detection request. %v", err)
+		}
+
+		request.Header.Set("Content-Type", "application/json")
+		resp, err := httpClient.Do(request)
+		if err != nil {
+			log.Printf("Failed to send drift detection request. %v", err)
+			return fmt.Errorf("failed to send drift detection request. %v", err)
+		}
+		if resp.StatusCode != 200 {
+			body, err := io.ReadAll(resp.Body)
+			if err != nil {
+				log.Printf("Failed to read response body. %v", err)
+				return fmt.Errorf("failed to read response body. %v", err)
+			}
+			log.Printf("Failed to send drift detection request. %v. Message: %v", resp.Status, body)
+			return fmt.Errorf("failed to send drift detection request. %v", resp.Status)
 		}
+		defer resp.Body.Close()
+	} else if planPerformed && !nonEmptyPlan {
+		log.Printf("No drift detected")
+	} else {
+		log.Printf("No plan performed")
 	}
 	return nil
 }


Test patch:
diff --git a/pkg/digger/digger_test.go b/pkg/digger/digger_test.go
index 54dea31b..57390231 100644
--- a/pkg/digger/digger_test.go
+++ b/pkg/digger/digger_test.go
@@ -242,7 +242,7 @@ func TestCorrectCommandExecutionWhenApplying(t *testing.T) {
 
 	commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage, planPathProvider)
 
-	assert.Equal(t, []string{"RetrievePlan plan", "Init ", "Apply ", "PublishComment 1 <details><summary>Apply for <b>#</b></summary>\n  \n```terraform\n\n  ```\n</details>", "Run   echo"}, commandStrings)
+	assert.Equal(t, []string{"RetrievePlan plan", "Init ", "Apply -lock-timeout=3m", "PublishComment 1 <details><summary>Apply for <b>#</b></summary>\n  \n```terraform\n\n  ```\n</details>", "Run   echo"}, commandStrings)
 }
 
 func TestCorrectCommandExecutionWhenPlanning(t *testing.T) {
@@ -289,7 +289,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) {
 
 	commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage, planPathProvider)
 
-	assert.Equal(t, []string{"Init ", "Plan -out plan", "PlanExists plan", "StorePlan plan", "Show -no-color -json plan", "Run   echo"}, commandStrings)
+	assert.Equal(t, []string{"Init ", "Plan -out plan -lock-timeout=3m", "PlanExists plan", "StorePlan plan", "Show -no-color -json plan", "Run   echo"}, commandStrings)
 }
 
 func allCommandsInOrderWithParams(terraformExecutor *MockTerraformExecutor, commandRunner *MockCommandRunner, prManager *MockPRManager, lock *MockProjectLock, planStorage *MockPlanStorage, planPathProvider *MockPlanPathProvider) []string {