diff --git a/docs/data-sources/deployment_freezes.md b/docs/data-sources/deployment_freezes.md index a4a365ba..148cb1b2 100644 --- a/docs/data-sources/deployment_freezes.md +++ b/docs/data-sources/deployment_freezes.md @@ -10,7 +10,29 @@ description: |- Provides information about deployment freezes +## Example Usage +```terraform +data "octopusdeploy_deployment_freezes" "test_freeze" { + ids = null + partial_name = "Freeze Name" + skip = 5 + take = 100 +} + + +data "octopusdeploy_deployment_freezes" "project_freezes" { + project_ids = ["projects-1"] + skip = 0 + take = 5 +} + +data "octopusdeploy_deployment_freezes" "tenant_freezes" { + tenant_ids = ["tenants-1"] + skip = 0 + take = 10 +} +``` ## Schema @@ -25,6 +47,7 @@ Provides information about deployment freezes - `skip` (Number) A filter to specify the number of items to skip in the response. - `status` (String) Filter by the status of the deployment freeze, value values are Expired, Active, Scheduled (case-insensitive) - `take` (Number) A filter to specify the number of items to take (or return) in the response. +- `tenant_ids` (List of String) A filter to search by a list of tenant IDs ### Read-Only @@ -40,6 +63,34 @@ Read-Only: - `id` (String) The unique ID for this resource. - `name` (String) The name of this resource. - `project_environment_scope` (Map of List of String) The project environment scope of the deployment freeze +- `recurring_schedule` (Attributes) (see [below for nested schema](#nestedatt--deployment_freezes--recurring_schedule)) - `start` (String) The start time of the freeze +- `tenant_project_environment_scope` (Attributes List) The tenant project environment scope of the deployment freeze (see [below for nested schema](#nestedatt--deployment_freezes--tenant_project_environment_scope)) + + +### Nested Schema for `deployment_freezes.recurring_schedule` + +Read-Only: + +- `date_of_month` (String) The date of the month for monthly schedules +- `day_number_of_month` (String) The day number of the month for monthly schedules +- `day_of_week` (String) The day of the week for monthly schedules +- `days_of_week` (List of String) List of days of the week for weekly schedules +- `end_after_occurrences` (Number) Number of occurrences after which the schedule should end +- `end_on_date` (String) The date when the recurring schedule should end +- `end_type` (String) When the recurring schedule should end (Never, OnDate, AfterOccurrences) +- `monthly_schedule_type` (String) Type of monthly schedule (DayOfMonth, DateOfMonth) +- `type` (String) Type of recurring schedule (OnceDaily, DaysPerWeek, DaysPerMonth, Annually) +- `unit` (Number) The unit value for the schedule + + + +### Nested Schema for `deployment_freezes.tenant_project_environment_scope` + +Read-Only: + +- `environment_id` (String) The environment ID +- `project_id` (String) The project ID +- `tenant_id` (String) The tenant ID diff --git a/docs/resources/deployment_freeze.md b/docs/resources/deployment_freeze.md index 70813e81..397a2474 100644 --- a/docs/resources/deployment_freeze.md +++ b/docs/resources/deployment_freeze.md @@ -27,6 +27,18 @@ resource "octopusdeploy_deployment_freeze" "freeze" { end = "2024-12-27T00:00:00+08:00" } +# Freeze recurring freeze yearly on Xmas +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "Xmas" + start = "2024-12-25T00:00:00+10:00" + end = "2024-12-27T00:00:00+08:00" + recurring_schedule = { + type = "Annually" + unit = 1 + end_type = "Never" + } +} + resource "octopusdeploy_deployment_freeze_project" "project_freeze" { deploymentfreeze_id= octopusdeploy_deployment_freeze.freeze.id project_id = "Projects-123" @@ -69,8 +81,31 @@ resource "octopusdeploy_deployment_freeze_tenant" "tenant_freeze" { - `name` (String) The name of this resource. - `start` (String) The start time of the freeze, must be RFC3339 format +### Optional + +- `recurring_schedule` (Attributes) (see [below for nested schema](#nestedatt--recurring_schedule)) + ### Read-Only - `id` (String) The unique ID for this resource. + +### Nested Schema for `recurring_schedule` + +Required: + +- `end_type` (String) When the recurring schedule should end (Never, OnDate, AfterOccurrences) +- `type` (String) Type of recurring schedule (Daily, Weekly, Monthly, Annually) +- `unit` (Number) The unit value for the schedule + +Optional: + +- `date_of_month` (String) The date of the month for monthly schedules +- `day_number_of_month` (String) Specifies which weekday position in the month. Valid values: 1 (First), 2 (Second), 3 (Third), 4 (Fourth), L (Last). Used with day_of_week +- `day_of_week` (String) The day of the week for monthly schedules when using DayOfMonth type +- `days_of_week` (List of String) List of days of the week for weekly schedules. Must follow order: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday +- `end_after_occurrences` (Number) Number of occurrences after which the schedule should end +- `end_on_date` (String) The date when the recurring schedule should end +- `monthly_schedule_type` (String) Type of monthly schedule (DayOfMonth, DateOfMonth) + diff --git a/examples/data-sources/octopusdeploy_deployment_freezes/data-source.tf b/examples/data-sources/octopusdeploy_deployment_freezes/data-source.tf new file mode 100644 index 00000000..9cb90699 --- /dev/null +++ b/examples/data-sources/octopusdeploy_deployment_freezes/data-source.tf @@ -0,0 +1,19 @@ +data "octopusdeploy_deployment_freezes" "test_freeze" { + ids = null + partial_name = "Freeze Name" + skip = 5 + take = 100 +} + + +data "octopusdeploy_deployment_freezes" "project_freezes" { + project_ids = ["projects-1"] + skip = 0 + take = 5 +} + +data "octopusdeploy_deployment_freezes" "tenant_freezes" { + tenant_ids = ["tenants-1"] + skip = 0 + take = 10 +} diff --git a/examples/resources/octopusdeploy_deployment_freeze/resource.tf b/examples/resources/octopusdeploy_deployment_freeze/resource.tf index 291928c7..746c9738 100644 --- a/examples/resources/octopusdeploy_deployment_freeze/resource.tf +++ b/examples/resources/octopusdeploy_deployment_freeze/resource.tf @@ -12,6 +12,18 @@ resource "octopusdeploy_deployment_freeze" "freeze" { end = "2024-12-27T00:00:00+08:00" } +# Freeze recurring freeze yearly on Xmas +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "Xmas" + start = "2024-12-25T00:00:00+10:00" + end = "2024-12-27T00:00:00+08:00" + recurring_schedule = { + type = "Annually" + unit = 1 + end_type = "Never" + } +} + resource "octopusdeploy_deployment_freeze_project" "project_freeze" { deploymentfreeze_id= octopusdeploy_deployment_freeze.freeze.id project_id = "Projects-123" diff --git a/go.mod b/go.mod index f5a145f5..cbfd298d 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.22 toolchain go1.22.3 require ( - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.1 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.64.0 github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20241206032352-dbc62b2d16cf github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 diff --git a/go.sum b/go.sum index 0a7a2e40..df4a0452 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.1 h1:ShfJ3VZqNblU5V0UnA/UNjQuUrgkljZdDp9Vxh8rhcI= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.1/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.64.0 h1:NWqQ/7JLUfEJQ8QHrkek7AfePuN121+f6+tUi3xP6vE= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.64.0/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20241206032352-dbc62b2d16cf h1:wuUJ6DbSZEHE4a3SfSJIcoeTQCSI6lbQ+i46ibY14+Q= github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20241206032352-dbc62b2d16cf/go.mod h1:xVv8DvYhhwxtQUQQDfOYA6CY8KTkHXccxQ2RfRj6IJ0= github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= diff --git a/octopusdeploy_framework/datasource_deployment_freeze.go b/octopusdeploy_framework/datasource_deployment_freeze.go index e02e71e2..5db7bfff 100644 --- a/octopusdeploy_framework/datasource_deployment_freeze.go +++ b/octopusdeploy_framework/datasource_deployment_freeze.go @@ -14,12 +14,13 @@ import ( const deploymentFreezeDatasourceName = "deployment_freezes" -type deploymentFreezesModel struct { +type deploymentFreezesDatasourceModel struct { ID types.String `tfsdk:"id"` IDs types.List `tfsdk:"ids"` PartialName types.String `tfsdk:"partial_name"` ProjectIDs types.List `tfsdk:"project_ids"` EnvironmentIDs types.List `tfsdk:"environment_ids"` + TenantIDs types.List `tfsdk:"tenant_ids"` IncludeComplete types.Bool `tfsdk:"include_complete"` Status types.String `tfsdk:"status"` Skip types.Int64 `tfsdk:"skip"` @@ -48,7 +49,7 @@ func (d *deploymentFreezeDataSource) Schema(ctx context.Context, req datasource. } func (d *deploymentFreezeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data deploymentFreezesModel + var data deploymentFreezesDatasourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return @@ -59,6 +60,7 @@ func (d *deploymentFreezeDataSource) Read(ctx context.Context, req datasource.Re PartialName: data.PartialName.ValueString(), ProjectIds: util.Ternary(data.ProjectIDs.IsNull(), []string{}, util.ExpandStringList(data.ProjectIDs)), EnvironmentIds: util.Ternary(data.EnvironmentIDs.IsNull(), []string{}, util.ExpandStringList(data.EnvironmentIDs)), + TenantIds: util.Ternary(data.TenantIDs.IsNull(), []string{}, util.ExpandStringList(data.TenantIDs)), IncludeComplete: data.IncludeComplete.ValueBool(), Status: data.Status.ValueString(), Skip: int(data.Skip.ValueInt64()), @@ -92,31 +94,134 @@ var _ datasource.DataSource = &deploymentFreezeDataSource{} var _ datasource.DataSourceWithConfigure = &deploymentFreezeDataSource{} func mapFreezeToAttribute(ctx context.Context, freeze deploymentfreezes.DeploymentFreeze) (attr.Value, diag.Diagnostics) { + var diags diag.Diagnostics + projectScopes := make(map[string]attr.Value) for projectId, environmentScopes := range freeze.ProjectEnvironmentScope { projectScopes[projectId] = util.FlattenStringList(environmentScopes) } - scopeType, diags := types.MapValueFrom(ctx, types.ListType{ElemType: types.StringType}, projectScopes) - if diags.HasError() { + scopeType, scopeDiags := types.MapValueFrom(ctx, types.ListType{ElemType: types.StringType}, projectScopes) + if scopeDiags.HasError() { + diags.Append(scopeDiags...) return nil, diags } - return types.ObjectValueMust(freezeObjectType(), map[string]attr.Value{ - "id": types.StringValue(freeze.ID), - "name": types.StringValue(freeze.Name), - "start": types.StringValue(freeze.Start.Format(time.RFC3339)), - "end": types.StringValue(freeze.End.Format(time.RFC3339)), - "project_environment_scope": scopeType, - }), diags + tenantScopes := make([]attr.Value, 0) + for _, scope := range freeze.TenantProjectEnvironmentScope { + tenantScope, tDiags := types.ObjectValue(tenantScopeObjectType(), map[string]attr.Value{ + "tenant_id": types.StringValue(scope.TenantId), + "project_id": types.StringValue(scope.ProjectId), + "environment_id": types.StringValue(scope.EnvironmentId), + }) + if tDiags.HasError() { + diags.Append(tDiags...) + return nil, diags + } + tenantScopes = append(tenantScopes, tenantScope) + } + + tenantScopesList, tsDiags := types.ListValue( + types.ObjectType{AttrTypes: tenantScopeObjectType()}, + tenantScopes, + ) + if tsDiags.HasError() { + diags.Append(tsDiags...) + return nil, diags + } + + attrs := map[string]attr.Value{ + "id": types.StringValue(freeze.ID), + "name": types.StringValue(freeze.Name), + "start": types.StringValue(freeze.Start.Format(time.RFC3339)), + "end": types.StringValue(freeze.End.Format(time.RFC3339)), + "project_environment_scope": scopeType, + "tenant_project_environment_scope": tenantScopesList, + } + + if freeze.RecurringSchedule != nil { + daysOfWeek, daysDiags := types.ListValueFrom(ctx, types.StringType, freeze.RecurringSchedule.DaysOfWeek) + if daysDiags.HasError() { + diags.Append(daysDiags...) + return nil, diags + } + + endOnDate := types.StringNull() + if freeze.RecurringSchedule.EndOnDate != nil { + endOnDate = types.StringValue(freeze.RecurringSchedule.EndOnDate.Format(time.RFC3339)) + } + + endAfterOccurrences := types.Int64Value(int64(freeze.RecurringSchedule.EndAfterOccurrences)) + + monthlyScheduleType := types.StringNull() + if freeze.RecurringSchedule.MonthlyScheduleType != "" { + monthlyScheduleType = types.StringValue(freeze.RecurringSchedule.MonthlyScheduleType) + } + + dateOfMonth := types.StringValue(freeze.RecurringSchedule.DateOfMonth) + + dayNumberOfMonth := types.StringValue(freeze.RecurringSchedule.DayNumberOfMonth) + + dayOfWeek := types.StringValue(freeze.RecurringSchedule.DayOfWeek) + + scheduleAttrs := map[string]attr.Value{ + "type": types.StringValue(string(freeze.RecurringSchedule.Type)), + "unit": types.Int64Value(int64(freeze.RecurringSchedule.Unit)), + "end_type": types.StringValue(string(freeze.RecurringSchedule.EndType)), + "end_on_date": endOnDate, + "end_after_occurrences": endAfterOccurrences, + "monthly_schedule_type": monthlyScheduleType, + "date_of_month": dateOfMonth, + "day_number_of_month": dayNumberOfMonth, + "days_of_week": daysOfWeek, + "day_of_week": dayOfWeek, + } + + recurringSchedule, rsDiags := types.ObjectValue(freezeRecurringScheduleObjectType(), scheduleAttrs) + if rsDiags.HasError() { + diags.Append(rsDiags...) + return nil, diags + } + + attrs["recurring_schedule"] = recurringSchedule + } else { + attrs["recurring_schedule"] = types.ObjectNull(freezeRecurringScheduleObjectType()) + } + + return types.ObjectValueMust(freezeObjectType(), attrs), diags +} + +func freezeRecurringScheduleObjectType() map[string]attr.Type { + return map[string]attr.Type{ + "type": types.StringType, + "unit": types.Int64Type, + "end_type": types.StringType, + "end_on_date": types.StringType, + "end_after_occurrences": types.Int64Type, + "monthly_schedule_type": types.StringType, + "date_of_month": types.StringType, + "day_number_of_month": types.StringType, + "days_of_week": types.ListType{ElemType: types.StringType}, + "day_of_week": types.StringType, + } +} + +func tenantScopeObjectType() map[string]attr.Type { + return map[string]attr.Type{ + "tenant_id": types.StringType, + "project_id": types.StringType, + "environment_id": types.StringType, + } } func freezeObjectType() map[string]attr.Type { return map[string]attr.Type{ - "id": types.StringType, - "name": types.StringType, - "start": types.StringType, - "end": types.StringType, - "project_environment_scope": types.MapType{ElemType: types.ListType{ElemType: types.StringType}}, + "id": types.StringType, + "name": types.StringType, + "start": types.StringType, + "end": types.StringType, + "project_environment_scope": types.MapType{ElemType: types.ListType{ElemType: types.StringType}}, + "tenant_project_environment_scope": types.ListType{ElemType: types.ObjectType{AttrTypes: tenantScopeObjectType()}}, + "recurring_schedule": types.ObjectType{AttrTypes: freezeRecurringScheduleObjectType()}, } } diff --git a/octopusdeploy_framework/datasource_deployment_freeze_test.go b/octopusdeploy_framework/datasource_deployment_freeze_test.go new file mode 100644 index 00000000..230a3fd5 --- /dev/null +++ b/octopusdeploy_framework/datasource_deployment_freeze_test.go @@ -0,0 +1,181 @@ +package octopusdeploy_framework + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "strings" + "testing" + "time" +) + +func TestAccDataSourceDeploymentFreezes(t *testing.T) { + spaceName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + freezeName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + environmentName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + dataSourceName := "data.octopusdeploy_deployment_freezes.test_freeze" + + startTime := time.Now().AddDate(1, 0, 0).UTC() + endTime := startTime.Add(24 * time.Hour) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testAccDataSourceDeploymentFreezesConfig(spaceName, freezeName, startTime, endTime, false, false, projectName, environmentName, tenantName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(dataSourceName, "id"), + resource.TestCheckResourceAttr(dataSourceName, "partial_name", freezeName), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.#", "1"), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.name", freezeName), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.tenant_project_environment_scope.#", "0"), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.project_environment_scope.%", "0"), + ), + }, + { + Config: testAccDataSourceDeploymentFreezesConfig(spaceName, freezeName, startTime, endTime, true, false, projectName, environmentName, tenantName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(dataSourceName, "id"), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.tenant_project_environment_scope.#", "0"), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.project_environment_scope.%", "1"), + ), + }, + { + Config: testAccDataSourceDeploymentFreezesConfig(spaceName, freezeName, startTime, endTime, true, true, projectName, environmentName, tenantName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(dataSourceName, "id"), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.tenant_project_environment_scope.#", "1"), + resource.TestCheckResourceAttrSet(dataSourceName, "deployment_freezes.0.tenant_project_environment_scope.0.tenant_id"), + resource.TestCheckResourceAttrSet(dataSourceName, "deployment_freezes.0.tenant_project_environment_scope.0.project_id"), + resource.TestCheckResourceAttrSet(dataSourceName, "deployment_freezes.0.tenant_project_environment_scope.0.environment_id"), + resource.TestCheckResourceAttr(dataSourceName, "deployment_freezes.0.project_environment_scope.%", "1"), + ), + }, + }, + }) +} + +func testAccDataSourceDeploymentFreezesConfig(spaceName, freezeName string, startTime, endTime time.Time, includeProject bool, includeTenant bool, projectName, environmentName, tenantName string) string { + baseConfig := fmt.Sprintf(` +resource "octopusdeploy_space" "test_space" { + name = "%s" + is_default = false + is_task_queue_stopped = false + description = "Test space for deployment freeze datasource" + space_managers_teams = ["teams-administrators"] +} + +resource "octopusdeploy_environment" "test_environment" { + name = "%s" + description = "Test environment" + space_id = octopusdeploy_space.test_space.id +} + +resource "octopusdeploy_project_group" "test_project_group" { + name = "Test Project Group" + description = "Test project group for deployment freeze datasource" + space_id = octopusdeploy_space.test_space.id +} + +resource "octopusdeploy_lifecycle" "test_lifecycle" { + name = "Test Lifecycle" + space_id = octopusdeploy_space.test_space.id +} + +resource "octopusdeploy_project" "test_project" { + name = "%s" + lifecycle_id = octopusdeploy_lifecycle.test_lifecycle.id + project_group_id = octopusdeploy_project_group.test_project_group.id + space_id = octopusdeploy_space.test_space.id +} + +resource "octopusdeploy_deployment_freeze" "test_freeze" { + name = "%s" + start = "%s" + end = "%s" + + recurring_schedule = { + type = "Weekly" + unit = 24 + end_type = "AfterOccurrences" + end_after_occurrences = 5 + days_of_week = ["Monday", "Wednesday", "Friday"] + } + + depends_on = [octopusdeploy_space.test_space] +} +`, spaceName, environmentName, projectName, freezeName, startTime.Format(time.RFC3339), endTime.Format(time.RFC3339)) + + if includeProject { + projectConfig := fmt.Sprintf(` +resource "octopusdeploy_deployment_freeze_project" "test_project_scope" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.test_freeze.id + project_id = octopusdeploy_project.test_project.id + environment_ids = [octopusdeploy_environment.test_environment.id] +} +`) + baseConfig = baseConfig + projectConfig + } + + if includeTenant { + tenantConfig := fmt.Sprintf(` +resource "octopusdeploy_tenant" "test_tenant" { + name = "%s" + space_id = octopusdeploy_space.test_space.id +} + +resource "octopusdeploy_tenant_project" "test_tenant_project" { + tenant_id = octopusdeploy_tenant.test_tenant.id + project_id = octopusdeploy_project.test_project.id + environment_ids = [octopusdeploy_environment.test_environment.id] + space_id = octopusdeploy_space.test_space.id +} + +resource "octopusdeploy_deployment_freeze_tenant" "test_tenant_scope" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.test_freeze.id + tenant_id = octopusdeploy_tenant.test_tenant.id + project_id = octopusdeploy_project.test_project.id + environment_id = octopusdeploy_environment.test_environment.id + + depends_on = [ + octopusdeploy_tenant_project.test_tenant_project + ] +} +`, tenantName) + baseConfig = baseConfig + tenantConfig + } + + datasourceConfig := ` +data "octopusdeploy_deployment_freezes" "test_freeze" { + partial_name = "%s" + skip = 0 + take = 1 + depends_on = [` + + deps := []string{"octopusdeploy_deployment_freeze.test_freeze"} + if includeProject { + deps = append(deps, "octopusdeploy_deployment_freeze_project.test_project_scope") + } + if includeTenant { + deps = append(deps, "octopusdeploy_deployment_freeze_tenant.test_tenant_scope") + } + + datasourceConfig += "\n " + strings.Join(deps, ",\n ") + "\n " + + datasourceConfig += `] +} + +output "octopus_space_id" { + value = octopusdeploy_space.test_space.id +} + +output "octopus_freeze_id" { + value = data.octopusdeploy_deployment_freezes.test_freeze.deployment_freezes[0].id +} +` + var config = baseConfig + fmt.Sprintf(datasourceConfig, freezeName) + return config +} diff --git a/octopusdeploy_framework/resource_deployment_freeze.go b/octopusdeploy_framework/resource_deployment_freeze.go index 6298faa4..ad204b44 100644 --- a/octopusdeploy_framework/resource_deployment_freeze.go +++ b/octopusdeploy_framework/resource_deployment_freeze.go @@ -8,6 +8,7 @@ import ( "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" @@ -16,14 +17,35 @@ import ( const deploymentFreezeResourceName = "deployment_freeze" -type deploymentFreezeModel struct { - Name types.String `tfsdk:"name"` - Start timetypes.RFC3339 `tfsdk:"start"` - End timetypes.RFC3339 `tfsdk:"end"` +type recurringScheduleModel struct { + Type types.String `tfsdk:"type"` + Unit types.Int64 `tfsdk:"unit"` + EndType types.String `tfsdk:"end_type"` + EndOnDate timetypes.RFC3339 `tfsdk:"end_on_date"` + EndAfterOccurrences types.Int64 `tfsdk:"end_after_occurrences"` + MonthlyScheduleType types.String `tfsdk:"monthly_schedule_type"` + DateOfMonth types.String `tfsdk:"date_of_month"` + DayNumberOfMonth types.String `tfsdk:"day_number_of_month"` + DaysOfWeek types.List `tfsdk:"days_of_week"` + DayOfWeek types.String `tfsdk:"day_of_week"` +} +type deploymentFreezeModel struct { + Name types.String `tfsdk:"name"` + Start timetypes.RFC3339 `tfsdk:"start"` + End timetypes.RFC3339 `tfsdk:"end"` + RecurringSchedule *recurringScheduleModel `tfsdk:"recurring_schedule"` schemas.ResourceModel } +func getStringPointer(s types.String) *string { + if s.IsNull() { + return nil + } + value := s.ValueString() + return &value +} + type deploymentFreezeResource struct { *Config } @@ -125,8 +147,9 @@ func (f *deploymentFreezeResource) Update(ctx context.Context, req resource.Upda return } - // this resource doesn't include scopes, need to copy it from the fetched resource + // Preserve both project and tenant scopes from the existing freeze updatedFreeze.ProjectEnvironmentScope = existingFreeze.ProjectEnvironmentScope + updatedFreeze.TenantProjectEnvironmentScope = existingFreeze.TenantProjectEnvironmentScope updatedFreeze.SetID(existingFreeze.GetID()) updatedFreeze.Links = existingFreeze.Links @@ -169,7 +192,60 @@ func (f *deploymentFreezeResource) Delete(ctx context.Context, req resource.Dele resp.State.RemoveResource(ctx) } +func mapFromState(state *deploymentFreezeModel) (*deploymentfreezes.DeploymentFreeze, diag.Diagnostics) { + start, diags := state.Start.ValueRFC3339Time() + if diags.HasError() { + return nil, diags + } + start = start.UTC() + end, diags := state.End.ValueRFC3339Time() + if diags.HasError() { + return nil, diags + } + end = end.UTC() + + freeze := deploymentfreezes.DeploymentFreeze{ + Name: state.Name.ValueString(), + Start: &start, + End: &end, + } + + if state.RecurringSchedule != nil { + var daysOfWeek []string + + if !state.RecurringSchedule.DaysOfWeek.IsNull() { + diags.Append(state.RecurringSchedule.DaysOfWeek.ElementsAs(context.TODO(), &daysOfWeek, false)...) + if diags.HasError() { + return nil, diags + } + } + + freeze.RecurringSchedule = &deploymentfreezes.RecurringSchedule{ + Type: deploymentfreezes.RecurringScheduleType(state.RecurringSchedule.Type.ValueString()), + Unit: int(state.RecurringSchedule.Unit.ValueInt64()), + EndType: deploymentfreezes.RecurringScheduleEndType(state.RecurringSchedule.EndType.ValueString()), + EndAfterOccurrences: getOptionalIntValue(state.RecurringSchedule.EndAfterOccurrences), + MonthlyScheduleType: getOptionalString(state.RecurringSchedule.MonthlyScheduleType), + DateOfMonth: getOptionalString(state.RecurringSchedule.DateOfMonth), + DayNumberOfMonth: getOptionalString(state.RecurringSchedule.DayNumberOfMonth), + DaysOfWeek: daysOfWeek, + DayOfWeek: getOptionalString(state.RecurringSchedule.DayOfWeek), + } + + if !state.RecurringSchedule.EndOnDate.IsNull() { + date, diagsDate := state.RecurringSchedule.EndOnDate.ValueRFC3339Time() + if diagsDate.HasError() { + diags.Append(diagsDate...) + return nil, diags + } + freeze.RecurringSchedule.EndOnDate = &date + } + } + + freeze.ID = state.ID.String() + return &freeze, nil +} func mapToState(ctx context.Context, state *deploymentFreezeModel, deploymentFreeze *deploymentfreezes.DeploymentFreeze) diag.Diagnostics { state.ID = types.StringValue(deploymentFreeze.ID) state.Name = types.StringValue(deploymentFreeze.Name) @@ -186,6 +262,44 @@ func mapToState(ctx context.Context, state *deploymentFreezeModel, deploymentFre } state.End = updatedEnd + if deploymentFreeze.RecurringSchedule != nil { + var daysOfWeek types.List + if len(deploymentFreeze.RecurringSchedule.DaysOfWeek) > 0 { + elements := make([]attr.Value, len(deploymentFreeze.RecurringSchedule.DaysOfWeek)) + for i, day := range deploymentFreeze.RecurringSchedule.DaysOfWeek { + elements[i] = types.StringValue(day) + } + + var listDiags diag.Diagnostics + daysOfWeek, listDiags = types.ListValue(types.StringType, elements) + if listDiags.HasError() { + diags.Append(listDiags...) + return diags + } + } else { + daysOfWeek = types.ListNull(types.StringType) + } + + state.RecurringSchedule = &recurringScheduleModel{ + Type: types.StringValue(string(deploymentFreeze.RecurringSchedule.Type)), + Unit: types.Int64Value(int64(deploymentFreeze.RecurringSchedule.Unit)), + EndType: types.StringValue(string(deploymentFreeze.RecurringSchedule.EndType)), + DaysOfWeek: daysOfWeek, + MonthlyScheduleType: mapOptionalStringValue(deploymentFreeze.RecurringSchedule.MonthlyScheduleType), + } + + if deploymentFreeze.RecurringSchedule.EndOnDate != nil { + state.RecurringSchedule.EndOnDate = timetypes.NewRFC3339TimeValue(*deploymentFreeze.RecurringSchedule.EndOnDate) + } else { + state.RecurringSchedule.EndOnDate = timetypes.NewRFC3339Null() + } + + state.RecurringSchedule.EndAfterOccurrences = mapOptionalIntValue(deploymentFreeze.RecurringSchedule.EndAfterOccurrences) + state.RecurringSchedule.DateOfMonth = mapOptionalStringValue(deploymentFreeze.RecurringSchedule.DateOfMonth) + state.RecurringSchedule.DayNumberOfMonth = mapOptionalStringValue(deploymentFreeze.RecurringSchedule.DayNumberOfMonth) + state.RecurringSchedule.DayOfWeek = mapOptionalStringValue(deploymentFreeze.RecurringSchedule.DayOfWeek) + } + return nil } @@ -210,25 +324,36 @@ func calculateStateTime(ctx context.Context, stateValue timetypes.RFC3339, updat return newValue, diags } -func mapFromState(state *deploymentFreezeModel) (*deploymentfreezes.DeploymentFreeze, diag.Diagnostics) { - start, diags := state.Start.ValueRFC3339Time() - if diags.HasError() { - return nil, diags +func getOptionalStringPointer(value types.String) *string { + if value.IsNull() { + return nil } - start = start.UTC() - - end, diags := state.End.ValueRFC3339Time() - if diags.HasError() { - return nil, diags + str := value.ValueString() + return &str +} +func mapOptionalStringValue(value string) types.String { + if value == "" { + return types.StringNull() } - end = end.UTC() + return types.StringValue(value) +} +func getOptionalIntValue(value types.Int64) int { + if value.IsNull() { + return 0 + } + return int(value.ValueInt64()) +} - freeze := deploymentfreezes.DeploymentFreeze{ - Name: state.Name.ValueString(), - Start: &start, - End: &end, +func mapOptionalIntValue(value int) types.Int64 { + if value == 0 { + return types.Int64Null() } + return types.Int64Value(int64(value)) +} - freeze.ID = state.ID.String() - return &freeze, nil +func getOptionalString(value types.String) string { + if value.IsNull() { + return "" + } + return value.ValueString() } diff --git a/octopusdeploy_framework/resource_deployment_freeze_tenant.go b/octopusdeploy_framework/resource_deployment_freeze_tenant.go index 3f4160ef..329fa54a 100644 --- a/octopusdeploy_framework/resource_deployment_freeze_tenant.go +++ b/octopusdeploy_framework/resource_deployment_freeze_tenant.go @@ -52,7 +52,7 @@ func (d *deploymentFreezeTenantResource) Create(ctx context.Context, req resourc return } - tflog.Info(ctx, fmt.Sprintf("adding tenant (%s) to deployment freeze (%s)", plan.TenantID.ValueString(), plan.DeploymentFreezeID.ValueString())) + tflog.Debug(ctx, fmt.Sprintf("adding tenant (%s) to deployment freeze (%s)", plan.TenantID.ValueString(), plan.DeploymentFreezeID.ValueString())) freeze, err := deploymentfreezes.GetById(d.Client, plan.DeploymentFreezeID.ValueString()) if err != nil { resp.Diagnostics.AddError("cannot load deployment freeze", err.Error()) @@ -86,7 +86,6 @@ func (d *deploymentFreezeTenantResource) Create(ctx context.Context, req resourc tflog.Debug(ctx, fmt.Sprintf("tenant scope (%s) added to deployment freeze (%s)", plan.TenantID.ValueString(), plan.DeploymentFreezeID.ValueString())) util.Created(ctx, tenantDescription) } - func (d *deploymentFreezeTenantResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { util.Reading(ctx, tenantDescription) @@ -104,14 +103,21 @@ func (d *deploymentFreezeTenantResource) Read(ctx context.Context, req resource. freeze, err := deploymentfreezes.GetById(d.Client, freezeId) if err != nil { - apiError := err.(*core.APIError) - if apiError.StatusCode != http.StatusNotFound { + apiError, ok := err.(*core.APIError) + if !ok { resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) return } + + if apiError.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError("unable to load deployment freeze", apiError.Error()) + return } - // Verify the tenant scope still exists exists := false for _, scope := range freeze.TenantProjectEnvironmentScope { if scope.TenantId == tenantId && scope.ProjectId == projectId && scope.EnvironmentId == environmentId { diff --git a/octopusdeploy_framework/resource_deployment_freeze_test.go b/octopusdeploy_framework/resource_deployment_freeze_test.go index 2080963c..b0cb96f7 100644 --- a/octopusdeploy_framework/resource_deployment_freeze_test.go +++ b/octopusdeploy_framework/resource_deployment_freeze_test.go @@ -1,13 +1,11 @@ package octopusdeploy_framework import ( - "context" "fmt" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deploymentfreezes" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" - "os" "strings" "testing" "time" @@ -40,7 +38,7 @@ func TestNewDeploymentFreezeResource(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "name", name), resource.TestCheckResourceAttr(resourceName, "start", start), resource.TestCheckResourceAttr(resourceName, "end", end)), - Config: testDeploymentFreezeBasic(localName, name, start, end, spaceName, []string{environmentName1}, projectName, projectGroupName, lifecycleName, tenantName, false), + Config: testDeploymentFreezeBasic(localName, name, start, end, spaceName, []string{environmentName1}, projectName, projectGroupName, lifecycleName, tenantName, false, false), }, { Check: resource.ComposeTestCheckFunc( @@ -48,19 +46,35 @@ func TestNewDeploymentFreezeResource(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "name", name+"1"), resource.TestCheckResourceAttr(resourceName, "start", start), resource.TestCheckResourceAttr(resourceName, "end", updatedEnd)), - Config: testDeploymentFreezeBasic(localName, name+"1", start, updatedEnd, spaceName, []string{environmentName1, environmentName2}, projectName, projectGroupName, lifecycleName, tenantName, false), + Config: testDeploymentFreezeBasic(localName, name+"1", start, updatedEnd, spaceName, []string{environmentName1, environmentName2}, projectName, projectGroupName, lifecycleName, tenantName, false, false), }, { Check: resource.ComposeTestCheckFunc( testDeploymentFreezeExists(resourceName), - testDeploymentFreezeTenantExists(fmt.Sprintf("octopusdeploy_deployment_freeze_tenant.tenant_%s", localName), t)), - Config: testDeploymentFreezeBasic(localName, name+"1", start, updatedEnd, spaceName, []string{environmentName1, environmentName2}, projectName, projectGroupName, lifecycleName, tenantName, true), + testDeploymentFreezeTenantExists(fmt.Sprintf("octopusdeploy_deployment_freeze_tenant.tenant_%s", localName))), + Config: testDeploymentFreezeBasic(localName, name+"1", start, updatedEnd, spaceName, []string{environmentName1, environmentName2}, projectName, projectGroupName, lifecycleName, tenantName, true, true), + }, + { + Check: resource.ComposeTestCheckFunc( + testDeploymentFreezeExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "name", name+"1"), + resource.TestCheckResourceAttr(resourceName, "start", start), + resource.TestCheckResourceAttr(resourceName, "end", updatedEnd), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.type", "Weekly"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.unit", "24"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.end_type", "AfterOccurrences"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.end_after_occurrences", "5"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.days_of_week.#", "3"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.days_of_week.0", "Monday"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.days_of_week.1", "Wednesday"), + resource.TestCheckResourceAttr(resourceName, "recurring_schedule.days_of_week.2", "Friday")), + Config: testDeploymentFreezeBasic(localName, name+"1", start, updatedEnd, spaceName, []string{environmentName1, environmentName2}, projectName, projectGroupName, lifecycleName, tenantName, true, true), }, }, }) } -func testDeploymentFreezeBasic(localName string, freezeName string, start string, end string, spaceName string, environments []string, projectName string, projectGroupName string, lifecycleName string, tenantName string, includeTenant bool) string { +func testDeploymentFreezeBasic(localName string, freezeName string, start string, end string, spaceName string, environments []string, projectName string, projectGroupName string, lifecycleName string, tenantName string, includeTenant bool, includeRecurringSchedule bool) string { spaceLocalName := fmt.Sprintf("space_%s", localName) projectScopeLocalName := fmt.Sprintf("project_scope_%s", localName) projectLocalName := fmt.Sprintf("project_%s", localName) @@ -76,8 +90,27 @@ func testDeploymentFreezeBasic(localName string, freezeName string, start string environmentScopes = append(environmentScopes, fmt.Sprintf("resource.octopusdeploy_environment.%s.id", environmentLocalName)) } + freezeConfig := fmt.Sprintf(` + resource "octopusdeploy_deployment_freeze" "%s" { + name = "%s" + start = "%s" + end = "%s"`, localName, freezeName, start, end) + + if includeRecurringSchedule { + freezeConfig += ` + recurring_schedule = { + type = "Weekly" + unit = 24 + end_type = "AfterOccurrences" + end_after_occurrences = 5 + days_of_week = ["Monday", "Wednesday", "Friday"] + }` + } + + freezeConfig += ` + }` + config := fmt.Sprintf(` - # Space Configuration %s @@ -93,11 +126,7 @@ func testDeploymentFreezeBasic(localName string, freezeName string, start string # Project Configuration %s - resource "octopusdeploy_deployment_freeze" "%s" { - name = "%s" - start = "%s" - end = "%s" - } + %s resource "octopusdeploy_deployment_freeze_project" "%s" { deploymentfreeze_id = octopusdeploy_deployment_freeze.%s.id @@ -109,7 +138,7 @@ func testDeploymentFreezeBasic(localName string, freezeName string, start string createLifecycle(spaceLocalName, lifecycleLocalName, lifecycleName), createProjectGroup(spaceLocalName, projectGroupLocalName, projectGroupName), createProject(spaceLocalName, projectLocalName, projectName, lifecycleLocalName, projectGroupLocalName), - localName, freezeName, start, end, + freezeConfig, projectScopeLocalName, localName, projectLocalName, strings.Join(environmentScopes, ",")) @@ -155,11 +184,11 @@ func testDeploymentFreezeExists(prefix string) resource.TestCheckFunc { } } -func testDeploymentFreezeTenantExists(prefix string, t *testing.T) resource.TestCheckFunc { +func testDeploymentFreezeTenantExists(prefix string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[prefix] if !ok { - return fmt.Errorf("Resource not found: %s", prefix) + return fmt.Errorf("Not found: %s", prefix) } bits := strings.Split(rs.Primary.ID, ":") @@ -172,47 +201,18 @@ func testDeploymentFreezeTenantExists(prefix string, t *testing.T) resource.Test projectId := bits[2] environmentId := bits[3] - retryErr := resource.RetryContext(context.Background(), 2*time.Minute, func() *resource.RetryError { - freeze, err := deploymentfreezes.GetById(octoClient, freezeId) - if err != nil { - t.Logf("Failed to get deployment freeze: %v", err) - return resource.NonRetryableError(fmt.Errorf("Error getting deployment freeze: %v", err)) - } - - t.Logf("Retrieved deployment freeze with %d tenant scopes", len(freeze.TenantProjectEnvironmentScope)) - - for i, scope := range freeze.TenantProjectEnvironmentScope { - t.Logf("Scope %d - Tenant: %s, Project: %s, Environment: %s", - i+1, scope.TenantId, scope.ProjectId, scope.EnvironmentId) - } - - for _, scope := range freeze.TenantProjectEnvironmentScope { - if scope.TenantId == tenantId && scope.ProjectId == projectId && scope.EnvironmentId == environmentId { - t.Log("Found matching tenant scope in deployment freeze") - return nil - } - } + freeze, err := deploymentfreezes.GetById(octoClient, freezeId) + if err != nil { + return err + } - t.Log("Tenant scope not yet found, will retry...") - return resource.RetryableError(fmt.Errorf("Tenant scope not yet found in deployment freeze (freezeId: %s)", freezeId)) - }) - - if retryErr != nil { - freeze, err := deploymentfreezes.GetById(octoClient, freezeId) - if err != nil { - t.Logf("Final attempt to get deployment freeze failed: %v", err) - } else { - t.Logf("Final state - Deployment freeze has %d tenant scopes", len(freeze.TenantProjectEnvironmentScope)) - for i, scope := range freeze.TenantProjectEnvironmentScope { - t.Logf("Final Scope %d - Tenant: %s, Project: %s, Environment: %s", - i+1, scope.TenantId, scope.ProjectId, scope.EnvironmentId) - } + for _, scope := range freeze.TenantProjectEnvironmentScope { + if scope.TenantId == tenantId && scope.ProjectId == projectId && scope.EnvironmentId == environmentId { + return nil } - - return fmt.Errorf("Failed to find tenant scope after retries. Error: %v", retryErr) } - return nil + return fmt.Errorf("Tenant scope not found in deployment freeze") } } @@ -227,7 +227,5 @@ func testDeploymentFreezeCheckDestroy(s *terraform.State) error { return fmt.Errorf("Deployment Freeze (%s) still exists", rs.Primary.ID) } } - - os.Setenv("TF_LOG", "") return nil } diff --git a/octopusdeploy_framework/schemas/deployment_freeze.go b/octopusdeploy_framework/schemas/deployment_freeze.go index 6baa8dd0..59bdd9ba 100644 --- a/octopusdeploy_framework/schemas/deployment_freeze.go +++ b/octopusdeploy_framework/schemas/deployment_freeze.go @@ -1,8 +1,10 @@ package schemas import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -15,6 +17,70 @@ func (d DeploymentFreezeSchema) GetResourceSchema() resourceSchema.Schema { "name": GetNameResourceSchema(true), "start": GetDateTimeResourceSchema("The start time of the freeze, must be RFC3339 format", true), "end": GetDateTimeResourceSchema("The end time of the freeze, must be RFC3339 format", true), + "recurring_schedule": resourceSchema.SingleNestedAttribute{ + Optional: true, + Validators: []validator.Object{ + NewRecurringScheduleValidator(), + }, + Attributes: map[string]resourceSchema.Attribute{ + "type": resourceSchema.StringAttribute{ + Description: "Type of recurring schedule (Daily, Weekly, Monthly, Annually)", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("Daily", "Weekly", "Monthly", "Annually"), + }, + }, + "unit": resourceSchema.Int64Attribute{ + Description: "The unit value for the schedule", + Required: true, + }, + "end_type": resourceSchema.StringAttribute{ + Description: "When the recurring schedule should end (Never, OnDate, AfterOccurrences)", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("Never", "OnDate", "AfterOccurrences"), + }, + }, + "end_on_date": GetDateTimeResourceSchema("The date when the recurring schedule should end", false), + "end_after_occurrences": resourceSchema.Int64Attribute{ + Description: "Number of occurrences after which the schedule should end", + Optional: true, + }, + "monthly_schedule_type": resourceSchema.StringAttribute{ + Description: "Type of monthly schedule (DayOfMonth, DateOfMonth)", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("DayOfMonth", "DateOfMonth"), + }, + }, + "date_of_month": resourceSchema.StringAttribute{ + Description: "The date of the month for monthly schedules", + Optional: true, + }, + "day_number_of_month": resourceSchema.StringAttribute{ + Description: "Specifies which weekday position in the month. Valid values: 1 (First), 2 (Second), 3 (Third), 4 (Fourth), L (Last). Used with day_of_week", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("1", "2", "3", "4", "L"), + }, + }, + "days_of_week": resourceSchema.ListAttribute{ + Description: "List of days of the week for weekly schedules. Must follow order: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday", + Optional: true, + ElementType: types.StringType, + Validators: []validator.List{ + NewDaysOfWeekValidator(), + }, + }, + "day_of_week": resourceSchema.StringAttribute{ + Description: "The day of the week for monthly schedules when using DayOfMonth type", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"), + }, + }, + }, + }, }, } } @@ -33,6 +99,11 @@ func (d DeploymentFreezeSchema) GetDatasourceSchema() datasourceSchema.Schema { ElementType: types.StringType, Optional: true, }, + "tenant_ids": datasourceSchema.ListAttribute{ + Description: "A filter to search by a list of tenant IDs", + ElementType: types.StringType, + Optional: true, + }, "environment_ids": datasourceSchema.ListAttribute{ Description: "A filter to search by a list of environment IDs", ElementType: types.StringType, @@ -64,14 +135,79 @@ func (d DeploymentFreezeSchema) GetDatasourceSchema() datasourceSchema.Schema { Optional: false, Computed: true, }, + "tenant_project_environment_scope": datasourceSchema.ListNestedAttribute{ + Description: "The tenant project environment scope of the deployment freeze", + Optional: false, + Computed: true, + NestedObject: datasourceSchema.NestedAttributeObject{ + Attributes: map[string]datasourceSchema.Attribute{ + "tenant_id": datasourceSchema.StringAttribute{ + Description: "The tenant ID", + Computed: true, + }, + "project_id": datasourceSchema.StringAttribute{ + Description: "The project ID", + Computed: true, + }, + "environment_id": datasourceSchema.StringAttribute{ + Description: "The environment ID", + Computed: true, + }, + }, + }, + }, + "recurring_schedule": datasourceSchema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]datasourceSchema.Attribute{ + "type": datasourceSchema.StringAttribute{ + Description: "Type of recurring schedule (OnceDaily, DaysPerWeek, DaysPerMonth, Annually)", + Computed: true, + }, + "unit": datasourceSchema.Int64Attribute{ + Description: "The unit value for the schedule", + Computed: true, + }, + "end_type": datasourceSchema.StringAttribute{ + Description: "When the recurring schedule should end (Never, OnDate, AfterOccurrences)", + Computed: true, + }, + "end_on_date": datasourceSchema.StringAttribute{ + Description: "The date when the recurring schedule should end", + Computed: true, + }, + "end_after_occurrences": datasourceSchema.Int64Attribute{ + Description: "Number of occurrences after which the schedule should end", + Computed: true, + }, + "monthly_schedule_type": datasourceSchema.StringAttribute{ + Description: "Type of monthly schedule (DayOfMonth, DateOfMonth)", + Computed: true, + }, + "date_of_month": datasourceSchema.StringAttribute{ + Description: "The date of the month for monthly schedules", + Computed: true, + }, + "day_number_of_month": datasourceSchema.StringAttribute{ + Description: "The day number of the month for monthly schedules", + Computed: true, + }, + "days_of_week": datasourceSchema.ListAttribute{ + Description: "List of days of the week for weekly schedules", + Computed: true, + ElementType: types.StringType, + }, + "day_of_week": datasourceSchema.StringAttribute{ + Description: "The day of the week for monthly schedules", + Computed: true, + }, + }, + }, }, }, - Optional: false, Computed: true, }, }, } - } var _ EntitySchema = &DeploymentFreezeSchema{} diff --git a/octopusdeploy_framework/schemas/deployment_freeze_validation.go b/octopusdeploy_framework/schemas/deployment_freeze_validation.go new file mode 100644 index 00000000..6effbb39 --- /dev/null +++ b/octopusdeploy_framework/schemas/deployment_freeze_validation.go @@ -0,0 +1,201 @@ +package schemas + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +type daysOfWeekValidator struct{} + +func NewDaysOfWeekValidator() daysOfWeekValidator { + return daysOfWeekValidator{} +} + +func (v daysOfWeekValidator) Description(ctx context.Context) string { + return "validates that days of the week are valid and in correct order (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)" +} + +func (v daysOfWeekValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v daysOfWeekValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + validDays := map[string]int{ + "Sunday": 0, + "Monday": 1, + "Tuesday": 2, + "Wednesday": 3, + "Thursday": 4, + "Friday": 5, + "Saturday": 6, + } + + var days []string + req.ConfigValue.ElementsAs(ctx, &days, false) + + for i := 1; i < len(days); i++ { + currentDay := days[i] + previousDay := days[i-1] + + currentPos, currentExists := validDays[currentDay] + previousPos, previousExists := validDays[previousDay] + + if !currentExists { + resp.Diagnostics.AddError( + "Invalid day of week", + fmt.Sprintf("'%s' is not a valid day of week. Must be one of: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday", currentDay), + ) + return + } + + if !previousExists { + resp.Diagnostics.AddError( + "Invalid day of week", + fmt.Sprintf("'%s' is not a valid day of week. Must be one of: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday", previousDay), + ) + return + } + + if currentPos <= previousPos { + resp.Diagnostics.AddError( + "Invalid day order", + fmt.Sprintf("Days of the week must be in order (Sunday through Saturday). Found '%s' after '%s'", currentDay, previousDay), + ) + return + } + } +} + +type recurringScheduleValidator struct{} + +func NewRecurringScheduleValidator() recurringScheduleValidator { + return recurringScheduleValidator{} +} + +func (v recurringScheduleValidator) Description(_ context.Context) string { + return "validates that required fields are set based on the schedule type" +} + +func (v recurringScheduleValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v recurringScheduleValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + var schedule struct { + Type types.String `tfsdk:"type"` + Unit types.Int64 `tfsdk:"unit"` + EndType types.String `tfsdk:"end_type"` + EndOnDate timetypes.RFC3339 `tfsdk:"end_on_date"` + EndAfterOccurrences types.Int64 `tfsdk:"end_after_occurrences"` + MonthlyScheduleType types.String `tfsdk:"monthly_schedule_type"` + DateOfMonth types.String `tfsdk:"date_of_month"` + DayNumberOfMonth types.String `tfsdk:"day_number_of_month"` + DaysOfWeek types.List `tfsdk:"days_of_week"` + DayOfWeek types.String `tfsdk:"day_of_week"` + } + + resp.Diagnostics.Append(req.ConfigValue.As(ctx, &schedule, basetypes.ObjectAsOptions{})...) + if resp.Diagnostics.HasError() { + return + } + + scheduleType := schedule.Type.ValueString() + + switch scheduleType { + case "Daily": + // Daily only requires type and unit which are already marked as required + + case "Weekly": + if schedule.DaysOfWeek.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("days_of_week"), + "Missing Required Field", + "days_of_week must be set when schedule type is DaysPerWeek", + ) + } + + case "Monthly": + if schedule.MonthlyScheduleType.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("monthly_schedule_type"), + "Missing Required Field", + "monthly_schedule_type must be set when schedule type is DaysPerMonth", + ) + return + } + + monthlyType := schedule.MonthlyScheduleType.ValueString() + switch monthlyType { + case "DateOfMonth": + if schedule.DateOfMonth.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("date_of_month"), + "Missing Required Field", + "date_of_month must be set when monthly_schedule_type is DateOfMonth", + ) + } + + case "DayOfMonth": + if schedule.DayNumberOfMonth.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("day_number_of_month"), + "Missing Required Field", + "day_number_of_month must be set when monthly_schedule_type is DayOfMonth", + ) + } else { + dayNum := schedule.DayNumberOfMonth.ValueString() + validDayNums := map[string]bool{ + "1": true, + "2": true, + "3": true, + "4": true, + "L": true, + } + if !validDayNums[dayNum] { + resp.Diagnostics.AddAttributeError( + path.Root("day_number_of_month"), + "Invalid Day Number", + fmt.Sprintf("day_number_of_month must be one of: 1, 2, 3, 4, L, got: %s", dayNum), + ) + } + } + if schedule.DayOfWeek.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("day_of_week"), + "Missing Required Field", + "day_of_week must be set when monthly_schedule_type is DayOfMonth", + ) + } + + default: + resp.Diagnostics.AddAttributeError( + path.Root("monthly_schedule_type"), + "Invalid Monthly Schedule Type", + fmt.Sprintf("monthly_schedule_type must be either DateOfMonth or DayOfMonth, got: %s", monthlyType), + ) + } + + case "Annually": + // Annually only requires type and unit which are already marked as required + + default: + resp.Diagnostics.AddAttributeError( + path.Root("type"), + "Invalid Schedule Type", + fmt.Sprintf("type must be one of: Daily, Weekly, Monthly, Annually, got: %s", scheduleType), + ) + } +} diff --git a/octopusdeploy_framework/schemas/schema.go b/octopusdeploy_framework/schemas/schema.go index 4e95c45f..576996a0 100644 --- a/octopusdeploy_framework/schemas/schema.go +++ b/octopusdeploy_framework/schemas/schema.go @@ -440,6 +440,7 @@ func GetDateTimeResourceSchema(description string, isRequired bool) resourceSche return resourceSchema.StringAttribute{ Description: description, Required: isRequired, + Optional: !isRequired, CustomType: timetypes.RFC3339Type{}, Validators: []validator.String{ stringvalidator.RegexMatches(regexp.MustCompile(regex), fmt.Sprintf("must match RFC3339 format, %s", regex)),