diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index 7416fd482..3d7cb89da 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -124,6 +124,10 @@ type TerraformComponent struct { // Destroy determines if the component should be destroyed during down operations. // Defaults to true if not specified. Destroy *bool `yaml:"destroy,omitempty"` + + // Parallelism limits the number of concurrent operations as Terraform walks the graph. + // This corresponds to the -parallelism flag in terraform apply/destroy commands. + Parallelism *int `yaml:"parallelism,omitempty"` } // Kustomization defines a kustomization config in a blueprint. @@ -241,12 +245,13 @@ func (b *Blueprint) DeepCopy() *Blueprint { dependsOnCopy := append([]string{}, component.DependsOn...) terraformComponentsCopy[i] = TerraformComponent{ - Source: component.Source, - Path: component.Path, - FullPath: component.FullPath, - DependsOn: dependsOnCopy, - Values: valuesCopy, - Destroy: component.Destroy, + Source: component.Source, + Path: component.Path, + FullPath: component.FullPath, + DependsOn: dependsOnCopy, + Values: valuesCopy, + Destroy: component.Destroy, + Parallelism: component.Parallelism, } } @@ -355,6 +360,10 @@ func (b *Blueprint) Merge(overlay *Blueprint) { mergedComponent.Destroy = overlayComponent.Destroy } + if overlayComponent.Parallelism != nil { + mergedComponent.Parallelism = overlayComponent.Parallelism + } + b.TerraformComponents = append(b.TerraformComponents, mergedComponent) } else { b.TerraformComponents = append(b.TerraformComponents, overlayComponent) diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index f45271b72..afbd44871 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -408,6 +408,83 @@ func TestBlueprint_Merge(t *testing.T) { } }) + t.Run("ParallelismFieldMerge", func(t *testing.T) { + tests := []struct { + name string + dst *int + overlay *int + expected *int + }{ + { + name: "BothNil", + dst: nil, + overlay: nil, + expected: nil, + }, + { + name: "DstNilOverlaySet", + dst: nil, + overlay: ptrInt(5), + expected: ptrInt(5), + }, + { + name: "DstSetOverlayNil", + dst: ptrInt(10), + overlay: nil, + expected: ptrInt(10), + }, + { + name: "BothSet", + dst: ptrInt(10), + overlay: ptrInt(5), + expected: ptrInt(5), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dst := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + Parallelism: tt.dst, + }, + }, + } + + overlay := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + Parallelism: tt.overlay, + }, + }, + } + + dst.Merge(overlay) + + if len(dst.TerraformComponents) != 1 { + t.Fatalf("Expected 1 TerraformComponent, but got %d", len(dst.TerraformComponents)) + } + + component := dst.TerraformComponents[0] + if tt.expected == nil { + if component.Parallelism != nil { + t.Errorf("Expected Parallelism to be nil, but got %v", component.Parallelism) + } + } else { + if component.Parallelism == nil { + t.Errorf("Expected Parallelism to be %v, but got nil", *tt.expected) + } else if *component.Parallelism != *tt.expected { + t.Errorf("Expected Parallelism to be %v, but got %v", *tt.expected, *component.Parallelism) + } + } + }) + } + }) + t.Run("OverlayComponentWithDifferentSource", func(t *testing.T) { base := &Blueprint{ TerraformComponents: []TerraformComponent{{Path: "mod", Source: "A"}}, diff --git a/api/v1alpha1/utils.go b/api/v1alpha1/utils.go index 434578466..f6f3f0089 100644 --- a/api/v1alpha1/utils.go +++ b/api/v1alpha1/utils.go @@ -8,3 +8,7 @@ func ptrString(s string) *string { func ptrBool(b bool) *bool { return &b } + +func ptrInt(i int) *int { + return &i +} diff --git a/docs/reference/blueprint.md b/docs/reference/blueprint.md index b24b7fbaa..a673a2123 100644 --- a/docs/reference/blueprint.md +++ b/docs/reference/blueprint.md @@ -114,6 +114,7 @@ terraform: # A Terraform module defined within the current blueprint source - path: apps/my-infra + parallelism: 5 ``` | Field | Type | Description | @@ -122,6 +123,7 @@ terraform: | `path` | `string` | Path of the Terraform module relative to the `terraform/` folder. | | `values` | `map[string]any` | Configuration values for the module. | | `variables`| `map[string]TerraformVariable` | Input variables for the module. | +| `parallelism`| `int` | Limits the number of concurrent operations as Terraform walks the graph. Corresponds to the `-parallelism` flag. | ### Kustomization For more information on Flux Kustomizations, which are sets of resources and configurations applied to a Kubernetes cluster, visit [Flux Kustomizations Documentation](https://fluxcd.io/flux/components/kustomize/kustomizations/). Most parameters are not necessary to define. diff --git a/pkg/env/terraform_env.go b/pkg/env/terraform_env.go index bc1098e81..c0b1b070f 100644 --- a/pkg/env/terraform_env.go +++ b/pkg/env/terraform_env.go @@ -133,9 +133,9 @@ func (e *TerraformEnvPrinter) Print() error { return e.BaseEnvPrinter.Print(envVars) } -// GenerateTerraformArgs constructs Terraform CLI argument lists and environment variables for the specified project and module paths. -// It resolves configuration root, locates relevant tfvars files, generates backend configuration arguments, and assembles -// all required CLI and environment variable values for Terraform operations. Returns a TerraformArgs struct or error. +// GenerateTerraformArgs constructs Terraform CLI arguments and environment variables for given project and module paths. +// Resolves config root, locates tfvars files, generates backend config args, and assembles all CLI/env values needed +// for Terraform operations. Returns a TerraformArgs struct or error. func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath string) (*TerraformArgs, error) { configRoot, err := e.configHandler.GetConfigRoot() if err != nil { @@ -182,8 +182,7 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri planArgs := []string{fmt.Sprintf("-out=%s", tfPlanPath)} planArgs = append(planArgs, varFileArgs...) - applyArgs := []string{tfPlanPath} - + applyArgs := []string{} refreshArgs := []string{} refreshArgs = append(refreshArgs, varFileArgs...) @@ -193,14 +192,29 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri destroyArgs := []string{"-auto-approve"} destroyArgs = append(destroyArgs, varFileArgs...) + var parallelismArg string + if e.blueprintHandler != nil { + components := e.blueprintHandler.GetTerraformComponents() + for _, component := range components { + if component.Path == projectPath && component.Parallelism != nil { + parallelismArg = fmt.Sprintf(" -parallelism=%d", *component.Parallelism) + applyArgs = append(applyArgs, fmt.Sprintf("-parallelism=%d", *component.Parallelism)) + destroyArgs = append(destroyArgs, fmt.Sprintf("-parallelism=%d", *component.Parallelism)) + break + } + } + } + + applyArgs = append(applyArgs, tfPlanPath) + terraformVars := make(map[string]string) terraformVars["TF_DATA_DIR"] = strings.TrimSpace(tfDataDir) terraformVars["TF_CLI_ARGS_init"] = strings.TrimSpace(fmt.Sprintf("-backend=true -force-copy %s", strings.Join(backendConfigArgsForEnv, " "))) terraformVars["TF_CLI_ARGS_plan"] = strings.TrimSpace(fmt.Sprintf("-out=\"%s\" %s", tfPlanPath, strings.Join(varFileArgsForEnv, " "))) - terraformVars["TF_CLI_ARGS_apply"] = strings.TrimSpace(fmt.Sprintf("\"%s\"", tfPlanPath)) + terraformVars["TF_CLI_ARGS_apply"] = strings.TrimSpace(fmt.Sprintf("\"%s\"%s", tfPlanPath, parallelismArg)) terraformVars["TF_CLI_ARGS_refresh"] = strings.TrimSpace(strings.Join(varFileArgsForEnv, " ")) terraformVars["TF_CLI_ARGS_import"] = strings.TrimSpace(strings.Join(varFileArgsForEnv, " ")) - terraformVars["TF_CLI_ARGS_destroy"] = strings.TrimSpace(strings.Join(varFileArgsForEnv, " ")) + terraformVars["TF_CLI_ARGS_destroy"] = strings.TrimSpace(fmt.Sprintf("%s%s", strings.Join(varFileArgsForEnv, " "), parallelismArg)) terraformVars["TF_VAR_context_path"] = strings.TrimSpace(filepath.ToSlash(configRoot)) terraformVars["TF_VAR_context_id"] = strings.TrimSpace(e.configHandler.GetString("id", "")) diff --git a/pkg/env/terraform_env_test.go b/pkg/env/terraform_env_test.go index f58d27b75..d1de6734f 100644 --- a/pkg/env/terraform_env_test.go +++ b/pkg/env/terraform_env_test.go @@ -1664,3 +1664,231 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { } }) } + +func TestTerraformEnv_GenerateTerraformArgs(t *testing.T) { + t.Run("GeneratesCorrectArgsWithoutParallelism", func(t *testing.T) { + mocks := setupTerraformEnvMocks(t) + + printer := &TerraformEnvPrinter{ + BaseEnvPrinter: *NewBaseEnvPrinter(mocks.Injector), + } + + if err := printer.Initialize(); err != nil { + t.Fatalf("Failed to initialize printer: %v", err) + } + + // When generating terraform args without parallelism + args, err := printer.GenerateTerraformArgs("test/path", "test/module") + + // Then no error should be returned + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // And apply args should contain only the plan file path + if len(args.ApplyArgs) != 1 { + t.Errorf("Expected 1 apply arg, got %d: %v", len(args.ApplyArgs), args.ApplyArgs) + } + + // And destroy args should contain only auto-approve + expectedDestroyArgs := []string{"-auto-approve"} + if !reflect.DeepEqual(args.DestroyArgs, expectedDestroyArgs) { + t.Errorf("Expected destroy args %v, got %v", expectedDestroyArgs, args.DestroyArgs) + } + + // And environment variables should not contain parallelism + if strings.Contains(args.TerraformVars["TF_CLI_ARGS_apply"], "parallelism") { + t.Errorf("Apply args should not contain parallelism: %s", args.TerraformVars["TF_CLI_ARGS_apply"]) + } + if strings.Contains(args.TerraformVars["TF_CLI_ARGS_destroy"], "parallelism") { + t.Errorf("Destroy args should not contain parallelism: %s", args.TerraformVars["TF_CLI_ARGS_destroy"]) + } + }) + + t.Run("GeneratesCorrectArgsWithParallelism", func(t *testing.T) { + mocks := setupTerraformEnvMocks(t) + + // Set up blueprint handler with parallelism component + mockBlueprint := blueprint.NewMockBlueprintHandler(mocks.Injector) + parallelism := 5 + mockBlueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test/path", + Parallelism: ¶llelism, + }, + } + } + mocks.Injector.Register("blueprintHandler", mockBlueprint) + + printer := &TerraformEnvPrinter{ + BaseEnvPrinter: *NewBaseEnvPrinter(mocks.Injector), + } + + if err := printer.Initialize(); err != nil { + t.Fatalf("Failed to initialize printer: %v", err) + } + + // When generating terraform args with parallelism + args, err := printer.GenerateTerraformArgs("test/path", "test/module") + + // Then no error should be returned + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // And apply args should contain parallelism flag before plan file + expectedApplyArgs := []string{"-parallelism=5"} + if len(args.ApplyArgs) < 1 || args.ApplyArgs[0] != expectedApplyArgs[0] { + t.Errorf("Expected apply args to start with %v, got %v", expectedApplyArgs, args.ApplyArgs) + } + + // And destroy args should contain parallelism flag + foundParallelismInDestroy := false + for _, arg := range args.DestroyArgs { + if arg == "-parallelism=5" { + foundParallelismInDestroy = true + break + } + } + if !foundParallelismInDestroy { + t.Errorf("Expected destroy args to contain -parallelism=5, got %v", args.DestroyArgs) + } + + // And environment variables should contain parallelism + if !strings.Contains(args.TerraformVars["TF_CLI_ARGS_apply"], " -parallelism=5") { + t.Errorf("Apply env var should contain parallelism: %s", args.TerraformVars["TF_CLI_ARGS_apply"]) + } + if !strings.Contains(args.TerraformVars["TF_CLI_ARGS_destroy"], "-parallelism=5") { + t.Errorf("Destroy env var should contain parallelism: %s", args.TerraformVars["TF_CLI_ARGS_destroy"]) + } + }) + + t.Run("ParallelismOnlyAppliedToMatchingComponent", func(t *testing.T) { + mocks := setupTerraformEnvMocks(t) + + // Set up blueprint handler with parallelism for different component + mockBlueprint := blueprint.NewMockBlueprintHandler(mocks.Injector) + parallelism := 10 + mockBlueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "other/path", + Parallelism: ¶llelism, + }, + { + Path: "test/path", + // No parallelism set + }, + } + } + mocks.Injector.Register("blueprintHandler", mockBlueprint) + + printer := &TerraformEnvPrinter{ + BaseEnvPrinter: *NewBaseEnvPrinter(mocks.Injector), + } + + if err := printer.Initialize(); err != nil { + t.Fatalf("Failed to initialize printer: %v", err) + } + + // When generating terraform args for component without parallelism + args, err := printer.GenerateTerraformArgs("test/path", "test/module") + + // Then no error should be returned + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // And apply args should not contain parallelism flag + for _, arg := range args.ApplyArgs { + if strings.Contains(arg, "parallelism") { + t.Errorf("Apply args should not contain parallelism for non-matching component: %v", args.ApplyArgs) + } + } + + // And environment variables should not contain parallelism + if strings.Contains(args.TerraformVars["TF_CLI_ARGS_apply"], "parallelism") { + t.Errorf("Apply env var should not contain parallelism: %s", args.TerraformVars["TF_CLI_ARGS_apply"]) + } + }) + + t.Run("HandlesNilBlueprintHandler", func(t *testing.T) { + mocks := setupTerraformEnvMocks(t) + + printer := &TerraformEnvPrinter{ + BaseEnvPrinter: *NewBaseEnvPrinter(mocks.Injector), + } + + // Initialize with the base dependencies but don't fail on missing blueprint handler + if err := printer.BaseEnvPrinter.Initialize(); err != nil { + t.Fatalf("Failed to initialize base printer: %v", err) + } + + // Set blueprint handler to nil explicitly + printer.blueprintHandler = nil + + // When generating terraform args with nil blueprint handler + args, err := printer.GenerateTerraformArgs("test/path", "test/module") + + // Then no error should be returned + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // And no parallelism should be applied + for _, arg := range args.ApplyArgs { + if strings.Contains(arg, "parallelism") { + t.Errorf("Apply args should not contain parallelism with nil blueprint handler: %v", args.ApplyArgs) + } + } + }) + + t.Run("CorrectArgumentOrdering", func(t *testing.T) { + mocks := setupTerraformEnvMocks(t) + + // Set up blueprint handler with parallelism + mockBlueprint := blueprint.NewMockBlueprintHandler(mocks.Injector) + parallelism := 3 + mockBlueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "test/path", + Parallelism: ¶llelism, + }, + } + } + mocks.Injector.Register("blueprintHandler", mockBlueprint) + + printer := &TerraformEnvPrinter{ + BaseEnvPrinter: *NewBaseEnvPrinter(mocks.Injector), + } + + if err := printer.Initialize(); err != nil { + t.Fatalf("Failed to initialize printer: %v", err) + } + + // When generating terraform args + args, err := printer.GenerateTerraformArgs("test/path", "test/module") + + // Then no error should be returned + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // And apply args should have parallelism flag before plan file path + if len(args.ApplyArgs) < 2 { + t.Fatalf("Expected at least 2 apply args, got %d: %v", len(args.ApplyArgs), args.ApplyArgs) + } + + if args.ApplyArgs[0] != "-parallelism=3" { + t.Errorf("Expected first apply arg to be -parallelism=3, got %s", args.ApplyArgs[0]) + } + + // Plan file should be last argument + lastArg := args.ApplyArgs[len(args.ApplyArgs)-1] + if !strings.Contains(lastArg, "terraform.tfplan") { + t.Errorf("Expected last apply arg to contain terraform.tfplan, got %s", lastArg) + } + }) +}