Skip to content

Commit 55068d3

Browse files
authored
pulumi support in digger (#1790)
* pulumi support in digger
1 parent 70ff70c commit 55068d3

36 files changed

+612
-199
lines changed

action.yml

+15
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ inputs:
6565
description: Setup OpenToFu
6666
required: false
6767
default: 'false'
68+
setup-pulumi:
69+
description: Setup Pulumi
70+
required: false
71+
default: 'false'
6872
terragrunt-version:
6973
description: Terragrunt version
7074
required: false
@@ -73,6 +77,11 @@ inputs:
7377
description: OpenTofu version
7478
required: false
7579
default: v1.6.1
80+
pulumi-version:
81+
description: Pulumi version
82+
required: false
83+
default: v3.3.0
84+
7685
setup-terraform:
7786
description: Setup terraform
7887
required: false
@@ -272,6 +281,12 @@ runs:
272281
tofu_wrapper: false
273282
if: inputs.setup-opentofu == 'true'
274283

284+
- name: Setup Pulumi
285+
uses: pulumi/actions@v4
286+
with:
287+
tofu_version: ${{ inputs.pulumi-version }}
288+
if: inputs.setup-pulumi == 'true'
289+
275290
- name: Setup Checkov
276291
run: |
277292
python3 -m venv .venv

backend/controllers/projects.go

+8-7
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import (
1111
"github.com/diggerhq/digger/libs/ci"
1212
"github.com/diggerhq/digger/libs/comment_utils/reporting"
1313
"github.com/diggerhq/digger/libs/digger_config"
14+
"github.com/diggerhq/digger/libs/iac_utils"
1415
orchestrator_scheduler "github.com/diggerhq/digger/libs/scheduler"
15-
"github.com/diggerhq/digger/libs/terraform_utils"
1616
"github.com/gin-gonic/gin"
1717
"gorm.io/gorm"
1818
"log"
@@ -317,12 +317,13 @@ func RunHistoryForProject(c *gin.Context) {
317317
}
318318

319319
type SetJobStatusRequest struct {
320-
Status string `json:"status"`
321-
Timestamp time.Time `json:"timestamp"`
322-
JobSummary *terraform_utils.TerraformSummary `json:"job_summary"`
323-
Footprint *terraform_utils.TerraformPlanFootprint `json:"job_plan_footprint"`
324-
PrCommentUrl string `json:"pr_comment_url"`
325-
TerraformOutput string `json:"terraform_output"`
320+
Status string `json:"status"`
321+
Timestamp time.Time `json:"timestamp"`
322+
JobSummary *iac_utils.IacSummary `json:"job_summary"`
323+
Footprint *iac_utils.IacPlanFootprint `json:"job_plan_footprint"`
324+
PrCommentUrl string `json:"pr_comment_url"`
325+
TerraformOutput string `json:"terraform_output"`
326+
326327
}
327328

328329
func (d DiggerController) SetJobStatusForProject(c *gin.Context) {

backend/services/spec.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func GetSpecFromJob(job models.DiggerJob) (*spec.Spec, error) {
106106
})
107107
hasDuplicates := len(justNames) != len(lo.Uniq(justNames))
108108
if hasDuplicates {
109-
return nil, fmt.Errorf("could not load variables due to duplicates: %v", err)
109+
return nil, fmt.Errorf("could not load variables due to duplicates")
110110
}
111111

112112
batch := job.Batch

cli/pkg/digger/digger.go

+15-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
utils "github.com/diggerhq/digger/cli/pkg/utils"
2424
"github.com/diggerhq/digger/libs/comment_utils/reporting"
2525
config "github.com/diggerhq/digger/libs/digger_config"
26-
"github.com/diggerhq/digger/libs/terraform_utils"
26+
"github.com/diggerhq/digger/libs/iac_utils"
2727

2828
"github.com/dominikbraun/graph"
2929
)
@@ -141,7 +141,9 @@ func RunJobs(jobs []orchestrator.Job, prService ci.PullRequestService, orgServic
141141
terraformOutput = exectorResults[0].TerraformOutput
142142
}
143143
prNumber := *currentJob.PullRequestNumber
144-
batchResult, err := backendApi.ReportProjectJobStatus(repoNameForBackendReporting, projectNameForBackendReporting, jobId, "succeeded", time.Now(), &summary, "", jobPrCommentUrl, terraformOutput)
144+
145+
iacUtils := iac_utils.GetIacUtilsIacType(currentJob.IacType())
146+
batchResult, err := backendApi.ReportProjectJobStatus(repoNameForBackendReporting, projectNameForBackendReporting, jobId, "succeeded", time.Now(), &summary, "", jobPrCommentUrl, terraformOutput, iacUtils)
145147
if err != nil {
146148
log.Printf("error reporting Job status: %v.\n", err)
147149
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
211213
}
212214

213215
var terraformExecutor execution.TerraformExecutor
216+
var iacUtils iac_utils.IacUtils
214217
projectPath := path.Join(workingDir, job.ProjectDir)
215218
if job.Terragrunt {
216219
terraformExecutor = execution.Terragrunt{WorkingDir: projectPath}
220+
iacUtils = iac_utils.TerraformUtils{}
217221
} else if job.OpenTofu {
218222
terraformExecutor = execution.OpenTofu{WorkingDir: projectPath, Workspace: job.ProjectWorkspace}
223+
iacUtils = iac_utils.TerraformUtils{}
224+
} else if job.Pulumi {
225+
terraformExecutor = execution.Pulumi{WorkingDir: projectPath, Stack: job.ProjectWorkspace}
226+
iacUtils = iac_utils.PulumiUtils{}
219227
} else {
220228
terraformExecutor = execution.Terraform{WorkingDir: projectPath, Workspace: job.ProjectWorkspace}
229+
iacUtils = iac_utils.TerraformUtils{}
221230
}
222231

223232
commandRunner := execution.CommandRunner{}
@@ -244,6 +253,7 @@ func run(command string, job orchestrator.Job, policyChecker policy.Checker, org
244253
Reporter: reporter,
245254
PlanStorage: planStorage,
246255
PlanPathProvider: planPathProvider,
256+
IacUtils: iacUtils,
247257
},
248258
}
249259
executor := diggerExecutor.Executor.(execution.DiggerExecutor)
@@ -289,7 +299,7 @@ func run(command string, job orchestrator.Job, policyChecker policy.Checker, org
289299
planPolicyFormatter = coreutils.AsComment(summary)
290300
}
291301

