diff --git a/docs/resources/tenant_common_variable.md b/docs/resources/tenant_common_variable.md index 778122ff..d7470c39 100644 --- a/docs/resources/tenant_common_variable.md +++ b/docs/resources/tenant_common_variable.md @@ -23,6 +23,7 @@ Manages a tenant common variable in Octopus Deploy. ### Optional +- `scope` (Block List) Sets the scope of the variable. (see [below for nested schema](#nestedblock--scope)) - `space_id` (String) The space ID associated with this Tenant Common Variable. - `value` (String, Sensitive) The value of the variable. @@ -30,4 +31,11 @@ Manages a tenant common variable in Octopus Deploy. - `id` (String) The unique ID for this resource. + +### Nested Schema for `scope` + +Optional: + +- `environment_ids` (Set of String) A set of environment IDs to scope this variable to. + diff --git a/docs/resources/tenant_project_variable.md b/docs/resources/tenant_project_variable.md index 2c65a549..37732633 100644 --- a/docs/resources/tenant_project_variable.md +++ b/docs/resources/tenant_project_variable.md @@ -26,13 +26,14 @@ resource "octopusdeploy_tenant_project_variable" "example" { ### Required -- `environment_id` (String) The ID of the environment. - `project_id` (String) The ID of the project. - `template_id` (String) The ID of the variable template. - `tenant_id` (String) The ID of the tenant. ### Optional +- `environment_id` (String) The ID of the environment. Use scope block for V2 API with multiple environments. +- `scope` (Block List) Sets the scope of the variable. (see [below for nested schema](#nestedblock--scope)) - `space_id` (String) The space ID associated with this Tenant Project Variable. - `value` (String, Sensitive) The value of the variable. @@ -40,6 +41,13 @@ resource "octopusdeploy_tenant_project_variable" "example" { - `id` (String) The unique ID for this resource. + +### Nested Schema for `scope` + +Optional: + +- `environment_ids` (Set of String) A set of environment IDs to scope this variable to. + ## Import Import is supported using the following syntax: diff --git a/octopusdeploy_framework/framework_provider_test.go b/octopusdeploy_framework/framework_provider_test.go index 4a172a7c..e55e3466 100644 --- a/octopusdeploy_framework/framework_provider_test.go +++ b/octopusdeploy_framework/framework_provider_test.go @@ -41,6 +41,30 @@ func ProtoV6ProviderFactories() map[string]func() (tfprotov6.ProviderServer, err } } +func ProtoV6ProviderFactoriesWithFeatureToggleOverrides(overrides map[string]bool) map[string]func() (tfprotov6.ProviderServer, error) { + return map[string]func() (tfprotov6.ProviderServer, error){ + "octopusdeploy": func() (tfprotov6.ProviderServer, error) { + ctx := context.Background() + + upgradedSdkServer, err := tf5to6server.UpgradeServer( + ctx, + octopusdeploy.Provider().GRPCProvider) + if err != nil { + log.Fatal(err) + } + + providers := []func() tfprotov6.ProviderServer{ + func() tfprotov6.ProviderServer { + return upgradedSdkServer + }, + providerserver.NewProtocol6(newTestOctopusDeployFrameworkProvider(overrides)), + } + + return tf6muxserver.NewMuxServer(context.Background(), providers...) + }, + } +} + func TestAccPreCheck(t *testing.T) { if v := os.Getenv("OCTOPUS_URL"); isEmpty(v) { t.Fatal("OCTOPUS_URL must be set for acceptance tests") @@ -53,3 +77,94 @@ func TestAccPreCheck(t *testing.T) { func isEmpty(s string) bool { return len(strings.TrimSpace(s)) == 0 } + +func TestTestProviderFeatureToggleOverrides(t *testing.T) { + tests := []struct { + name string + overrides map[string]bool + existingToggles map[string]bool + expectedToggles map[string]bool + description string + }{ + { + name: "No overrides", + overrides: map[string]bool{}, + existingToggles: map[string]bool{ + "SomeFeatureToggle": true, + "SomeOtherFeatureToggle": true, + }, + expectedToggles: map[string]bool{ + "SomeFeatureToggle": true, + "SomeOtherFeatureToggle": true, + }, + description: "No overrides should preserve all toggles", + }, + { + name: "Single override", + overrides: map[string]bool{ + "FeatureToggleToOverride": false, + }, + existingToggles: map[string]bool{ + "FeatureToggleToOverride": true, + "SomeOtherFeatureToggle": true, + }, + expectedToggles: map[string]bool{ + "FeatureToggleToOverride": false, + "SomeOtherFeatureToggle": true, + }, + description: "Should override specified toggle while preserving others", + }, + { + name: "Multiple overrides", + overrides: map[string]bool{ + "FeatureToggleToOverride": true, + "AnotherFeatureToggleToOverride": true, + }, + existingToggles: map[string]bool{ + "FeatureToggleToOverride": false, + "AnotherFeatureToggleToOverride": false, + "SomeOtherFeatureToggle": false, + }, + expectedToggles: map[string]bool{ + "FeatureToggleToOverride": true, + "AnotherFeatureToggleToOverride": true, + "SomeOtherFeatureToggle": false, + }, + description: "Should override multiple toggles while preserving others", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a config with existing toggles + config := &Config{ + FeatureToggles: tt.existingToggles, + } + + // Simulate what the test provider does + if tt.overrides != nil { + if config.FeatureToggles == nil { + config.FeatureToggles = make(map[string]bool) + } + + for key, value := range tt.overrides { + config.FeatureToggles[key] = value + } + } + + // Verify the result + for expectedKey, expectedValue := range tt.expectedToggles { + if actualValue, ok := config.FeatureToggles[expectedKey]; !ok { + t.Errorf("%s: expected toggle %q to exist but it was missing", tt.description, expectedKey) + } else if actualValue != expectedValue { + t.Errorf("%s: toggle %q = %t, want %t", tt.description, expectedKey, actualValue, expectedValue) + } + } + + // Verify no extra toggles were added + if len(config.FeatureToggles) != len(tt.expectedToggles) { + t.Errorf("%s: got %d toggles, want %d toggles", tt.description, len(config.FeatureToggles), len(tt.expectedToggles)) + } + }) + } +} diff --git a/octopusdeploy_framework/framework_provider_test_helper.go b/octopusdeploy_framework/framework_provider_test_helper.go new file mode 100644 index 00000000..4054198f --- /dev/null +++ b/octopusdeploy_framework/framework_provider_test_helper.go @@ -0,0 +1,65 @@ +package octopusdeploy_framework + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type testOctopusDeployFrameworkProvider struct { + *octopusDeployFrameworkProvider + featureToggleOverrides map[string]bool +} + +var _ provider.Provider = (*testOctopusDeployFrameworkProvider)(nil) + +func newTestOctopusDeployFrameworkProvider(overrides map[string]bool) *testOctopusDeployFrameworkProvider { + return &testOctopusDeployFrameworkProvider{ + octopusDeployFrameworkProvider: NewOctopusDeployFrameworkProvider(), + featureToggleOverrides: overrides, + } +} + +func (p *testOctopusDeployFrameworkProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + p.octopusDeployFrameworkProvider.Metadata(ctx, req, resp) +} + +func (p *testOctopusDeployFrameworkProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + p.octopusDeployFrameworkProvider.Schema(ctx, req, resp) +} + +func (p *testOctopusDeployFrameworkProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + p.octopusDeployFrameworkProvider.Configure(ctx, req, resp) + + if p.featureToggleOverrides != nil && !resp.Diagnostics.HasError() { + config, ok := resp.ResourceData.(*Config) + if !ok { + resp.Diagnostics.AddError("Test configuration error", "Failed to cast provider data to Config") + return + } + + if config.FeatureToggles == nil { + config.FeatureToggles = make(map[string]bool) + } + + for key, value := range p.featureToggleOverrides { + config.FeatureToggles[key] = value + tflog.Debug(ctx, fmt.Sprintf("Test override: feature toggle %s = %t", key, value)) + } + + resp.ResourceData = config + resp.DataSourceData = config + } +} + +func (p *testOctopusDeployFrameworkProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return p.octopusDeployFrameworkProvider.DataSources(ctx) +} + +func (p *testOctopusDeployFrameworkProvider) Resources(ctx context.Context) []func() resource.Resource { + return p.octopusDeployFrameworkProvider.Resources(ctx) +} diff --git a/octopusdeploy_framework/resource_tenant_common_variable.go b/octopusdeploy_framework/resource_tenant_common_variable.go index 69c8ddd0..a3feabea 100644 --- a/octopusdeploy_framework/resource_tenant_common_variable.go +++ b/octopusdeploy_framework/resource_tenant_common_variable.go @@ -3,6 +3,7 @@ package octopusdeploy_framework import ( "context" "fmt" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal" "strings" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" @@ -10,6 +11,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" @@ -23,12 +25,17 @@ type tenantCommonVariableResource struct { *Config } +type tenantCommonVariableScopeModel struct { + EnvironmentIDs types.Set `tfsdk:"environment_ids"` +} + type tenantCommonVariableResourceModel struct { - SpaceID types.String `tfsdk:"space_id"` - TenantID types.String `tfsdk:"tenant_id"` - LibraryVariableSetID types.String `tfsdk:"library_variable_set_id"` - TemplateID types.String `tfsdk:"template_id"` - Value types.String `tfsdk:"value"` + SpaceID types.String `tfsdk:"space_id"` + TenantID types.String `tfsdk:"tenant_id"` + LibraryVariableSetID types.String `tfsdk:"library_variable_set_id"` + TemplateID types.String `tfsdk:"template_id"` + Value types.String `tfsdk:"value"` + Scope []tenantCommonVariableScopeModel `tfsdk:"scope"` schemas.ResourceModel } @@ -49,6 +56,89 @@ func (t *tenantCommonVariableResource) Configure(_ context.Context, req resource t.Config = ResourceConfiguration(req, resp) } +func (t *tenantCommonVariableResource) supportsV2() bool { + if t.Config == nil || t.Config.FeatureToggles == nil { + // If we can't check feature toggles, the server is too old for V2 + return false + } + return t.Config.FeatureToggleEnabled("CommonVariableScopingFeatureToggle") +} + +func isCompositeID(id string) bool { + return strings.Contains(id, ":") +} + +func findCommonVariableTemplateByLibrarySetAndTemplate(variables []variables.TenantCommonVariable, missingVariables []variables.TenantCommonVariable, libraryVariableSetID, templateID string) (isSensitive bool, found bool) { + for _, v := range append(variables, missingVariables...) { + if v.LibraryVariableSetId == libraryVariableSetID && v.TemplateID == templateID { + return isTemplateControlTypeSensitive(v.Template.DisplaySettings), true + } + } + return false, false +} + +func findCommonVariableByID(variables []variables.TenantCommonVariable, id string) (isSensitive bool, found bool) { + for _, v := range variables { + if v.GetID() == id { + return isTemplateControlTypeSensitive(v.Template.DisplaySettings), true + } + } + return false, false +} + +func isTemplateControlTypeSensitive(displaySettings map[string]string) bool { + return displaySettings["Octopus.ControlType"] == "Sensitive" +} + +func (t *tenantCommonVariableResource) validateScopeSupport(planScope []tenantCommonVariableScopeModel, diags *diag.Diagnostics) bool { + if len(planScope) > 0 && !t.supportsV2() { + diags.AddError( + "Scope block is not supported", + "The 'scope' block requires V2 API support. Your Octopus Server does not support this feature.", + ) + return false + } + return true +} + +func commonVariableMatchesPlan(variable variables.TenantCommonVariable, planLibrarySetID, planTemplateID string, planScope []tenantCommonVariableScopeModel) bool { + if variable.LibraryVariableSetId != planLibrarySetID || variable.TemplateID != planTemplateID { + return false + } + + if len(planScope) == 0 { + return len(variable.Scope.EnvironmentIds) == 0 + } + + return scopesMatch(planScope[0].EnvironmentIDs, variable.Scope.EnvironmentIds) +} + +func scopesMatch(planEnvIDs types.Set, serverEnvIDs []string) bool { + if planEnvIDs.IsNull() || planEnvIDs.IsUnknown() { + return len(serverEnvIDs) == 0 + } + + planEnvironments := make([]types.String, 0, len(planEnvIDs.Elements())) + planEnvIDs.ElementsAs(context.Background(), &planEnvironments, false) + + if len(planEnvironments) != len(serverEnvIDs) { + return false + } + + planEnvSet := make(map[string]bool) + for _, e := range planEnvironments { + planEnvSet[e.ValueString()] = true + } + + for _, serverEnv := range serverEnvIDs { + if !planEnvSet[serverEnv] { + return false + } + } + + return true +} + func (t *tenantCommonVariableResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan tenantCommonVariableResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -56,8 +146,38 @@ func (t *tenantCommonVariableResource) Create(ctx context.Context, req resource. return } - tflog.Debug(ctx, "Creating tenant common variable") + internal.KeyedMutex.Lock(plan.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(plan.TenantID.ValueString()) + + if !t.validateScopeSupport(plan.Scope, &resp.Diagnostics) { + return + } + + tenant, err := tenants.GetByID(t.Client, plan.SpaceID.ValueString(), plan.TenantID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) + return + } + + spaceID := plan.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + if t.supportsV2() { + t.createV2(ctx, &plan, tenant, spaceID, resp) + } else { + t.createV1(ctx, &plan, tenant, resp) + } + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} +func (t *tenantCommonVariableResource) createV1(ctx context.Context, plan *tenantCommonVariableResourceModel, tenant *tenants.Tenant, resp *resource.CreateResponse) { id := fmt.Sprintf("%s:%s:%s", plan.TenantID.ValueString(), plan.LibraryVariableSetID.ValueString(), plan.TemplateID.ValueString()) tenant, err := tenants.GetByID(t.Client, plan.SpaceID.ValueString(), plan.TenantID.ValueString()) @@ -72,19 +192,19 @@ func (t *tenantCommonVariableResource) Create(ctx context.Context, req resource. return } - err = checkIfCandidateVariableRequiredForTenant(tenant, tenantVariables, plan) + err = checkIfCandidateVariableRequiredForTenant(tenant, tenantVariables, *plan) if err != nil { resp.Diagnostics.AddError("Tenant doesn't need a value for this Common Variable", "Tenants must be connected to a Project with an included Library Variable Set that defines Common Variable templates, before common variable values can be provided ("+err.Error()+")") return } - isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, plan) + isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, *plan) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return } - if err := updateTenantCommonVariable(tenantVariables, plan, isSensitive); err != nil { + if err := updateTenantCommonVariable(tenantVariables, *plan, isSensitive); err != nil { resp.Diagnostics.AddError("Error updating tenant common variable", err.Error()) return } @@ -98,11 +218,97 @@ func (t *tenantCommonVariableResource) Create(ctx context.Context, req resource. plan.ID = types.StringValue(id) plan.SpaceID = types.StringValue(tenant.SpaceID) - tflog.Debug(ctx, "Tenant common variable created", map[string]interface{}{ + tflog.Debug(ctx, "Tenant common variable created with V1 API", map[string]interface{}{ "id": plan.ID.ValueString(), }) +} - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +func (t *tenantCommonVariableResource) createV2(ctx context.Context, plan *tenantCommonVariableResourceModel, tenant *tenants.Tenant, spaceID string, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Using V2 API for tenant common variable") + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: plan.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: true, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + isSensitive, found := findCommonVariableTemplateByLibrarySetAndTemplate( + getResp.Variables, + getResp.MissingVariables, + plan.LibraryVariableSetID.ValueString(), + plan.TemplateID.ValueString(), + ) + + if !found { + resp.Diagnostics.AddError("Template not found", fmt.Sprintf("Template %s not found in library variable set %s", plan.TemplateID.ValueString(), plan.LibraryVariableSetID.ValueString())) + return + } + + scope := variables.TenantVariableScope{} + if len(plan.Scope) > 0 { + envIDs, diags := util.SetToStringArray(ctx, plan.Scope[0].EnvironmentIDs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scope.EnvironmentIds = envIDs + } + + payloads := []variables.TenantCommonVariablePayload{} + + for _, v := range getResp.Variables { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: v.GetID(), + LibraryVariableSetId: v.LibraryVariableSetId, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + + newPayload := variables.TenantCommonVariablePayload{ + LibraryVariableSetId: plan.LibraryVariableSetID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + } + payloads = append(payloads, newPayload) + + cmd := &variables.ModifyTenantCommonVariablesCommand{ + Variables: payloads, + } + + updateResp, err := tenants.UpdateCommonVariables(t.Client, spaceID, plan.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error updating tenant common variables", err.Error()) + return + } + + var createdID string + for _, v := range updateResp.Variables { + if commonVariableMatchesPlan(v, plan.LibraryVariableSetID.ValueString(), plan.TemplateID.ValueString(), plan.Scope) { + createdID = v.GetID() + break + } + } + + if createdID == "" { + resp.Diagnostics.AddError("Failed to get variable ID", "Variable was created but ID not returned in response") + return + } + + plan.ID = types.StringValue(createdID) + plan.SpaceID = types.StringValue(tenant.SpaceID) + + tflog.Debug(ctx, "Tenant common variable created with V2 API", map[string]interface{}{ + "id": plan.ID.ValueString(), + }) } func (t *tenantCommonVariableResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { @@ -112,19 +318,46 @@ func (t *tenantCommonVariableResource) Read(ctx context.Context, req resource.Re return } + internal.KeyedMutex.Lock(state.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(state.TenantID.ValueString()) + tenant, err := tenants.GetByID(t.Client, state.SpaceID.ValueString(), state.TenantID.ValueString()) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) return } + spaceID := state.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + isV1ID := isCompositeID(state.ID.ValueString()) + if isV1ID && t.supportsV2() { + t.migrateV1ToV2OnRead(ctx, &state, spaceID, resp) + } else if !isV1ID { + t.readV2(ctx, &state, spaceID, resp) + } else { + t.readV1(ctx, &state, tenant, resp) + } + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (t *tenantCommonVariableResource) readV1(ctx context.Context, state *tenantCommonVariableResourceModel, tenant *tenants.Tenant, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Reading tenant common variable with V1 API") + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, state) + isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, *state) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return @@ -140,8 +373,90 @@ func (t *tenantCommonVariableResource) Read(ctx context.Context, req resource.Re return } } +} - resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +func (t *tenantCommonVariableResource) readV2(ctx context.Context, state *tenantCommonVariableResourceModel, spaceID string, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Reading tenant common variable with V2 API") + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: state.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + var found bool + for _, v := range getResp.Variables { + if v.GetID() == state.ID.ValueString() { + if len(v.Scope.EnvironmentIds) > 0 { + envSet := util.BuildStringSetOrEmpty(v.Scope.EnvironmentIds) + state.Scope = []tenantCommonVariableScopeModel{{EnvironmentIDs: envSet}} + } else { + state.Scope = nil + } + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + state.Value = types.StringValue(v.Value.Value) + } + + found = true + break + } + } + + if !found { + resp.State.RemoveResource(ctx) + return + } +} + +func (t *tenantCommonVariableResource) migrateV1ToV2OnRead(ctx context.Context, state *tenantCommonVariableResourceModel, spaceID string, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Migrating tenant common variable from V1 to V2") + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: state.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + var found bool + for _, v := range getResp.Variables { + if v.LibraryVariableSetId == state.LibraryVariableSetID.ValueString() && v.TemplateID == state.TemplateID.ValueString() { + state.ID = types.StringValue(v.GetID()) + + if len(v.Scope.EnvironmentIds) > 0 { + envSet := util.BuildStringSetOrEmpty(v.Scope.EnvironmentIds) + state.Scope = []tenantCommonVariableScopeModel{{EnvironmentIDs: envSet}} + } else { + state.Scope = nil + } + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + state.Value = types.StringValue(v.Value.Value) + } + + found = true + break + } + } + + if !found { + resp.State.RemoveResource(ctx) + return + } } func (t *tenantCommonVariableResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { @@ -151,25 +466,121 @@ func (t *tenantCommonVariableResource) Update(ctx context.Context, req resource. return } + internal.KeyedMutex.Lock(plan.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(plan.TenantID.ValueString()) + + if !t.validateScopeSupport(plan.Scope, &resp.Diagnostics) { + return + } + tenant, err := tenants.GetByID(t.Client, plan.SpaceID.ValueString(), plan.TenantID.ValueString()) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) return } + spaceID := plan.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + isV1ID := isCompositeID(plan.ID.ValueString()) + if isV1ID && t.supportsV2() { + t.migrateV1ToV2OnUpdate(ctx, &plan, spaceID, resp) + } else if !isV1ID { + t.updateV2(ctx, &plan, spaceID, resp) + } else { + t.updateV1(ctx, &plan, tenant, resp) + } + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (t *tenantCommonVariableResource) updateV2(ctx context.Context, plan *tenantCommonVariableResourceModel, spaceID string, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Updating tenant common variable with V2 API") + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: plan.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + isSensitive, found := findCommonVariableByID(getResp.Variables, plan.ID.ValueString()) + + if !found { + resp.Diagnostics.AddError("Variable not found", fmt.Sprintf("Variable with ID %s not found", plan.ID.ValueString())) + return + } + + scope := variables.TenantVariableScope{} + if len(plan.Scope) > 0 { + envIDs, diags := util.SetToStringArray(ctx, plan.Scope[0].EnvironmentIDs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scope.EnvironmentIds = envIDs + } + + payloads := []variables.TenantCommonVariablePayload{} + + for _, v := range getResp.Variables { + if v.GetID() == plan.ID.ValueString() { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: plan.ID.ValueString(), + LibraryVariableSetId: plan.LibraryVariableSetID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + }) + } else { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: v.GetID(), + LibraryVariableSetId: v.LibraryVariableSetId, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + } + + cmd := &variables.ModifyTenantCommonVariablesCommand{ + Variables: payloads, + } + + _, err = tenants.UpdateCommonVariables(t.Client, spaceID, plan.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error updating tenant common variables", err.Error()) + return + } +} + +func (t *tenantCommonVariableResource) updateV1(ctx context.Context, plan *tenantCommonVariableResourceModel, tenant *tenants.Tenant, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Updating tenant common variable with V1 API") + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, plan) + isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, *plan) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return } - if err := updateTenantCommonVariable(tenantVariables, plan, isSensitive); err != nil { + if err := updateTenantCommonVariable(tenantVariables, *plan, isSensitive); err != nil { resp.Diagnostics.AddError("Error updating tenant common variable", err.Error()) return } @@ -179,8 +590,96 @@ func (t *tenantCommonVariableResource) Update(ctx context.Context, req resource. resp.Diagnostics.AddError("Error updating tenant variables", err.Error()) return } +} - resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +func (t *tenantCommonVariableResource) migrateV1ToV2OnUpdate(ctx context.Context, plan *tenantCommonVariableResourceModel, spaceID string, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Migrating tenant common variable from V1 to V2 during update") + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: plan.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: true, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + var isSensitive bool + var existingID string + var foundExisting bool + for _, v := range append(getResp.Variables, getResp.MissingVariables...) { + if v.LibraryVariableSetId == plan.LibraryVariableSetID.ValueString() && v.TemplateID == plan.TemplateID.ValueString() { + isSensitive = isTemplateControlTypeSensitive(v.Template.DisplaySettings) + existingID = v.GetID() + foundExisting = true + break + } + } + + scope := variables.TenantVariableScope{} + if len(plan.Scope) > 0 { + envIDs, diags := util.SetToStringArray(ctx, plan.Scope[0].EnvironmentIDs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scope.EnvironmentIds = envIDs + } + + payloads := []variables.TenantCommonVariablePayload{} + + for _, v := range getResp.Variables { + if v.LibraryVariableSetId == plan.LibraryVariableSetID.ValueString() && v.TemplateID == plan.TemplateID.ValueString() { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: existingID, + LibraryVariableSetId: plan.LibraryVariableSetID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + }) + } else { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: v.GetID(), + LibraryVariableSetId: v.LibraryVariableSetId, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + } + + if !foundExisting { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + LibraryVariableSetId: plan.LibraryVariableSetID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + }) + } + + cmd := &variables.ModifyTenantCommonVariablesCommand{ + Variables: payloads, + } + + updateResp, err := tenants.UpdateCommonVariables(t.Client, spaceID, plan.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error updating tenant common variables", err.Error()) + return + } + + for _, v := range updateResp.Variables { + if v.LibraryVariableSetId == plan.LibraryVariableSetID.ValueString() && v.TemplateID == plan.TemplateID.ValueString() { + plan.ID = types.StringValue(v.GetID()) + break + } + } + + tflog.Debug(ctx, "Tenant common variable migrated to V2", map[string]interface{}{ + "new_id": plan.ID.ValueString(), + }) } func (t *tenantCommonVariableResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { @@ -190,19 +689,38 @@ func (t *tenantCommonVariableResource) Delete(ctx context.Context, req resource. return } + internal.KeyedMutex.Lock(state.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(state.TenantID.ValueString()) + tenant, err := tenants.GetByID(t.Client, state.SpaceID.ValueString(), state.TenantID.ValueString()) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) return } + spaceID := state.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + isV1ID := isCompositeID(state.ID.ValueString()) + if !isV1ID { + t.deleteV2(ctx, &state, spaceID, resp) + } else { + t.deleteV1(ctx, &state, tenant, resp) + } +} + +func (t *tenantCommonVariableResource) deleteV1(ctx context.Context, state *tenantCommonVariableResourceModel, tenant *tenants.Tenant, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Deleting tenant common variable with V1 API") + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, state) + isSensitive, err := checkIfCommonVariableIsSensitive(tenantVariables, *state) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return @@ -223,28 +741,144 @@ func (t *tenantCommonVariableResource) Delete(ctx context.Context, req resource. } } +func (t *tenantCommonVariableResource) deleteV2(ctx context.Context, state *tenantCommonVariableResourceModel, spaceID string, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Deleting tenant common variable with V2 API") + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: state.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + isSensitive, found := findCommonVariableByID(getResp.Variables, state.ID.ValueString()) + + if !found { + return + } + + payloads := []variables.TenantCommonVariablePayload{} + + for _, v := range getResp.Variables { + if v.GetID() == state.ID.ValueString() { + if isSensitive { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: state.ID.ValueString(), + LibraryVariableSetId: state.LibraryVariableSetID.ValueString(), + TemplateID: state.TemplateID.ValueString(), + Value: core.PropertyValue{IsSensitive: true, SensitiveValue: &core.SensitiveValue{HasValue: false}}, + Scope: variables.TenantVariableScope{}, + }) + } + } else { + payloads = append(payloads, variables.TenantCommonVariablePayload{ + ID: v.GetID(), + LibraryVariableSetId: v.LibraryVariableSetId, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + } + + cmd := &variables.ModifyTenantCommonVariablesCommand{ + Variables: payloads, + } + + _, err = tenants.UpdateCommonVariables(t.Client, spaceID, state.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error deleting tenant common variable", err.Error()) + return + } +} + func (t *tenantCommonVariableResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { idParts := strings.Split(req.ID, ":") - if len(idParts) != 3 { - resp.Diagnostics.AddError( - "Incorrect Import Format", - "ID must be in the format: TenantID:LibraryVariableSetID:TemplateID (e.g. Tenants-123:LibraryVariableSets-456:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c)", - ) + // V1 format: TenantID:LibraryVariableSetID:TemplateID + if len(idParts) == 3 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("library_variable_set_id"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), idParts[2])...) + return + } + + // V2 format: TenantID:VariableID + if len(idParts) == 2 { + tenantID := idParts[0] + variableID := idParts[1] + + tenant, err := tenants.GetByID(t.Client, "", tenantID) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) + return + } + + query := variables.GetTenantCommonVariablesQuery{ + TenantID: tenantID, + SpaceID: tenant.SpaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant common variables", err.Error()) + return + } + + var found bool + for _, v := range getResp.Variables { + if v.GetID() == variableID { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), variableID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), tenantID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("library_variable_set_id"), v.LibraryVariableSetId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), v.TemplateID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("space_id"), tenant.SpaceID)...) + + if len(v.Scope.EnvironmentIds) > 0 { + envSet := util.BuildStringSetOrEmpty(v.Scope.EnvironmentIds) + scopeModel := []tenantCommonVariableScopeModel{{EnvironmentIDs: envSet}} + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("scope"), scopeModel)...) + } + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("value"), v.Value.Value)...) + } + + found = true + break + } + } + + if !found { + resp.Diagnostics.AddError( + "Variable not found", + fmt.Sprintf("Variable with ID %s not found for tenant %s", variableID, tenantID), + ) + } return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("library_variable_set_id"), idParts[1])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), idParts[2])...) + resp.Diagnostics.AddError( + "Incorrect Import Format", + "ID must be in one of these formats:\n"+ + " V1: TenantID:LibraryVariableSetID:TemplateID (e.g. Tenants-123:LibraryVariableSets-456:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c)\n"+ + " V2: TenantID:VariableID (e.g. Tenants-123:TenantVariables-456)", + ) } func checkIfCommonVariableIsSensitive(tenantVariables *variables.TenantVariables, plan tenantCommonVariableResourceModel) (bool, error) { if libraryVariable, ok := tenantVariables.LibraryVariables[plan.LibraryVariableSetID.ValueString()]; ok { for _, template := range libraryVariable.Templates { if template.GetID() == plan.TemplateID.ValueString() { - return template.DisplaySettings["Octopus.ControlType"] == "Sensitive", nil + return isTemplateControlTypeSensitive(template.DisplaySettings), nil } } } diff --git a/octopusdeploy_framework/resource_tenant_common_variable_test.go b/octopusdeploy_framework/resource_tenant_common_variable_test.go index 85fbc2d6..4fcb23ea 100644 --- a/octopusdeploy_framework/resource_tenant_common_variable_test.go +++ b/octopusdeploy_framework/resource_tenant_common_variable_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" @@ -12,6 +14,7 @@ import ( internalTest "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal/test" ) +// TestAccTenantCommonVariableBasic tests V1 API func TestAccTenantCommonVariableBasic(t *testing.T) { //SkipCI(t, "A managed resource \"octopusdeploy_project_group\" \"ewtxiwplhaenzmhpaqyx\" has\n not been declared in the root module.") lifecycleLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) @@ -34,9 +37,11 @@ func TestAccTenantCommonVariableBasic(t *testing.T) { newValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) resource.Test(t, resource.TestCase{ - CheckDestroy: testAccTenantCommonVariableCheckDestroy, - PreCheck: func() { TestAccPreCheck(t) }, - ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + CheckDestroy: testAccTenantCommonVariableCheckDestroy, + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": false, + }), Steps: []resource.TestStep{ { Check: resource.ComposeTestCheckFunc( @@ -60,7 +65,7 @@ func testAccTenantCommonVariableBasic(lifecycleLocalName string, lifecycleName s projectGroup := internalTest.NewProjectGroupTestOptions() allowDynamicInfrastructure := false description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) - sortOrder := acctest.RandIntRange(0, 10) + sortOrder := acctest.RandIntRange(1, 10) useGuidedFailure := false projectGroup.LocalName = projectGroupLocalName @@ -122,6 +127,31 @@ func testTenantCommonVariableExists(resourceName string) resource.TestCheckFunc return fmt.Errorf("Library variable ID is not set") } + if !strings.Contains(rs.Primary.ID, ":") { + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantCommonVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(client, query) + if err != nil { + return fmt.Errorf("Error retrieving tenant common variables: %s", err.Error()) + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return nil + } + } + + return fmt.Errorf("Tenant common variable with ID %s not found via V2 API", rs.Primary.ID) + } + importStrings := strings.Split(rs.Primary.ID, ":") if len(importStrings) != 3 { return fmt.Errorf("octopusdeploy_tenant_common_variable import must be in the form of TenantID:LibraryVariableSetID:VariableID (e.g. Tenants-123:LibraryVariableSets-456:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c") @@ -157,6 +187,31 @@ func testAccTenantCommonVariableCheckDestroy(s *terraform.State) error { continue } + if !strings.Contains(rs.Primary.ID, ":") { + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantCommonVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(client, query) + if err != nil { + return nil + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return fmt.Errorf("Tenant common variable (%s) still exists", rs.Primary.ID) + } + } + + continue + } + importStrings := strings.Split(rs.Primary.ID, ":") if len(importStrings) != 3 { return fmt.Errorf("octopusdeploy_tenant_common_variable import must be in the form of TenantID:LibraryVariableSetID:VariableID (e.g. Tenants-123:LibraryVariableSets-456:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c") @@ -185,3 +240,377 @@ func testAccTenantCommonVariableCheckDestroy(s *terraform.State) error { return nil } + +// TestAccTenantCommonVariableMigration tests migration from V1 to V2 API +func TestAccTenantCommonVariableMigration(t *testing.T) { + lifecycleLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + lifecycleName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantVariablesLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resourceName := "octopusdeploy_tenant_common_variable." + tenantVariablesLocalName + + value := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + newValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + finalValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + CheckDestroy: testAccTenantCommonVariableCheckDestroy, + PreCheck: func() { TestAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": false, + }), + Check: resource.ComposeTestCheckFunc( + testTenantCommonVariableExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", value), + resource.TestCheckNoResourceAttr(resourceName, "scope.#"), + func(s *terraform.State) error { + rs := s.RootModule().Resources[resourceName] + if !strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V1 composite ID with colons, got: %s", rs.Primary.ID) + } + return nil + }, + ), + Config: testAccTenantCommonVariableMigrationV1(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, tenantVariablesLocalName, value), + }, + { + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": true, + }), + Check: resource.ComposeTestCheckFunc( + testTenantCommonVariableExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", newValue), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + func(s *terraform.State) error { + rs := s.RootModule().Resources[resourceName] + if strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V2 real ID without colons, got: %s", rs.Primary.ID) + } + return nil + }, + ), + Config: testAccTenantCommonVariableMigrationV2(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, tenantVariablesLocalName, newValue), + }, + { + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": true, + }), + Check: resource.ComposeTestCheckFunc( + testTenantCommonVariableExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", finalValue), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + func(s *terraform.State) error { + rs := s.RootModule().Resources[resourceName] + if strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V2 real ID without colons, got: %s", rs.Primary.ID) + } + return nil + }, + ), + Config: testAccTenantCommonVariableMigrationV2(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, tenantVariablesLocalName, finalValue), + }, + }, + }) +} + +func testAccTenantCommonVariableMigrationV1(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, localName, value string) string { + allowDynamicInfrastructure := false + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + sortOrder := acctest.RandIntRange(1, 10) + useGuidedFailure := false + + return fmt.Sprintf(testAccLifecycle(lifecycleLocalName, lifecycleName)+"\n"+ + testAccProjectGroup(projectGroupLocalName, projectGroupName)+"\n"+ + testAccEnvironment(env1LocalName, env1Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+ + testAccEnvironment(env2LocalName, env2Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+` + resource "octopusdeploy_library_variable_set" "test-library-variable-set-migration" { + name = "test-migration" + + template { + default_value = "Default Value" + help_text = "This is the help text" + label = "Test Label" + name = "Test Template Migration" + + display_settings = { + "Octopus.ControlType" = "Sensitive" + } + } + } + + resource "octopusdeploy_project" "%[1]s" { + included_library_variable_sets = [octopusdeploy_library_variable_set.test-library-variable-set-migration.id] + lifecycle_id = octopusdeploy_lifecycle.%[2]s.id + name = "%[3]s" + project_group_id = octopusdeploy_project_group.%[4]s.id + } + + resource "octopusdeploy_tenant" "%[5]s" { + name = "%[6]s" + } + + resource "octopusdeploy_tenant_project" "project_environment" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + resource "octopusdeploy_tenant_common_variable" "%[9]s" { + library_variable_set_id = octopusdeploy_library_variable_set.test-library-variable-set-migration.id + template_id = octopusdeploy_library_variable_set.test-library-variable-set-migration.template[0].id + tenant_id = octopusdeploy_tenant.%[5]s.id + value = "%[10]s" + + depends_on = [octopusdeploy_tenant_project.project_environment] + }`, projectLocalName, lifecycleLocalName, projectName, projectGroupLocalName, tenantLocalName, tenantName, env1LocalName, env2LocalName, localName, value) +} + +func testAccTenantCommonVariableMigrationV2(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, localName, value string) string { + allowDynamicInfrastructure := false + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + sortOrder := acctest.RandIntRange(1, 10) + useGuidedFailure := false + + return fmt.Sprintf(testAccLifecycle(lifecycleLocalName, lifecycleName)+"\n"+ + testAccProjectGroup(projectGroupLocalName, projectGroupName)+"\n"+ + testAccEnvironment(env1LocalName, env1Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+ + testAccEnvironment(env2LocalName, env2Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+` + resource "octopusdeploy_library_variable_set" "test-library-variable-set-migration" { + name = "test-migration" + + template { + default_value = "Default Value" + help_text = "This is the help text" + label = "Test Label" + name = "Test Template Migration" + + display_settings = { + "Octopus.ControlType" = "Sensitive" + } + } + } + + resource "octopusdeploy_project" "%[1]s" { + included_library_variable_sets = [octopusdeploy_library_variable_set.test-library-variable-set-migration.id] + lifecycle_id = octopusdeploy_lifecycle.%[2]s.id + name = "%[3]s" + project_group_id = octopusdeploy_project_group.%[4]s.id + } + + resource "octopusdeploy_tenant" "%[5]s" { + name = "%[6]s" + } + + resource "octopusdeploy_tenant_project" "project_environment" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + resource "octopusdeploy_tenant_common_variable" "%[9]s" { + library_variable_set_id = octopusdeploy_library_variable_set.test-library-variable-set-migration.id + template_id = octopusdeploy_library_variable_set.test-library-variable-set-migration.template[0].id + tenant_id = octopusdeploy_tenant.%[5]s.id + value = "%[10]s" + + scope { + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + depends_on = [octopusdeploy_tenant_project.project_environment] + }`, projectLocalName, lifecycleLocalName, projectName, projectGroupLocalName, tenantLocalName, tenantName, env1LocalName, env2LocalName, localName, value) +} + +// TestAccTenantCommonVariableWithScope tests V2 API with environment scoping +func TestAccTenantCommonVariableWithScope(t *testing.T) { + lifecycleLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + lifecycleName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantVariablesLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resourceName := "octopusdeploy_tenant_common_variable." + tenantVariablesLocalName + + value := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + newValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + CheckDestroy: testAccTenantCommonVariableCheckDestroyV2, + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Check: resource.ComposeTestCheckFunc( + testTenantCommonVariableExistsV2(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", value), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + ), + Config: testAccTenantCommonVariableWithScope(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, tenantVariablesLocalName, value), + }, + { + Check: resource.ComposeTestCheckFunc( + testTenantCommonVariableExistsV2(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", newValue), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + ), + Config: testAccTenantCommonVariableWithScope(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, tenantVariablesLocalName, newValue), + }, + }, + }) +} + +func testAccTenantCommonVariableWithScope(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, localName, value string) string { + allowDynamicInfrastructure := false + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + sortOrder := acctest.RandIntRange(1, 10) + useGuidedFailure := false + + return fmt.Sprintf(testAccLifecycle(lifecycleLocalName, lifecycleName)+"\n"+ + testAccProjectGroup(projectGroupLocalName, projectGroupName)+"\n"+ + testAccEnvironment(env1LocalName, env1Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+ + testAccEnvironment(env2LocalName, env2Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+` + resource "octopusdeploy_library_variable_set" "test-library-variable-set" { + name = "test-scope" + + template { + default_value = "Default Value" + help_text = "This is the help text" + label = "Test Label" + name = "Test Template Scope" + + display_settings = { + "Octopus.ControlType" = "Sensitive" + } + } + } + + resource "octopusdeploy_project" "%[1]s" { + included_library_variable_sets = [octopusdeploy_library_variable_set.test-library-variable-set.id] + lifecycle_id = octopusdeploy_lifecycle.%[2]s.id + name = "%[3]s" + project_group_id = octopusdeploy_project_group.%[4]s.id + } + + resource "octopusdeploy_tenant" "%[5]s" { + name = "%[6]s" + } + + resource "octopusdeploy_tenant_project" "project_environment" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + resource "octopusdeploy_tenant_common_variable" "%[9]s" { + library_variable_set_id = octopusdeploy_library_variable_set.test-library-variable-set.id + template_id = octopusdeploy_library_variable_set.test-library-variable-set.template[0].id + tenant_id = octopusdeploy_tenant.%[5]s.id + value = "%[10]s" + + scope { + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + depends_on = [octopusdeploy_tenant_project.project_environment] + }`, projectLocalName, lifecycleLocalName, projectName, projectGroupLocalName, tenantLocalName, tenantName, env1LocalName, env2LocalName, localName, value) +} + +func testTenantCommonVariableExistsV2(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + if len(rs.Primary.ID) == 0 { + return fmt.Errorf("Tenant common variable ID is not set") + } + + if strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V2 ID (e.g., TenantVariables-123) but got V1 composite ID: %s", rs.Primary.ID) + } + + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantCommonVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(client, query) + if err != nil { + return fmt.Errorf("Error retrieving tenant common variables: %s", err.Error()) + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return nil + } + } + + return fmt.Errorf("Tenant common variable with ID %s not found via V2 API", rs.Primary.ID) + } +} + +// testAccTenantCommonVariableCheckDestroyV2 checks that V2 tenant common variables are destroyed +func testAccTenantCommonVariableCheckDestroyV2(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "octopusdeploy_tenant_common_variable" { + continue + } + + if strings.Contains(rs.Primary.ID, ":") { + continue + } + + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantCommonVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetCommonVariables(client, query) + if err != nil { + return nil + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return fmt.Errorf("Tenant common variable (%s) still exists", rs.Primary.ID) + } + } + } + + return nil +} diff --git a/octopusdeploy_framework/resource_tenant_project_variable.go b/octopusdeploy_framework/resource_tenant_project_variable.go index 6057aaa6..7a187905 100644 --- a/octopusdeploy_framework/resource_tenant_project_variable.go +++ b/octopusdeploy_framework/resource_tenant_project_variable.go @@ -11,6 +11,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" @@ -19,18 +20,24 @@ import ( var _ resource.Resource = &tenantProjectVariableResource{} var _ resource.ResourceWithImportState = &tenantProjectVariableResource{} +var _ resource.ResourceWithConfigValidators = &tenantProjectVariableResource{} type tenantProjectVariableResource struct { *Config } +type tenantProjectVariableScopeModel struct { + EnvironmentIDs types.Set `tfsdk:"environment_ids"` +} + type tenantProjectVariableResourceModel struct { - SpaceID types.String `tfsdk:"space_id"` - TenantID types.String `tfsdk:"tenant_id"` - ProjectID types.String `tfsdk:"project_id"` - EnvironmentID types.String `tfsdk:"environment_id"` - TemplateID types.String `tfsdk:"template_id"` - Value types.String `tfsdk:"value"` + SpaceID types.String `tfsdk:"space_id"` + TenantID types.String `tfsdk:"tenant_id"` + ProjectID types.String `tfsdk:"project_id"` + EnvironmentID types.String `tfsdk:"environment_id"` + TemplateID types.String `tfsdk:"template_id"` + Value types.String `tfsdk:"value"` + Scope []tenantProjectVariableScopeModel `tfsdk:"scope"` schemas.ResourceModel } @@ -51,18 +58,131 @@ func (t *tenantProjectVariableResource) Configure(_ context.Context, req resourc t.Config = ResourceConfiguration(req, resp) } +func (t *tenantProjectVariableResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + TenantProjectVariableValidator(), + } +} + +func (t *tenantProjectVariableResource) supportsV2() bool { + if t.Config == nil || t.Config.FeatureToggles == nil { + // If we can't check feature toggles, the server is too old for V2 + return false + } + return t.Config.FeatureToggleEnabled("CommonVariableScopingFeatureToggle") +} + +func (t *tenantProjectVariableResource) validateScopeSupport(planScope []tenantProjectVariableScopeModel, diags *diag.Diagnostics) bool { + if len(planScope) > 0 && !t.supportsV2() { + diags.AddError( + "Scope block is not supported", + "The 'scope' block requires V2 API support. Your Octopus Server does not support this feature.", + ) + return false + } + return true +} + +// findProjectVariableTemplateByProjectAndTemplate finds a project variable template and returns whether it's sensitive and its ID +func findProjectVariableTemplateByProjectAndTemplate(variables []variables.TenantProjectVariable, missingVariables []variables.TenantProjectVariable, projectID, templateID string) (isSensitive bool, variableID string, found bool) { + for _, v := range append(variables, missingVariables...) { + if v.ProjectID == projectID && v.TemplateID == templateID { + return isTemplateControlTypeSensitive(v.Template.DisplaySettings), v.GetID(), true + } + } + return false, "", false +} + +// findProjectVariableByID finds a project variable by ID and returns whether it's sensitive +func findProjectVariableByID(variables []variables.TenantProjectVariable, id string) (isSensitive bool, found bool) { + for _, v := range variables { + if v.GetID() == id { + return isTemplateControlTypeSensitive(v.Template.DisplaySettings), true + } + } + return false, false +} + +// mapApiEnvironmentIdsToState sets either environment_id or the scope, to support backwards compatibility if users are using the environment_id field over scope +func mapApiEnvironmentIdsToState(state *tenantProjectVariableResourceModel, environmentIds []string) { + stateUsesEnvironmentId := !state.EnvironmentID.IsNull() && state.EnvironmentID.ValueString() != "" + + if len(environmentIds) > 0 { + if stateUsesEnvironmentId && len(environmentIds) == 1 && environmentIds[0] == state.EnvironmentID.ValueString() { + state.EnvironmentID = types.StringValue(environmentIds[0]) + } else { + envSet := util.BuildStringSetOrEmpty(environmentIds) + state.Scope = []tenantProjectVariableScopeModel{{EnvironmentIDs: envSet}} + state.EnvironmentID = types.StringNull() + } + } else { + state.EnvironmentID = types.StringNull() + } +} + +func projectVariableMatchesPlan(variable variables.TenantProjectVariable, planProjectID, planTemplateID string, planScope []tenantProjectVariableScopeModel, planEnvironmentID types.String) bool { + if variable.ProjectID != planProjectID || variable.TemplateID != planTemplateID { + return false + } + + // Check if scope block matches + if len(planScope) > 0 { + return projectScopesMatch(planScope[0].EnvironmentIDs, variable.Scope.EnvironmentIds) + } + + // Check if legacy environment_id matches + if !planEnvironmentID.IsNull() && planEnvironmentID.ValueString() != "" { + if len(variable.Scope.EnvironmentIds) == 1 && variable.Scope.EnvironmentIds[0] == planEnvironmentID.ValueString() { + return true + } + } + + return false +} + +func projectScopesMatch(planEnvIDs types.Set, serverEnvIDs []string) bool { + if planEnvIDs.IsNull() || planEnvIDs.IsUnknown() { + return len(serverEnvIDs) == 0 + } + + planEnvironments := make([]types.String, 0, len(planEnvIDs.Elements())) + planEnvIDs.ElementsAs(context.Background(), &planEnvironments, false) + + if len(planEnvironments) != len(serverEnvIDs) { + return false + } + + planEnvSet := make(map[string]bool) + for _, e := range planEnvironments { + planEnvSet[e.ValueString()] = true + } + + for _, serverEnv := range serverEnvIDs { + if !planEnvSet[serverEnv] { + return false + } + } + + return true +} + func (t *tenantProjectVariableResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - internal.Mutex.Lock() - defer internal.Mutex.Unlock() var plan tenantProjectVariableResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } + internal.KeyedMutex.Lock(plan.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(plan.TenantID.ValueString()) + tflog.Debug(ctx, "Creating tenant project variable") - id := fmt.Sprintf("%s:%s:%s:%s", plan.TenantID.ValueString(), plan.ProjectID.ValueString(), plan.EnvironmentID.ValueString(), plan.TemplateID.ValueString()) + hasEnvironmentID := !plan.EnvironmentID.IsNull() && plan.EnvironmentID.ValueString() != "" + + if !t.validateScopeSupport(plan.Scope, &resp.Diagnostics) { + return + } tenant, err := tenants.GetByID(t.Client, plan.SpaceID.ValueString(), plan.TenantID.ValueString()) if err != nil { @@ -70,19 +190,47 @@ func (t *tenantProjectVariableResource) Create(ctx context.Context, req resource return } + spaceID := plan.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + if t.supportsV2() { + t.createV2(ctx, &plan, tenant, spaceID, hasEnvironmentID, resp) + } else { + t.createV1(ctx, &plan, tenant, hasEnvironmentID, resp) + } + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (t *tenantProjectVariableResource) createV1(ctx context.Context, plan *tenantProjectVariableResourceModel, tenant *tenants.Tenant, hasEnvironmentID bool, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Using V1 API for tenant project variable") + + if !hasEnvironmentID { + resp.Diagnostics.AddError("Invalid configuration", "environment_id is required for V1 API") + return + } + + id := fmt.Sprintf("%s:%s:%s:%s", plan.TenantID.ValueString(), plan.ProjectID.ValueString(), plan.EnvironmentID.ValueString(), plan.TemplateID.ValueString()) + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - isSensitive, err := checkIfVariableIsSensitive(tenantVariables, plan) + isSensitive, err := checkIfVariableIsSensitive(tenantVariables, *plan) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return } - if err := updateTenantProjectVariable(tenantVariables, plan, isSensitive); err != nil { + if err := updateTenantProjectVariable(tenantVariables, *plan, isSensitive); err != nil { resp.Diagnostics.AddError("Error updating tenant project variable", err.Error()) return } @@ -96,41 +244,153 @@ func (t *tenantProjectVariableResource) Create(ctx context.Context, req resource plan.ID = types.StringValue(id) plan.SpaceID = types.StringValue(tenant.SpaceID) - tflog.Debug(ctx, "Tenant project variable created", map[string]interface{}{ + tflog.Debug(ctx, "Tenant project variable created with V1 API", map[string]interface{}{ "id": plan.ID.ValueString(), }) +} - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +func (t *tenantProjectVariableResource) createV2(ctx context.Context, plan *tenantProjectVariableResourceModel, tenant *tenants.Tenant, spaceID string, hasEnvironmentID bool, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Using V2 API for tenant project variable") + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: plan.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: true, + } + + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + isSensitive, _, found := findProjectVariableTemplateByProjectAndTemplate( + getResp.Variables, + getResp.MissingVariables, + plan.ProjectID.ValueString(), + plan.TemplateID.ValueString(), + ) + + if !found { + resp.Diagnostics.AddError("Template not found", fmt.Sprintf("Template %s not found in project %s", plan.TemplateID.ValueString(), plan.ProjectID.ValueString())) + return + } + + scope := variables.TenantVariableScope{} + if len(plan.Scope) > 0 { + envIDs, diags := util.SetToStringArray(ctx, plan.Scope[0].EnvironmentIDs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scope.EnvironmentIds = envIDs + } else if hasEnvironmentID { + scope.EnvironmentIds = []string{plan.EnvironmentID.ValueString()} + } + + payloads := []variables.TenantProjectVariablePayload{} + + for _, v := range getResp.Variables { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: v.GetID(), + ProjectID: v.ProjectID, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + + newPayload := variables.TenantProjectVariablePayload{ + ProjectID: plan.ProjectID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + } + payloads = append(payloads, newPayload) + + cmd := &variables.ModifyTenantProjectVariablesCommand{ + Variables: payloads, + } + + updateResp, err := tenants.UpdateProjectVariables(t.Client, spaceID, plan.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error updating tenant project variables", err.Error()) + return + } + + var createdID string + for _, v := range updateResp.Variables { + if projectVariableMatchesPlan(v, plan.ProjectID.ValueString(), plan.TemplateID.ValueString(), plan.Scope, plan.EnvironmentID) { + createdID = v.GetID() + break + } + } + + if createdID == "" { + resp.Diagnostics.AddError("Failed to get variable ID", "Variable was created but ID not returned in response") + return + } + + plan.ID = types.StringValue(createdID) + plan.SpaceID = types.StringValue(tenant.SpaceID) + + tflog.Debug(ctx, "Tenant project variable created with V2 API", map[string]interface{}{ + "id": plan.ID.ValueString(), + }) } func (t *tenantProjectVariableResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - internal.Mutex.Lock() - defer internal.Mutex.Unlock() - var state tenantProjectVariableResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } + internal.KeyedMutex.Lock(state.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(state.TenantID.ValueString()) + tenant, err := tenants.GetByID(t.Client, state.SpaceID.ValueString(), state.TenantID.ValueString()) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) return } + spaceID := state.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + isV1ID := isCompositeID(state.ID.ValueString()) + if isV1ID && t.supportsV2() { + t.migrateV1ToV2OnRead(ctx, &state, spaceID, resp) + } else if !isV1ID { + t.readV2(ctx, &state, spaceID, resp) + } else { + t.readV1(ctx, &state, tenant, resp) + } + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (t *tenantProjectVariableResource) readV1(ctx context.Context, state *tenantProjectVariableResourceModel, tenant *tenants.Tenant, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Reading tenant project variable with V1 API") + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - if !checkIfTemplateExists(tenantVariables, state) { + if !checkIfTemplateExists(tenantVariables, *state) { // The template no longer exists, so the variable can no longer exist either return } - isSensitive, err := checkIfVariableIsSensitive(tenantVariables, state) + isSensitive, err := checkIfVariableIsSensitive(tenantVariables, *state) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return @@ -148,38 +408,161 @@ func (t *tenantProjectVariableResource) Read(ctx context.Context, req resource.R } } } - resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } -func (t *tenantProjectVariableResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - internal.Mutex.Lock() - defer internal.Mutex.Unlock() +func (t *tenantProjectVariableResource) readV2(ctx context.Context, state *tenantProjectVariableResourceModel, spaceID string, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Reading tenant project variable with V2 API") + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: state.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + var found bool + for _, v := range getResp.Variables { + if v.GetID() == state.ID.ValueString() { + mapApiEnvironmentIdsToState(state, v.Scope.EnvironmentIds) + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + state.Value = types.StringValue(v.Value.Value) + } + + found = true + break + } + } + + if !found { + resp.State.RemoveResource(ctx) + return + } +} + +func (t *tenantProjectVariableResource) migrateV1ToV2OnRead(ctx context.Context, state *tenantProjectVariableResourceModel, spaceID string, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Migrating tenant project variable from V1 to V2") + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: state.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + var found bool + for _, v := range getResp.Variables { + if v.ProjectID == state.ProjectID.ValueString() && v.TemplateID == state.TemplateID.ValueString() { + if len(v.Scope.EnvironmentIds) > 0 { + for _, envID := range v.Scope.EnvironmentIds { + if envID == state.EnvironmentID.ValueString() { + state.ID = types.StringValue(v.GetID()) + mapApiEnvironmentIdsToState(state, v.Scope.EnvironmentIds) + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + state.Value = types.StringValue(v.Value.Value) + } + + found = true + break + } + } + } else { + state.ID = types.StringValue(v.GetID()) + mapApiEnvironmentIdsToState(state, v.Scope.EnvironmentIds) + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + state.Value = types.StringValue(v.Value.Value) + } + + found = true + break + } + } + if found { + break + } + } + + if !found { + resp.State.RemoveResource(ctx) + return + } +} + +func (t *tenantProjectVariableResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan tenantProjectVariableResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } + internal.KeyedMutex.Lock(plan.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(plan.TenantID.ValueString()) + + hasEnvironmentID := !plan.EnvironmentID.IsNull() && plan.EnvironmentID.ValueString() != "" + + if !t.validateScopeSupport(plan.Scope, &resp.Diagnostics) { + return + } + tenant, err := tenants.GetByID(t.Client, plan.SpaceID.ValueString(), plan.TenantID.ValueString()) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) return } + spaceID := plan.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + isV1ID := isCompositeID(plan.ID.ValueString()) + + if isV1ID && t.supportsV2() { + t.migrateV1ToV2OnUpdate(ctx, &plan, spaceID, hasEnvironmentID, resp) + } else if !isV1ID { + t.updateV2(ctx, &plan, spaceID, hasEnvironmentID, resp) + } else { + t.updateV1(ctx, &plan, tenant, resp) + } + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (t *tenantProjectVariableResource) updateV1(ctx context.Context, plan *tenantProjectVariableResourceModel, tenant *tenants.Tenant, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Updating tenant project variable with V1 API") + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - isSensitive, err := checkIfVariableIsSensitive(tenantVariables, plan) + isSensitive, err := checkIfVariableIsSensitive(tenantVariables, *plan) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return } - if err := updateTenantProjectVariable(tenantVariables, plan, isSensitive); err != nil { + if err := updateTenantProjectVariable(tenantVariables, *plan, isSensitive); err != nil { resp.Diagnostics.AddError("Error updating tenant project variable", err.Error()) return } @@ -189,33 +572,201 @@ func (t *tenantProjectVariableResource) Update(ctx context.Context, req resource resp.Diagnostics.AddError("Error updating tenant variables", err.Error()) return } +} - resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +func (t *tenantProjectVariableResource) updateV2(ctx context.Context, plan *tenantProjectVariableResourceModel, spaceID string, hasEnvironmentID bool, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Updating tenant project variable with V2 API") + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: plan.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + isSensitive, found := findProjectVariableByID(getResp.Variables, plan.ID.ValueString()) + + if !found { + resp.Diagnostics.AddError("Variable not found", fmt.Sprintf("Variable with ID %s not found", plan.ID.ValueString())) + return + } + + scope := variables.TenantVariableScope{} + if len(plan.Scope) > 0 { + envIDs, diags := util.SetToStringArray(ctx, plan.Scope[0].EnvironmentIDs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scope.EnvironmentIds = envIDs + } else if hasEnvironmentID { + scope.EnvironmentIds = []string{plan.EnvironmentID.ValueString()} + } + + payloads := []variables.TenantProjectVariablePayload{} + + for _, v := range getResp.Variables { + if v.GetID() == plan.ID.ValueString() { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: plan.ID.ValueString(), + ProjectID: plan.ProjectID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + }) + } else { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: v.GetID(), + ProjectID: v.ProjectID, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + } + + cmd := &variables.ModifyTenantProjectVariablesCommand{ + Variables: payloads, + } + + _, err = tenants.UpdateProjectVariables(t.Client, spaceID, plan.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error updating tenant project variables", err.Error()) + return + } } -func (t *tenantProjectVariableResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - internal.Mutex.Lock() - defer internal.Mutex.Unlock() +func (t *tenantProjectVariableResource) migrateV1ToV2OnUpdate(ctx context.Context, plan *tenantProjectVariableResourceModel, spaceID string, hasEnvironmentID bool, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Migrating tenant project variable from V1 to V2 during update") + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: plan.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: true, + } + + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + isSensitive, existingID, foundExisting := findProjectVariableTemplateByProjectAndTemplate( + getResp.Variables, + getResp.MissingVariables, + plan.ProjectID.ValueString(), + plan.TemplateID.ValueString(), + ) + + scope := variables.TenantVariableScope{} + if len(plan.Scope) > 0 { + envIDs, diags := util.SetToStringArray(ctx, plan.Scope[0].EnvironmentIDs) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + scope.EnvironmentIds = envIDs + } else if hasEnvironmentID { + scope.EnvironmentIds = []string{plan.EnvironmentID.ValueString()} + } + + payloads := []variables.TenantProjectVariablePayload{} + + for _, v := range getResp.Variables { + if v.ProjectID == plan.ProjectID.ValueString() && v.TemplateID == plan.TemplateID.ValueString() { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: existingID, + ProjectID: plan.ProjectID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + }) + } else { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: v.GetID(), + ProjectID: v.ProjectID, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + } + + if !foundExisting { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ProjectID: plan.ProjectID.ValueString(), + TemplateID: plan.TemplateID.ValueString(), + Value: core.NewPropertyValue(plan.Value.ValueString(), isSensitive), + Scope: scope, + }) + } + + cmd := &variables.ModifyTenantProjectVariablesCommand{ + Variables: payloads, + } + + updateResp, err := tenants.UpdateProjectVariables(t.Client, spaceID, plan.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error updating tenant project variables", err.Error()) + return + } + + for _, v := range updateResp.Variables { + if v.ProjectID == plan.ProjectID.ValueString() && v.TemplateID == plan.TemplateID.ValueString() { + plan.ID = types.StringValue(v.GetID()) + break + } + } + + tflog.Debug(ctx, "Tenant project variable migrated to V2", map[string]interface{}{ + "new_id": plan.ID.ValueString(), + }) +} +func (t *tenantProjectVariableResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state tenantProjectVariableResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } + internal.KeyedMutex.Lock(state.TenantID.ValueString()) + defer internal.KeyedMutex.Unlock(state.TenantID.ValueString()) + tenant, err := tenants.GetByID(t.Client, state.SpaceID.ValueString(), state.TenantID.ValueString()) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) return } + spaceID := state.SpaceID.ValueString() + if spaceID == "" { + spaceID = tenant.SpaceID + } + + isV1ID := isCompositeID(state.ID.ValueString()) + if !isV1ID { + t.deleteV2(ctx, &state, spaceID, resp) + } else { + t.deleteV1(ctx, &state, tenant, resp) + } +} + +func (t *tenantProjectVariableResource) deleteV1(ctx context.Context, state *tenantProjectVariableResourceModel, tenant *tenants.Tenant, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Deleting tenant project variable with V1 API") + tenantVariables, err := t.Client.Tenants.GetVariables(tenant) if err != nil { resp.Diagnostics.AddError("Error retrieving tenant variables", err.Error()) return } - isSensitive, err := checkIfVariableIsSensitive(tenantVariables, state) + isSensitive, err := checkIfVariableIsSensitive(tenantVariables, *state) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) return @@ -238,22 +789,138 @@ func (t *tenantProjectVariableResource) Delete(ctx context.Context, req resource } } +func (t *tenantProjectVariableResource) deleteV2(ctx context.Context, state *tenantProjectVariableResourceModel, spaceID string, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Deleting tenant project variable with V2 API") + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: state.TenantID.ValueString(), + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + isSensitive, found := findProjectVariableByID(getResp.Variables, state.ID.ValueString()) + + if !found { + return + } + + payloads := []variables.TenantProjectVariablePayload{} + + for _, v := range getResp.Variables { + if v.GetID() == state.ID.ValueString() { + if isSensitive { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: state.ID.ValueString(), + ProjectID: state.ProjectID.ValueString(), + TemplateID: state.TemplateID.ValueString(), + Value: core.PropertyValue{IsSensitive: true, SensitiveValue: &core.SensitiveValue{HasValue: false}}, + Scope: variables.TenantVariableScope{}, + }) + } + } else { + payloads = append(payloads, variables.TenantProjectVariablePayload{ + ID: v.GetID(), + ProjectID: v.ProjectID, + TemplateID: v.TemplateID, + Value: v.Value, + Scope: v.Scope, + }) + } + } + + cmd := &variables.ModifyTenantProjectVariablesCommand{ + Variables: payloads, + } + + _, err = tenants.UpdateProjectVariables(t.Client, spaceID, state.TenantID.ValueString(), cmd) + if err != nil { + resp.Diagnostics.AddError("Error deleting tenant project variable", err.Error()) + return + } +} + func (t *tenantProjectVariableResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { idParts := strings.Split(req.ID, ":") - if len(idParts) != 4 { - resp.Diagnostics.AddError( - "Incorrect Import Format", - "ID must be in the format: TenantID:ProjectID:EnvironmentID:TemplateID (e.g. Tenants-123:Projects-456:Environments-789:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c)", - ) + // V1 format: TenantID:ProjectID:EnvironmentID:TemplateID + if len(idParts) == 4 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("environment_id"), idParts[2])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), idParts[3])...) + return + } + + // V2 format: TenantID:VariableID + if len(idParts) == 2 { + tenantID := idParts[0] + variableID := idParts[1] + + tenant, err := tenants.GetByID(t.Client, "", tenantID) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant", err.Error()) + return + } + + query := variables.GetTenantProjectVariablesQuery{ + TenantID: tenantID, + SpaceID: tenant.SpaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(t.Client, query) + if err != nil { + resp.Diagnostics.AddError("Error retrieving tenant project variables", err.Error()) + return + } + + var found bool + for _, v := range getResp.Variables { + if v.GetID() == variableID { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), variableID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), tenantID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), v.ProjectID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), v.TemplateID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("space_id"), tenant.SpaceID)...) + + if len(v.Scope.EnvironmentIds) > 0 { + envSet := util.BuildStringSetOrEmpty(v.Scope.EnvironmentIds) + scopeModel := []tenantProjectVariableScopeModel{{EnvironmentIDs: envSet}} + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("scope"), scopeModel)...) + } + + isSensitive := isTemplateControlTypeSensitive(v.Template.DisplaySettings) + if !isSensitive { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("value"), v.Value.Value)...) + } + + found = true + break + } + } + + if !found { + resp.Diagnostics.AddError( + "Variable not found", + fmt.Sprintf("Variable with ID %s not found for tenant %s", variableID, tenantID), + ) + } return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tenant_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[1])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("environment_id"), idParts[2])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), idParts[3])...) + resp.Diagnostics.AddError( + "Incorrect Import Format", + "ID must be in one of these formats:\n"+ + " V1: TenantID:ProjectID:EnvironmentID:TemplateID (e.g. Tenants-123:Projects-456:Environments-789:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c)\n"+ + " V2: TenantID:VariableID (e.g. Tenants-123:TenantVariables-456)", + ) } func checkIfTemplateExists(tenantVariables *variables.TenantVariables, plan tenantProjectVariableResourceModel) bool { @@ -271,7 +938,7 @@ func checkIfVariableIsSensitive(tenantVariables *variables.TenantVariables, plan if projectVariable, ok := tenantVariables.ProjectVariables[plan.ProjectID.ValueString()]; ok { for _, template := range projectVariable.Templates { if template.GetID() == plan.TemplateID.ValueString() { - return template.DisplaySettings["Octopus.ControlType"] == "Sensitive", nil + return isTemplateControlTypeSensitive(template.DisplaySettings), nil } } } diff --git a/octopusdeploy_framework/resource_tenant_project_variable_test.go b/octopusdeploy_framework/resource_tenant_project_variable_test.go index 3c915e5c..3feb97c8 100644 --- a/octopusdeploy_framework/resource_tenant_project_variable_test.go +++ b/octopusdeploy_framework/resource_tenant_project_variable_test.go @@ -5,11 +5,14 @@ import ( "strings" "testing" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" ) +// TestAccTenantProjectVariableBasic tests V1 API func TestAccTenantProjectVariableBasic(t *testing.T) { lifecycleLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) lifecycleName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) @@ -36,11 +39,14 @@ func TestAccTenantProjectVariableBasic(t *testing.T) { newValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) resource.Test(t, resource.TestCase{ - CheckDestroy: testAccTenantProjectVariableCheckDestroy, - PreCheck: func() { TestAccPreCheck(t) }, - ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + CheckDestroy: testAccTenantProjectVariableCheckDestroy, + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": false, + }), Steps: []resource.TestStep{ { + Check: resource.ComposeTestCheckFunc( testTenantProjectVariableExists(primaryResourceName), testTenantProjectVariableExists(secondaryResourceName), @@ -130,20 +136,53 @@ func testTenantProjectVariable(localName string, environmentLocalName string, pr func testTenantProjectVariableExists(prefix string) resource.TestCheckFunc { return func(s *terraform.State) error { - var environmentID string - var projectID string - var templateID string - var tenantID string - + var resourceState *terraform.ResourceState for _, r := range s.RootModule().Resources { if r.Type == "octopusdeploy_tenant_project_variable" { - environmentID = r.Primary.Attributes["environment_id"] - projectID = r.Primary.Attributes["project_id"] - templateID = r.Primary.Attributes["template_id"] - tenantID = r.Primary.Attributes["tenant_id"] + resourceState = r + break + } + } + + if resourceState == nil { + return fmt.Errorf("tenant project variable resource not found") + } + + if len(resourceState.Primary.ID) == 0 { + return fmt.Errorf("tenant project variable ID is not set") + } + + if !strings.Contains(resourceState.Primary.ID, ":") { + // V2 API - use real ID + tenantID := resourceState.Primary.Attributes["tenant_id"] + spaceID := resourceState.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantProjectVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(client, query) + if err != nil { + return fmt.Errorf("Error retrieving tenant project variables: %s", err.Error()) } + + for _, v := range getResp.Variables { + if v.GetID() == resourceState.Primary.ID { + return nil + } + } + + return fmt.Errorf("Tenant project variable with ID %s not found via V2 API", resourceState.Primary.ID) } + environmentID := resourceState.Primary.Attributes["environment_id"] + projectID := resourceState.Primary.Attributes["project_id"] + templateID := resourceState.Primary.Attributes["template_id"] + tenantID := resourceState.Primary.Attributes["tenant_id"] + tenant, err := octoClient.Tenants.GetByID(tenantID) if err != nil { return err @@ -172,6 +211,31 @@ func testAccTenantProjectVariableCheckDestroy(s *terraform.State) error { continue } + if !strings.Contains(rs.Primary.ID, ":") { + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantProjectVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(client, query) + if err != nil { + return nil + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return fmt.Errorf("Tenant project variable (%s) still exists", rs.Primary.ID) + } + } + + continue + } + importStrings := strings.Split(rs.Primary.ID, ":") if len(importStrings) != 4 { return fmt.Errorf("octopusdeploy_tenant_project_variable import must be in the form of TenantID:ProjectID:EnvironmentID:TemplateID (e.g. Tenants-123:Projects-456:Environments-789:6c9f2ba3-3ccd-407f-bbdf-6618e4fd0a0c") @@ -203,3 +267,349 @@ func testAccTenantProjectVariableCheckDestroy(s *terraform.State) error { return nil } + +// TestAccTenantProjectVariableMigration tests migration from V1 to V2 API +func TestAccTenantProjectVariableMigration(t *testing.T) { + lifecycleLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + lifecycleName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + variableLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resourceName := "octopusdeploy_tenant_project_variable." + variableLocalName + + value := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + newValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + finalValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + CheckDestroy: testAccTenantProjectVariableCheckDestroy, + PreCheck: func() { TestAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": false, + }), + Check: resource.ComposeTestCheckFunc( + testTenantProjectVariableExists(""), + resource.TestCheckResourceAttr(resourceName, "value", value), + resource.TestCheckResourceAttrSet(resourceName, "environment_id"), + resource.TestCheckNoResourceAttr(resourceName, "scope.#"), + func(s *terraform.State) error { + rs := s.RootModule().Resources[resourceName] + if !strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V1 composite ID with colons, got: %s", rs.Primary.ID) + } + return nil + }, + ), + Config: testAccTenantProjectVariableMigrationV1(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, variableLocalName, value), + }, + { + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": true, + }), + Check: resource.ComposeTestCheckFunc( + testTenantProjectVariableExists(""), + resource.TestCheckResourceAttr(resourceName, "value", newValue), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + resource.TestCheckNoResourceAttr(resourceName, "environment_id"), + func(s *terraform.State) error { + rs := s.RootModule().Resources[resourceName] + if strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V2 real ID without colons, got: %s", rs.Primary.ID) + } + return nil + }, + ), + Config: testAccTenantProjectVariableMigrationV2(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, variableLocalName, newValue), + }, + { + ProtoV6ProviderFactories: ProtoV6ProviderFactoriesWithFeatureToggleOverrides(map[string]bool{ + "CommonVariableScopingFeatureToggle": true, + }), + Check: resource.ComposeTestCheckFunc( + testTenantProjectVariableExists(""), + resource.TestCheckResourceAttr(resourceName, "value", finalValue), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + resource.TestCheckNoResourceAttr(resourceName, "environment_id"), + func(s *terraform.State) error { + rs := s.RootModule().Resources[resourceName] + if strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V2 real ID without colons, got: %s", rs.Primary.ID) + } + return nil + }, + ), + Config: testAccTenantProjectVariableMigrationV2(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, variableLocalName, finalValue), + }, + }, + }) +} + +func testAccTenantProjectVariableMigrationV1(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, localName, value string) string { + allowDynamicInfrastructure := false + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + sortOrder := acctest.RandIntRange(1, 10) + useGuidedFailure := false + + return fmt.Sprintf(testAccLifecycle(lifecycleLocalName, lifecycleName)+"\n"+ + testAccProjectGroup(projectGroupLocalName, projectGroupName)+"\n"+ + testAccEnvironment(env1LocalName, env1Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+ + testAccEnvironment(env2LocalName, env2Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+` + resource "octopusdeploy_project" "%[1]s" { + lifecycle_id = octopusdeploy_lifecycle.%[2]s.id + name = "%[3]s" + project_group_id = octopusdeploy_project_group.%[4]s.id + + template { + default_value = "Default Value" + help_text = "This is help text" + label = "Test Label" + name = "Test Template Migration" + + display_settings = { + "Octopus.ControlType" = "Sensitive" + } + } + } + + resource "octopusdeploy_tenant" "%[5]s" { + name = "%[6]s" + } + + resource "octopusdeploy_tenant_project" "project_environment_migration" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + resource "octopusdeploy_tenant_project_variable" "%[9]s" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + environment_id = octopusdeploy_environment.%[7]s.id + template_id = octopusdeploy_project.%[1]s.template[0].id + value = "%[10]s" + + depends_on = [octopusdeploy_tenant_project.project_environment_migration] + }`, projectLocalName, lifecycleLocalName, projectName, projectGroupLocalName, tenantLocalName, tenantName, env1LocalName, env2LocalName, localName, value) +} + +func testAccTenantProjectVariableMigrationV2(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, localName, value string) string { + allowDynamicInfrastructure := false + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + sortOrder := acctest.RandIntRange(1, 10) + useGuidedFailure := false + + return fmt.Sprintf(testAccLifecycle(lifecycleLocalName, lifecycleName)+"\n"+ + testAccProjectGroup(projectGroupLocalName, projectGroupName)+"\n"+ + testAccEnvironment(env1LocalName, env1Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+ + testAccEnvironment(env2LocalName, env2Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure)+"\n"+` + resource "octopusdeploy_project" "%[1]s" { + lifecycle_id = octopusdeploy_lifecycle.%[2]s.id + name = "%[3]s" + project_group_id = octopusdeploy_project_group.%[4]s.id + + template { + default_value = "Default Value" + help_text = "This is help text" + label = "Test Label" + name = "Test Template Migration" + + display_settings = { + "Octopus.ControlType" = "Sensitive" + } + } + } + + resource "octopusdeploy_tenant" "%[5]s" { + name = "%[6]s" + } + + resource "octopusdeploy_tenant_project" "project_environment_migration" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + resource "octopusdeploy_tenant_project_variable" "%[9]s" { + tenant_id = octopusdeploy_tenant.%[5]s.id + project_id = octopusdeploy_project.%[1]s.id + template_id = octopusdeploy_project.%[1]s.template[0].id + value = "%[10]s" + + scope { + environment_ids = [octopusdeploy_environment.%[7]s.id, octopusdeploy_environment.%[8]s.id] + } + + depends_on = [octopusdeploy_tenant_project.project_environment_migration] + }`, projectLocalName, lifecycleLocalName, projectName, projectGroupLocalName, tenantLocalName, tenantName, env1LocalName, env2LocalName, localName, value) +} + +// TestAccTenantProjectVariableWithScope tests V2 API with scoping +func TestAccTenantProjectVariableWithScope(t *testing.T) { + lifecycleLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + lifecycleName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env1Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2LocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + env2Name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + variableLocalName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resourceName := "octopusdeploy_tenant_project_variable." + variableLocalName + + value := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + newValue := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + CheckDestroy: testAccTenantProjectVariableCheckDestroyV2, + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Check: resource.ComposeTestCheckFunc( + testTenantProjectVariableExistsV2(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", value), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + resource.TestCheckNoResourceAttr(resourceName, "environment_id"), + ), + Config: testAccTenantProjectVariableWithScope(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, variableLocalName, value), + }, + { + Check: resource.ComposeTestCheckFunc( + testTenantProjectVariableExistsV2(resourceName), + resource.TestCheckResourceAttr(resourceName, "value", newValue), + resource.TestCheckResourceAttr(resourceName, "scope.#", "1"), + resource.TestCheckResourceAttr(resourceName, "scope.0.environment_ids.#", "2"), + resource.TestCheckNoResourceAttr(resourceName, "environment_id"), + ), + Config: testAccTenantProjectVariableWithScope(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, variableLocalName, newValue), + }, + }, + }) +} + +func testAccTenantProjectVariableWithScope(lifecycleLocalName, lifecycleName, projectGroupLocalName, projectGroupName, projectLocalName, projectName, env1LocalName, env1Name, env2LocalName, env2Name, tenantLocalName, tenantName, variableLocalName, value string) string { + allowDynamicInfrastructure := false + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + sortOrder := acctest.RandIntRange(1, 10) + useGuidedFailure := false + + return testAccLifecycle(lifecycleLocalName, lifecycleName) + "\n" + + testAccProjectGroup(projectGroupLocalName, projectGroupName) + "\n" + + testAccProjectWithTemplate(projectLocalName, projectName, lifecycleLocalName, projectGroupLocalName) + "\n" + + testAccEnvironment(env1LocalName, env1Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure) + "\n" + + testAccEnvironment(env2LocalName, env2Name, description, allowDynamicInfrastructure, sortOrder, useGuidedFailure) + "\n" + + testAccTenantWithProjectEnvironment(tenantLocalName, tenantName, projectLocalName, env1LocalName, env2LocalName) + "\n" + + testTenantProjectVariableWithScope(variableLocalName, projectLocalName, tenantLocalName, env1LocalName, env2LocalName, value) +} + +func testTenantProjectVariableWithScope(localName, projectLocalName, tenantLocalName, env1LocalName, env2LocalName, value string) string { + return fmt.Sprintf(`resource "octopusdeploy_tenant_project_variable" "%s" { + project_id = octopusdeploy_project.%s.id + tenant_id = octopusdeploy_tenant.%s.id + template_id = octopusdeploy_project.%s.template[0].id + value = "%s" + + scope { + environment_ids = [octopusdeploy_environment.%s.id, octopusdeploy_environment.%s.id] + } + + depends_on = [ + octopusdeploy_project.%s, + octopusdeploy_tenant_project.project_environment + ] + }`, localName, projectLocalName, tenantLocalName, projectLocalName, value, env1LocalName, env2LocalName, projectLocalName) +} + +func testTenantProjectVariableExistsV2(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + if len(rs.Primary.ID) == 0 { + return fmt.Errorf("Tenant project variable ID is not set") + } + + if strings.Contains(rs.Primary.ID, ":") { + return fmt.Errorf("Expected V2 ID (e.g., TenantVariables-123) but got V1 composite ID: %s", rs.Primary.ID) + } + + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantProjectVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(client, query) + if err != nil { + return fmt.Errorf("Error retrieving tenant project variables: %s", err.Error()) + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return nil + } + } + + return fmt.Errorf("Tenant project variable with ID %s not found via V2 API", rs.Primary.ID) + } +} + +func testAccTenantProjectVariableCheckDestroyV2(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "octopusdeploy_tenant_project_variable" { + continue + } + + if strings.Contains(rs.Primary.ID, ":") { + continue + } + + tenantID := rs.Primary.Attributes["tenant_id"] + spaceID := rs.Primary.Attributes["space_id"] + + client := octoClient + query := variables.GetTenantProjectVariablesQuery{ + TenantID: tenantID, + SpaceID: spaceID, + IncludeMissingVariables: false, + } + + getResp, err := tenants.GetProjectVariables(client, query) + if err != nil { + return nil + } + + for _, v := range getResp.Variables { + if v.GetID() == rs.Primary.ID { + return fmt.Errorf("Tenant project variable (%s) still exists", rs.Primary.ID) + } + } + } + + return nil +} diff --git a/octopusdeploy_framework/schemas/certificate.go b/octopusdeploy_framework/schemas/certificate.go index b3b1513b..0aee647d 100644 --- a/octopusdeploy_framework/schemas/certificate.go +++ b/octopusdeploy_framework/schemas/certificate.go @@ -57,7 +57,7 @@ func (c CertificateSchema) GetResourceSchema() resourceSchema.Schema { Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, }, "certificate_data_format": getCertificateDataFormatResourceSchema(), - "environments": getEnvironmentsResourceSchema(), + "environments": getEnvironmentsResourceSchema("A set of environment IDs associated with this resource."), "has_private_key": resourceSchema.BoolAttribute{ Description: "Indicates if the certificate has a private key.", Computed: true, diff --git a/octopusdeploy_framework/schemas/schema.go b/octopusdeploy_framework/schemas/schema.go index c5b8092e..1e279d73 100644 --- a/octopusdeploy_framework/schemas/schema.go +++ b/octopusdeploy_framework/schemas/schema.go @@ -471,9 +471,9 @@ func getCertificateDataFormatResourceSchema() resourceSchema.Attribute { } } -func getEnvironmentsResourceSchema() resourceSchema.Attribute { +func getEnvironmentsResourceSchema(description string) resourceSchema.Attribute { return resourceSchema.SetAttribute{ - Description: "A set of environment IDs associated with this resource.", + Description: description, Computed: true, Optional: true, ElementType: types.StringType, diff --git a/octopusdeploy_framework/schemas/tenant_common_variable.go b/octopusdeploy_framework/schemas/tenant_common_variable.go index 2c2eb542..7422e186 100644 --- a/octopusdeploy_framework/schemas/tenant_common_variable.go +++ b/octopusdeploy_framework/schemas/tenant_common_variable.go @@ -24,5 +24,15 @@ func GetTenantCommonVariableResourceSchema() schema.Schema { Sensitive: true, }, }, + Blocks: map[string]schema.Block{ + "scope": schema.ListNestedBlock{ + Description: "Sets the scope of the variable.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "environment_ids": getEnvironmentsResourceSchema("A set of environment IDs to scope this variable to."), + }, + }, + }, + }, } } diff --git a/octopusdeploy_framework/schemas/tenant_project_variable.go b/octopusdeploy_framework/schemas/tenant_project_variable.go index 1a392d89..cc180699 100644 --- a/octopusdeploy_framework/schemas/tenant_project_variable.go +++ b/octopusdeploy_framework/schemas/tenant_project_variable.go @@ -72,8 +72,8 @@ func (t TenantProjectVariableSchema) GetResourceSchema() schema.Schema { Description("The ID of the project."). Build(), "environment_id": util.ResourceString(). - Required(). - Description("The ID of the environment."). + Optional(). + Description("The ID of the environment. Use scope block for V2 API with multiple environments."). Build(), "template_id": util.ResourceString(). Required(). @@ -85,5 +85,15 @@ func (t TenantProjectVariableSchema) GetResourceSchema() schema.Schema { Description("The value of the variable."). Build(), }, + Blocks: map[string]schema.Block{ + "scope": schema.ListNestedBlock{ + Description: "Sets the scope of the variable.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "environment_ids": getEnvironmentsResourceSchema("A set of environment IDs to scope this variable to."), + }, + }, + }, + }, } } diff --git a/octopusdeploy_framework/tenant_project_variable_validator.go b/octopusdeploy_framework/tenant_project_variable_validator.go new file mode 100644 index 00000000..b9d568a4 --- /dev/null +++ b/octopusdeploy_framework/tenant_project_variable_validator.go @@ -0,0 +1,60 @@ +package octopusdeploy_framework + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type tenantProjectVariableValidator struct{} + +func (v tenantProjectVariableValidator) Description(ctx context.Context) string { + return "Ensures either environment_id or scope block is provided, but not both" +} + +func (v tenantProjectVariableValidator) MarkdownDescription(ctx context.Context) string { + return "Ensures either `environment_id` or `scope` block is provided, but not both" +} + +func (v tenantProjectVariableValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var environmentID types.String + var scope types.List + + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("environment_id"), &environmentID)...) + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("scope"), &scope)...) + + if resp.Diagnostics.HasError() { + return + } + + if environmentID.IsUnknown() || scope.IsUnknown() { + return + } + + hasEnvironmentID := !environmentID.IsNull() && environmentID.ValueString() != "" + hasScope := !scope.IsNull() && len(scope.Elements()) > 0 + + if hasEnvironmentID && hasScope { + resp.Diagnostics.AddAttributeError( + path.Root("environment_id"), + "Invalid Configuration", + "Cannot specify both 'environment_id' and 'scope' block. Use 'environment_id' for V1 API or 'scope' block for V2 API with multiple environments.", + ) + return + } + + if !hasEnvironmentID && !hasScope { + resp.Diagnostics.AddError( + "Invalid Configuration", + "Must specify either 'environment_id' or 'scope' block.", + ) + return + } +} + +// TenantProjectVariableValidator returns a validator that ensures either environment_id or scope is provided, but not both. +func TenantProjectVariableValidator() resource.ConfigValidator { + return tenantProjectVariableValidator{} +}