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{}
+}