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) {