292-
planSummary, err := terraform_utils.GetTfSummarizePlan(planJsonOutput)
302+
planSummary, err := iacUtils.GetSummarizePlan(planJsonOutput)
293303
if err != nil {
294304
log.Printf("Failed to summarize plan. %v", err)
295305
}
@@ -588,6 +598,8 @@ func RunJob(
588598
terraformExecutor = execution.Terragrunt{WorkingDir: projectPath}
589599
} else if job.OpenTofu {
590600
terraformExecutor = execution.OpenTofu{WorkingDir: projectPath, Workspace: job.ProjectWorkspace}
601+
} else if job.Pulumi {
602+
terraformExecutor = execution.Pulumi{WorkingDir: projectPath, Stack: job.ProjectWorkspace}
591603
} else {
592604
terraformExecutor = execution.Terraform{WorkingDir: projectPath, Workspace: job.ProjectWorkspace}
593605
}

cli/pkg/digger/digger_test.go

+7-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"github.com/diggerhq/digger/libs/ci"
66
"github.com/diggerhq/digger/libs/execution"
7+
"github.com/diggerhq/digger/libs/iac_utils"
78
orchestrator "github.com/diggerhq/digger/libs/scheduler"
89
"os"
910
"sort"
@@ -55,13 +56,13 @@ func (m *MockTerraformExecutor) Destroy(params []string, envs map[string]string)
5556
return "", "", nil
5657
}
5758

58-
func (m *MockTerraformExecutor) Show(params []string, envs map[string]string) (string, string, error) {
59+
func (m *MockTerraformExecutor) Show(params []string, envs map[string]string, planJsonFilePath string) (string, string, error) {
5960
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"
6061
m.Commands = append(m.Commands, RunInfo{"Show", strings.Join(params, " "), time.Now()})
6162
return nonEmptyTerraformPlanJson, "", nil
6263
}
6364

64-
func (m *MockTerraformExecutor) Plan(params []string, envs map[string]string) (bool, string, string, error) {
65+
func (m *MockTerraformExecutor) Plan(params []string, envs map[string]string, planJsonFilePath string) (bool, string, string, error) {
6566
m.Commands = append(m.Commands, RunInfo{"Plan", strings.Join(params, " "), time.Now()})
6667
return true, "", "", nil
6768
}
@@ -279,13 +280,14 @@ func TestCorrectCommandExecutionWhenApplying(t *testing.T) {
279280
Reporter: reporter,
280281
PlanStorage: planStorage,
281282
PlanPathProvider: planPathProvider,
283+
IacUtils: iac_utils.TerraformUtils{},
282284
}
283285

284286
executor.Apply()
285287

286288
commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage, planPathProvider)
287289

288-
assert.Equal(t, []string{"RetrievePlan plan", "Init ", "Apply -lock-timeout=3m", "PublishComment 1 <details ><summary>Apply output</summary>\n\n```terraform\n\n```\n</details>", "Run echo"}, commandStrings)
290+
assert.Equal(t, []string{"RetrievePlan plan", "Init ", "Apply ", "PublishComment 1 <details ><summary>Apply output</summary>\n\n```terraform\n\n```\n</details>", "Run echo"}, commandStrings)
289291
}
290292

