From 55068d3699751ee01da0b4cc793f5110024bf227 Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Mon, 4 Nov 2024 17:56:47 +0000 Subject: [PATCH] pulumi support in digger (#1790) * pulumi support in digger --- action.yml | 15 +++ backend/controllers/projects.go | 15 ++- backend/services/spec.go | 2 +- cli/pkg/digger/digger.go | 18 ++- cli/pkg/digger/digger_test.go | 11 +- cli/pkg/github/github.go | 2 + cli/pkg/spec/spec.go | 6 +- ee/drift/controllers/ci_jobs.go | 14 +- libs/backendapi/backend.go | 4 +- libs/backendapi/diggerapi.go | 10 +- libs/backendapi/mocks.go | 4 +- libs/ci/azure/azure.go | 12 +- libs/ci/generic/events.go | 1 + libs/ci/github/github.go | 12 +- libs/ci/gitlab/gitlab.go | 5 +- libs/ci/gitlab/webhooks.go | 10 +- .../reporting/source_grouping.go | 20 +-- libs/digger_config/config.go | 6 +- libs/digger_config/converters.go | 11 +- libs/digger_config/digger_config.go | 45 +++++++ libs/digger_config/yaml.go | 18 +-- libs/execution/execution.go | 58 ++++---- libs/execution/opentofu.go | 10 +- libs/execution/opentofu_test.go | 6 +- libs/execution/pulumi.go | 125 ++++++++++++++++++ libs/execution/terragrunt.go | 15 ++- libs/execution/tf.go | 14 +- libs/execution/tf_test.go | 6 +- libs/iac_utils/iac_utils.go | 67 ++++++++++ libs/iac_utils/pulumi.go | 125 ++++++++++++++++++ .../terraform.go} | 76 +++-------- .../terraform_test.go} | 32 ++--- libs/scheduler/convert.go | 3 +- libs/scheduler/jobs.go | 16 ++- libs/scheduler/json_models.go | 3 + next/controllers/projects.go | 14 +- 36 files changed, 612 insertions(+), 199 deletions(-) create mode 100644 libs/execution/pulumi.go create mode 100644 libs/iac_utils/iac_utils.go create mode 100644 libs/iac_utils/pulumi.go rename libs/{terraform_utils/plan_summary.go => iac_utils/terraform.go} (56%) rename libs/{terraform_utils/plan_summary_test.go => iac_utils/terraform_test.go} (97%) diff --git a/action.yml b/action.yml index 69ad4c9e5..4df05c8ac 100644 --- a/action.yml +++ b/action.yml @@ -65,6 +65,10 @@ inputs: description: Setup OpenToFu required: false default: 'false' + setup-pulumi: + description: Setup Pulumi + required: false + default: 'false' terragrunt-version: description: Terragrunt version required: false @@ -73,6 +77,11 @@ inputs: description: OpenTofu version required: false default: v1.6.1 + pulumi-version: + description: Pulumi version + required: false + default: v3.3.0 + setup-terraform: description: Setup terraform required: false @@ -272,6 +281,12 @@ runs: tofu_wrapper: false if: inputs.setup-opentofu == 'true' + - name: Setup Pulumi + uses: pulumi/actions@v4 + with: + tofu_version: ${{ inputs.pulumi-version }} + if: inputs.setup-pulumi == 'true' + - name: Setup Checkov run: | python3 -m venv .venv diff --git a/backend/controllers/projects.go b/backend/controllers/projects.go index bd31b5305..b9caeb8f0 100644 --- a/backend/controllers/projects.go +++ b/backend/controllers/projects.go @@ -11,8 +11,8 @@ import ( "github.com/diggerhq/digger/libs/ci" "github.com/diggerhq/digger/libs/comment_utils/reporting" "github.com/diggerhq/digger/libs/digger_config" + "github.com/diggerhq/digger/libs/iac_utils" orchestrator_scheduler "github.com/diggerhq/digger/libs/scheduler" - "github.com/diggerhq/digger/libs/terraform_utils" "github.com/gin-gonic/gin" "gorm.io/gorm" "log" @@ -317,12 +317,13 @@ func RunHistoryForProject(c *gin.Context) { } type SetJobStatusRequest struct { - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` - JobSummary *terraform_utils.TerraformSummary `json:"job_summary"` - Footprint *terraform_utils.TerraformPlanFootprint `json:"job_plan_footprint"` - PrCommentUrl string `json:"pr_comment_url"` - TerraformOutput string `json:"terraform_output"` + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + JobSummary *iac_utils.IacSummary `json:"job_summary"` + Footprint *iac_utils.IacPlanFootprint `json:"job_plan_footprint"` + PrCommentUrl string `json:"pr_comment_url"` + TerraformOutput string `json:"terraform_output"` + } func (d DiggerController) SetJobStatusForProject(c *gin.Context) { diff --git a/backend/services/spec.go b/backend/services/spec.go index 71ac799dd..d363e74aa 100644 --- a/backend/services/spec.go +++ b/backend/services/spec.go @@ -106,7 +106,7 @@ func GetSpecFromJob(job models.DiggerJob) (*spec.Spec, error) { }) hasDuplicates := len(justNames) != len(lo.Uniq(justNames)) if hasDuplicates { - return nil, fmt.Errorf("could not load variables due to duplicates: %v", err) + return nil, fmt.Errorf("could not load variables due to duplicates") } batch := job.Batch diff --git a/cli/pkg/digger/digger.go b/cli/pkg/digger/digger.go index 28a6b360c..905fe86d0 100644 --- a/cli/pkg/digger/digger.go +++ b/cli/pkg/digger/digger.go @@ -23,7 +23,7 @@ import ( utils "github.com/diggerhq/digger/cli/pkg/utils" "github.com/diggerhq/digger/libs/comment_utils/reporting" config "github.com/diggerhq/digger/libs/digger_config" - "github.com/diggerhq/digger/libs/terraform_utils" + "github.com/diggerhq/digger/libs/iac_utils" "github.com/dominikbraun/graph" ) @@ -141,7 +141,9 @@ func RunJobs(jobs []orchestrator.Job, prService ci.PullRequestService, orgServic terraformOutput = exectorResults[0].TerraformOutput } prNumber := *currentJob.PullRequestNumber - batchResult, err := backendApi.ReportProjectJobStatus(repoNameForBackendReporting, projectNameForBackendReporting, jobId, "succeeded", time.Now(), &summary, "", jobPrCommentUrl, terraformOutput) + + iacUtils := iac_utils.GetIacUtilsIacType(currentJob.IacType()) + batchResult, err := backendApi.ReportProjectJobStatus(repoNameForBackendReporting, projectNameForBackendReporting, jobId, "succeeded", time.Now(), &summary, "", jobPrCommentUrl, terraformOutput, iacUtils) if err != nil { log.Printf("error reporting Job status: %v.\n", err) return false, false, fmt.Errorf("error while running command: %v", err) @@ -211,13 +213,20 @@ func run(command string, job orchestrator.Job, policyChecker policy.Checker, org } var terraformExecutor execution.TerraformExecutor + var iacUtils iac_utils.IacUtils projectPath := path.Join(workingDir, job.ProjectDir) if job.Terragrunt { terraformExecutor = execution.Terragrunt{WorkingDir: projectPath} + iacUtils = iac_utils.TerraformUtils{} } else if job.OpenTofu { terraformExecutor = execution.OpenTofu{WorkingDir: projectPath, Workspace: job.ProjectWorkspace} + iacUtils = iac_utils.TerraformUtils{} + } else if job.Pulumi { + terraformExecutor = execution.Pulumi{WorkingDir: projectPath, Stack: job.ProjectWorkspace} + iacUtils = iac_utils.PulumiUtils{} } else { terraformExecutor = execution.Terraform{WorkingDir: projectPath, Workspace: job.ProjectWorkspace} + iacUtils = iac_utils.TerraformUtils{} } commandRunner := execution.CommandRunner{} @@ -244,6 +253,7 @@ func run(command string, job orchestrator.Job, policyChecker policy.Checker, org Reporter: reporter, PlanStorage: planStorage, PlanPathProvider: planPathProvider, + IacUtils: iacUtils, }, } executor := diggerExecutor.Executor.(execution.DiggerExecutor) @@ -289,7 +299,7 @@ func run(command string, job orchestrator.Job, policyChecker policy.Checker, org planPolicyFormatter = coreutils.AsComment(summary) } - planSummary, err := terraform_utils.GetTfSummarizePlan(planJsonOutput) + planSummary, err := iacUtils.GetSummarizePlan(planJsonOutput) if err != nil { log.Printf("Failed to summarize plan. %v", err) } @@ -588,6 +598,8 @@ func RunJob( terraformExecutor = execution.Terragrunt{WorkingDir: projectPath} } else if job.OpenTofu { terraformExecutor = execution.OpenTofu{WorkingDir: projectPath, Workspace: job.ProjectWorkspace} + } else if job.Pulumi { + terraformExecutor = execution.Pulumi{WorkingDir: projectPath, Stack: job.ProjectWorkspace} } else { terraformExecutor = execution.Terraform{WorkingDir: projectPath, Workspace: job.ProjectWorkspace} } diff --git a/cli/pkg/digger/digger_test.go b/cli/pkg/digger/digger_test.go index 92568c2f6..aaba37658 100644 --- a/cli/pkg/digger/digger_test.go +++ b/cli/pkg/digger/digger_test.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/diggerhq/digger/libs/ci" "github.com/diggerhq/digger/libs/execution" + "github.com/diggerhq/digger/libs/iac_utils" orchestrator "github.com/diggerhq/digger/libs/scheduler" "os" "sort" @@ -55,13 +56,13 @@ func (m *MockTerraformExecutor) Destroy(params []string, envs map[string]string) return "", "", nil } -func (m *MockTerraformExecutor) Show(params []string, envs map[string]string) (string, string, error) { +func (m *MockTerraformExecutor) Show(params []string, envs map[string]string, planJsonFilePath string) (string, string, error) { nonEmptyTerraformPlanJson := "{\"format_version\":\"1.1\",\"terraform_version\":\"1.4.6\",\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"triggers\":null},\"sensitive_values\":{}}]}},\"resource_changes\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"no-op\"],\"before\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after_unknown\":{},\"before_sensitive\":{},\"after_sensitive\":{}}},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"create\"],\"before\":null,\"after\":{\"triggers\":null},\"after_unknown\":{\"id\":true},\"before_sensitive\":false,\"after_sensitive\":{}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.4.6\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}}]}}},\"configuration\":{\"provider_config\":{\"null\":{\"name\":\"null\",\"full_name\":\"registry.terraform.io/hashicorp/null\"}},\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_config_key\":\"null\",\"schema_version\":0},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_config_key\":\"null\",\"schema_version\":0}]}}}\n" m.Commands = append(m.Commands, RunInfo{"Show", strings.Join(params, " "), time.Now()}) return nonEmptyTerraformPlanJson, "", nil } -func (m *MockTerraformExecutor) Plan(params []string, envs map[string]string) (bool, string, string, error) { +func (m *MockTerraformExecutor) Plan(params []string, envs map[string]string, planJsonFilePath string) (bool, string, string, error) { m.Commands = append(m.Commands, RunInfo{"Plan", strings.Join(params, " "), time.Now()}) return true, "", "", nil } @@ -279,13 +280,14 @@ func TestCorrectCommandExecutionWhenApplying(t *testing.T) { Reporter: reporter, PlanStorage: planStorage, PlanPathProvider: planPathProvider, + IacUtils: iac_utils.TerraformUtils{}, } executor.Apply() commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage, planPathProvider) - assert.Equal(t, []string{"RetrievePlan plan", "Init ", "Apply -lock-timeout=3m", "PublishComment 1
Apply output\n\n```terraform\n\n```\n
", "Run echo"}, commandStrings) + assert.Equal(t, []string{"RetrievePlan plan", "Init ", "Apply ", "PublishComment 1
Apply output\n\n```terraform\n\n```\n
", "Run echo"}, commandStrings) } func TestCorrectCommandExecutionWhenDestroying(t *testing.T) { @@ -368,6 +370,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) { Reporter: reporter, PlanStorage: planStorage, PlanPathProvider: planPathProvider, + IacUtils: iac_utils.TerraformUtils{}, } os.WriteFile(planPathProvider.LocalPlanFilePath(), []byte{123}, 0644) @@ -377,7 +380,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) { commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage, planPathProvider) - assert.Equal(t, []string{"Init ", "Plan -out plan -lock-timeout=3m", "Show -no-color -json plan", "StorePlanFile plan", "Run echo"}, commandStrings) + assert.Equal(t, []string{"Init ", "Plan ", "Show ", "StorePlanFile plan", "Run echo"}, commandStrings) } func allCommandsInOrderWithParams(terraformExecutor *MockTerraformExecutor, commandRunner *MockCommandRunner, prManager *MockPRManager, lock *MockProjectLock, planStorage *MockPlanStorage, planPathProvider *MockPlanPathProvider) []string { diff --git a/cli/pkg/github/github.go b/cli/pkg/github/github.go index 551f3e068..0da3afd0e 100644 --- a/cli/pkg/github/github.go +++ b/cli/pkg/github/github.go @@ -148,6 +148,7 @@ func GitHubCI(lock core_locking.Lock, policyCheckerProvider core_policy.PolicyCh ProjectWorkspace: projectConfig.Workspace, Terragrunt: projectConfig.Terragrunt, OpenTofu: projectConfig.OpenTofu, + Pulumi: projectConfig.Pulumi, Commands: []string{command}, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -180,6 +181,7 @@ func GitHubCI(lock core_locking.Lock, policyCheckerProvider core_policy.PolicyCh ProjectWorkspace: projectConfig.Workspace, Terragrunt: projectConfig.Terragrunt, OpenTofu: projectConfig.OpenTofu, + Pulumi: projectConfig.Pulumi, Commands: []string{"digger drift-detect"}, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), diff --git a/cli/pkg/spec/spec.go b/cli/pkg/spec/spec.go index 7e3e4a2e3..350293e1c 100644 --- a/cli/pkg/spec/spec.go +++ b/cli/pkg/spec/spec.go @@ -17,7 +17,7 @@ import ( func reportError(spec spec.Spec, backendApi backend2.Api, message string, err error) { log.Printf(message) - _, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "") + _, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "", nil) if reportingError != nil { usage.ReportErrorAndExit(spec.VCS.RepoOwner, fmt.Sprintf("Failed to run commands. %v", err), 5) } @@ -131,7 +131,7 @@ func RunSpec( jobs := []scheduler.Job{job} fullRepoName := fmt.Sprintf("%v-%v", spec.VCS.RepoOwner, spec.VCS.RepoName) - _, err = backendApi.ReportProjectJobStatus(fullRepoName, spec.Job.ProjectName, spec.JobId, "started", time.Now(), nil, "", "", "") + _, err = backendApi.ReportProjectJobStatus(fullRepoName, spec.Job.ProjectName, spec.JobId, "started", time.Now(), nil, "", "", "", nil) if err != nil { message := fmt.Sprintf("Failed to report jobSpec status to backend. Exiting. %v", err) reportError(spec, backendApi, message, err) @@ -152,7 +152,7 @@ func RunSpec( reportTerraformOutput := spec.Reporter.ReportTerraformOutput allAppliesSuccess, _, err := digger.RunJobs(jobs, prService, orgService, lock, reporter, planStorage, policyChecker, commentUpdater, backendApi, spec.JobId, true, reportTerraformOutput, commentId, currentDir) if !allAppliesSuccess || err != nil { - serializedBatch, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "") + serializedBatch, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "", nil) if reportingError != nil { message := fmt.Sprintf("Failed run commands. %s", err) reportError(spec, backendApi, message, err) diff --git a/ee/drift/controllers/ci_jobs.go b/ee/drift/controllers/ci_jobs.go index 895aeda98..e04eea1d3 100644 --- a/ee/drift/controllers/ci_jobs.go +++ b/ee/drift/controllers/ci_jobs.go @@ -7,17 +7,17 @@ import ( "github.com/diggerhq/digger/ee/drift/dbmodels" "github.com/diggerhq/digger/ee/drift/model" - "github.com/diggerhq/digger/libs/terraform_utils" + "github.com/diggerhq/digger/libs/iac_utils" "github.com/gin-gonic/gin" ) type SetJobStatusRequest struct { - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` - JobSummary *terraform_utils.TerraformSummary `json:"job_summary"` - Footprint *terraform_utils.TerraformPlanFootprint `json:"job_plan_footprint"` - PrCommentUrl string `json:"pr_comment_url"` - TerraformOutput string `json:"terraform_output"` + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + JobSummary *iac_utils.IacSummary `json:"job_summary"` + Footprint *iac_utils.IacPlanFootprint `json:"job_plan_footprint"` + PrCommentUrl string `json:"pr_comment_url"` + TerraformOutput string `json:"terraform_output"` } func (mc MainController) SetJobStatusForProject(c *gin.Context) { diff --git a/libs/backendapi/backend.go b/libs/backendapi/backend.go index cb9841cda..7213bc413 100644 --- a/libs/backendapi/backend.go +++ b/libs/backendapi/backend.go @@ -1,15 +1,15 @@ package backendapi import ( + "github.com/diggerhq/digger/libs/iac_utils" "github.com/diggerhq/digger/libs/scheduler" - "github.com/diggerhq/digger/libs/terraform_utils" "time" ) type Api interface { ReportProject(repo string, projectName string, configuration string) error ReportProjectRun(repo string, projectName string, startedAt time.Time, endedAt time.Time, status string, command string, output string) error - ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *terraform_utils.TerraformSummary, planJson string, PrCommentUrl string, terraformOutput string) (*scheduler.SerializedBatch, error) + ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *iac_utils.IacSummary, planJson string, PrCommentUrl string, terraformOutput string, iacUtils iac_utils.IacUtils) (*scheduler.SerializedBatch, error) UploadJobArtefact(zipLocation string) (*int, *string, error) DownloadJobArtefact(downloadTo string) (*string, error) } diff --git a/libs/backendapi/diggerapi.go b/libs/backendapi/diggerapi.go index 916e9d84f..145a13e2f 100644 --- a/libs/backendapi/diggerapi.go +++ b/libs/backendapi/diggerapi.go @@ -4,8 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/diggerhq/digger/libs/iac_utils" "github.com/diggerhq/digger/libs/scheduler" - "github.com/diggerhq/digger/libs/terraform_utils" "io" "log" "mime" @@ -29,7 +29,7 @@ func (n NoopApi) ReportProjectRun(namespace string, projectName string, startedA return nil } -func (n NoopApi) ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *terraform_utils.TerraformSummary, planJson string, PrCommentUrl string, terraformOutput string) (*scheduler.SerializedBatch, error) { +func (n NoopApi) ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *iac_utils.IacSummary, planJson string, PrCommentUrl string, terraformOutput string, iacUtils iac_utils.IacUtils) (*scheduler.SerializedBatch, error) { return nil, nil } @@ -129,14 +129,14 @@ func (d DiggerApi) ReportProjectRun(namespace string, projectName string, starte return nil } -func (d DiggerApi) ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *terraform_utils.TerraformSummary, planJson string, PrCommentUrl string, terraformOutput string) (*scheduler.SerializedBatch, error) { +func (d DiggerApi) ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *iac_utils.IacSummary, planJson string, PrCommentUrl string, terraformOutput string, iacUtils iac_utils.IacUtils) (*scheduler.SerializedBatch, error) { u, err := url.Parse(d.DiggerHost) if err != nil { log.Fatalf("Not able to parse digger cloud url: %v", err) } var planSummaryJson interface{} - var planFootprint *terraform_utils.TerraformPlanFootprint = &terraform_utils.TerraformPlanFootprint{} + var planFootprint = &iac_utils.IacPlanFootprint{} if summary == nil { log.Printf("Warning: nil passed to plan result, sending empty") planSummaryJson = nil @@ -145,7 +145,7 @@ func (d DiggerApi) ReportProjectJobStatus(repo string, projectName string, jobId planSummary := summary planSummaryJson = planSummary.ToJson() if planJson != "" { - planFootprint, err = terraform_utils.GetPlanFootprint(planJson) + planFootprint, err = iacUtils.GetPlanFootprint(planJson) if err != nil { log.Printf("Error, could not get footprint from json plan: %v", err) } diff --git a/libs/backendapi/mocks.go b/libs/backendapi/mocks.go index 65a3162ef..f3544a398 100644 --- a/libs/backendapi/mocks.go +++ b/libs/backendapi/mocks.go @@ -1,8 +1,8 @@ package backendapi import ( + "github.com/diggerhq/digger/libs/iac_utils" "github.com/diggerhq/digger/libs/scheduler" - "github.com/diggerhq/digger/libs/terraform_utils" "time" ) @@ -17,7 +17,7 @@ func (t MockBackendApi) ReportProjectRun(repo string, projectName string, starte return nil } -func (t MockBackendApi) ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *terraform_utils.TerraformSummary, planJson string, PrCommentUrl string, terraformOutput string) (*scheduler.SerializedBatch, error) { +func (t MockBackendApi) ReportProjectJobStatus(repo string, projectName string, jobId string, status string, timestamp time.Time, summary *iac_utils.IacSummary, planJson string, PrCommentUrl string, terraformOutput string, iacUtils iac_utils.IacUtils) (*scheduler.SerializedBatch, error) { return nil, nil } diff --git a/libs/ci/azure/azure.go b/libs/ci/azure/azure.go index a3730c2ea..96574cc5a 100644 --- a/libs/ci/azure/azure.go +++ b/libs/ci/azure/azure.go @@ -434,8 +434,6 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig jobs := make([]scheduler.Job, 0) //&dependencyGraph, diggerProjectNamespace, parsedAzureContext.BaseUrl, parsedAzureContext.EventType, prNumber, - - switch parseAzureContext.EventType { case AzurePrCreated, AzurePrUpdated, AzurePrReopened: for _, project := range impactedProjects { @@ -460,6 +458,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig ProjectWorkspace: project.Workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestPushed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -498,6 +497,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig ProjectWorkspace: project.Workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestClosed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -509,7 +509,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig CommandEnvVars: commandEnvVars, StateEnvProvider: StateEnvProvider, CommandEnvProvider: CommandEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } return jobs, true, nil @@ -537,6 +537,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig ProjectWorkspace: project.Workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnCommitToDefault, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -592,7 +593,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig } else { skipMerge = false } - + stateEnvVars, commandEnvVars := digger_config2.CollectTerraformEnvConfig(workflow.EnvVars, true) StateEnvProvider, CommandEnvProvider := scheduler.GetStateAndCommandProviders(project) jobs = append(jobs, scheduler.Job{ @@ -601,6 +602,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig ProjectWorkspace: workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: []string{command}, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -612,7 +614,7 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []dig CommandEnvVars: commandEnvVars, StateEnvProvider: StateEnvProvider, CommandEnvProvider: CommandEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } } diff --git a/libs/ci/generic/events.go b/libs/ci/generic/events.go index 347ec9269..7179e68ff 100644 --- a/libs/ci/generic/events.go +++ b/libs/ci/generic/events.go @@ -165,6 +165,7 @@ func CreateJobsForProjects(projects []digger_config.Project, command string, eve ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: []string{command}, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), diff --git a/libs/ci/github/github.go b/libs/ci/github/github.go index e5b541df6..f6f73ffdf 100644 --- a/libs/ci/github/github.go +++ b/libs/ci/github/github.go @@ -466,6 +466,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnCommitToDefault, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -478,7 +479,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac RequestedBy: *payload.Sender.Login, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } else if *payload.Action == "opened" || *payload.Action == "reopened" || *payload.Action == "synchronize" { jobs = append(jobs, scheduler.Job{ @@ -488,6 +489,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestPushed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -500,7 +502,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac RequestedBy: *payload.Sender.Login, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } else if *payload.Action == "closed" { jobs = append(jobs, scheduler.Job{ @@ -510,6 +512,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestClosed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -522,7 +525,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac RequestedBy: *payload.Sender.Login, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } else if *payload.Action == "converted_to_draft" { var commands []string @@ -539,6 +542,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: commands, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -551,7 +555,7 @@ func ConvertGithubPullRequestEventToJobs(payload *github.PullRequestEvent, impac RequestedBy: *payload.Sender.Login, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } diff --git a/libs/ci/gitlab/gitlab.go b/libs/ci/gitlab/gitlab.go index 3c77e1252..36c096ff5 100644 --- a/libs/ci/gitlab/gitlab.go +++ b/libs/ci/gitlab/gitlab.go @@ -372,6 +372,7 @@ func ConvertGitLabEventToCommands(event GitLabEvent, gitLabContext *GitLabContex ProjectWorkspace: project.Workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestPushed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -383,7 +384,7 @@ func ConvertGitLabEventToCommands(event GitLabEvent, gitLabContext *GitLabContex CommandEnvVars: commandEnvVars, StateEnvProvider: StateEnvProvider, CommandEnvProvider: CommandEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } return jobs, true, nil @@ -416,6 +417,7 @@ func ConvertGitLabEventToCommands(event GitLabEvent, gitLabContext *GitLabContex ProjectWorkspace: project.Workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestClosed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -471,6 +473,7 @@ func ConvertGitLabEventToCommands(event GitLabEvent, gitLabContext *GitLabContex ProjectWorkspace: workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: []string{command}, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), diff --git a/libs/ci/gitlab/webhooks.go b/libs/ci/gitlab/webhooks.go index b595667f4..778512101 100644 --- a/libs/ci/gitlab/webhooks.go +++ b/libs/ci/gitlab/webhooks.go @@ -51,7 +51,6 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro namespace := payload.Project.PathWithNamespace sender := payload.User.Username - var skipMerge bool if workflow.Configuration != nil { skipMerge = workflow.Configuration.SkipMergeCheck @@ -89,6 +88,7 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestPushed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -101,7 +101,7 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro RequestedBy: sender, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } else if payload.ObjectAttributes.Action == "close" { jobs = append(jobs, scheduler.Job{ @@ -111,6 +111,7 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: workflow.Configuration.OnPullRequestClosed, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -123,7 +124,7 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro RequestedBy: sender, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) // TODO: Figure how to detect gitlab's "PR converted to draft" event } else if payload.ObjectAttributes.Action == "converted_to_draft" { @@ -141,6 +142,7 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro ProjectWorkflow: project.Workflow, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, Commands: commands, ApplyStage: scheduler.ToConfigStage(workflow.Apply), PlanStage: scheduler.ToConfigStage(workflow.Plan), @@ -153,7 +155,7 @@ func ConvertGithubPullRequestEventToJobs(payload *gitlab.MergeEvent, impactedPro RequestedBy: sender, CommandEnvProvider: CommandEnvProvider, StateEnvProvider: StateEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } diff --git a/libs/comment_utils/reporting/source_grouping.go b/libs/comment_utils/reporting/source_grouping.go index ad97e76c8..b8363d914 100644 --- a/libs/comment_utils/reporting/source_grouping.go +++ b/libs/comment_utils/reporting/source_grouping.go @@ -6,8 +6,8 @@ import ( "github.com/diggerhq/digger/libs/ci" "github.com/diggerhq/digger/libs/comment_utils/utils" "github.com/diggerhq/digger/libs/digger_config" + "github.com/diggerhq/digger/libs/iac_utils" "github.com/diggerhq/digger/libs/scheduler" - "github.com/diggerhq/digger/libs/terraform_utils" "github.com/samber/lo" "log" ) @@ -17,7 +17,7 @@ type ProjectNameSourceDetail struct { Source string Job scheduler.SerializedJob JobSpec scheduler.JobJson - PlanFootPrint terraform_utils.TerraformPlanFootprint + PlanFootPrint iac_utils.IacPlanFootprint } type SourceGroupingReporter struct { @@ -42,9 +42,9 @@ func (r SourceGroupingReporter) UpdateComment(sourceDetails []SourceDetails, loc return fmt.Errorf("could not convert jobs to map: %v", err) } - projectNameToFootPrintMap := make(map[string]terraform_utils.TerraformPlanFootprint) + projectNameToFootPrintMap := make(map[string]iac_utils.IacPlanFootprint) for _, job := range r.Jobs { - var footprint terraform_utils.TerraformPlanFootprint + var footprint iac_utils.IacPlanFootprint if job.PlanFootprint != nil { err := json.Unmarshal(job.PlanFootprint, &footprint) if err != nil { @@ -52,18 +52,20 @@ func (r SourceGroupingReporter) UpdateComment(sourceDetails []SourceDetails, loc return fmt.Errorf("could not unmarshal footprint: %v", err) } } else { - footprint = terraform_utils.TerraformPlanFootprint{} + footprint = iac_utils.IacPlanFootprint{} } projectNameToFootPrintMap[job.ProjectName] = footprint } - footprints := lo.FilterMap(sourceDetaiItem.Projects, func(project string, i int) (terraform_utils.TerraformPlanFootprint, bool) { + // TODO: make it generic based on iac type + iacUtils := iac_utils.TerraformUtils{} + footprints := lo.FilterMap(sourceDetaiItem.Projects, func(project string, i int) (iac_utils.IacPlanFootprint, bool) { if projectNameToJobMap[project].Status == scheduler.DiggerJobSucceeded { return projectNameToFootPrintMap[project], true } - return terraform_utils.TerraformPlanFootprint{}, false + return iac_utils.IacPlanFootprint{}, false }) - allSimilarInGroup, err := terraform_utils.SimilarityCheck(footprints) + allSimilarInGroup, err := iacUtils.SimilarityCheck(footprints) if err != nil { return fmt.Errorf("error performing similar check: %v", err) } @@ -90,7 +92,7 @@ func (r SourceGroupingReporter) UpdateComment(sourceDetails []SourceDetails, loc } // returns a map inverting locations -func ImpactedSourcesMapToGroupMapping(impactedSources map[string]digger_config.ProjectToSourceMapping, jobMapping map[string]scheduler.SerializedJob, jobSpecMapping map[string]scheduler.JobJson, footprintsMap map[string]terraform_utils.TerraformPlanFootprint) map[string][]ProjectNameSourceDetail { +func ImpactedSourcesMapToGroupMapping(impactedSources map[string]digger_config.ProjectToSourceMapping, jobMapping map[string]scheduler.SerializedJob, jobSpecMapping map[string]scheduler.JobJson, footprintsMap map[string]iac_utils.IacPlanFootprint) map[string][]ProjectNameSourceDetail { projectNameSourceList := make([]ProjectNameSourceDetail, 0) for projectName, locations := range impactedSources { diff --git a/libs/digger_config/config.go b/libs/digger_config/config.go index 967d4a399..22165584a 100644 --- a/libs/digger_config/config.go +++ b/libs/digger_config/config.go @@ -33,6 +33,7 @@ type Project struct { Workspace string Terragrunt bool OpenTofu bool + Pulumi bool Workflow string WorkflowFile string IncludePatterns []string @@ -41,6 +42,7 @@ type Project struct { DriftDetection bool AwsRoleToAssume *AssumeRoleForProject Generated bool + PulumiStack string } type Workflow struct { @@ -55,7 +57,7 @@ type WorkflowConfiguration struct { OnPullRequestClosed []string OnPullRequestConvertedToDraft []string OnCommitToDefault []string - SkipMergeCheck bool + SkipMergeCheck bool } type TerraformEnvConfig struct { @@ -87,7 +89,7 @@ func defaultWorkflow() *Workflow { OnPullRequestPushed: []string{"digger plan"}, OnPullRequestConvertedToDraft: []string{}, OnPullRequestClosed: []string{"digger unlock"}, - SkipMergeCheck: false, + SkipMergeCheck: false, }, Plan: &Stage{ Steps: []Step{ diff --git a/libs/digger_config/converters.go b/libs/digger_config/converters.go index ac818386b..018ce2a52 100644 --- a/libs/digger_config/converters.go +++ b/libs/digger_config/converters.go @@ -52,11 +52,19 @@ func copyProjects(projects []*ProjectYaml) []Project { workflowFile = *p.WorkflowFile } + workspace := "" + if p.Pulumi { + workspace = p.PulumiStack + } else { + workspace = p.Workspace + } + item := Project{p.Name, p.Dir, - p.Workspace, + workspace, p.Terragrunt, p.OpenTofu, + p.Pulumi, p.Workflow, workflowFile, p.IncludePatterns, @@ -65,6 +73,7 @@ func copyProjects(projects []*ProjectYaml) []Project { driftDetection, roleToAssume, p.Generated, + workspace, } result[i] = item } diff --git a/libs/digger_config/digger_config.go b/libs/digger_config/digger_config.go index fb7f51246..6792f9196 100644 --- a/libs/digger_config/digger_config.go +++ b/libs/digger_config/digger_config.go @@ -373,7 +373,52 @@ func ValidateDiggerConfigYaml(configYaml *DiggerConfigYaml, fileName string) err return nil } +func checkThatOnlyOneIacSpecifiedPerProject(project *Project) error { + nOfIac := 0 + if project.Terragrunt { + nOfIac++ + } + if project.OpenTofu { + nOfIac++ + } + if project.Pulumi { + nOfIac++ + } + if nOfIac > 1 { + return fmt.Errorf("project %v has more than one IAC defined, please specify one of terragrunt or pulumi or opentofu", project.Name) + } + return nil +} + +func validatePulumiProject(project *Project) error { + if project.Pulumi { + if project.PulumiStack == "" { + return fmt.Errorf("for pulumi project %v you must specify a pulumi stack", project.Name) + } + } + return nil +} +func ValidateProjects(config *DiggerConfig) error { + projects := config.Projects + for _, project := range projects { + err := checkThatOnlyOneIacSpecifiedPerProject(&project) + if err != nil { + return err + } + + err = validatePulumiProject(&project) + if err != nil { + return err + } + } + return nil +} + func ValidateDiggerConfig(config *DiggerConfig) error { + err := ValidateProjects(config) + if err != nil { + return err + } if config.CommentRenderMode != CommentRenderModeBasic && config.CommentRenderMode != CommentRenderModeGroupByModule { return fmt.Errorf("invalid value for comment_render_mode, %v expecting %v, %v", config.CommentRenderMode, CommentRenderModeBasic, CommentRenderModeGroupByModule) diff --git a/libs/digger_config/yaml.go b/libs/digger_config/yaml.go index 6dfa722d4..9fb108fca 100644 --- a/libs/digger_config/yaml.go +++ b/libs/digger_config/yaml.go @@ -31,6 +31,7 @@ type ProjectYaml struct { Workspace string `yaml:"workspace"` Terragrunt bool `yaml:"terragrunt"` OpenTofu bool `yaml:"opentofu"` + Pulumi bool `yaml:"pulumi"` Workflow string `yaml:"workflow"` WorkflowFile *string `yaml:"workflow_file"` IncludePatterns []string `yaml:"include_patterns,omitempty"` @@ -39,6 +40,7 @@ type ProjectYaml struct { DriftDetection *bool `yaml:"drift_detection,omitempty"` AwsRoleToAssume *AssumeRoleForProjectConfig `yaml:"aws_role_to_assume,omitempty"` Generated bool `yaml:"generated"` + PulumiStack string `yaml:"pulumi_stack"` } type WorkflowYaml struct { @@ -54,7 +56,7 @@ type WorkflowConfigurationYaml struct { // pull request converted to draft OnPullRequestConvertedToDraft []string `yaml:"on_pull_request_to_draft"` OnCommitToDefault []string `yaml:"on_commit_to_default"` - SkipMergeCheck bool `yaml:"skip_merge_check"` + SkipMergeCheck bool `yaml:"skip_merge_check"` } func (s *StageYaml) ToCoreStage() Stage { @@ -140,13 +142,13 @@ type TerragruntParsingConfig struct { CascadeDependencies *bool `yaml:"cascadeDependencies,omitempty"` DefaultApplyRequirements []string `yaml:"defaultApplyRequirements"` //NumExecutors int64 `yaml:"numExecutors"` - ProjectHclFiles []string `yaml:"projectHclFiles"` - CreateHclProjectChilds bool `yaml:"createHclProjectChilds"` - CreateHclProjectExternalChilds *bool `yaml:"createHclProjectExternalChilds,omitempty"` - UseProjectMarkers bool `yaml:"useProjectMarkers"` - ExecutionOrderGroups *bool `yaml:"executionOrderGroups"` - WorkflowFile string `yaml:"workflow_file"` - AwsRoleToAssume *AssumeRoleForProjectConfig `yaml:"aws_role_to_assume,omitempty"` + ProjectHclFiles []string `yaml:"projectHclFiles"` + CreateHclProjectChilds bool `yaml:"createHclProjectChilds"` + CreateHclProjectExternalChilds *bool `yaml:"createHclProjectExternalChilds,omitempty"` + UseProjectMarkers bool `yaml:"useProjectMarkers"` + ExecutionOrderGroups *bool `yaml:"executionOrderGroups"` + WorkflowFile string `yaml:"workflow_file"` + AwsRoleToAssume *AssumeRoleForProjectConfig `yaml:"aws_role_to_assume,omitempty"` } func (p *ProjectYaml) UnmarshalYAML(unmarshal func(interface{}) error) error { diff --git a/libs/execution/execution.go b/libs/execution/execution.go index a83749b54..a3dbea138 100644 --- a/libs/execution/execution.go +++ b/libs/execution/execution.go @@ -3,10 +3,10 @@ package execution import ( "fmt" "github.com/diggerhq/digger/libs/comment_utils/utils" + "github.com/diggerhq/digger/libs/iac_utils" "github.com/diggerhq/digger/libs/locking" "github.com/diggerhq/digger/libs/scheduler" "github.com/diggerhq/digger/libs/storage" - "github.com/diggerhq/digger/libs/terraform_utils" "github.com/samber/lo" "log" "os" @@ -21,8 +21,8 @@ import ( ) type Executor interface { - Plan() (*terraform_utils.TerraformSummary, bool, bool, string, string, error) - Apply() (*terraform_utils.TerraformSummary, bool, string, error) + Plan() (*iac_utils.IacSummary, bool, bool, string, string, error) + Apply() (*iac_utils.IacSummary, bool, string, error) Destroy() (bool, error) } @@ -31,7 +31,7 @@ type LockingExecutorWrapper struct { Executor Executor } -func (l LockingExecutorWrapper) Plan() (*terraform_utils.TerraformSummary, bool, bool, string, string, error) { +func (l LockingExecutorWrapper) Plan() (*iac_utils.IacSummary, bool, bool, string, string, error) { plan := "" locked, err := l.ProjectLock.Lock() if err != nil { @@ -45,7 +45,7 @@ func (l LockingExecutorWrapper) Plan() (*terraform_utils.TerraformSummary, bool, } } -func (l LockingExecutorWrapper) Apply() (*terraform_utils.TerraformSummary, bool, string, error) { +func (l LockingExecutorWrapper) Apply() (*iac_utils.IacSummary, bool, string, error) { locked, err := l.ProjectLock.Lock() if err != nil { msg := fmt.Sprintf("digger apply, error locking project: %v", err) @@ -102,6 +102,7 @@ type DiggerExecutor struct { Reporter reporting.Reporter PlanStorage storage.PlanStorage PlanPathProvider PlanPathProvider + IacUtils iac_utils.IacUtils } type DiggerOperationType string @@ -117,16 +118,16 @@ type DiggerExecutorResult struct { } type DiggerExecutorApplyResult struct { - ApplySummary terraform_utils.TerraformSummary + ApplySummary iac_utils.IacSummary } type DiggerExecutorPlanResult struct { - PlanSummary terraform_utils.TerraformSummary + PlanSummary iac_utils.IacSummary TerraformJson string } -func (d DiggerExecutorResult) GetTerraformSummary() terraform_utils.TerraformSummary { - var summary terraform_utils.TerraformSummary +func (d DiggerExecutorResult) GetTerraformSummary() iac_utils.IacSummary { + var summary iac_utils.IacSummary if d.OperationType == DiggerOparationTypePlan && d.PlanResult != nil { summary = d.PlanResult.PlanSummary } else if d.OperationType == DiggerOparationTypeApply && d.ApplyResult != nil { @@ -189,8 +190,8 @@ func (d DiggerExecutor) RetrievePlanJson() (string, error) { } } - showArgs := []string{"-no-color", "-json", *storedPlanPath} - terraformPlanOutput, _, _ := executor.TerraformExecutor.Show(showArgs, executor.CommandEnvVars) + showArgs := make([]string, 0) + terraformPlanOutput, _, _ := executor.TerraformExecutor.Show(showArgs, executor.CommandEnvVars, *storedPlanPath) return terraformPlanOutput, nil } else { @@ -198,10 +199,10 @@ func (d DiggerExecutor) RetrievePlanJson() (string, error) { } } -func (d DiggerExecutor) Plan() (*terraform_utils.TerraformSummary, bool, bool, string, string, error) { +func (d DiggerExecutor) Plan() (*iac_utils.IacSummary, bool, bool, string, string, error) { plan := "" terraformPlanOutput := "" - planSummary := &terraform_utils.TerraformSummary{} + planSummary := &iac_utils.IacSummary{} isEmptyPlan := true var planSteps []scheduler.Step @@ -227,16 +228,19 @@ func (d DiggerExecutor) Plan() (*terraform_utils.TerraformSummary, bool, bool, s } } if step.Action == "plan" { - planArgs := []string{"-out", d.PlanPathProvider.LocalPlanFilePath(), "-lock-timeout=3m"} + planArgs := make([]string, 0) + + // TODO remove those only for pulumi project planArgs = append(planArgs, step.ExtraArgs...) - _, stdout, stderr, err := d.TerraformExecutor.Plan(planArgs, d.CommandEnvVars) + + _, stdout, stderr, err := d.TerraformExecutor.Plan(planArgs, d.CommandEnvVars, d.PlanPathProvider.LocalPlanFilePath()) if err != nil { return nil, false, false, "", "", fmt.Errorf("error executing plan: %v", err) } - showArgs := []string{"-no-color", "-json", d.PlanPathProvider.LocalPlanFilePath()} - terraformPlanOutput, _, _ = d.TerraformExecutor.Show(showArgs, d.CommandEnvVars) + showArgs := make([]string, 0) + terraformPlanOutput, _, _ = d.TerraformExecutor.Show(showArgs, d.CommandEnvVars, d.PlanPathProvider.LocalPlanFilePath()) - isEmptyPlan, planSummary, err = terraform_utils.GetSummaryFromPlanJson(terraformPlanOutput) + isEmptyPlan, planSummary, err = d.IacUtils.GetSummaryFromPlanJson(terraformPlanOutput) if err != nil { return nil, false, false, "", "", fmt.Errorf("error checking for empty plan: %v", err) } @@ -250,9 +254,6 @@ func (d DiggerExecutor) Plan() (*terraform_utils.TerraformSummary, bool, bool, s defer file.Close() } - if err != nil { - return nil, false, false, "", "", fmt.Errorf("error executing plan: %v", err) - } if d.PlanStorage != nil { fileBytes, err := os.ReadFile(d.PlanPathProvider.LocalPlanFilePath()) @@ -267,6 +268,8 @@ func (d DiggerExecutor) Plan() (*terraform_utils.TerraformSummary, bool, bool, s return nil, false, false, "", "", fmt.Errorf("error storing artifact file: %v", err) } } + + // TODO: move this function to iacUtils interface and implement for pulumi plan = cleanupTerraformPlan(!isEmptyPlan, err, stdout, stderr) if err != nil { log.Printf("error publishing comment: %v", err) @@ -303,10 +306,10 @@ func reportError(r reporting.Reporter, stderr string) { } } -func (d DiggerExecutor) Apply() (*terraform_utils.TerraformSummary, bool, string, error) { +func (d DiggerExecutor) Apply() (*iac_utils.IacSummary, bool, string, error) { var applyOutput string var plansFilename *string - summary := terraform_utils.TerraformSummary{} + summary := iac_utils.IacSummary{} if d.PlanStorage != nil { var err error plansFilename, err = d.PlanStorage.RetrievePlan(d.PlanPathProvider.LocalPlanFilePath(), d.PlanPathProvider.ArtifactName(), d.PlanPathProvider.StoredPlanFilePath()) @@ -339,8 +342,7 @@ func (d DiggerExecutor) Apply() (*terraform_utils.TerraformSummary, bool, string } } if step.Action == "apply" { - applyArgs := []string{"-lock-timeout=3m"} - applyArgs = append(applyArgs, step.ExtraArgs...) + applyArgs := step.ExtraArgs stdout, stderr, err := d.TerraformExecutor.Apply(applyArgs, plansFilename, d.CommandEnvVars) applyOutput = cleanupTerraformApply(true, err, stdout, stderr) @@ -350,7 +352,7 @@ func (d DiggerExecutor) Apply() (*terraform_utils.TerraformSummary, bool, string return nil, false, stdout, fmt.Errorf("error executing apply: %v", err) } - summary, err = terraform_utils.GetSummaryFromTerraformApplyOutput(stdout) + summary, err = d.IacUtils.GetSummaryFromApplyOutput(stdout) if err != nil { log.Printf("Warning: get summary from apply output failed: %v", err) } @@ -537,11 +539,11 @@ func (d DiggerExecutor) projectId() string { // this will log an exit code and error based on the executor of the executor drivers are by filename func logCommandFail(exitCode int, err error) { - _, filename, _, ok := runtime.Caller(1); + _, filename, _, ok := runtime.Caller(1) if ok { executor := strings.TrimSuffix(path.Base(filename), path.Ext(filename)) log.Printf("Command failed in %v with exit code %v and error %v", executor, exitCode, err) } else { log.Printf("Command failed in unknown executor with exit code %v and error %v", exitCode, err) } -} \ No newline at end of file +} diff --git a/libs/execution/opentofu.go b/libs/execution/opentofu.go index 71da1a52b..0909ebc46 100644 --- a/libs/execution/opentofu.go +++ b/libs/execution/opentofu.go @@ -30,6 +30,7 @@ func (tf OpenTofu) Apply(params []string, plan *string, envs map[string]string) return "", "", err } } + params = append(params, []string{"-lock-timeout=3m"}...) params = append(append(append(params, "-input=false"), "-no-color"), "-auto-approve") if plan != nil { params = append(params, *plan) @@ -38,7 +39,7 @@ func (tf OpenTofu) Apply(params []string, plan *string, envs map[string]string) return stdout, stderr, err } -func (tf OpenTofu) Plan(params []string, envs map[string]string) (bool, string, string, error) { +func (tf OpenTofu) Plan(params []string, envs map[string]string, planArtefactFilePath string) (bool, string, string, error) { if tf.Workspace != "default" { err := tf.switchToWorkspace(envs) if err != nil { @@ -46,6 +47,10 @@ func (tf OpenTofu) Plan(params []string, envs map[string]string) (bool, string, return false, "", "", err } } + if planArtefactFilePath != "" { + params = append(params, []string{"-out", planArtefactFilePath}...) + } + params = append(params, "-lock-timeout=3m") params = append(append(append(params, "-input=false"), "-no-color"), "-detailed-exitcode") stdout, stderr, statusCode, err := tf.runOpentofuCommand("plan", true, envs, params...) if err != nil && statusCode != 2 { @@ -54,7 +59,8 @@ func (tf OpenTofu) Plan(params []string, envs map[string]string) (bool, string, return statusCode == 2, stdout, stderr, nil } -func (tf OpenTofu) Show(params []string, envs map[string]string) (string, string, error) { +func (tf OpenTofu) Show(params []string, envs map[string]string, planArtefactFilePath string) (string, string, error) { + params = append(params, []string{"-no-color", "-json", planArtefactFilePath}...) stdout, stderr, _, err := tf.runOpentofuCommand("show", false, envs, params...) if err != nil { return "", "", err diff --git a/libs/execution/opentofu_test.go b/libs/execution/opentofu_test.go index 095ab8c5a..0955337e1 100644 --- a/libs/execution/opentofu_test.go +++ b/libs/execution/opentofu_test.go @@ -20,7 +20,7 @@ func TestExecuteTofuPlan(t *testing.T) { tf := OpenTofu{WorkingDir: dir, Workspace: "dev"} tf.Init([]string{}, map[string]string{}) - _, _, _, err := tf.Plan([]string{}, map[string]string{}) + _, _, _, err := tf.Plan([]string{}, map[string]string{}, "") assert.NoError(t, err) } @@ -37,7 +37,7 @@ func TestExecuteTofuApply(t *testing.T) { tf := OpenTofu{WorkingDir: dir, Workspace: "dev"} tf.Init([]string{}, map[string]string{}) - _, _, _, err := tf.Plan([]string{}, map[string]string{}) + _, _, _, err := tf.Plan([]string{}, map[string]string{}, "") assert.NoError(t, err) } @@ -56,7 +56,7 @@ func TestExecuteTofuApplyDefaultWorkspace(t *testing.T) { tf.Init([]string{}, map[string]string{}) var planArgs []string planArgs = append(planArgs, "-out", "plan.tfplan") - tf.Plan(planArgs, map[string]string{}) + tf.Plan(planArgs, map[string]string{}, "") plan := "plan.tfplan" _, _, err := tf.Apply([]string{}, &plan, map[string]string{}) assert.NoError(t, err) diff --git a/libs/execution/pulumi.go b/libs/execution/pulumi.go new file mode 100644 index 000000000..841648922 --- /dev/null +++ b/libs/execution/pulumi.go @@ -0,0 +1,125 @@ +package execution + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" +) + +type Pulumi struct { + WorkingDir string + Stack string +} + +func (pl Pulumi) Init(params []string, envs map[string]string) (string, string, error) { + // TODO: there is no equivalent of "init" in pulumi world, lets do login instead + stdout, stderr, _, err := pl.runPululmiCommand("install", true, envs, params...) + if err != nil { + return stdout, stderr, err + } + stdout, stderr, _, err = pl.runPululmiCommand("login", true, envs, params...) + return stdout, stderr, err +} + +func (pl Pulumi) Apply(params []string, plan *string, envs map[string]string) (string, string, error) { + pl.selectStack() + params = append(params, "--yes") + if plan != nil { + params = append(params, []string{"--plan", *plan}...) + } + stdout, stderr, _, err := pl.runPululmiCommand("up", true, envs, params...) + return stdout, stderr, err +} + +func (pl Pulumi) Plan(params []string, envs map[string]string, planArtefactFilePath string) (bool, string, string, error) { + pl.selectStack() + params = append(params, []string{"--save-plan", planArtefactFilePath}...) + stdout, stderr, statusCode, err := pl.runPululmiCommand("preview", true, envs, params...) + if err != nil && statusCode != 2 { + return false, "", "", err + } + return statusCode == 2, stdout, stderr, nil +} + +func (pl Pulumi) Show(params []string, envs map[string]string, planArtefactFilePath string) (string, string, error) { + pl.selectStack() + // TODO figure out how to avoid running a second plan (preview) here + params = append(params, []string{"--json"}...) + stdout, stderr, statusCode, err := pl.runPululmiCommand("preview", false, envs, params...) + if err != nil && statusCode != 2 { + return "", "", err + } + return stdout, stderr, nil +} + +func (pl Pulumi) Destroy(params []string, envs map[string]string) (string, string, error) { + pl.selectStack() + params = append(params, "--yes") + stdout, stderr, _, err := pl.runPululmiCommand("destroy", true, envs, params...) + return stdout, stderr, err +} + +func (pl Pulumi) selectStack() error { + _, _, _, err := pl.runPululmiCommand("stack", true, make(map[string]string, 0), "select", pl.Stack) + if err != nil { + return err + } + return nil +} + +func (pl Pulumi) runPululmiCommand(command string, printOutputToStdout bool, envs map[string]string, arg ...string) (string, string, int, error) { + args := []string{command} + args = append(args, arg...) + envs["PULUMI_CI"] = "true" + expandedArgs := make([]string, 0) + for _, p := range args { + s := os.ExpandEnv(p) + s = strings.TrimSpace(s) + if s != "" { + expandedArgs = append(expandedArgs, s) + } + } + + var mwout, mwerr io.Writer + var stdout, stderr bytes.Buffer + if printOutputToStdout { + mwout = io.MultiWriter(os.Stdout, &stdout) + mwerr = io.MultiWriter(os.Stderr, &stderr) + } else { + mwout = io.Writer(&stdout) + mwerr = io.Writer(&stderr) + } + + cmd := exec.Command("pulumi", expandedArgs...) + log.Printf("Running command: pulumi %v", expandedArgs) + cmd.Dir = pl.WorkingDir + + env := os.Environ() + for k, v := range envs { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + cmd.Env = env + cmd.Stdout = mwout + cmd.Stderr = mwerr + + err := cmd.Run() + + // 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 { + log.Println("pulumi command error:", err) + log.Printf("stdout %v | stderr %v", stdout.String(), stderr.String()) + } + + return stdout.String(), stderr.String(), cmd.ProcessState.ExitCode(), err +} + +func (pl Pulumi) formatPulumiWorkspaces(list string) string { + list = strings.TrimSpace(list) + char_replace := strings.NewReplacer("*", "", "\n", ",", " ", "") + list = char_replace.Replace(list) + return list +} diff --git a/libs/execution/terragrunt.go b/libs/execution/terragrunt.go index 7c313ded3..604b0c98a 100644 --- a/libs/execution/terragrunt.go +++ b/libs/execution/terragrunt.go @@ -15,7 +15,7 @@ type Terragrunt struct { } func (terragrunt Terragrunt) Init(params []string, envs map[string]string) (string, string, error) { - + stdout, stderr, exitCode, err := terragrunt.runTerragruntCommand("init", true, envs, params...) if exitCode != 0 { logCommandFail(exitCode, err) @@ -25,6 +25,7 @@ func (terragrunt Terragrunt) Init(params []string, envs map[string]string) (stri } func (terragrunt Terragrunt) Apply(params []string, plan *string, envs map[string]string) (string, string, error) { + params = append(params, []string{"-lock-timeout=3m"}...) params = append(params, "--auto-approve") params = append(params, "--terragrunt-non-interactive") if plan != nil { @@ -46,11 +47,14 @@ func (terragrunt Terragrunt) Destroy(params []string, envs map[string]string) (s logCommandFail(exitCode, err) } - return stdout, stderr, err } -func (terragrunt Terragrunt) Plan(params []string, envs map[string]string) (bool, string, string, error) { +func (terragrunt Terragrunt) Plan(params []string, envs map[string]string, planArtefactFilePath string) (bool, string, string, error) { + if planArtefactFilePath != "" { + params = append(params, []string{"-out", planArtefactFilePath}...) + } + params = append(params, "-lock-timeout=3m") stdout, stderr, exitCode, err := terragrunt.runTerragruntCommand("plan", true, envs, params...) if exitCode != 0 { logCommandFail(exitCode, err) @@ -59,7 +63,8 @@ func (terragrunt Terragrunt) Plan(params []string, envs map[string]string) (bool return true, stdout, stderr, err } -func (terragrunt Terragrunt) Show(params []string, envs map[string]string) (string, string, error) { +func (terragrunt Terragrunt) Show(params []string, envs map[string]string, planArtefactFilePath string) (string, string, error) { + params = append(params, []string{"-no-color", "-json", planArtefactFilePath}...) stdout, stderr, exitCode, err := terragrunt.runTerragruntCommand("show", false, envs, params...) if exitCode != 0 { logCommandFail(exitCode, err) @@ -81,7 +86,7 @@ func (terragrunt Terragrunt) runTerragruntCommand(command string, printOutputToS } } - // Set up common output buffers + // Set up common output buffers var mwout, mwerr io.Writer var stdout, stderr bytes.Buffer if printOutputToStdout { diff --git a/libs/execution/tf.go b/libs/execution/tf.go index 64f67f1e8..b68fb5bc8 100644 --- a/libs/execution/tf.go +++ b/libs/execution/tf.go @@ -15,8 +15,8 @@ type TerraformExecutor interface { Init([]string, map[string]string) (string, string, error) Apply([]string, *string, map[string]string) (string, string, error) Destroy([]string, map[string]string) (string, string, error) - Plan([]string, map[string]string) (bool, string, string, error) - Show([]string, map[string]string) (string, string, error) + Plan([]string, map[string]string, string) (bool, string, string, error) + Show([]string, map[string]string, string) (string, string, error) } type Terraform struct { @@ -43,6 +43,7 @@ func (tf Terraform) Init(params []string, envs map[string]string) (string, strin } func (tf Terraform) Apply(params []string, plan *string, envs map[string]string) (string, string, error) { + params = append(params, []string{"-lock-timeout=3m"}...) params = append(append(append(params, "-input=false"), "-no-color"), "-auto-approve") if plan != nil { params = append(params, *plan) @@ -135,8 +136,12 @@ func (tf Terraform) formatTerraformWorkspaces(list string) string { return list } -func (tf Terraform) Plan(params []string, envs map[string]string) (bool, string, string, error) { +func (tf Terraform) Plan(params []string, envs map[string]string, planArtefactFilePath string) (bool, string, string, error) { params = append(append(append(params, "-input=false"), "-no-color"), "-detailed-exitcode") + if planArtefactFilePath != "" { + params = append(params, []string{"-out", planArtefactFilePath}...) + } + params = append(params, "-lock-timeout=3m") stdout, stderr, statusCode, err := tf.runTerraformCommand("plan", true, envs, params...) if err != nil && statusCode != 2 { return false, "", "", err @@ -144,7 +149,8 @@ func (tf Terraform) Plan(params []string, envs map[string]string) (bool, string, return statusCode == 2, stdout, stderr, nil } -func (tf Terraform) Show(params []string, envs map[string]string) (string, string, error) { +func (tf Terraform) Show(params []string, envs map[string]string, planArtefactFilePath string) (string, string, error) { + params = append(params, []string{"-no-color", "-json", planArtefactFilePath}...) stdout, stderr, _, err := tf.runTerraformCommand("show", false, envs, params...) if err != nil { return "", "", err diff --git a/libs/execution/tf_test.go b/libs/execution/tf_test.go index 6cc66fab1..532b72a18 100644 --- a/libs/execution/tf_test.go +++ b/libs/execution/tf_test.go @@ -20,7 +20,7 @@ func TestExecuteTerraformPlan(t *testing.T) { tf := Terraform{WorkingDir: dir, Workspace: "dev"} tf.Init([]string{}, map[string]string{}) - _, _, _, err := tf.Plan([]string{}, map[string]string{}) + _, _, _, err := tf.Plan([]string{}, map[string]string{}, "") assert.NoError(t, err) } @@ -37,7 +37,7 @@ func TestExecuteTerraformApply(t *testing.T) { tf := Terraform{WorkingDir: dir, Workspace: "dev"} tf.Init([]string{}, map[string]string{}) - _, _, _, err := tf.Plan([]string{}, map[string]string{}) + _, _, _, err := tf.Plan([]string{}, map[string]string{}, "") assert.NoError(t, err) } @@ -56,7 +56,7 @@ func TestExecuteTerraformApplyDefaultWorkspace(t *testing.T) { tf.Init([]string{}, map[string]string{}) var planArgs []string planArgs = append(planArgs, "-out", "plan.tfplan") - tf.Plan(planArgs, map[string]string{}) + tf.Plan(planArgs, map[string]string{}, "") plan := "plan.tfplan" _, _, err := tf.Apply([]string{}, &plan, map[string]string{}) assert.NoError(t, err) diff --git a/libs/iac_utils/iac_utils.go b/libs/iac_utils/iac_utils.go new file mode 100644 index 000000000..244ff37cb --- /dev/null +++ b/libs/iac_utils/iac_utils.go @@ -0,0 +1,67 @@ +package iac_utils + +import ( + "github.com/diggerhq/digger/libs/scheduler" + "github.com/samber/lo" + "sort" +) + +type IacSummary struct { + ResourcesCreated uint `json:"resources_created"` + ResourcesUpdated uint `json:"resources_updated"` + ResourcesDeleted uint `json:"resources_deleted"` +} + +func (p *IacSummary) ToJson() map[string]interface{} { + if p == nil { + return map[string]interface{}{} + } + return map[string]interface{}{ + "resources_created": p.ResourcesCreated, + "resources_updated": p.ResourcesUpdated, + "resources_deleted": p.ResourcesDeleted, + } +} + +// IacPlanFootprint represents a derivation of a terraform plan json that has +// any sensitive data stripped out. Used for performing operations such +// as plan similarity check +type IacPlanFootprint struct { + Addresses []string `json:"addresses"` +} + +func (f *IacPlanFootprint) ToJson() map[string]interface{} { + if f == nil { + return map[string]interface{}{} + } + return map[string]interface{}{ + "addresses": f.Addresses, + } +} + +func (footprint IacPlanFootprint) hash() string { + addresses := make([]string, len(footprint.Addresses)) + copy(addresses, footprint.Addresses) + sort.Strings(addresses) + // concatenate all the addreses after sorting to form the hash + return lo.Reduce(addresses, func(a string, b string, i int) string { + return a + b + }, "") +} + +type IacUtils interface { + GetSummaryFromPlanJson(planJson string) (bool, *IacSummary, error) + GetSummaryFromApplyOutput(applyOutput string) (IacSummary, error) + GetPlanFootprint(planJson string) (*IacPlanFootprint, error) + PerformPlanSimilarityCheck(footprint1 IacPlanFootprint, footprint2 IacPlanFootprint) (bool, error) + SimilarityCheck(footprints []IacPlanFootprint) (bool, error) + GetSummarizePlan(planJson string) (string, error) +} + +func GetIacUtilsIacType(iacType scheduler.IacType) IacUtils { + if iacType == scheduler.IacTypePulumi { + return PulumiUtils{} + } else { + return TerraformUtils{} + } +} diff --git a/libs/iac_utils/pulumi.go b/libs/iac_utils/pulumi.go new file mode 100644 index 000000000..3af64e070 --- /dev/null +++ b/libs/iac_utils/pulumi.go @@ -0,0 +1,125 @@ +package iac_utils + +import ( + "bufio" + "encoding/json" + "fmt" + tfjson "github.com/hashicorp/terraform-json" + "github.com/samber/lo" + "regexp" + "strings" +) + +// PulumiPreview represents the root structure of Pulumi preview JSON +type PulumiPreview struct { + ChangeSummary ChangeSummary `json:"changeSummary"` +} + +// ChangeSummary contains the summary of all changes +type ChangeSummary struct { + Create int `json:"create"` + Same int `json:"same"` + Update int `json:"update"` + Delete int `json:"delete"` +} + +// PreviewStats holds the statistics of resource changes +type PreviewStats struct { + Created int + Updated int + Deleted int + Total int +} + +type PulumiUtils struct{} + +func parsePulumiPlanOutput(terraformJson string) (*tfjson.Plan, error) { + var plan tfjson.Plan + if err := json.Unmarshal([]byte(terraformJson), &plan); err != nil { + return nil, fmt.Errorf("Unable to parse the plan file: %v", err) + } + + return &plan, nil +} + +func (tu PulumiUtils) GetSummaryFromPlanJson(planJson string) (bool, *IacSummary, error) { + var preview PulumiPreview + if err := json.Unmarshal([]byte(planJson), &preview); err != nil { + return false, &IacSummary{}, err + } + + summary := IacSummary{ + ResourcesCreated: uint(preview.ChangeSummary.Create), + ResourcesUpdated: uint(preview.ChangeSummary.Update), + ResourcesDeleted: uint(preview.ChangeSummary.Delete), + } + + total := summary.ResourcesCreated + summary.ResourcesUpdated + summary.ResourcesDeleted + isPlanEmpty := total == 0 + return isPlanEmpty, &summary, nil +} + +func (tu PulumiUtils) GetSummaryFromApplyOutput(applyOutput string) (IacSummary, error) { + scanner := bufio.NewScanner(strings.NewReader(applyOutput)) + var added, changed, destroyed uint = 0, 0, 0 + + summaryRegex := regexp.MustCompile(`(\d+) added, (\d+) changed, (\d+) destroyed`) + + foundResourcesLine := false + for scanner.Scan() { + line := scanner.Text() + if matches := summaryRegex.FindStringSubmatch(line); matches != nil { + foundResourcesLine = true + fmt.Sscanf(matches[1], "%d", &added) + fmt.Sscanf(matches[2], "%d", &changed) + fmt.Sscanf(matches[3], "%d", &destroyed) + } + } + + if !foundResourcesLine { + return IacSummary{}, fmt.Errorf("could not find resources line in terraform apply output") + } + + return IacSummary{ + ResourcesCreated: added, + ResourcesUpdated: changed, + ResourcesDeleted: destroyed, + }, nil +} + +func (tu PulumiUtils) GetPlanFootprint(planJson string) (*IacPlanFootprint, error) { + tfplan, err := parseTerraformPlanOutput(planJson) + if err != nil { + return nil, err + } + planAddresses := lo.Map[*tfjson.ResourceChange, string](tfplan.ResourceChanges, func(change *tfjson.ResourceChange, idx int) string { + return change.Address + }) + footprint := IacPlanFootprint{ + Addresses: planAddresses, + } + return &footprint, nil +} + +func (tu PulumiUtils) PerformPlanSimilarityCheck(footprint1 IacPlanFootprint, footprint2 IacPlanFootprint) (bool, error) { + return footprint1.hash() == footprint2.hash(), nil +} + +func (tu PulumiUtils) SimilarityCheck(footprints []IacPlanFootprint) (bool, error) { + if len(footprints) < 2 { + return true, nil + } + footprintHashes := lo.Map(footprints, func(footprint IacPlanFootprint, i int) string { + return footprint.hash() + }) + allSimilar := lo.EveryBy(footprintHashes, func(footprint string) bool { + return footprint == footprintHashes[0] + }) + return allSimilar, nil + +} + +func (tu PulumiUtils) GetSummarizePlan(planJson string) (string, error) { + // TODO: Implement me (equivalent of tfsummarize for pulumi) + return "", nil +} diff --git a/libs/terraform_utils/plan_summary.go b/libs/iac_utils/terraform.go similarity index 56% rename from libs/terraform_utils/plan_summary.go rename to libs/iac_utils/terraform.go index fb541e19d..e95cfae42 100644 --- a/libs/terraform_utils/plan_summary.go +++ b/libs/iac_utils/terraform.go @@ -1,67 +1,21 @@ -package terraform_utils +package iac_utils import ( "bufio" "bytes" "encoding/json" "fmt" - "regexp" - "sort" - "strings" - "github.com/dineshba/tf-summarize/terraformstate" "github.com/dineshba/tf-summarize/writer" tfjson "github.com/hashicorp/terraform-json" - "github.com/samber/lo" + "regexp" + "strings" ) -type TerraformSummary struct { - ResourcesCreated uint `json:"resources_created"` - ResourcesUpdated uint `json:"resources_updated"` - ResourcesDeleted uint `json:"resources_deleted"` -} - -type Change struct { - Actions []string `json:"actions"` -} - -// TerraformPlanFootprint represents a derivation of a terraform plan json that has -// any sensitive data stripped out. Used for performing operations such -// as plan similarity check -type TerraformPlanFootprint struct { - Addresses []string `json:"addresses"` +type TerraformUtils struct { } -func (f *TerraformPlanFootprint) ToJson() map[string]interface{} { - if f == nil { - return map[string]interface{}{} - } - return map[string]interface{}{ - "addresses": f.Addresses, - } -} - -func (footprint TerraformPlanFootprint) hash() string { - addresses := make([]string, len(footprint.Addresses)) - copy(addresses, footprint.Addresses) - sort.Strings(addresses) - // concatenate all the addreses after sorting to form the hash - return lo.Reduce(addresses, func(a string, b string, i int) string { - return a + b - }, "") -} - -func (p *TerraformSummary) ToJson() map[string]interface{} { - if p == nil { - return map[string]interface{}{} - } - return map[string]interface{}{ - "resources_created": p.ResourcesCreated, - "resources_updated": p.ResourcesUpdated, - "resources_deleted": p.ResourcesDeleted, - } -} func parseTerraformPlanOutput(terraformJson string) (*tfjson.Plan, error) { var plan tfjson.Plan if err := json.Unmarshal([]byte(terraformJson), &plan); err != nil { @@ -71,7 +25,7 @@ func parseTerraformPlanOutput(terraformJson string) (*tfjson.Plan, error) { return &plan, nil } -func GetSummaryFromPlanJson(planJson string) (bool, *TerraformSummary, error) { +func (tu TerraformUtils) GetSummaryFromPlanJson(planJson string) (bool, *IacSummary, error) { tfplan, err := parseTerraformPlanOutput(planJson) if err != nil { return false, nil, fmt.Errorf("Error while parsing json file: %v", err) @@ -89,7 +43,7 @@ func GetSummaryFromPlanJson(planJson string) (bool, *TerraformSummary, error) { isPlanEmpty = false } - planSummary := TerraformSummary{} + planSummary := IacSummary{} for _, resourceChange := range tfplan.ResourceChanges { switch resourceChange.Change.Actions[0] { case "create": @@ -103,7 +57,7 @@ func GetSummaryFromPlanJson(planJson string) (bool, *TerraformSummary, error) { return isPlanEmpty, &planSummary, nil } -func GetSummaryFromTerraformApplyOutput(applyOutput string) (TerraformSummary, error) { +func (tu TerraformUtils) GetSummaryFromApplyOutput(applyOutput string) (IacSummary, error) { scanner := bufio.NewScanner(strings.NewReader(applyOutput)) var added, changed, destroyed uint = 0, 0, 0 @@ -121,17 +75,17 @@ func GetSummaryFromTerraformApplyOutput(applyOutput string) (TerraformSummary, e } if !foundResourcesLine { - return TerraformSummary{}, fmt.Errorf("could not find resources line in terraform apply output") + return IacSummary{}, fmt.Errorf("could not find resources line in terraform apply output") } - return TerraformSummary{ + return IacSummary{ ResourcesCreated: added, ResourcesUpdated: changed, ResourcesDeleted: destroyed, }, nil } -func GetPlanFootprint(planJson string) (*TerraformPlanFootprint, error) { +func (tu TerraformUtils) GetPlanFootprint(planJson string) (*IacPlanFootprint, error) { tfplan, err := parseTerraformPlanOutput(planJson) if err != nil { return nil, err @@ -139,21 +93,21 @@ func GetPlanFootprint(planJson string) (*TerraformPlanFootprint, error) { planAddresses := lo.Map[*tfjson.ResourceChange, string](tfplan.ResourceChanges, func(change *tfjson.ResourceChange, idx int) string { return change.Address }) - footprint := TerraformPlanFootprint{ + footprint := IacPlanFootprint{ Addresses: planAddresses, } return &footprint, nil } -func PerformPlanSimilarityCheck(footprint1 TerraformPlanFootprint, footprint2 TerraformPlanFootprint) (bool, error) { +func (tu TerraformUtils) PerformPlanSimilarityCheck(footprint1 IacPlanFootprint, footprint2 IacPlanFootprint) (bool, error) { return footprint1.hash() == footprint2.hash(), nil } -func SimilarityCheck(footprints []TerraformPlanFootprint) (bool, error) { +func (tu TerraformUtils) SimilarityCheck(footprints []IacPlanFootprint) (bool, error) { if len(footprints) < 2 { return true, nil } - footprintHashes := lo.Map(footprints, func(footprint TerraformPlanFootprint, i int) string { + footprintHashes := lo.Map(footprints, func(footprint IacPlanFootprint, i int) string { return footprint.hash() }) allSimilar := lo.EveryBy(footprintHashes, func(footprint string) bool { @@ -163,7 +117,7 @@ func SimilarityCheck(footprints []TerraformPlanFootprint) (bool, error) { } -func GetTfSummarizePlan(planJson string) (string, error) { +func (tu TerraformUtils) GetSummarizePlan(planJson string) (string, error) { plan := tfjson.Plan{} err := json.Unmarshal([]byte(planJson), &plan) if err != nil { diff --git a/libs/terraform_utils/plan_summary_test.go b/libs/iac_utils/terraform_test.go similarity index 97% rename from libs/terraform_utils/plan_summary_test.go rename to libs/iac_utils/terraform_test.go index 0ba41dfb9..19a6fe37d 100644 --- a/libs/terraform_utils/plan_summary_test.go +++ b/libs/iac_utils/terraform_test.go @@ -1,4 +1,4 @@ -package terraform_utils +package iac_utils import ( "testing" @@ -8,21 +8,21 @@ import ( func TestPlanOutputEmpty(t *testing.T) { emptyTerraformPlanJson := "{\"format_version\":\"1.1\",\"terraform_version\":\"1.4.6\",\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}}]}},\"resource_changes\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"no-op\"],\"before\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after_unknown\":{},\"before_sensitive\":{},\"after_sensitive\":{}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.4.6\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}}]}}},\"configuration\":{\"provider_config\":{\"null\":{\"name\":\"null\",\"full_name\":\"registry.terraform.io/hashicorp/null\"}},\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_config_key\":\"null\",\"schema_version\":0}]}}}\n" - isEmpty, _, err := GetSummaryFromPlanJson(emptyTerraformPlanJson) + isEmpty, _, err := TerraformUtils{}.GetSummaryFromPlanJson(emptyTerraformPlanJson) assert.Nil(t, err) assert.True(t, isEmpty) } func TestPlanOutputNonEmpty(t *testing.T) { nonEmptyTerraformPlanJson := "{\"format_version\":\"1.1\",\"terraform_version\":\"1.4.6\",\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"triggers\":null},\"sensitive_values\":{}}]}},\"resource_changes\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"no-op\"],\"before\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after_unknown\":{},\"before_sensitive\":{},\"after_sensitive\":{}}},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"create\"],\"before\":null,\"after\":{\"triggers\":null},\"after_unknown\":{\"id\":true},\"before_sensitive\":false,\"after_sensitive\":{}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.4.6\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}}]}}},\"configuration\":{\"provider_config\":{\"null\":{\"name\":\"null\",\"full_name\":\"registry.terraform.io/hashicorp/null\"}},\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_config_key\":\"null\",\"schema_version\":0},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_config_key\":\"null\",\"schema_version\":0}]}}}\n" - isEmpty, _, err := GetSummaryFromPlanJson(nonEmptyTerraformPlanJson) + isEmpty, _, err := TerraformUtils{}.GetSummaryFromPlanJson(nonEmptyTerraformPlanJson) assert.Nil(t, err) assert.False(t, isEmpty) } func TestGetPlanSummaryOnlyOutputsChanged(t *testing.T) { onlyOutputsChangedJson := "{\"format_version\":\"1.2\",\"terraform_version\":\"1.7.3\",\"planned_values\":{\"outputs\":{\"tt\":{\"sensitive\":false,\"type\":\"string\",\"value\":\"yy\"}},\"root_module\":{}},\"output_changes\":{\"tt\":{\"actions\":[\"create\"],\"before\":null,\"after\":\"yy\",\"after_unknown\":false,\"before_sensitive\":false,\"after_sensitive\":false}},\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.7.3\",\"values\":{\"outputs\":{\"tt\":{\"sensitive\":false,\"value\":\"yy\",\"type\":\"string\"}},\"root_module\":{}}},\"configuration\":{\"root_module\":{\"outputs\":{\"tt\":{\"expression\":{\"constant_value\":\"yy\"}}}}},\"timestamp\":\"2024-07-12T14:50:56Z\",\"errored\":false}\n" - isEmpty, _, err := GetSummaryFromPlanJson(onlyOutputsChangedJson) + isEmpty, _, err := TerraformUtils{}.GetSummaryFromPlanJson(onlyOutputsChangedJson) assert.Nil(t, err) assert.False(t, isEmpty) @@ -30,39 +30,39 @@ func TestGetPlanSummaryOnlyOutputsChanged(t *testing.T) { func TestPlanOutputInvalidJsonFailsGracefully(t *testing.T) { InvalidJson := "{\"format_version\":\" notsovalid" - _, _, err := GetSummaryFromPlanJson(InvalidJson) + _, _, err := TerraformUtils{}.GetSummaryFromPlanJson(InvalidJson) assert.NotNil(t, err) } func TestPlanFootprintSimilarity(t *testing.T) { planJson1 := "{\"format_version\":\"1.2\",\"terraform_version\":\"1.7.3\",\"variables\":{\"environment\":{\"value\":\"devel\"}},\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}},\"resource_changes\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"change\":{\"actions\":[\"update\"],\"before\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after_unknown\":{},\"before_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]},\"after_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.7.3\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}}},\"configuration\":{\"provider_config\":{\"aws\":{\"name\":\"aws\",\"full_name\":\"registry.terraform.io/hashicorp/aws\",\"version_constraint\":\"~\\u003e 5.0\"}},\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_config_key\":\"aws\",\"expressions\":{\"bucket_prefix\":{\"constant_value\":\"my-tf-test-bucket\"},\"tags\":{\"references\":[\"var.environment\",\"var.environment\"]}},\"schema_version\":0}],\"variables\":{\"environment\":{}}}},\"timestamp\":\"2024-05-10T15:36:02Z\",\"errored\":false}\n" planJson2 := "{\"format_version\":\"1.2\",\"terraform_version\":\"1.7.3\",\"variables\":{\"environment\":{\"value\":\"staging\"}},\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}},\"resource_changes\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"change\":{\"actions\":[\"update\"],\"before\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after_unknown\":{},\"before_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]},\"after_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.7.3\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}}},\"configuration\":{\"provider_config\":{\"aws\":{\"name\":\"aws\",\"full_name\":\"registry.terraform.io/hashicorp/aws\",\"version_constraint\":\"~\\u003e 5.0\"}},\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_config_key\":\"aws\",\"expressions\":{\"bucket_prefix\":{\"constant_value\":\"my-tf-test-bucket\"},\"tags\":{\"references\":[\"var.environment\",\"var.environment\"]}},\"schema_version\":0}],\"variables\":{\"environment\":{}}}},\"timestamp\":\"2024-05-10T15:38:45Z\",\"errored\":false}\n" - footprint1, _ := GetPlanFootprint(planJson1) - footprint2, _ := GetPlanFootprint(planJson2) - isSimilar, _ := PerformPlanSimilarityCheck(*footprint1, *footprint2) + footprint1, _ := TerraformUtils{}.GetPlanFootprint(planJson1) + footprint2, _ := TerraformUtils{}.GetPlanFootprint(planJson2) + isSimilar, _ := TerraformUtils{}.PerformPlanSimilarityCheck(*footprint1, *footprint2) assert.True(t, isSimilar) - footPrints := []TerraformPlanFootprint{*footprint1, *footprint2} - isSimilar, _ = SimilarityCheck(footPrints) + footPrints := []IacPlanFootprint{*footprint1, *footprint2} + isSimilar, _ = TerraformUtils{}.SimilarityCheck(footPrints) assert.True(t, isSimilar) // In this case addresses don't match so expecting false similarity planJson1 = "{\"format_version\":\"1.2\",\"terraform_version\":\"1.7.3\",\"variables\":{\"environment\":{\"value\":\"devel\"}},\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}},\"resource_changes\":[{\"address\":\"aws_s3_bucket.example_noooooot_same\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"change\":{\"actions\":[\"update\"],\"before\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"The bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after_unknown\":{},\"before_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]},\"after_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.7.3\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510105906923000000001\",\"bucket\":\"my-tf-test-bucket20240510105906923000000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510105906923000000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510105906923000000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"tags_all\":{\"Environment\":\"devel\",\"Name\":\"My bucket devel\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}}},\"configuration\":{\"provider_config\":{\"aws\":{\"name\":\"aws\",\"full_name\":\"registry.terraform.io/hashicorp/aws\",\"version_constraint\":\"~\\u003e 5.0\"}},\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_config_key\":\"aws\",\"expressions\":{\"bucket_prefix\":{\"constant_value\":\"my-tf-test-bucket\"},\"tags\":{\"references\":[\"var.environment\",\"var.environment\"]}},\"schema_version\":0}],\"variables\":{\"environment\":{}}}},\"timestamp\":\"2024-05-10T15:36:02Z\",\"errored\":false}\n" planJson2 = "{\"format_version\":\"1.2\",\"terraform_version\":\"1.7.3\",\"variables\":{\"environment\":{\"value\":\"staging\"}},\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}},\"resource_changes\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"change\":{\"actions\":[\"update\"],\"before\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"The bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"after_unknown\":{},\"before_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]},\"after_sensitive\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.7.3\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_name\":\"registry.terraform.io/hashicorp/aws\",\"schema_version\":0,\"values\":{\"acceleration_status\":\"\",\"acl\":null,\"arn\":\"arn:aws:s3:::my-tf-test-bucket20240510110101962500000001\",\"bucket\":\"my-tf-test-bucket20240510110101962500000001\",\"bucket_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.amazonaws.com\",\"bucket_prefix\":\"my-tf-test-bucket\",\"bucket_regional_domain_name\":\"my-tf-test-bucket20240510110101962500000001.s3.eu-west-2.amazonaws.com\",\"cors_rule\":[],\"force_destroy\":false,\"grant\":[{\"id\":\"48ca234ec08b854fd7875d07ed50011a403a0297310717063d53e2085019f22f\",\"permissions\":[\"FULL_CONTROL\"],\"type\":\"CanonicalUser\",\"uri\":\"\"}],\"hosted_zone_id\":\"Z3GKZC51ZF0DB4\",\"id\":\"my-tf-test-bucket20240510110101962500000001\",\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"object_lock_enabled\":false,\"policy\":\"\",\"region\":\"eu-west-2\",\"replication_configuration\":[],\"request_payer\":\"BucketOwner\",\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{\"kms_master_key_id\":\"\",\"sse_algorithm\":\"AES256\"}],\"bucket_key_enabled\":false}]}],\"tags\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"tags_all\":{\"Environment\":\"staging\",\"Name\":\"My bucket staging\"},\"timeouts\":null,\"versioning\":[{\"enabled\":false,\"mfa_delete\":false}],\"website\":[],\"website_domain\":null,\"website_endpoint\":null},\"sensitive_values\":{\"cors_rule\":[],\"grant\":[{\"permissions\":[false]}],\"lifecycle_rule\":[],\"logging\":[],\"object_lock_configuration\":[],\"replication_configuration\":[],\"server_side_encryption_configuration\":[{\"rule\":[{\"apply_server_side_encryption_by_default\":[{}]}]}],\"tags\":{},\"tags_all\":{},\"versioning\":[{}],\"website\":[]}}]}}},\"configuration\":{\"provider_config\":{\"aws\":{\"name\":\"aws\",\"full_name\":\"registry.terraform.io/hashicorp/aws\",\"version_constraint\":\"~\\u003e 5.0\"}},\"root_module\":{\"resources\":[{\"address\":\"aws_s3_bucket.example\",\"mode\":\"managed\",\"type\":\"aws_s3_bucket\",\"name\":\"example\",\"provider_config_key\":\"aws\",\"expressions\":{\"bucket_prefix\":{\"constant_value\":\"my-tf-test-bucket\"},\"tags\":{\"references\":[\"var.environment\",\"var.environment\"]}},\"schema_version\":0}],\"variables\":{\"environment\":{}}}},\"timestamp\":\"2024-05-10T15:38:45Z\",\"errored\":false}\n" - footprint1, _ = GetPlanFootprint(planJson1) - footprint2, _ = GetPlanFootprint(planJson2) - isSimilar, _ = PerformPlanSimilarityCheck(*footprint1, *footprint2) + footprint1, _ = TerraformUtils{}.GetPlanFootprint(planJson1) + footprint2, _ = TerraformUtils{}.GetPlanFootprint(planJson2) + isSimilar, _ = TerraformUtils{}.PerformPlanSimilarityCheck(*footprint1, *footprint2) assert.False(t, isSimilar) - footPrints = []TerraformPlanFootprint{*footprint1, *footprint2} - isSimilar, _ = SimilarityCheck(footPrints) + footPrints = []IacPlanFootprint{*footprint1, *footprint2} + isSimilar, _ = TerraformUtils{}.SimilarityCheck(footPrints) assert.False(t, isSimilar) } func TestGetTfSummarizePlan(t *testing.T) { nonEmptyTerraformPlanJson := "{\"format_version\":\"1.1\",\"terraform_version\":\"1.4.6\",\"planned_values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"triggers\":null},\"sensitive_values\":{}}]}},\"resource_changes\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"no-op\"],\"before\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"after_unknown\":{},\"before_sensitive\":{},\"after_sensitive\":{}}},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"change\":{\"actions\":[\"create\"],\"before\":null,\"after\":{\"triggers\":null},\"after_unknown\":{\"id\":true},\"before_sensitive\":false,\"after_sensitive\":{}}}],\"prior_state\":{\"format_version\":\"1.0\",\"terraform_version\":\"1.4.6\",\"values\":{\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_name\":\"registry.terraform.io/hashicorp/null\",\"schema_version\":0,\"values\":{\"id\":\"7587790946951100994\",\"triggers\":null},\"sensitive_values\":{}}]}}},\"configuration\":{\"provider_config\":{\"null\":{\"name\":\"null\",\"full_name\":\"registry.terraform.io/hashicorp/null\"}},\"root_module\":{\"resources\":[{\"address\":\"null_resource.test\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"test\",\"provider_config_key\":\"null\",\"schema_version\":0},{\"address\":\"null_resource.testx\",\"mode\":\"managed\",\"type\":\"null_resource\",\"name\":\"testx\",\"provider_config_key\":\"null\",\"schema_version\":0}]}}}\n" - planSummary, err := GetTfSummarizePlan(nonEmptyTerraformPlanJson) + planSummary, err := TerraformUtils{}.GetSummarizePlan(nonEmptyTerraformPlanJson) assert.Nil(t, err) assert.NotEmpty(t, planSummary) } diff --git a/libs/scheduler/convert.go b/libs/scheduler/convert.go index 0590597b6..28e0b4646 100644 --- a/libs/scheduler/convert.go +++ b/libs/scheduler/convert.go @@ -31,6 +31,7 @@ func ConvertProjectsToJobs(actor string, repoNamespace string, command string, p ProjectWorkspace: project.Workspace, Terragrunt: project.Terragrunt, OpenTofu: project.OpenTofu, + Pulumi: project.Pulumi, // TODO: expose lower level api per command configuration Commands: []string{command}, ApplyStage: ToConfigStage(workflow.Apply), @@ -44,7 +45,7 @@ func ConvertProjectsToJobs(actor string, repoNamespace string, command string, p CommandEnvVars: commandEnvVars, StateEnvProvider: StateEnvProvider, CommandEnvProvider: CommandEnvProvider, - SkipMergeCheck: skipMerge, + SkipMergeCheck: skipMerge, }) } return jobs, true, nil diff --git a/libs/scheduler/jobs.go b/libs/scheduler/jobs.go index ce61969fe..8e3cbe652 100644 --- a/libs/scheduler/jobs.go +++ b/libs/scheduler/jobs.go @@ -7,6 +7,11 @@ import ( configuration "github.com/diggerhq/digger/libs/digger_config" ) +type IacType string + +var IacTypeTerraform IacType = "terraform" +var IacTypePulumi IacType = "pulumi" + type Job struct { ProjectName string ProjectDir string @@ -14,6 +19,7 @@ type Job struct { ProjectWorkflow string Terragrunt bool OpenTofu bool + Pulumi bool Commands []string ApplyStage *Stage PlanStage *Stage @@ -26,7 +32,7 @@ type Job struct { CommandEnvVars map[string]string StateEnvProvider *stscreds.WebIdentityRoleProvider CommandEnvProvider *stscreds.WebIdentityRoleProvider - SkipMergeCheck bool + SkipMergeCheck bool } type Step struct { @@ -71,6 +77,14 @@ func (j *Job) IsApply() bool { return slices.Contains(j.Commands, "digger apply") } +func (j *Job) IacType() IacType { + if j.Pulumi { + return IacTypePulumi + } else { + return IacTypeTerraform + } +} + func IsPlanJobs(jobs []Job) bool { isPlan := true for _, job := range jobs { diff --git a/libs/scheduler/json_models.go b/libs/scheduler/json_models.go index 4aa605f23..275e38aa6 100644 --- a/libs/scheduler/json_models.go +++ b/libs/scheduler/json_models.go @@ -24,6 +24,7 @@ type JobJson struct { ProjectWorkspace string `json:"projectWorkspace"` Terragrunt bool `json:"terragrunt"` OpenTofu bool `json:"opentofu"` + Pulumi bool `json:"pulumi"` Commands []string `json:"commands"` ApplyStage StageJson `json:"applyStage"` PlanStage StageJson `json:"planStage"` @@ -67,6 +68,7 @@ func JobToJson(job Job, jobType DiggerCommand, organisationName string, branch s ProjectDir: job.ProjectDir, ProjectWorkspace: job.ProjectWorkspace, OpenTofu: job.OpenTofu, + Pulumi: job.Pulumi, Terragrunt: job.Terragrunt, Commands: job.Commands, ApplyStage: stageToJson(job.ApplyStage), @@ -96,6 +98,7 @@ func JsonToJob(jobJson JobJson) Job { ProjectDir: jobJson.ProjectDir, ProjectWorkspace: jobJson.ProjectWorkspace, OpenTofu: jobJson.OpenTofu, + Pulumi: jobJson.Pulumi, Terragrunt: jobJson.Terragrunt, Commands: jobJson.Commands, ApplyStage: jsonToStage(jobJson.ApplyStage), diff --git a/next/controllers/projects.go b/next/controllers/projects.go index 87ffa73c0..bdbfe3b5a 100644 --- a/next/controllers/projects.go +++ b/next/controllers/projects.go @@ -6,8 +6,8 @@ import ( "github.com/diggerhq/digger/backend/models" "github.com/diggerhq/digger/libs/comment_utils/reporting" "github.com/diggerhq/digger/libs/digger_config" + "github.com/diggerhq/digger/libs/iac_utils" orchestrator_scheduler "github.com/diggerhq/digger/libs/scheduler" - "github.com/diggerhq/digger/libs/terraform_utils" "github.com/diggerhq/digger/next/dbmodels" "github.com/diggerhq/digger/next/services" //"github.com/diggerhq/digger/next/middleware" @@ -20,12 +20,12 @@ import ( ) type SetJobStatusRequest struct { - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` - JobSummary *terraform_utils.TerraformSummary `json:"job_summary"` - Footprint *terraform_utils.TerraformPlanFootprint `json:"job_plan_footprint"` - PrCommentUrl string `json:"pr_comment_url"` - TerraformOutput string `json:"terraform_output"` + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + JobSummary *iac_utils.IacSummary `json:"job_summary"` + Footprint *iac_utils.IacPlanFootprint `json:"job_plan_footprint"` + PrCommentUrl string `json:"pr_comment_url"` + TerraformOutput string `json:"terraform_output"` } func (d DiggerController) SetJobStatusForProject(c *gin.Context) {