From df9187af4292004fb79e2aea0f1c0cd7b42a4cfa Mon Sep 17 00:00:00 2001 From: Robert Wagner Date: Thu, 28 Nov 2024 11:56:42 +1000 Subject: [PATCH 1/8] chore: fix missing quote in documentation guide --- docs/guides/2-provider-configuration.md | 4 ++-- templates/guides/2-provider-configuration.md.tmpl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/2-provider-configuration.md b/docs/guides/2-provider-configuration.md index 158c59df..94deaf8a 100644 --- a/docs/guides/2-provider-configuration.md +++ b/docs/guides/2-provider-configuration.md @@ -15,7 +15,7 @@ subcategory: "Guides" terraform { required_providers { octopusdeploy = { - source = OctopusDeployLabs/octopusdeploy + source = "OctopusDeployLabs/octopusdeploy" } } } @@ -39,7 +39,7 @@ The environment variable fallback values that the Terraform Provider search for terraform { required_providers { octopusdeploy = { - source = OctopusDeployLabs/octopusdeploy + source = "OctopusDeployLabs/octopusdeploy" } } } diff --git a/templates/guides/2-provider-configuration.md.tmpl b/templates/guides/2-provider-configuration.md.tmpl index 158c59df..94deaf8a 100644 --- a/templates/guides/2-provider-configuration.md.tmpl +++ b/templates/guides/2-provider-configuration.md.tmpl @@ -15,7 +15,7 @@ subcategory: "Guides" terraform { required_providers { octopusdeploy = { - source = OctopusDeployLabs/octopusdeploy + source = "OctopusDeployLabs/octopusdeploy" } } } @@ -39,7 +39,7 @@ The environment variable fallback values that the Terraform Provider search for terraform { required_providers { octopusdeploy = { - source = OctopusDeployLabs/octopusdeploy + source = "OctopusDeployLabs/octopusdeploy" } } } From 7432680e0410ad9573b3992e822486aca2f41fa9 Mon Sep 17 00:00:00 2001 From: domenicsim1 <87625140+domenicsim1@users.noreply.github.com> Date: Thu, 28 Nov 2024 17:55:57 +1100 Subject: [PATCH 2/8] fix: panic on resource tenant project error handling (#824) --- .../resource_tenant_project.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/octopusdeploy_framework/resource_tenant_project.go b/octopusdeploy_framework/resource_tenant_project.go index a5f68eac..40812802 100644 --- a/octopusdeploy_framework/resource_tenant_project.go +++ b/octopusdeploy_framework/resource_tenant_project.go @@ -2,7 +2,9 @@ package octopusdeploy_framework import ( "context" + "errors" "fmt" + internalErrors "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal/errors" "net/http" "strings" @@ -90,11 +92,10 @@ func (t *tenantProjectResource) Read(ctx context.Context, req resource.ReadReque tenant, err := tenants.GetByID(t.Client, spaceID, tenantID) if err != nil { - apiError := err.(*core.APIError) - if apiError.StatusCode != http.StatusNotFound { + if err := internalErrors.ProcessApiErrorV2(ctx, resp, data, err, "tenant"); err != nil { resp.Diagnostics.AddError("unable to load tenant", err.Error()) - return } + return } data.EnvironmentIDs = util.FlattenStringList(tenant.ProjectEnvironments[projectID]) @@ -162,10 +163,12 @@ func (t *tenantProjectResource) Delete(ctx context.Context, req resource.DeleteR tenant, err := tenants.GetByID(t.Client, spaceId, data.TenantID.ValueString()) if err != nil { - apiError := err.(*core.APIError) - if apiError.StatusCode == http.StatusNotFound { - tflog.Info(ctx, fmt.Sprintf("tenant (%s) no longer exists", data.TenantID.ValueString())) - return + var apiError *core.APIError + if errors.As(err, &apiError) { + if apiError.StatusCode == http.StatusNotFound { + tflog.Info(ctx, fmt.Sprintf("tenant (%s) no longer exists", data.TenantID.ValueString())) + return + } } else { resp.Diagnostics.AddError("cannot load tenant", err.Error()) return From e709e02d2111d3204d54375c0547faa63f094451 Mon Sep 17 00:00:00 2001 From: domenicsim1 <87625140+domenicsim1@users.noreply.github.com> Date: Fri, 29 Nov 2024 09:08:53 +1100 Subject: [PATCH 3/8] fix: small step template fixes --- .../resource_step_template.go | 43 +++++++++++-------- .../schemas/step_template.go | 3 +- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/octopusdeploy_framework/resource_step_template.go b/octopusdeploy_framework/resource_step_template.go index 01bf4542..00980590 100644 --- a/octopusdeploy_framework/resource_step_template.go +++ b/octopusdeploy_framework/resource_step_template.go @@ -210,19 +210,7 @@ func mapStepTemplateResourceModelToActionTemplate(ctx context.Context, data sche at.Packages = make([]packages.PackageReference, len(pkgs)) if len(pkgs) > 0 { for i, val := range pkgs { - pkgProps := make(map[string]string, len(val.Properties.Attributes())) - for key, prop := range val.Properties.Attributes() { - if prop.Type(ctx) == types.StringType { - pkgProps[key] = prop.(types.String).ValueString() - } else { - // We should not get this error unless we add a field to package properties in the schema that is not a string - resp.AddError("Unexpected value type in package properties.", - fmt.Sprintf("Expected [%s] to have value of string but got [%s].", key, prop.String())) - } - } - if resp.HasError() { - return at, resp - } + pkgProps := convertAttributeStepTemplatePackageProperty(val.Properties.Attributes()) pkgRef := packages.PackageReference{ AcquisitionLocation: val.AcquisitionLocation.ValueString(), FeedID: val.FeedID.ValueString(), @@ -335,28 +323,28 @@ func convertStepTemplatePackagePropertyAttribute(atpp map[string]string) (types. diags := diag.Diagnostics{} // We need to manually convert the string map to ensure all fields are set. - if extract, ok := atpp["extract"]; ok { + if extract, ok := atpp["Extract"]; ok { prop["extract"] = types.StringValue(extract) } else { diags.AddWarning("Package property missing value.", "extract value missing from package property") prop["extract"] = types.StringNull() } - if purpose, ok := atpp["purpose"]; ok { + if purpose, ok := atpp["Purpose"]; ok { prop["purpose"] = types.StringValue(purpose) } else { diags.AddWarning("Package property missing value.", "purpose value missing from package property") prop["purpose"] = types.StringNull() } - if purpose, ok := atpp["package_parameter_name"]; ok { + if purpose, ok := atpp["PackageParameterName"]; ok { prop["package_parameter_name"] = types.StringValue(purpose) } else { diags.AddWarning("Package property missing value.", "package_parameter_name value missing from package property") prop["package_parameter_name"] = types.StringNull() } - if selectionMode, ok := atpp["selection_mode"]; ok { + if selectionMode, ok := atpp["SelectionMode"]; ok { prop["selection_mode"] = types.StringValue(selectionMode) } else { diags.AddWarning("Package property missing value.", "selection_mode value missing from package property") @@ -370,3 +358,24 @@ func convertStepTemplatePackagePropertyAttribute(atpp map[string]string) (types. } return propMap, diags } + +func convertAttributeStepTemplatePackageProperty(prop map[string]attr.Value) map[string]string { + atpp := make(map[string]string) + + if extract, ok := prop["extract"]; ok { + atpp["Extract"] = extract.(types.String).ValueString() + } + + if purpose, ok := prop["purpose"]; ok { + atpp["Purpose"] = purpose.(types.String).ValueString() + } + + if purpose, ok := prop["package_parameter_name"]; ok { + atpp["PackageParameterName"] = purpose.(types.String).ValueString() + } + + if selectionMode, ok := prop["selection_mode"]; ok { + atpp["SelectionMode"] = selectionMode.(types.String).ValueString() + } + return atpp +} diff --git a/octopusdeploy_framework/schemas/step_template.go b/octopusdeploy_framework/schemas/step_template.go index 7daa17bb..fab264a2 100644 --- a/octopusdeploy_framework/schemas/step_template.go +++ b/octopusdeploy_framework/schemas/step_template.go @@ -163,6 +163,7 @@ func GetStepTemplateParameterResourceSchema() rs.ListNestedAttribute { "label": rs.StringAttribute{ Description: "The label shown beside the parameter when presented in the deployment process. Example: `Server name`.", Optional: true, + Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -213,7 +214,7 @@ func GetStepTemplatePackageResourceSchema() rs.ListNestedAttribute { Optional: true, Computed: true, Validators: []validator.String{ - stringvalidator.RegexMatches(regexp.MustCompile("^(True|Fasle)$"), "Extract must be True or False"), + stringvalidator.RegexMatches(regexp.MustCompile("^(True|False)$"), "Extract must be True or False"), }, }, "package_parameter_name": rs.StringAttribute{ From 9361f05a2c7392a0dabdeb1e53b46c4f6c9f4ad5 Mon Sep 17 00:00:00 2001 From: Matthew Casperson Date: Wed, 4 Dec 2024 11:02:05 +1000 Subject: [PATCH 4/8] Validate that the template exists before proceeding (#833) --- .../resource_tenant_project_variable.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/octopusdeploy_framework/resource_tenant_project_variable.go b/octopusdeploy_framework/resource_tenant_project_variable.go index 39a8957c..6057aaa6 100644 --- a/octopusdeploy_framework/resource_tenant_project_variable.go +++ b/octopusdeploy_framework/resource_tenant_project_variable.go @@ -125,6 +125,11 @@ func (t *tenantProjectVariableResource) Read(ctx context.Context, req resource.R return } + if !checkIfTemplateExists(tenantVariables, state) { + // The template no longer exists, so the variable can no longer exist either + return + } + isSensitive, err := checkIfVariableIsSensitive(tenantVariables, state) if err != nil { resp.Diagnostics.AddError("Error checking if variable is sensitive", err.Error()) @@ -251,6 +256,17 @@ func (t *tenantProjectVariableResource) ImportState(ctx context.Context, req res resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("template_id"), idParts[3])...) } +func checkIfTemplateExists(tenantVariables *variables.TenantVariables, plan tenantProjectVariableResourceModel) bool { + if projectVariable, ok := tenantVariables.ProjectVariables[plan.ProjectID.ValueString()]; ok { + for _, template := range projectVariable.Templates { + if template.GetID() == plan.TemplateID.ValueString() { + return true + } + } + } + return false +} + func checkIfVariableIsSensitive(tenantVariables *variables.TenantVariables, plan tenantProjectVariableResourceModel) (bool, error) { if projectVariable, ok := tenantVariables.ProjectVariables[plan.ProjectID.ValueString()]; ok { for _, template := range projectVariable.Templates { From d009e07d53e7238352fa823e06324c9a218041fa Mon Sep 17 00:00:00 2001 From: domenicsim1 <87625140+domenicsim1@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:54:21 +1100 Subject: [PATCH 5/8] feat: project versioning strategy resource (#834) --- docs/resources/project.md | 2 +- docs/resources/project_versioning_strategy.md | 100 +++++++++ .../resource.tf | 60 ++++++ octopusdeploy_framework/framework_provider.go | 1 + .../resource_project_versioning_strategy.go | 190 ++++++++++++++++++ octopusdeploy_framework/schemas/project.go | 2 +- .../schemas/project_versioning_strategy.go | 71 +++++++ 7 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 docs/resources/project_versioning_strategy.md create mode 100644 examples/resources/octopusdeploy_project_versioning_strategy/resource.tf create mode 100644 octopusdeploy_framework/resource_project_versioning_strategy.go create mode 100644 octopusdeploy_framework/schemas/project_versioning_strategy.go diff --git a/docs/resources/project.md b/docs/resources/project.md index 218c4259..d6479688 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -97,7 +97,7 @@ resource "octopusdeploy_project" "example" { - `space_id` (String) The space ID associated with this project. - `template` (Block List) (see [below for nested schema](#nestedblock--template)) - `tenanted_deployment_participation` (String) The tenanted deployment mode of the resource. Valid account types are `Untenanted`, `TenantedOrUntenanted`, or `Tenanted`. -- `versioning_strategy` (Block List) (see [below for nested schema](#nestedblock--versioning_strategy)) +- `versioning_strategy` (Block List, Deprecated) (see [below for nested schema](#nestedblock--versioning_strategy)) ### Read-Only diff --git a/docs/resources/project_versioning_strategy.md b/docs/resources/project_versioning_strategy.md new file mode 100644 index 00000000..eb86b23f --- /dev/null +++ b/docs/resources/project_versioning_strategy.md @@ -0,0 +1,100 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "octopusdeploy_project_versioning_strategy Resource - terraform-provider-octopusdeploy" +subcategory: "" +description: |- + +--- + +# octopusdeploy_project_versioning_strategy (Resource) + + + +## Example Usage + +```terraform +resource "octopusdeploy_project_group" "tp" { + name = "DevOps Projects" + description = "My DevOps projects group" +} + +resource "octopusdeploy_project" "tp" { + name = "My DevOps Project" + description = "test project" + lifecycle_id = "Lifecycles-1" + project_group_id = octopusdeploy_project_group.tp.id + + depends_on = [octopusdeploy_project_group.tp] +} + +resource "octopusdeploy_deployment_process" "process" { + project_id = octopusdeploy_project.tp.id + + step { + name = "Hello World" + target_roles = [ "hello-world" ] + start_trigger = "StartAfterPrevious" + package_requirement = "LetOctopusDecide" + condition = "Success" + + run_script_action { + name = "Hello World" + is_disabled = false + is_required = true + script_body = "Write-Host 'hello world'" + script_syntax = "PowerShell" + can_be_used_for_project_versioning = true + sort_order = 1 + + + package { + name = "Package" + feed_id = "feeds-builtin" + package_id = "myExpressApp" + acquisition_location = "Server" + extract_during_deployment = true + } + } + } + + depends_on = [octopusdeploy_project.tp] +} + +resource "octopusdeploy_project_versioning_strategy" "tp" { + project_id = octopusdeploy_project.tp.id + space_id = octopusdeploy_project.tp.space_id + donor_package_step_id = octopusdeploy_deployment_process.process.step[0].run_script_action[0].id + donor_package = { + deployment_action = "Hello World" + package_reference = "Package" + } + depends_on = [ + octopusdeploy_project_group.tp, + octopusdeploy_deployment_process.process + ] +} +``` + + +## Schema + +### Required + +- `donor_package` (Attributes) Donor Packages. (see [below for nested schema](#nestedatt--donor_package)) +- `project_id` (String) The associated project ID. +- `space_id` (String) Space ID of the associated project. + +### Optional + +- `donor_package_step_id` (String) The associated donor package step ID. +- `template` (String) + + +### Nested Schema for `donor_package` + +Optional: + +- `deployment_action` (String) Deployment action. +- `package_reference` (String) Package reference. + + diff --git a/examples/resources/octopusdeploy_project_versioning_strategy/resource.tf b/examples/resources/octopusdeploy_project_versioning_strategy/resource.tf new file mode 100644 index 00000000..dfcc6bbe --- /dev/null +++ b/examples/resources/octopusdeploy_project_versioning_strategy/resource.tf @@ -0,0 +1,60 @@ +resource "octopusdeploy_project_group" "tp" { + name = "DevOps Projects" + description = "My DevOps projects group" +} + +resource "octopusdeploy_project" "tp" { + name = "My DevOps Project" + description = "test project" + lifecycle_id = "Lifecycles-1" + project_group_id = octopusdeploy_project_group.tp.id + + depends_on = [octopusdeploy_project_group.tp] +} + +resource "octopusdeploy_deployment_process" "process" { + project_id = octopusdeploy_project.tp.id + + step { + name = "Hello World" + target_roles = [ "hello-world" ] + start_trigger = "StartAfterPrevious" + package_requirement = "LetOctopusDecide" + condition = "Success" + + run_script_action { + name = "Hello World" + is_disabled = false + is_required = true + script_body = "Write-Host 'hello world'" + script_syntax = "PowerShell" + can_be_used_for_project_versioning = true + sort_order = 1 + + + package { + name = "Package" + feed_id = "feeds-builtin" + package_id = "myExpressApp" + acquisition_location = "Server" + extract_during_deployment = true + } + } + } + + depends_on = [octopusdeploy_project.tp] +} + +resource "octopusdeploy_project_versioning_strategy" "tp" { + project_id = octopusdeploy_project.tp.id + space_id = octopusdeploy_project.tp.space_id + donor_package_step_id = octopusdeploy_deployment_process.process.step[0].run_script_action[0].id + donor_package = { + deployment_action = "Hello World" + package_reference = "Package" + } + depends_on = [ + octopusdeploy_project_group.tp, + octopusdeploy_deployment_process.process + ] +} diff --git a/octopusdeploy_framework/framework_provider.go b/octopusdeploy_framework/framework_provider.go index a1cdb241..161b5461 100644 --- a/octopusdeploy_framework/framework_provider.go +++ b/octopusdeploy_framework/framework_provider.go @@ -113,6 +113,7 @@ func (p *octopusDeployFrameworkProvider) Resources(ctx context.Context) []func() NewLibraryVariableSetFeedResource, NewVariableResource, NewProjectResource, + NewProjectVersioningStrategyResource, NewMachineProxyResource, NewTagResource, NewDockerContainerRegistryFeedResource, diff --git a/octopusdeploy_framework/resource_project_versioning_strategy.go b/octopusdeploy_framework/resource_project_versioning_strategy.go new file mode 100644 index 00000000..bf587d86 --- /dev/null +++ b/octopusdeploy_framework/resource_project_versioning_strategy.go @@ -0,0 +1,190 @@ +package octopusdeploy_framework + +import ( + "context" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/packages" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "log" + "net/http" +) + +var _ resource.Resource = &projectVersioningStrategyResource{} + +type projectVersioningStrategyResource struct { + *Config +} + +func NewProjectVersioningStrategyResource() resource.Resource { + return &projectVersioningStrategyResource{} +} + +func (r *projectVersioningStrategyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = util.GetTypeName(schemas.ProjectVersioningStrategyResourceName) +} + +func (r *projectVersioningStrategyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schemas.ProjectVersioningStrategySchema{}.GetResourceSchema() +} + +func (r *projectVersioningStrategyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.Config = ResourceConfiguration(req, resp) +} + +func (r *projectVersioningStrategyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan schemas.ProjectVersioningStrategyModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + project, err := projects.GetByID(r.Client, plan.SpaceID.ValueString(), plan.ProjectID.ValueString()) + if err != nil { + if apiError, ok := err.(*core.APIError); ok { + if apiError.StatusCode == http.StatusNotFound { + log.Printf("[INFO] associated project (%s) not found; deleting version strategy from state", plan.ProjectID.ValueString()) + resp.State.RemoveResource(ctx) + } + } else { + resp.Diagnostics.AddError("Failed to read associated project", err.Error()) + } + return + } + versioningStrategy := mapStateToProjectVersioningStrategy(&plan) + project.VersioningStrategy = versioningStrategy + + _, err = projects.Update(r.Client, project) + if err != nil { + resp.Diagnostics.AddError("Error updating associated project", err.Error()) + return + } + + updatedProject, err := projects.GetByID(r.Client, plan.SpaceID.ValueString(), plan.ProjectID.ValueString()) + if err != nil { + if apiError, ok := err.(*core.APIError); ok { + if apiError.StatusCode == http.StatusNotFound { + log.Printf("[INFO] associated project (%s) not found; deleting version strategy from state", plan.ProjectID.ValueString()) + resp.State.RemoveResource(ctx) + } + } else { + resp.Diagnostics.AddError("Failed to read associated project", err.Error()) + } + return + } + + mapProjectVersioningStrategyToState(updatedProject.VersioningStrategy, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *projectVersioningStrategyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state schemas.ProjectVersioningStrategyModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + project, err := projects.GetByID(r.Client, state.SpaceID.ValueString(), state.ProjectID.ValueString()) + if err != nil { + if apiError, ok := err.(*core.APIError); ok { + if apiError.StatusCode == http.StatusNotFound { + log.Printf("[INFO] associated project (%s) not found; deleting version strategy from state", state.ProjectID.ValueString()) + resp.State.RemoveResource(ctx) + } + } else { + resp.Diagnostics.AddError("Failed to read associated project", err.Error()) + } + return + } + mapProjectVersioningStrategyToState(project.VersioningStrategy, &state) + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *projectVersioningStrategyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan schemas.ProjectVersioningStrategyModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + existingProject, err := projects.GetByID(r.Client, plan.SpaceID.ValueString(), plan.ProjectID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error retrieving associated project", err.Error()) + return + } + + versioningStrategy := mapStateToProjectVersioningStrategy(&plan) + existingProject.VersioningStrategy = versioningStrategy + + _, err = projects.Update(r.Client, existingProject) + if err != nil { + resp.Diagnostics.AddError("Error updating associated project", err.Error()) + return + } + + updatedProject, err := projects.GetByID(r.Client, plan.SpaceID.ValueString(), plan.ProjectID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error retrieving associated project", err.Error()) + return + } + + mapProjectVersioningStrategyToState(updatedProject.VersioningStrategy, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *projectVersioningStrategyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state schemas.ProjectVersioningStrategyModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + project, err := projects.GetByID(r.Client, state.SpaceID.ValueString(), state.ProjectID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error retrieving project", err.Error()) + return + } + + project.VersioningStrategy = &projects.VersioningStrategy{} + _, err = projects.Update(r.Client, project) + if err != nil { + resp.Diagnostics.AddError("Error updating project to remove versioning strategy", err.Error()) + return + } + + resp.State.RemoveResource(ctx) +} + +func mapStateToProjectVersioningStrategy(state *schemas.ProjectVersioningStrategyModel) *projects.VersioningStrategy { + var donorPackageStepID *string + donorPackageStepIDString := state.DonorPackageStepID.ValueString() + if donorPackageStepIDString != "" { + donorPackageStepID = &donorPackageStepIDString + } + + return &projects.VersioningStrategy{ + Template: state.Template.ValueString(), + DonorPackageStepID: donorPackageStepID, + DonorPackage: &packages.DeploymentActionPackage{ + DeploymentAction: state.DonorPackage.DeploymentAction.ValueString(), + PackageReference: state.DonorPackage.PackageReference.ValueString(), + }, + } +} + +func mapProjectVersioningStrategyToState(versioningStrategy *projects.VersioningStrategy, state *schemas.ProjectVersioningStrategyModel) { + if versioningStrategy.DonorPackageStepID != nil { + state.DonorPackageStepID = types.StringValue(*versioningStrategy.DonorPackageStepID) + } + state.Template = types.StringValue(versioningStrategy.Template) + state.DonorPackage.PackageReference = types.StringValue(versioningStrategy.DonorPackage.PackageReference) + state.DonorPackage.DeploymentAction = types.StringValue(versioningStrategy.DonorPackage.DeploymentAction) +} diff --git a/octopusdeploy_framework/schemas/project.go b/octopusdeploy_framework/schemas/project.go index ac7898ab..93d7b32a 100644 --- a/octopusdeploy_framework/schemas/project.go +++ b/octopusdeploy_framework/schemas/project.go @@ -147,8 +147,8 @@ func (p ProjectSchema) GetResourceSchema() resourceSchema.Schema { }, }, "versioning_strategy": resourceSchema.ListNestedBlock{ + DeprecationMessage: "versioning_strategy is deprecated in favor of resource project_versioning strategy", NestedObject: resourceSchema.NestedBlockObject{ - Attributes: map[string]resourceSchema.Attribute{ "donor_package_step_id": util.ResourceString().Optional().Build(), "template": util.ResourceString().Optional().Computed().Build(), diff --git a/octopusdeploy_framework/schemas/project_versioning_strategy.go b/octopusdeploy_framework/schemas/project_versioning_strategy.go new file mode 100644 index 00000000..c581f803 --- /dev/null +++ b/octopusdeploy_framework/schemas/project_versioning_strategy.go @@ -0,0 +1,71 @@ +package schemas + +import ( + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ProjectVersioningStrategySchema struct{} + +var _ EntitySchema = ProjectVersioningStrategySchema{} + +const ProjectVersioningStrategyResourceName = "project_versioning_strategy" + +func (p ProjectVersioningStrategySchema) GetResourceSchema() resourceSchema.Schema { + return resourceSchema.Schema{ + Attributes: map[string]resourceSchema.Attribute{ + "project_id": util.ResourceString(). + Description("The associated project ID."). + PlanModifiers(stringplanmodifier.RequiresReplace()). + Required(). + Build(), + "space_id": util.ResourceString(). + Description("Space ID of the associated project."). + Required(). + Build(), + "donor_package_step_id": util.ResourceString(). + Description("The associated donor package step ID."). + Optional(). + Build(), + "template": util.ResourceString(). + Optional(). + Computed(). + Build(), + "donor_package": resourceSchema.SingleNestedAttribute{ + Required: true, + Description: "Donor Packages.", + Attributes: map[string]resourceSchema.Attribute{ + "deployment_action": util.ResourceString(). + Description("Deployment action."). + Optional(). + Build(), + "package_reference": util.ResourceString(). + Description("Package reference."). + Optional(). + Build(), + }, + }, + }, + } +} + +func (p ProjectVersioningStrategySchema) GetDatasourceSchema() datasourceSchema.Schema { + // no datasource required, returned as part of project datasource + return datasourceSchema.Schema{} +} + +type ProjectVersioningStrategyModel struct { + ProjectID types.String `tfsdk:"project_id"` + SpaceID types.String `tfsdk:"space_id"` + DonorPackageStepID types.String `tfsdk:"donor_package_step_id"` + Template types.String `tfsdk:"template"` + DonorPackage DonorPackageModel `tfsdk:"donor_package"` +} + +type DonorPackageModel struct { + DeploymentAction types.String `tfsdk:"deployment_action"` + PackageReference types.String `tfsdk:"package_reference"` +} From 1566f96f35789d269338534a6a695e263356cd7d Mon Sep 17 00:00:00 2001 From: domenicsim1 <87625140+domenicsim1@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:59:16 +1100 Subject: [PATCH 6/8] fix: Same variable with a different scope now gets an ID (#836) --- octopusdeploy_framework/resource_variable.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/octopusdeploy_framework/resource_variable.go b/octopusdeploy_framework/resource_variable.go index d2876380..9010ced7 100644 --- a/octopusdeploy_framework/resource_variable.go +++ b/octopusdeploy_framework/resource_variable.go @@ -267,13 +267,14 @@ func validateVariable(variableSet *variables.VariableSet, newVariable *variables for _, v := range variableSet.Variables { if v.Name == newVariable.Name && v.Type == newVariable.Type && (v.IsSensitive || v.Value == newVariable.Value) && v.Description == newVariable.Description && v.IsSensitive == newVariable.IsSensitive { scopeMatches, _, err := variables.MatchesScope(v.Scope, &newVariable.Scope) - if err != nil || !scopeMatches { + if err != nil { return err } - if scopeMatches { - newVariable.ID = v.GetID() - return nil + if !scopeMatches { + continue } + newVariable.ID = v.GetID() + return nil } } From eede862754c01bc2c0f979ab9e76fd55fb645ba4 Mon Sep 17 00:00:00 2001 From: Ben Pearce Date: Thu, 5 Dec 2024 09:12:36 +1000 Subject: [PATCH 7/8] feat: deployment freezes (#822) Co-authored-by: domenicsim1 Co-authored-by: Huy Nguyen --- docs/data-sources/deployment_freezes.md | 45 ++++ docs/resources/deployment_freeze.md | 69 ++++++ docs/resources/deployment_freeze_project.md | 31 +++ .../resource.tf | 38 +++ go.mod | 3 +- go.sum | 6 +- internal/errors/error.go | 2 +- .../datasource_deployment_freeze.go | 122 +++++++++ octopusdeploy_framework/framework_provider.go | 3 + .../resource_deployment_freeze.go | 234 ++++++++++++++++++ .../resource_deployment_freeze_project.go | 183 ++++++++++++++ .../resource_deployment_freeze_test.go | 125 ++++++++++ .../resource_project_flatten.go | 10 +- .../resource_tenant_project.go | 8 +- .../schemas/deployment_freeze.go | 77 ++++++ .../schemas/deployment_freeze_project.go | 57 +++++ octopusdeploy_framework/schemas/schema.go | 14 ++ .../schemas/tenant_projects.go | 8 +- octopusdeploy_framework/util/util.go | 32 +++ 19 files changed, 1044 insertions(+), 23 deletions(-) create mode 100644 docs/data-sources/deployment_freezes.md create mode 100644 docs/resources/deployment_freeze.md create mode 100644 docs/resources/deployment_freeze_project.md create mode 100644 examples/resources/octopusdeploy_deployment_freeze/resource.tf create mode 100644 octopusdeploy_framework/datasource_deployment_freeze.go create mode 100644 octopusdeploy_framework/resource_deployment_freeze.go create mode 100644 octopusdeploy_framework/resource_deployment_freeze_project.go create mode 100644 octopusdeploy_framework/resource_deployment_freeze_test.go create mode 100644 octopusdeploy_framework/schemas/deployment_freeze.go create mode 100644 octopusdeploy_framework/schemas/deployment_freeze_project.go diff --git a/docs/data-sources/deployment_freezes.md b/docs/data-sources/deployment_freezes.md new file mode 100644 index 00000000..a4a365ba --- /dev/null +++ b/docs/data-sources/deployment_freezes.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "octopusdeploy_deployment_freezes Data Source - terraform-provider-octopusdeploy" +subcategory: "" +description: |- + Provides information about deployment freezes +--- + +# octopusdeploy_deployment_freezes (Data Source) + +Provides information about deployment freezes + + + + +## Schema + +### Optional + +- `environment_ids` (List of String) A filter to search by a list of environment IDs +- `ids` (List of String) A filter to search by a list of IDs. +- `include_complete` (Boolean) Include deployment freezes that completed, default is true +- `partial_name` (String) A filter to search by a partial name. +- `project_ids` (List of String) A filter to search by a list of project IDs +- `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. + +### Read-Only + +- `deployment_freezes` (Attributes List) (see [below for nested schema](#nestedatt--deployment_freezes)) +- `id` (String) The unique ID for this resource. + + +### Nested Schema for `deployment_freezes` + +Read-Only: + +- `end` (String) The end time of the freeze +- `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 +- `start` (String) The start time of the freeze + + diff --git a/docs/resources/deployment_freeze.md b/docs/resources/deployment_freeze.md new file mode 100644 index 00000000..9f018813 --- /dev/null +++ b/docs/resources/deployment_freeze.md @@ -0,0 +1,69 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "octopusdeploy_deployment_freeze Resource - terraform-provider-octopusdeploy" +subcategory: "" +description: |- + +--- + +# octopusdeploy_deployment_freeze (Resource) + + + +## Example Usage + +```terraform +# basic freeze with no project scopes +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "Xmas" + start = "2024-12-25T00:00:00+10:00" + end = "2024-12-27T00:00:00+08:00" +} + +# Freeze with different timezones and single project/environment scope +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "Xmas" + start = "2024-12-25T00:00:00+10:00" + end = "2024-12-27T00:00:00+08:00" +} + +resource "octopusdeploy_deployment_freeze_project" "project_freeze" { + deploymentfreeze_id= octopusdeploy_deployment_freeze.freeze.id + project_id = "Projects-123" + environment_ids = [ "Environments-123", "Environments-456" ] +} + +# Freeze with ids sourced from resources and datasources. Projects can be sourced from different spaces, a single scope can only reference projects and environments from the same space. +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "End of financial year shutdown" + start = "2025-06-30T00:00:00+10:00" + end = "2025-07-02T00:00:00+10:00" +} + +resource "octopusdeploy_deployment_freeze_project" "project_freeze" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.freeze.id + project_id = resource.octopusdeploy_project.project1.id + environment_ids = [resource.octopusdeploy_environment.production.id] +} + +resource "octopusdeploy_deployment_freeze_project" "project_freeze" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.freeze.id + project_id = data.octopusdeploy_projects.second_project.projects[0].id + environment_ids = [ data.octopusdeploy_environments.default_environment.environments[0].id ] +} +``` + + +## Schema + +### Required + +- `end` (String) The end time of the freeze, must be RFC3339 format +- `name` (String) The name of this resource. +- `start` (String) The start time of the freeze, must be RFC3339 format + +### Read-Only + +- `id` (String) The unique ID for this resource. + + diff --git a/docs/resources/deployment_freeze_project.md b/docs/resources/deployment_freeze_project.md new file mode 100644 index 00000000..829dc8b6 --- /dev/null +++ b/docs/resources/deployment_freeze_project.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "octopusdeploy_deployment_freeze_project Resource - terraform-provider-octopusdeploy" +subcategory: "" +description: |- + +--- + +# octopusdeploy_deployment_freeze_project (Resource) + + + + + + +## Schema + +### Required + +- `deploymentfreeze_id` (String) The deployment freeze ID associated with this freeze scope. +- `project_id` (String) The project ID associated with this freeze scope. + +### Optional + +- `environment_ids` (List of String) The environment IDs associated with this project deployment freeze scope. + +### Read-Only + +- `id` (String) The unique ID for this resource. + + diff --git a/examples/resources/octopusdeploy_deployment_freeze/resource.tf b/examples/resources/octopusdeploy_deployment_freeze/resource.tf new file mode 100644 index 00000000..d868261c --- /dev/null +++ b/examples/resources/octopusdeploy_deployment_freeze/resource.tf @@ -0,0 +1,38 @@ +# basic freeze with no project scopes +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "Xmas" + start = "2024-12-25T00:00:00+10:00" + end = "2024-12-27T00:00:00+08:00" +} + +# Freeze with different timezones and single project/environment scope +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "Xmas" + start = "2024-12-25T00:00:00+10:00" + end = "2024-12-27T00:00:00+08:00" +} + +resource "octopusdeploy_deployment_freeze_project" "project_freeze" { + deploymentfreeze_id= octopusdeploy_deployment_freeze.freeze.id + project_id = "Projects-123" + environment_ids = [ "Environments-123", "Environments-456" ] +} + +# Freeze with ids sourced from resources and datasources. Projects can be sourced from different spaces, a single scope can only reference projects and environments from the same space. +resource "octopusdeploy_deployment_freeze" "freeze" { + name = "End of financial year shutdown" + start = "2025-06-30T00:00:00+10:00" + end = "2025-07-02T00:00:00+10:00" +} + +resource "octopusdeploy_deployment_freeze_project" "project_freeze" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.freeze.id + project_id = resource.octopusdeploy_project.project1.id + environment_ids = [resource.octopusdeploy_environment.production.id] +} + +resource "octopusdeploy_deployment_freeze_project" "project_freeze" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.freeze.id + project_id = data.octopusdeploy_projects.second_project.projects[0].id + environment_ids = [ data.octopusdeploy_environments.default_environment.environments[0].id ] +} diff --git a/go.mod b/go.mod index 7e10c51b..5ec432ca 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/OctopusDeploy/terraform-provider-octopusdeploy go 1.21 require ( - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.60.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.2 github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20240729041805-46db6fb717b4 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 @@ -77,6 +77,7 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.21.0 // indirect github.com/hashicorp/terraform-json v0.22.1 // indirect + github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect diff --git a/go.sum b/go.sum index d8eeb086..2816a74c 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/Microsoft/hcsshim v0.12.4 h1:Ev7YUMHAHoWNm+aDSPzc5W9s6E2jyL1szpVDJeZ/ github.com/Microsoft/hcsshim v0.12.4/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6CtY2Qg/JVQ= 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.60.0 h1:9j4IQ1UcAuaTytlBzQ7Mmoy/dLtofYfSGNiM22+sLXs= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.60.0/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.2 h1:8CexD1Jnf8ng4S6bHilG7s3+iQOraXZY31Dn0SAxjEM= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.2/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20240729041805-46db6fb717b4 h1:QfbVf0bOIRMp/WHAWsuVDB7KHoWnRsGbvDuOf2ua7k4= github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20240729041805-46db6fb717b4/go.mod h1:Oq9KbiRNDBB5jFmrwnrgLX0urIqR/1ptY18TzkqXm7M= github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= @@ -166,6 +166,8 @@ github.com/hashicorp/terraform-plugin-docs v0.13.0 h1:6e+VIWsVGb6jYJewfzq2ok2smP github.com/hashicorp/terraform-plugin-docs v0.13.0/go.mod h1:W0oCmHAjIlTHBbvtppWHe8fLfZ2BznQbuv8+UD8OucQ= github.com/hashicorp/terraform-plugin-framework v1.11.0 h1:M7+9zBArexHFXDx/pKTxjE6n/2UCXY6b8FIq9ZYhwfE= github.com/hashicorp/terraform-plugin-framework v1.11.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 h1:v3DapR8gsp3EM8fKMh6up9cJUFQ2iRaFsYLP8UJnCco= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0/go.mod h1:c3PnGE9pHBDfdEVG9t1S1C9ia5LW+gkFR0CygXlM8ak= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= diff --git a/internal/errors/error.go b/internal/errors/error.go index 733b4667..ecd9d107 100644 --- a/internal/errors/error.go +++ b/internal/errors/error.go @@ -2,12 +2,12 @@ package errors import ( "context" + "github.com/hashicorp/terraform-plugin-framework/resource" "log" "net/http" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" - "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) diff --git a/octopusdeploy_framework/datasource_deployment_freeze.go b/octopusdeploy_framework/datasource_deployment_freeze.go new file mode 100644 index 00000000..e02e71e2 --- /dev/null +++ b/octopusdeploy_framework/datasource_deployment_freeze.go @@ -0,0 +1,122 @@ +package octopusdeploy_framework + +import ( + "context" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deploymentfreezes" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "time" +) + +const deploymentFreezeDatasourceName = "deployment_freezes" + +type deploymentFreezesModel 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"` + IncludeComplete types.Bool `tfsdk:"include_complete"` + Status types.String `tfsdk:"status"` + Skip types.Int64 `tfsdk:"skip"` + Take types.Int64 `tfsdk:"take"` + DeploymentFreezes types.List `tfsdk:"deployment_freezes"` +} + +type deploymentFreezeDataSource struct { + *Config +} + +func (d *deploymentFreezeDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.Config = DataSourceConfiguration(req, resp) +} + +func NewDeploymentFreezeDataSource() datasource.DataSource { + return &deploymentFreezeDataSource{} +} + +func (d *deploymentFreezeDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = util.GetTypeName(deploymentFreezeDatasourceName) +} + +func (d *deploymentFreezeDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schemas.DeploymentFreezeSchema{}.GetDatasourceSchema() +} + +func (d *deploymentFreezeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data deploymentFreezesModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + query := deploymentfreezes.DeploymentFreezeQuery{ + IDs: util.Ternary(data.IDs.IsNull(), []string{}, util.ExpandStringList(data.IDs)), + 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)), + IncludeComplete: data.IncludeComplete.ValueBool(), + Status: data.Status.ValueString(), + Skip: int(data.Skip.ValueInt64()), + Take: int(data.Take.ValueInt64()), + } + + util.DatasourceReading(ctx, "deployment freezes", query) + + existingFreezes, err := deploymentfreezes.Get(d.Client, query) + if err != nil { + resp.Diagnostics.AddError("unable to load deployment freezes", err.Error()) + return + } + + flattenedFreezes := []interface{}{} + for _, freeze := range existingFreezes.DeploymentFreezes { + flattenedFreeze, diags := mapFreezeToAttribute(ctx, freeze) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + flattenedFreezes = append(flattenedFreezes, flattenedFreeze) + } + + data.ID = types.StringValue("Deployment Freezes " + time.Now().UTC().String()) + data.DeploymentFreezes, _ = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: freezeObjectType()}, flattenedFreezes) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +var _ datasource.DataSource = &deploymentFreezeDataSource{} +var _ datasource.DataSourceWithConfigure = &deploymentFreezeDataSource{} + +func mapFreezeToAttribute(ctx context.Context, freeze deploymentfreezes.DeploymentFreeze) (attr.Value, 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() { + 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 +} + +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}}, + } +} diff --git a/octopusdeploy_framework/framework_provider.go b/octopusdeploy_framework/framework_provider.go index 161b5461..0bd77334 100644 --- a/octopusdeploy_framework/framework_provider.go +++ b/octopusdeploy_framework/framework_provider.go @@ -86,6 +86,7 @@ func (p *octopusDeployFrameworkProvider) DataSources(ctx context.Context) []func NewUsersDataSource, NewServiceAccountOIDCIdentityDataSource, NewWorkersDataSource, + NewDeploymentFreezeDataSource, } } @@ -126,6 +127,8 @@ func (p *octopusDeployFrameworkProvider) Resources(ctx context.Context) []func() NewSSHConnectionWorkerResource, NewScriptModuleResource, NewUserResource, + NewDeploymentFreezeResource, + NewDeploymentFreezeProjectResource, NewServiceAccountOIDCIdentity, } } diff --git a/octopusdeploy_framework/resource_deployment_freeze.go b/octopusdeploy_framework/resource_deployment_freeze.go new file mode 100644 index 00000000..6298faa4 --- /dev/null +++ b/octopusdeploy_framework/resource_deployment_freeze.go @@ -0,0 +1,234 @@ +package octopusdeploy_framework + +import ( + "context" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deploymentfreezes" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal/errors" + "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/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "time" +) + +const deploymentFreezeResourceName = "deployment_freeze" + +type deploymentFreezeModel struct { + Name types.String `tfsdk:"name"` + Start timetypes.RFC3339 `tfsdk:"start"` + End timetypes.RFC3339 `tfsdk:"end"` + + schemas.ResourceModel +} + +type deploymentFreezeResource struct { + *Config +} + +var _ resource.Resource = &deploymentFreezeResource{} + +func NewDeploymentFreezeResource() resource.Resource { + return &deploymentFreezeResource{} +} + +func (f *deploymentFreezeResource) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = util.GetTypeName(deploymentFreezeResourceName) +} + +func (f *deploymentFreezeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schemas.DeploymentFreezeSchema{}.GetResourceSchema() +} + +func (f *deploymentFreezeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + f.Config = ResourceConfiguration(req, resp) +} + +func (f *deploymentFreezeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + var state *deploymentFreezeModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + deploymentFreeze, err := deploymentfreezes.GetById(f.Config.Client, state.GetID()) + if err != nil { + if err := errors.ProcessApiErrorV2(ctx, resp, state, err, "deployment freeze"); err != nil { + resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) + } + return + } + + if deploymentFreeze.Name != state.Name.ValueString() { + state.Name = types.StringValue(deploymentFreeze.Name) + } + + mapToState(ctx, state, deploymentFreeze) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (f *deploymentFreezeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + var plan *deploymentFreezeModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + deploymentFreeze, diags := mapFromState(plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + createdFreeze, err := deploymentfreezes.Add(f.Config.Client, deploymentFreeze) + if err != nil { + resp.Diagnostics.AddError("error while creating deployment freeze", err.Error()) + return + } + + diags.Append(mapToState(ctx, plan, createdFreeze)...) + if diags.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (f *deploymentFreezeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + var plan *deploymentFreezeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + existingFreeze, err := deploymentfreezes.GetById(f.Config.Client, plan.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) + return + } + + updatedFreeze, diags := mapFromState(plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + // this resource doesn't include scopes, need to copy it from the fetched resource + updatedFreeze.ProjectEnvironmentScope = existingFreeze.ProjectEnvironmentScope + + updatedFreeze.SetID(existingFreeze.GetID()) + updatedFreeze.Links = existingFreeze.Links + + updatedFreeze, err = deploymentfreezes.Update(f.Config.Client, updatedFreeze) + if err != nil { + resp.Diagnostics.AddError("error while updating deployment freeze", err.Error()) + return + } + + diags.Append(mapToState(ctx, plan, updatedFreeze)...) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (f *deploymentFreezeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + var state *deploymentFreezeModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + freeze, err := deploymentfreezes.GetById(f.Config.Client, state.GetID()) + if err != nil { + resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) + return + } + + err = deploymentfreezes.Delete(f.Config.Client, freeze) + if err != nil { + resp.Diagnostics.AddError("unable to delete deployment freeze", err.Error()) + } + + resp.State.RemoveResource(ctx) +} + +func mapToState(ctx context.Context, state *deploymentFreezeModel, deploymentFreeze *deploymentfreezes.DeploymentFreeze) diag.Diagnostics { + state.ID = types.StringValue(deploymentFreeze.ID) + state.Name = types.StringValue(deploymentFreeze.Name) + + updatedStart, diags := calculateStateTime(ctx, state.Start, *deploymentFreeze.Start) + if diags.HasError() { + return diags + } + state.Start = updatedStart + + updatedEnd, diags := calculateStateTime(ctx, state.End, *deploymentFreeze.End) + if diags.HasError() { + return diags + } + state.End = updatedEnd + + return nil +} + +func calculateStateTime(ctx context.Context, stateValue timetypes.RFC3339, updatedValue time.Time) (timetypes.RFC3339, diag.Diagnostics) { + stateTime, diags := stateValue.ValueRFC3339Time() + if diags.HasError() { + return timetypes.RFC3339{}, diags + } + stateTimeUTC := timetypes.NewRFC3339TimeValue(stateTime.UTC()) + updatedValueUTC := updatedValue.UTC() + valuesAreEqual, diags := stateTimeUTC.StringSemanticEquals(ctx, timetypes.NewRFC3339TimeValue(updatedValueUTC)) + if diags.HasError() { + return timetypes.NewRFC3339Null(), diags + } + + if valuesAreEqual { + return stateValue, diags + } + + location := stateTime.Location() + newValue := timetypes.NewRFC3339TimeValue(updatedValueUTC.In(location)) + return newValue, diags +} + +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, + } + + freeze.ID = state.ID.String() + return &freeze, nil +} diff --git a/octopusdeploy_framework/resource_deployment_freeze_project.go b/octopusdeploy_framework/resource_deployment_freeze_project.go new file mode 100644 index 00000000..4dd5465f --- /dev/null +++ b/octopusdeploy_framework/resource_deployment_freeze_project.go @@ -0,0 +1,183 @@ +package octopusdeploy_framework + +import ( + "context" + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deploymentfreezes" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "net/http" +) + +type deploymentFreezeProjectResource struct { + *Config +} + +const description = "deployment freeze project scope" + +var _ resource.Resource = &deploymentFreezeProjectResource{} +var _ resource.ResourceWithConfigure = &deploymentFreezeProjectResource{} + +func NewDeploymentFreezeProjectResource() resource.Resource { + return &deploymentFreezeProjectResource{} +} + +func (d *deploymentFreezeProjectResource) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = util.GetTypeName("deployment_freeze_project") +} + +func (d *deploymentFreezeProjectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schemas.DeploymentFreezeProjectSchema{}.GetResourceSchema() +} + +func (d *deploymentFreezeProjectResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + d.Config = ResourceConfiguration(req, resp) +} + +func (d *deploymentFreezeProjectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + util.Create(ctx, description) + + var plan schemas.DeploymentFreezeProjectResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("adding project (%s) to deployment freeze (%s)", plan.ProjectID.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()) + return + } + freeze.ProjectEnvironmentScope[plan.ProjectID.ValueString()] = util.ExpandStringList(plan.EnvironmentIDs) + + freeze, err = deploymentfreezes.Update(d.Client, freeze) + if err != nil { + resp.Diagnostics.AddError("error while updating deployment freeze", err.Error()) + return + } + + plan.ID = types.StringValue(util.BuildCompositeId(plan.DeploymentFreezeID.ValueString(), plan.ProjectID.ValueString())) + plan.EnvironmentIDs = mapEnvironmentIds(freeze.ProjectEnvironmentScope[plan.ProjectID.ValueString()]) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + tflog.Debug(ctx, fmt.Sprintf("scope for project (%s) added to deployment freeze (%s)", plan.ProjectID, plan.DeploymentFreezeID)) + util.Created(ctx, description) +} + +func (d *deploymentFreezeProjectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + util.Reading(ctx, description) + var data schemas.DeploymentFreezeProjectResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + bits := util.SplitCompositeId(data.ID.ValueString()) + freezeId := bits[0] + projectId := bits[1] + + freeze, err := deploymentfreezes.GetById(d.Client, freezeId) + if err != nil { + apiError := err.(*core.APIError) + if apiError.StatusCode != http.StatusNotFound { + resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) + return + } + } + + data.EnvironmentIDs = mapEnvironmentIds(freeze.ProjectEnvironmentScope[projectId]) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + util.Read(ctx, description) +} + +func (d *deploymentFreezeProjectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + util.Update(ctx, description) + + var plan, state schemas.DeploymentFreezeProjectResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + freeze, err := deploymentfreezes.GetById(d.Client, state.DeploymentFreezeID.ValueString()) + if err != nil { + apiError := err.(*core.APIError) + if apiError.StatusCode != http.StatusNotFound { + resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) + return + } + } + + tflog.Debug(ctx, fmt.Sprintf("updating project (%s) to deployment freeze (%s)", plan.ProjectID.ValueString(), plan.DeploymentFreezeID.ValueString())) + freeze.ProjectEnvironmentScope[plan.ProjectID.ValueString()] = util.ExpandStringList(plan.EnvironmentIDs) + _, err = deploymentfreezes.Update(d.Client, freeze) + if err != nil { + resp.Diagnostics.AddError("error while updating deployment freeze", err.Error()) + return + } + + plan.ID = types.StringValue(util.BuildCompositeId(plan.DeploymentFreezeID.ValueString(), plan.ProjectID.ValueString())) + plan.EnvironmentIDs = mapEnvironmentIds(freeze.ProjectEnvironmentScope[plan.ProjectID.ValueString()]) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + tflog.Debug(ctx, fmt.Sprintf("updated project (%s) to deployment freeze (%s)", plan.ProjectID.ValueString(), plan.DeploymentFreezeID.ValueString())) + util.Updated(ctx, description) +} + +func (d *deploymentFreezeProjectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + internal.Mutex.Lock() + defer internal.Mutex.Unlock() + + util.Delete(ctx, description) + + var data schemas.DeploymentFreezeProjectResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + freeze, err := deploymentfreezes.GetById(d.Client, data.DeploymentFreezeID.ValueString()) + if err != nil { + apiError := err.(*core.APIError) + if apiError.StatusCode != http.StatusNotFound { + resp.Diagnostics.AddError("unable to load deployment freeze", err.Error()) + return + } + } + + delete(freeze.ProjectEnvironmentScope, data.ProjectID.ValueString()) + freeze, err = deploymentfreezes.Update(d.Client, freeze) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("cannot remove project scope (%s) from deployment freeze (%s)", data.ProjectID.ValueString(), data.DeploymentFreezeID.ValueString()), err.Error()) + } + + tflog.Debug(ctx, fmt.Sprintf("scope for project (%s) removed from deployment freeze (%s)", data.ProjectID.ValueString(), data.DeploymentFreezeID.ValueString())) + util.Deleted(ctx, description) +} + +func mapEnvironmentIds(ids []string) basetypes.ListValue { + environmentIDs := make([]attr.Value, len(ids)) + for i, envID := range ids { + environmentIDs[i] = types.StringValue(envID) + } + environmentIdList, _ := types.ListValue(types.StringType, environmentIDs) + return environmentIdList +} diff --git a/octopusdeploy_framework/resource_deployment_freeze_test.go b/octopusdeploy_framework/resource_deployment_freeze_test.go new file mode 100644 index 00000000..1098c1fd --- /dev/null +++ b/octopusdeploy_framework/resource_deployment_freeze_test.go @@ -0,0 +1,125 @@ +package octopusdeploy_framework + +import ( + "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" + "strings" + "testing" + "time" +) + +func TestNewDeploymentFreezeResource(t *testing.T) { + localName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + resourceName := "octopusdeploy_deployment_freeze." + localName + name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + start := fmt.Sprintf("%d-11-21T06:30:00+10:00", time.Now().Year()+1) + end := fmt.Sprintf("%d-11-21T08:30:00+10:00", time.Now().Year()+1) + updatedEnd := fmt.Sprintf("%d-11-21T08:30:00+10:00", time.Now().Year()+2) + projectName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + environmentName1 := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + environmentName2 := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + spaceName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + projectGroupName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + lifecycleName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + resource.Test(t, resource.TestCase{ + CheckDestroy: testDeploymentFreezeCheckDestroy, + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Check: resource.ComposeTestCheckFunc( + testDeploymentFreezeExists(resourceName), + 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), + }, + { + Check: resource.ComposeTestCheckFunc( + testDeploymentFreezeExists(resourceName), + 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), + }, + }, + }) +} + +func testDeploymentFreezeBasic(localName string, freezeName string, start string, end string, spaceName string, environments []string, projectName string, projectGroupName string, lifecycleName string) string { + spaceLocalName := fmt.Sprintf("space_%s", localName) + projectScopeLocalName := fmt.Sprintf("project_scope_%s", localName) + projectLocalName := fmt.Sprintf("project_%s", localName) + lifecycleLocalName := fmt.Sprintf("lifecycle_%s", localName) + projectGroupLocalName := fmt.Sprintf("project_group_%s", localName) + environmentScopes := make([]string, 0, len(environments)) + environmentResources := "" + for i, environmentName := range environments { + environmentLocalName := fmt.Sprintf("environment_%d_%s", i, localName) + environmentResources += fmt.Sprintln(createEnvironment(spaceLocalName, environmentLocalName, environmentName)) + environmentScopes = append(environmentScopes, fmt.Sprintf("resource.octopusdeploy_environment.%s.id", environmentLocalName)) + } + + projectScopes := fmt.Sprintf(`resource "octopusdeploy_deployment_freeze_project" "%s" { + deploymentfreeze_id = octopusdeploy_deployment_freeze.%s.id + project_id = octopusdeploy_project.%s.id + environment_ids = [ %s ] + }`, projectScopeLocalName, localName, projectLocalName, strings.Join(environmentScopes, ",")) + + fmt.Println(projectScopes) + + return fmt.Sprintf(` + %s + + %s + + %s + + %s + + %s + + resource "octopusdeploy_deployment_freeze" "%s" { + name = "%s" + start = "%s" + end = "%s" + } + + %s`, + createSpace(spaceLocalName, spaceName), + environmentResources, + createLifecycle(spaceLocalName, lifecycleLocalName, lifecycleName), + createProjectGroup(spaceLocalName, projectGroupLocalName, projectGroupName), + createProject(spaceLocalName, projectLocalName, projectName, lifecycleLocalName, projectGroupLocalName), + localName, freezeName, start, end, projectScopes) +} + +func testDeploymentFreezeExists(prefix string) resource.TestCheckFunc { + return func(s *terraform.State) error { + freezeId := s.RootModule().Resources[prefix].Primary.ID + if _, err := deploymentfreezes.GetById(octoClient, freezeId); err != nil { + return err + } + + return nil + } +} + +func testDeploymentFreezeCheckDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "octopusdeploy_deployment_freeze" { + continue + } + + feed, err := deploymentfreezes.GetById(octoClient, rs.Primary.ID) + if err == nil && feed != nil { + return fmt.Errorf("Deployment Freeze (%s) still exists", rs.Primary.ID) + } + } + + return nil +} diff --git a/octopusdeploy_framework/resource_project_flatten.go b/octopusdeploy_framework/resource_project_flatten.go index 6e411b08..834d5caf 100644 --- a/octopusdeploy_framework/resource_project_flatten.go +++ b/octopusdeploy_framework/resource_project_flatten.go @@ -260,7 +260,7 @@ func flattenTemplates(templates []actiontemplates.ActionTemplateParameter) types "default_value": util.StringOrNull(template.DefaultValue.Value), "display_settings": types.MapValueMust( types.StringType, - convertMapStringToMapAttrValue(template.DisplaySettings), + util.ConvertMapStringToMapAttrValue(template.DisplaySettings), ), }) @@ -310,14 +310,6 @@ func flattenReleaseCreationStrategy(strategy *projects.ReleaseCreationStrategy) return types.ListValueMust(types.ObjectType{AttrTypes: getReleaseCreationStrategyAttrTypes()}, []attr.Value{obj}) } -func convertMapStringToMapAttrValue(m map[string]string) map[string]attr.Value { - result := make(map[string]attr.Value, len(m)) - for k, v := range m { - result[k] = types.StringValue(v) - } - return result -} - func flattenDeploymentActionPackage(pkg *packages.DeploymentActionPackage) types.List { if pkg == nil { return types.ListNull(types.ObjectType{AttrTypes: getDonorPackageAttrTypes()}) diff --git a/octopusdeploy_framework/resource_tenant_project.go b/octopusdeploy_framework/resource_tenant_project.go index 40812802..3ee22ee5 100644 --- a/octopusdeploy_framework/resource_tenant_project.go +++ b/octopusdeploy_framework/resource_tenant_project.go @@ -6,7 +6,6 @@ import ( "fmt" internalErrors "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal/errors" "net/http" - "strings" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" @@ -68,9 +67,10 @@ func (t *tenantProjectResource) Create(ctx context.Context, req resource.CreateR _, err = tenants.Update(t.Client, tenant) if err != nil { resp.Diagnostics.AddError("cannot update tenant environment", err.Error()) + return } - plan.ID = types.StringValue(schemas.BuildTenantProjectID(spaceId, plan.TenantID.ValueString(), plan.ProjectID.ValueString())) + plan.ID = types.StringValue(util.BuildCompositeId(spaceId, plan.TenantID.ValueString(), plan.ProjectID.ValueString())) plan.SpaceID = types.StringValue(spaceId) plan.EnvironmentIDs = util.FlattenStringList(tenant.ProjectEnvironments[plan.ProjectID.ValueString()]) @@ -85,7 +85,7 @@ func (t *tenantProjectResource) Read(ctx context.Context, req resource.ReadReque return } - bits := strings.Split(data.ID.ValueString(), ":") + bits := util.SplitCompositeId(data.ID.ValueString()) spaceID := bits[0] tenantID := bits[1] projectID := bits[2] @@ -133,7 +133,7 @@ func (t *tenantProjectResource) Update(ctx context.Context, req resource.UpdateR resp.Diagnostics.AddError("cannot update tenant environment", err.Error()) } - plan.ID = types.StringValue(schemas.BuildTenantProjectID(spaceId, plan.TenantID.ValueString(), plan.ProjectID.ValueString())) + plan.ID = types.StringValue(util.BuildCompositeId(spaceId, plan.TenantID.ValueString(), plan.ProjectID.ValueString())) plan.SpaceID = types.StringValue(spaceId) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) diff --git a/octopusdeploy_framework/schemas/deployment_freeze.go b/octopusdeploy_framework/schemas/deployment_freeze.go new file mode 100644 index 00000000..6baa8dd0 --- /dev/null +++ b/octopusdeploy_framework/schemas/deployment_freeze.go @@ -0,0 +1,77 @@ +package schemas + +import ( + datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type DeploymentFreezeSchema struct{} + +func (d DeploymentFreezeSchema) GetResourceSchema() resourceSchema.Schema { + return resourceSchema.Schema{ + Attributes: map[string]resourceSchema.Attribute{ + "id": GetIdResourceSchema(), + "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), + }, + } +} + +func (d DeploymentFreezeSchema) GetDatasourceSchema() datasourceSchema.Schema { + return datasourceSchema.Schema{ + Description: "Provides information about deployment freezes", + Attributes: map[string]datasourceSchema.Attribute{ + "id": GetIdDatasourceSchema(true), + "ids": GetQueryIDsDatasourceSchema(), + "skip": GetQuerySkipDatasourceSchema(), + "take": GetQueryTakeDatasourceSchema(), + "partial_name": GetQueryPartialNameDatasourceSchema(), + "project_ids": datasourceSchema.ListAttribute{ + Description: "A filter to search by a list of project IDs", + ElementType: types.StringType, + Optional: true, + }, + "environment_ids": datasourceSchema.ListAttribute{ + Description: "A filter to search by a list of environment IDs", + ElementType: types.StringType, + Optional: true, + }, + "include_complete": GetBooleanDatasourceAttribute("Include deployment freezes that completed, default is true", true), + "status": datasourceSchema.StringAttribute{ + Description: "Filter by the status of the deployment freeze, value values are Expired, Active, Scheduled (case-insensitive)", + Optional: true, + }, + "deployment_freezes": datasourceSchema.ListNestedAttribute{ + NestedObject: datasourceSchema.NestedAttributeObject{ + Attributes: map[string]datasourceSchema.Attribute{ + "id": GetIdDatasourceSchema(true), + "name": GetReadonlyNameDatasourceSchema(), + "start": datasourceSchema.StringAttribute{ + Description: "The start time of the freeze", + Optional: false, + Computed: true, + }, + "end": datasourceSchema.StringAttribute{ + Description: "The end time of the freeze", + Optional: false, + Computed: true, + }, + "project_environment_scope": datasourceSchema.MapAttribute{ + ElementType: types.ListType{ElemType: types.StringType}, + Description: "The project environment scope of the deployment freeze", + Optional: false, + Computed: true, + }, + }, + }, + Optional: false, + Computed: true, + }, + }, + } + +} + +var _ EntitySchema = &DeploymentFreezeSchema{} diff --git a/octopusdeploy_framework/schemas/deployment_freeze_project.go b/octopusdeploy_framework/schemas/deployment_freeze_project.go new file mode 100644 index 00000000..93030337 --- /dev/null +++ b/octopusdeploy_framework/schemas/deployment_freeze_project.go @@ -0,0 +1,57 @@ +package schemas + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type DeploymentFreezeProjectSchema struct{} + +type DeploymentFreezeProjectResourceModel struct { + DeploymentFreezeID types.String `tfsdk:"deploymentfreeze_id"` + ProjectID types.String `tfsdk:"project_id"` + EnvironmentIDs types.List `tfsdk:"environment_ids"` + ResourceModel +} + +func (d DeploymentFreezeProjectSchema) GetResourceSchema() resourceSchema.Schema { + return resourceSchema.Schema{ + Attributes: map[string]resourceSchema.Attribute{ + "id": GetIdResourceSchema(), + "deploymentfreeze_id": resourceSchema.StringAttribute{ + Description: "The deployment freeze ID associated with this freeze scope.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": resourceSchema.StringAttribute{ + Description: "The project ID associated with this freeze scope.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "environment_ids": resourceSchema.ListAttribute{ + Description: "The environment IDs associated with this project deployment freeze scope.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + }, + } +} + +func (d DeploymentFreezeProjectSchema) GetDatasourceSchema() datasourceSchema.Schema { + //TODO implement me + panic("implement me") +} + +var _ EntitySchema = DeploymentFreezeProjectSchema{} diff --git a/octopusdeploy_framework/schemas/schema.go b/octopusdeploy_framework/schemas/schema.go index ad468ca8..4e95c45f 100644 --- a/octopusdeploy_framework/schemas/schema.go +++ b/octopusdeploy_framework/schemas/schema.go @@ -2,10 +2,12 @@ package schemas import ( "fmt" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "regexp" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" @@ -432,3 +434,15 @@ func GetSensitiveResourceSchema(description string, isRequired bool) resourceSch return s } + +func GetDateTimeResourceSchema(description string, isRequired bool) resourceSchema.Attribute { + regex := "^((?:(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?))(?:Z|[\\+-]\\d{2}:\\d{2})?)" + return resourceSchema.StringAttribute{ + Description: description, + Required: isRequired, + CustomType: timetypes.RFC3339Type{}, + Validators: []validator.String{ + stringvalidator.RegexMatches(regexp.MustCompile(regex), fmt.Sprintf("must match RFC3339 format, %s", regex)), + }, + } +} diff --git a/octopusdeploy_framework/schemas/tenant_projects.go b/octopusdeploy_framework/schemas/tenant_projects.go index c5f2effb..98a9c4f9 100644 --- a/octopusdeploy_framework/schemas/tenant_projects.go +++ b/octopusdeploy_framework/schemas/tenant_projects.go @@ -1,8 +1,8 @@ package schemas import ( - "fmt" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" "github.com/hashicorp/terraform-plugin-framework/attr" datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -94,10 +94,6 @@ func (t TenantProjectsSchema) GetResourceSchema() resourceSchema.Schema { }} } -func BuildTenantProjectID(spaceID string, tenantID string, projectID string) string { - return fmt.Sprintf("%s:%s:%s", spaceID, tenantID, projectID) -} - func TenantProjectType() map[string]attr.Type { return map[string]attr.Type{ "id": types.StringType, @@ -116,7 +112,7 @@ func MapTenantToTenantProject(tenant *tenants.Tenant, projectID string) attr.Val environmentIdList, _ := types.ListValue(types.StringType, environmentIDs) return types.ObjectValueMust(TenantProjectType(), map[string]attr.Value{ - "id": types.StringValue(BuildTenantProjectID(tenant.SpaceID, tenant.ID, projectID)), + "id": types.StringValue(util.BuildCompositeId(tenant.SpaceID, tenant.ID, projectID)), "tenant_id": types.StringValue(tenant.ID), "project_id": types.StringValue(projectID), "environment_ids": environmentIdList, diff --git a/octopusdeploy_framework/util/util.go b/octopusdeploy_framework/util/util.go index b67394f7..41ac7329 100644 --- a/octopusdeploy_framework/util/util.go +++ b/octopusdeploy_framework/util/util.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" + "strings" ) func GetProviderName() string { @@ -141,3 +142,34 @@ func GetNumber(val types.Int64) int { return v } + +func ConvertMapStringToMapAttrValue(m map[string]string) map[string]attr.Value { + result := make(map[string]attr.Value, len(m)) + for k, v := range m { + result[k] = types.StringValue(v) + } + return result +} + +func ConvertMapStringArrayToMapAttrValue(ctx context.Context, m map[string][]string) (map[string]attr.Value, diag.Diagnostics) { + var diags diag.Diagnostics + result := make(map[string]attr.Value, len(m)) + for k, v := range m { + values := make([]attr.Value, len(v)) + for i, s := range v { + values[i] = types.StringValue(s) + } + result[k], diags = types.SetValueFrom(ctx, types.StringType, v) + } + + return result, diags +} + +const sep = ":" + +func BuildCompositeId(keys ...string) string { + return strings.Join(keys, sep) +} +func SplitCompositeId(id string) []string { + return strings.Split(id, sep) +} From 8bfcde1e8c7b9a398112340099798d6efec8ef43 Mon Sep 17 00:00:00 2001 From: grace-rehn Date: Thu, 5 Dec 2024 10:45:38 +1000 Subject: [PATCH 8/8] feat: Add support for generic oidc accounts (#831) * feat: Add support for generic oidc accounts * chore: remove subject keys which are not needed * Use go client branch for now and update docs * fix: typos * fix: more typos and unused fields * fix: move implementation to framework * chore: doc updates * fix: docs * fix: docs again * fix: docs again * chore: update go client version * chore: add addition test step to test update --- docs/data-sources/accounts.md | 2 +- docs/resources/generic_oidc_account.md | 51 +++++ .../import.sh | 1 + .../resource.tf | 5 + go.mod | 2 +- go.sum | 4 +- octopusdeploy/schema_queries.go | 3 +- octopusdeploy/schema_utilities.go | 3 +- octopusdeploy_framework/framework_provider.go | 1 + .../resource_generic_oidc_account.go | 179 ++++++++++++++++++ .../resource_generic_oidc_account_test.go | 78 ++++++++ .../schemas/generic_oidc_account.go | 86 +++++++++ 12 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 docs/resources/generic_oidc_account.md create mode 100644 examples/resources/octopusdeploy_generic_oidc_account/import.sh create mode 100644 examples/resources/octopusdeploy_generic_oidc_account/resource.tf create mode 100644 octopusdeploy_framework/resource_generic_oidc_account.go create mode 100644 octopusdeploy_framework/resource_generic_oidc_account_test.go create mode 100644 octopusdeploy_framework/schemas/generic_oidc_account.go diff --git a/docs/data-sources/accounts.md b/docs/data-sources/accounts.md index a108cfe1..27bb16f6 100644 --- a/docs/data-sources/accounts.md +++ b/docs/data-sources/accounts.md @@ -26,7 +26,7 @@ data "octopusdeploy_accounts" "example" { ### Optional -- `account_type` (String) A filter to search by a list of account types. Valid account types are `AmazonWebServicesAccount`, `AmazonWebServicesRoleAccount`, `AmazonWebServicesOidcAccount`, `AzureServicePrincipal`, `AzureSubscription`, `None`, `SshKeyPair`, `Token`, or `UsernamePassword`. +- `account_type` (String) A filter to search by a list of account types. Valid account types are `AmazonWebServicesAccount`, `AmazonWebServicesRoleAccount`, `AmazonWebServicesOidcAccount`, `AzureServicePrincipal`, `AzureSubscription`, `GenericOidcAccount`, `None`, `SshKeyPair`, `Token`, or `UsernamePassword`. - `ids` (List of String) A filter to search by a list of IDs. - `partial_name` (String) A filter to search by the partial match of a name. - `skip` (Number) A filter to specify the number of items to skip in the response. diff --git a/docs/resources/generic_oidc_account.md b/docs/resources/generic_oidc_account.md new file mode 100644 index 00000000..b6be4b96 --- /dev/null +++ b/docs/resources/generic_oidc_account.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "octopusdeploy_generic_oidc_account Resource - terraform-provider-octopusdeploy" +subcategory: "" +description: |- + This resource manages a Generic OIDC Account in Octopus Deploy. +--- + +# octopusdeploy_generic_oidc_account (Resource) + +This resource manages a Generic OIDC Account in Octopus Deploy. + +## Example Usage + +```terraform +resource "octopusdeploy_generic_oidc_account" "example" { + name = "Generic OpenID Connect Account (OK to Delete)" + execution_subject_keys = ["space", "project"] + audience = "api://default" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the generic oidc account. + +### Optional + +- `audience` (String) The audience associated with this resource. +- `description` (String) The description of this generic oidc account. +- `environments` (List of String) A list of environment IDs associated with this resource. +- `execution_subject_keys` (List of String) Keys to include in a deployment or runbook. Valid options are `space`, `environment`, `project`, `tenant`, `runbook`, `account`, `type`. +- `space_id` (String) The space ID associated with this resource. +- `tenant_tags` (List of String) A list of tenant tags associated with this resource. +- `tenanted_deployment_participation` (String) The tenanted deployment mode of the resource. Valid account types are `Untenanted`, `TenantedOrUntenanted`, or `Tenanted`. +- `tenants` (List of String) A list of tenant IDs associated with this resource. + +### Read-Only + +- `id` (String) The unique ID for this resource. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import [options] octopusdeploy_generic_oidc_account. +``` diff --git a/examples/resources/octopusdeploy_generic_oidc_account/import.sh b/examples/resources/octopusdeploy_generic_oidc_account/import.sh new file mode 100644 index 00000000..f9540307 --- /dev/null +++ b/examples/resources/octopusdeploy_generic_oidc_account/import.sh @@ -0,0 +1 @@ +terraform import [options] octopusdeploy_generic_oidc_account. diff --git a/examples/resources/octopusdeploy_generic_oidc_account/resource.tf b/examples/resources/octopusdeploy_generic_oidc_account/resource.tf new file mode 100644 index 00000000..6ad25b7d --- /dev/null +++ b/examples/resources/octopusdeploy_generic_oidc_account/resource.tf @@ -0,0 +1,5 @@ +resource "octopusdeploy_generic_oidc_account" "example" { + name = "Generic OpenID Connect Account (OK to Delete)" + execution_subject_keys = ["space", "project"] + audience = "api://default" +} diff --git a/go.mod b/go.mod index 5ec432ca..42cb0945 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/OctopusDeploy/terraform-provider-octopusdeploy go 1.21 require ( - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.2 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.0 github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20240729041805-46db6fb717b4 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 2816a74c..2dbca877 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/Microsoft/hcsshim v0.12.4 h1:Ev7YUMHAHoWNm+aDSPzc5W9s6E2jyL1szpVDJeZ/ github.com/Microsoft/hcsshim v0.12.4/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6CtY2Qg/JVQ= 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.62.2 h1:8CexD1Jnf8ng4S6bHilG7s3+iQOraXZY31Dn0SAxjEM= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.2/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.0 h1:TshwN+IqKt21uY9aXzj0ou0Ew92uIi3+ZGTccVd9Z8g= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.0/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20240729041805-46db6fb717b4 h1:QfbVf0bOIRMp/WHAWsuVDB7KHoWnRsGbvDuOf2ua7k4= github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework v0.0.0-20240729041805-46db6fb717b4/go.mod h1:Oq9KbiRNDBB5jFmrwnrgLX0urIqR/1ptY18TzkqXm7M= github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= diff --git a/octopusdeploy/schema_queries.go b/octopusdeploy/schema_queries.go index 1322c931..651fc55d 100644 --- a/octopusdeploy/schema_queries.go +++ b/octopusdeploy/schema_queries.go @@ -7,7 +7,7 @@ import ( func getQueryAccountType() *schema.Schema { return &schema.Schema{ - Description: "A filter to search by a list of account types. Valid account types are `AmazonWebServicesAccount`, `AmazonWebServicesRoleAccount`, `AmazonWebServicesOidcAccount`, `AzureServicePrincipal`, `AzureSubscription`, `None`, `SshKeyPair`, `Token`, or `UsernamePassword`.", + Description: "A filter to search by a list of account types. Valid account types are `AmazonWebServicesAccount`, `AmazonWebServicesRoleAccount`, `AmazonWebServicesOidcAccount`, `AzureServicePrincipal`, `AzureSubscription`, `GenericOidcAccount`, `None`, `SshKeyPair`, `Token`, or `UsernamePassword`.", Optional: true, Type: schema.TypeString, ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{ @@ -17,6 +17,7 @@ func getQueryAccountType() *schema.Schema { "AzureServicePrincipal", "AzureOIDC", "AzureSubscription", + "GenericOidcAccount", "None", "SshKeyPair", "Token", diff --git a/octopusdeploy/schema_utilities.go b/octopusdeploy/schema_utilities.go index 1cb48166..3b80f909 100644 --- a/octopusdeploy/schema_utilities.go +++ b/octopusdeploy/schema_utilities.go @@ -9,7 +9,7 @@ import ( func getAccountTypeSchema(isRequired bool) *schema.Schema { schema := &schema.Schema{ - Description: "Specifies the type of the account. Valid account types are `AmazonWebServicesAccount`, `AmazonWebServicesRoleAccount`, `AzureServicePrincipal`, `AzureOIDC`, `AzureSubscription`, `AmazonWebServicesOidcAccount`, `None`, `SshKeyPair`, `Token`, or `UsernamePassword`.", + Description: "Specifies the type of the account. Valid account types are `AmazonWebServicesAccount`, `AmazonWebServicesRoleAccount`, `AzureServicePrincipal`, `AzureOIDC`, `AzureSubscription`, `AmazonWebServicesOidcAccount`, `GenericOidcAccount`, `None`, `SshKeyPair`, `Token`, or `UsernamePassword`.", ForceNew: true, Type: schema.TypeString, ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{ @@ -18,6 +18,7 @@ func getAccountTypeSchema(isRequired bool) *schema.Schema { "AzureServicePrincipal", "AzureOIDC", "AzureSubscription", + "GenericOidcAccount", "None", "SshKeyPair", "Token", diff --git a/octopusdeploy_framework/framework_provider.go b/octopusdeploy_framework/framework_provider.go index 0bd77334..514471aa 100644 --- a/octopusdeploy_framework/framework_provider.go +++ b/octopusdeploy_framework/framework_provider.go @@ -130,6 +130,7 @@ func (p *octopusDeployFrameworkProvider) Resources(ctx context.Context) []func() NewDeploymentFreezeResource, NewDeploymentFreezeProjectResource, NewServiceAccountOIDCIdentity, + NewGenericOidcResource, } } diff --git a/octopusdeploy_framework/resource_generic_oidc_account.go b/octopusdeploy_framework/resource_generic_oidc_account.go new file mode 100644 index 00000000..5de260ca --- /dev/null +++ b/octopusdeploy_framework/resource_generic_oidc_account.go @@ -0,0 +1,179 @@ +package octopusdeploy_framework + +import ( + "context" + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal/errors" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &genericOidcAccountResource{} +var _ resource.ResourceWithImportState = &genericOidcAccountResource{} + +type genericOidcAccountResource struct { + *Config +} + +func NewGenericOidcResource() resource.Resource { + return &genericOidcAccountResource{} +} + +func (r *genericOidcAccountResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = util.GetTypeName("generic_oidc_account") +} + +func (r *genericOidcAccountResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schemas.GenericOidcAccountSchema{}.GetResourceSchema() +} + +func (r *genericOidcAccountResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.Config = ResourceConfiguration(req, resp) +} +func (r *genericOidcAccountResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan schemas.GenericOidcAccountResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "Creating generic oidc account", map[string]interface{}{ + "name": plan.Name.ValueString(), + }) + + account := expandGenericOidcAccountResource(ctx, plan) + createdAccount, err := accounts.Add(r.Client, account) + if err != nil { + resp.Diagnostics.AddError("Error creating generic oidc account", err.Error()) + return + } + + state := flattenGenericOidcAccountResource(ctx, createdAccount.(*accounts.GenericOIDCAccount), plan) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *genericOidcAccountResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state schemas.GenericOidcAccountResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + account, err := accounts.GetByID(r.Client, state.SpaceID.ValueString(), state.ID.ValueString()) + if err != nil { + if err := errors.ProcessApiErrorV2(ctx, resp, state, err, "genericOidcAccountResource"); err != nil { + resp.Diagnostics.AddError("unable to load generic oidc account", err.Error()) + } + return + } + + newState := flattenGenericOidcAccountResource(ctx, account.(*accounts.GenericOIDCAccount), state) + resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) +} + +func (r *genericOidcAccountResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan schemas.GenericOidcAccountResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + account := expandGenericOidcAccountResource(ctx, plan) + updatedAccount, err := accounts.Update(r.Client, account) + if err != nil { + resp.Diagnostics.AddError("Error updating generic oidc account", err.Error()) + return + } + + state := flattenGenericOidcAccountResource(ctx, updatedAccount.(*accounts.GenericOIDCAccount), plan) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *genericOidcAccountResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state schemas.GenericOidcAccountResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := accounts.DeleteByID(r.Client, state.SpaceID.ValueString(), state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting generic oidc account", err.Error()) + return + } +} + +func (r *genericOidcAccountResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + accountID := req.ID + + account, err := accounts.GetByID(r.Client, r.Client.GetSpaceID(), accountID) + if err != nil { + resp.Diagnostics.AddError( + "Error reading generic oidc account", + fmt.Sprintf("Unable to read generic oidc account with ID %s: %s", accountID, err.Error()), + ) + return + } + + genericOidcAccount, ok := account.(*accounts.GenericOIDCAccount) + if !ok { + resp.Diagnostics.AddError( + "Unexpected account type", + fmt.Sprintf("Expected generic oidc account, got: %T", account), + ) + return + } + + state := schemas.GenericOidcAccountResourceModel{ + SpaceID: types.StringValue(genericOidcAccount.GetSpaceID()), + Name: types.StringValue(genericOidcAccount.GetName()), + Description: types.StringValue(genericOidcAccount.GetDescription()), + TenantedDeploymentParticipation: types.StringValue(string(genericOidcAccount.GetTenantedDeploymentMode())), + Environments: flattenStringList(genericOidcAccount.GetEnvironmentIDs(), types.ListNull(types.StringType)), + Tenants: flattenStringList(genericOidcAccount.GetTenantIDs(), types.ListNull(types.StringType)), + TenantTags: flattenStringList(genericOidcAccount.TenantTags, types.ListNull(types.StringType)), + ExecutionSubjectKeys: flattenStringList(genericOidcAccount.DeploymentSubjectKeys, types.ListNull(types.StringType)), + Audience: types.StringValue(genericOidcAccount.Audience), + } + state.ID = types.StringValue(genericOidcAccount.ID) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func expandGenericOidcAccountResource(ctx context.Context, model schemas.GenericOidcAccountResourceModel) *accounts.GenericOIDCAccount { + account, _ := accounts.NewGenericOIDCAccount(model.Name.ValueString()) + + account.SetID(model.ID.ValueString()) + account.SetDescription(model.Description.ValueString()) + account.SetSpaceID(model.SpaceID.ValueString()) + account.SetEnvironmentIDs(util.ExpandStringList(model.Environments)) + account.SetTenantedDeploymentMode(core.TenantedDeploymentMode(model.TenantedDeploymentParticipation.ValueString())) + account.SetTenantIDs(util.ExpandStringList(model.Tenants)) + account.SetTenantTags(util.ExpandStringList(model.TenantTags)) + account.DeploymentSubjectKeys = util.ExpandStringList(model.ExecutionSubjectKeys) + account.Audience = model.Audience.ValueString() + + return account +} + +func flattenGenericOidcAccountResource(ctx context.Context, account *accounts.GenericOIDCAccount, model schemas.GenericOidcAccountResourceModel) schemas.GenericOidcAccountResourceModel { + model.ID = types.StringValue(account.GetID()) + model.SpaceID = types.StringValue(account.GetSpaceID()) + model.Name = types.StringValue(account.GetName()) + model.Description = types.StringValue(account.GetDescription()) + model.TenantedDeploymentParticipation = types.StringValue(string(account.GetTenantedDeploymentMode())) + + model.Environments = util.FlattenStringList(account.GetEnvironmentIDs()) + model.Tenants = util.FlattenStringList(account.GetTenantIDs()) + model.TenantTags = util.FlattenStringList(account.TenantTags) + + model.ExecutionSubjectKeys = util.FlattenStringList(account.DeploymentSubjectKeys) + model.Audience = types.StringValue(account.Audience) + + return model +} diff --git a/octopusdeploy_framework/resource_generic_oidc_account_test.go b/octopusdeploy_framework/resource_generic_oidc_account_test.go new file mode 100644 index 00000000..69e12c05 --- /dev/null +++ b/octopusdeploy_framework/resource_generic_oidc_account_test.go @@ -0,0 +1,78 @@ +package octopusdeploy_framework + +import ( + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "strings" + "testing" +) + +func TestAccGenericOidcAccountBasic(t *testing.T) { + localName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + resourceName := "octopusdeploy_generic_oidc_account." + localName + + description := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + name := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + tenantedDeploymentParticipation := core.TenantedDeploymentModeTenantedOrUntenanted + + executionKeys := []string{"space"} + audience := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + updatedAudience := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + + config := testGenericOidcAccountBasic(localName, name, description, tenantedDeploymentParticipation, executionKeys, audience) + updateConfig := testGenericOidcAccountBasic(localName, name, description, tenantedDeploymentParticipation, executionKeys, updatedAudience) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccountExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "description", description), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttrSet(resourceName, "space_id"), + resource.TestCheckResourceAttr(resourceName, "tenanted_deployment_participation", string(tenantedDeploymentParticipation)), + resource.TestCheckResourceAttr(resourceName, "execution_subject_keys.0", executionKeys[0]), + resource.TestCheckResourceAttr(resourceName, "audience", audience), + ), + ResourceName: resourceName, + }, + { + Config: updateConfig, + Check: resource.ComposeTestCheckFunc( + testAccountExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "description", description), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", name), + resource.TestCheckResourceAttrSet(resourceName, "space_id"), + resource.TestCheckResourceAttr(resourceName, "tenanted_deployment_participation", string(tenantedDeploymentParticipation)), + resource.TestCheckResourceAttr(resourceName, "execution_subject_keys.0", executionKeys[0]), + resource.TestCheckResourceAttr(resourceName, "audience", updatedAudience), + ), + ResourceName: resourceName, + }, + }, + }) +} + +func testGenericOidcAccountBasic(localName string, name string, description string, tenantedDeploymentParticipation core.TenantedDeploymentMode, execution_subject_keys []string, audience string) string { + + execKeysStr := fmt.Sprintf(`["%s"]`, strings.Join(execution_subject_keys, `", "`)) + + return fmt.Sprintf(`resource "octopusdeploy_generic_oidc_account" "%s" { + description = "%s" + name = "%s" + tenanted_deployment_participation = "%s" + execution_subject_keys = %s + audience = "%s" + } + + data "octopusdeploy_accounts" "test" { + ids = [octopusdeploy_generic_oidc_account.%s.id] + }`, localName, description, name, tenantedDeploymentParticipation, execKeysStr, audience, localName) +} diff --git a/octopusdeploy_framework/schemas/generic_oidc_account.go b/octopusdeploy_framework/schemas/generic_oidc_account.go new file mode 100644 index 00000000..a68f46f7 --- /dev/null +++ b/octopusdeploy_framework/schemas/generic_oidc_account.go @@ -0,0 +1,86 @@ +package schemas + +import ( + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type GenericOidcAccountSchema struct{} + +var _ EntitySchema = GenericOidcAccountSchema{} + +func (a GenericOidcAccountSchema) GetDatasourceSchema() datasourceSchema.Schema { + return datasourceSchema.Schema{} +} + +func (a GenericOidcAccountSchema) GetResourceSchema() resourceSchema.Schema { + return resourceSchema.Schema{ + Description: "This resource manages a Generic OIDC Account in Octopus Deploy.", + Attributes: map[string]resourceSchema.Attribute{ + "description": util.ResourceString(). + Optional(). + Computed(). + PlanModifiers(stringplanmodifier.UseStateForUnknown()). + Default(""). + Description("The description of this generic oidc account."). + Build(), + "environments": util.ResourceList(types.StringType). + Optional(). + Computed(). + Description("A list of environment IDs associated with this resource."). + Build(), + "id": GetIdResourceSchema(), + "name": util.ResourceString(). + Required(). + Description("The name of the generic oidc account."). + Build(), + "space_id": util.ResourceString(). + Optional(). + Computed(). + PlanModifiers(stringplanmodifier.UseStateForUnknown()). + Description("The space ID associated with this resource."). + Build(), + "tenanted_deployment_participation": util.ResourceString(). + Optional(). + Computed(). + PlanModifiers(stringplanmodifier.UseStateForUnknown()). + Description("The tenanted deployment mode of the resource. Valid account types are `Untenanted`, `TenantedOrUntenanted`, or `Tenanted`."). + Build(), + "tenants": util.ResourceList(types.StringType). + Optional(). + Computed(). + Description("A list of tenant IDs associated with this resource."). + Build(), + "tenant_tags": util.ResourceList(types.StringType). + Optional(). + Computed(). + Description("A list of tenant tags associated with this resource."). + Build(), + "execution_subject_keys": util.ResourceList(types.StringType). + Optional(). + Description("Keys to include in a deployment or runbook. Valid options are `space`, `environment`, `project`, `tenant`, `runbook`, `account`, `type`."). + Build(), + "audience": util.ResourceString(). + Optional(). + Description("The audience associated with this resource."). + Build(), + }, + } +} + +type GenericOidcAccountResourceModel struct { + Description types.String `tfsdk:"description"` + Environments types.List `tfsdk:"environments"` + Name types.String `tfsdk:"name"` + SpaceID types.String `tfsdk:"space_id"` + TenantedDeploymentParticipation types.String `tfsdk:"tenanted_deployment_participation"` + Tenants types.List `tfsdk:"tenants"` + TenantTags types.List `tfsdk:"tenant_tags"` + ExecutionSubjectKeys types.List `tfsdk:"execution_subject_keys"` + Audience types.String `tfsdk:"audience"` + + ResourceModel +}