291293
func TestCorrectCommandExecutionWhenDestroying(t *testing.T) {
@@ -368,6 +370,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) {
368370
Reporter: reporter,
369371
PlanStorage: planStorage,
370372
PlanPathProvider: planPathProvider,
373+
IacUtils: iac_utils.TerraformUtils{},
371374
}
372375

373376
os.WriteFile(planPathProvider.LocalPlanFilePath(), []byte{123}, 0644)
@@ -377,7 +380,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) {
377380

378381
commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage, planPathProvider)
379382

380-
assert.Equal(t, []string{"Init ", "Plan -out plan -lock-timeout=3m", "Show -no-color -json plan", "StorePlanFile plan", "Run echo"}, commandStrings)
383+
assert.Equal(t, []string{"Init ", "Plan ", "Show ", "StorePlanFile plan", "Run echo"}, commandStrings)
381384
}
382385

383386
func allCommandsInOrderWithParams(terraformExecutor *MockTerraformExecutor, commandRunner *MockCommandRunner, prManager *MockPRManager, lock *MockProjectLock, planStorage *MockPlanStorage, planPathProvider *MockPlanPathProvider) []string {

cli/pkg/github/github.go

+2
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ func GitHubCI(lock core_locking.Lock, policyCheckerProvider core_policy.PolicyCh
148148
ProjectWorkspace: projectConfig.Workspace,
149149
Terragrunt: projectConfig.Terragrunt,
150150
OpenTofu: projectConfig.OpenTofu,
151+
Pulumi: projectConfig.Pulumi,
151152
Commands: []string{command},
152153
ApplyStage: scheduler.ToConfigStage(workflow.Apply),
153154
PlanStage: scheduler.ToConfigStage(workflow.Plan),
@@ -180,6 +181,7 @@ func GitHubCI(lock core_locking.Lock, policyCheckerProvider core_policy.PolicyCh
180181
ProjectWorkspace: projectConfig.Workspace,
181182
Terragrunt: projectConfig.Terragrunt,
182183
OpenTofu: projectConfig.OpenTofu,
184+
Pulumi: projectConfig.Pulumi,
183185
Commands: []string{"digger drift-detect"},
184186
ApplyStage: scheduler.ToConfigStage(workflow.Apply),
185187
PlanStage: scheduler.ToConfigStage(workflow.Plan),

cli/pkg/spec/spec.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717

1818
func reportError(spec spec.Spec, backendApi backend2.Api, message string, err error) {
1919
log.Printf(message)
20-
_, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "")
20+
_, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "", nil)
2121
if reportingError != nil {
2222
usage.ReportErrorAndExit(spec.VCS.RepoOwner, fmt.Sprintf("Failed to run commands. %v", err), 5)
2323
}
@@ -131,7 +131,7 @@ func RunSpec(
131131
jobs := []scheduler.Job{job}
132132

133133
fullRepoName := fmt.Sprintf("%v-%v", spec.VCS.RepoOwner, spec.VCS.RepoName)
134-
_, err = backendApi.ReportProjectJobStatus(fullRepoName, spec.Job.ProjectName, spec.JobId, "started", time.Now(), nil, "", "", "")
134+
_, err = backendApi.ReportProjectJobStatus(fullRepoName, spec.Job.ProjectName, spec.JobId, "started", time.Now(), nil, "", "", "", nil)
135135
if err != nil {
136136
message := fmt.Sprintf("Failed to report jobSpec status to backend. Exiting. %v", err)
137137
reportError(spec, backendApi, message, err)
@@ -152,7 +152,7 @@ func RunSpec(
152152
reportTerraformOutput := spec.Reporter.ReportTerraformOutput
153153
allAppliesSuccess, _, err := digger.RunJobs(jobs, prService, orgService, lock, reporter, planStorage, policyChecker, commentUpdater, backendApi, spec.JobId, true, reportTerraformOutput, commentId, currentDir)
154154
if !allAppliesSuccess || err != nil {
155-
serializedBatch, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "")
155+
serializedBatch, reportingError := backendApi.ReportProjectJobStatus(spec.VCS.RepoName, spec.Job.ProjectName, spec.JobId, "failed", time.Now(), nil, "", "", "", nil)
156156
if reportingError != nil {
157157
message := fmt.Sprintf("Failed run commands. %s", err)
158158
reportError(spec, backendApi, message, err)

ee/drift/controllers/ci_jobs.go

+7-7
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@ import (
77

88
"github.com/diggerhq/digger/ee/drift/dbmodels"
99
"github.com/diggerhq/digger/ee/drift/model"
10-
"github.com/diggerhq/digger/libs/terraform_utils"
10+
"github.com/diggerhq/digger/libs/iac_utils"
1111
"github.com/gin-gonic/gin"
1212
)
1313

1414
type SetJobStatusRequest struct {
15-
Status string `json:"status"`
16-
Timestamp time.Time `json:"timestamp"`
17-
JobSummary *terraform_utils.TerraformSummary `json:"job_summary"`
18-
Footprint *terraform_utils.TerraformPlanFootprint `json:"job_plan_footprint"`
19-
PrCommentUrl string `json:"pr_comment_url"`
20-
TerraformOutput string `json:"terraform_output"`
15+
Status string `json:"status"`
16+
Timestamp time.Time `json:"timestamp"`
17+
JobSummary *iac_utils.IacSummary `json:"job_summary"`
18+
Footprint *iac_utils.IacPlanFootprint `json:"job_plan_footprint"`
19+
PrCommentUrl string `json:"pr_comment_url"`
20+
TerraformOutput string `json:"terraform_output"`
2121
}
2222

2323
func (mc MainController) SetJobStatusForProject(c *gin.Context) {

libs/backendapi/backend.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package backendapi
22

33
import (
4+
"github.com/diggerhq/digger/libs/iac_utils"
45
"github.com/diggerhq/digger/libs/scheduler"
5-
"github.com/diggerhq/digger/libs/terraform_utils"
66
"time"
77
)
88

99
type Api interface {
1010
ReportProject(repo string, projectName string, configuration string) error
1111
ReportProjectRun(repo string, projectName string, startedAt time.Time, endedAt time.Time, status string, command string, output string) error
12-
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)
12+
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)
1313
UploadJobArtefact(zipLocation string) (*int, *string, error)
1414
DownloadJobArtefact(downloadTo string) (*string, error)
1515
}

libs/backendapi/diggerapi.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7+
"github.com/diggerhq/digger/libs/iac_utils"
78
"github.com/diggerhq/digger/libs/scheduler"
8-
"github.com/diggerhq/digger/libs/terraform_utils"
99
"io"
1010
"log"
1111
"mime"
@@ -29,7 +29,7 @@ func (n NoopApi) ReportProjectRun(namespace string, projectName string, startedA
2929
return nil
3030
}
3131

32-
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) {
32+
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) {
3333
return nil, nil
3434
}
3535

@@ -129,14 +129,14 @@ func (d DiggerApi) ReportProjectRun(namespace string, projectName string, starte
129129
return nil
130130
}
131131

132-
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) {
132+
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) {
133133
u, err := url.Parse(d.DiggerHost)
134134
if err != nil {
135135
log.Fatalf("Not able to parse digger cloud url: %v", err)
136136
}
137137

138138
var planSummaryJson interface{}
139-
var planFootprint *terraform_utils.TerraformPlanFootprint = &terraform_utils.TerraformPlanFootprint{}
139+
var planFootprint = &iac_utils.IacPlanFootprint{}
140140
if summary == nil {
141141
log.Printf("Warning: nil passed to plan result, sending empty")
142142
planSummaryJson = nil
@@ -145,7 +145,7 @@ func (d DiggerApi) ReportProjectJobStatus(repo string, projectName string, jobId
145145
planSummary := summary
146146
planSummaryJson = planSummary.ToJson()
147147
if planJson != "" {
148-
planFootprint, err = terraform_utils.GetPlanFootprint(planJson)
148+
planFootprint, err = iacUtils.GetPlanFootprint(planJson)
149149
if err != nil {
150150
log.Printf("Error, could not get footprint from json plan: %v", err)
151151
}

libs/backendapi/mocks.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package backendapi
22

33
import (
4+
"github.com/diggerhq/digger/libs/iac_utils"
45
"github.com/diggerhq/digger/libs/scheduler"
5-
"github.com/diggerhq/digger/libs/terraform_utils"
66
"time"
77
)
88

@@ -17,7 +17,7 @@ func (t MockBackendApi) ReportProjectRun(repo string, projectName string, starte
1717
return nil
1818
}
1919

20-
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) {
20+
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) {
2121
return nil, nil
2222
}
2323

0 commit comments

Comments
 (0)