From b5ce160d13eacdfb2068f4770b19756e85e7878c Mon Sep 17 00:00:00 2001 From: Marcel Jacek <72880145+marceljk@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:07:32 +0100 Subject: [PATCH] feat: Onboard affinity groups resource and data source (#652) * onboard affinity_groups resource and data source - add tests and descriptions - fix: server doesn't use affinity_group value for payload * Update descriptions --- docs/data-sources/affinity_group.md | 35 ++ docs/resources/affinity_group.md | 103 +++++ .../stackit_affinity_group/data-source.tf | 4 + .../stackit_affinity_group/resource.tf | 5 + .../services/iaas/affinitygroup/const.go | 41 ++ .../services/iaas/affinitygroup/datasource.go | 177 +++++++++ .../services/iaas/affinitygroup/resource.go | 363 ++++++++++++++++++ .../iaas/affinitygroup/resource_test.go | 114 ++++++ .../internal/services/iaas/server/resource.go | 1 + stackit/provider.go | 3 + 10 files changed, 846 insertions(+) create mode 100644 docs/data-sources/affinity_group.md create mode 100644 docs/resources/affinity_group.md create mode 100644 examples/data-sources/stackit_affinity_group/data-source.tf create mode 100644 examples/resources/stackit_affinity_group/resource.tf create mode 100644 stackit/internal/services/iaas/affinitygroup/const.go create mode 100644 stackit/internal/services/iaas/affinitygroup/datasource.go create mode 100644 stackit/internal/services/iaas/affinitygroup/resource.go create mode 100644 stackit/internal/services/iaas/affinitygroup/resource_test.go diff --git a/docs/data-sources/affinity_group.md b/docs/data-sources/affinity_group.md new file mode 100644 index 00000000..904746cd --- /dev/null +++ b/docs/data-sources/affinity_group.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_affinity_group Data Source - stackit" +subcategory: "" +description: |- + Affinity Group schema. Must have a region specified in the provider configuration. +--- + +# stackit_affinity_group (Data Source) + +Affinity Group schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +data "stackit_affinity_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + affinity_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `affinity_group_id` (String) The affinity group ID. +- `project_id` (String) STACKIT Project ID to which the affinity group is associated. + +### Read-Only + +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`affinity_group_id`". +- `members` (List of String) Affinity Group schema. Must have a `region` specified in the provider configuration. +- `name` (String) The name of the affinity group. +- `policy` (String) The policy of the affinity group. diff --git a/docs/resources/affinity_group.md b/docs/resources/affinity_group.md new file mode 100644 index 00000000..6650e416 --- /dev/null +++ b/docs/resources/affinity_group.md @@ -0,0 +1,103 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_affinity_group Resource - stackit" +subcategory: "" +description: |- + Affinity Group schema. Must have a region specified in the provider configuration. + Usage with server + ```terraform + resource "stackitaffinitygroup" "affinity-group" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-key-pair" + policy = "soft-affinity" + } + resource "stackitserver" "example-server" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + bootvolume = { + size = 64 + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + affinitygroup = stackitaffinitygroup.affinity-group.affinitygroupid + availabilityzone = "eu01-1" + machinetype = "g1.1" + } + ``` + Policies + hard-affinity- All servers launched in this group will be hosted on the same compute node.hard-anti-affinity- All servers launched in this group will be + hosted on different compute nodes.soft-affinity- All servers launched in this group will be hosted + on the same compute node if possible, but if not possible they still will be scheduled instead of failure.soft-anti-affinity- All servers launched in this group will be hosted on different compute nodes if possible, + but if not possible they still will be scheduled instead of failure. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_affinity_group (Resource) + +Affinity Group schema. Must have a `region` specified in the provider configuration. + + + +### Usage with server +```terraform +resource "stackit_affinity_group" "affinity-group" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-key-pair" + policy = "soft-affinity" +} + +resource "stackit_server" "example-server" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + affinity_group = stackit_affinity_group.affinity-group.affinity_group_id + availability_zone = "eu01-1" + machine_type = "g1.1" +} + +``` + +### Policies + +* `hard-affinity`- All servers launched in this group will be hosted on the same compute node. + +* `hard-anti-affinity`- All servers launched in this group will be + hosted on different compute nodes. + +* `soft-affinity`- All servers launched in this group will be hosted + on the same compute node if possible, but if not possible they still will be scheduled instead of failure. + +* `soft-anti-affinity`- All servers launched in this group will be hosted on different compute nodes if possible, + but if not possible they still will be scheduled instead of failure. + + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_affinity_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-affinity-group-name" + policy = "hard-anti-affinity" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the affinity group. +- `policy` (String) The policy of the affinity group. +- `project_id` (String) STACKIT Project ID to which the affinity group is associated. + +### Read-Only + +- `affinity_group_id` (String) The affinity group ID. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`affinity_group_id`". +- `members` (List of String) The servers that are part of the affinity group. diff --git a/examples/data-sources/stackit_affinity_group/data-source.tf b/examples/data-sources/stackit_affinity_group/data-source.tf new file mode 100644 index 00000000..0d6fe625 --- /dev/null +++ b/examples/data-sources/stackit_affinity_group/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_affinity_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + affinity_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_affinity_group/resource.tf b/examples/resources/stackit_affinity_group/resource.tf new file mode 100644 index 00000000..b222dc55 --- /dev/null +++ b/examples/resources/stackit_affinity_group/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_affinity_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-affinity-group-name" + policy = "hard-anti-affinity" +} \ No newline at end of file diff --git a/stackit/internal/services/iaas/affinitygroup/const.go b/stackit/internal/services/iaas/affinitygroup/const.go new file mode 100644 index 00000000..e811a233 --- /dev/null +++ b/stackit/internal/services/iaas/affinitygroup/const.go @@ -0,0 +1,41 @@ +package affinitygroup + +const exampleUsageWithServer = ` + +### Usage with server` + "\n" + + "```terraform" + ` +resource "stackit_affinity_group" "affinity-group" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-key-pair" + policy = "soft-affinity" +} + +resource "stackit_server" "example-server" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + affinity_group = stackit_affinity_group.affinity-group.affinity_group_id + availability_zone = "eu01-1" + machine_type = "g1.1" +} +` + "\n```" + +const policies = ` + +### Policies + +* ` + "`hard-affinity`" + `- All servers launched in this group will be hosted on the same compute node. + +* ` + "`hard-anti-affinity`" + `- All servers launched in this group will be + hosted on different compute nodes. + +* ` + "`soft-affinity`" + `- All servers launched in this group will be hosted + on the same compute node if possible, but if not possible they still will be scheduled instead of failure. + +* ` + "`soft-anti-affinity`" + `- All servers launched in this group will be hosted on different compute nodes if possible, + but if not possible they still will be scheduled instead of failure. +` diff --git a/stackit/internal/services/iaas/affinitygroup/datasource.go b/stackit/internal/services/iaas/affinitygroup/datasource.go new file mode 100644 index 00000000..015e2374 --- /dev/null +++ b/stackit/internal/services/iaas/affinitygroup/datasource.go @@ -0,0 +1,177 @@ +package affinitygroup + +import ( + "context" + "fmt" + "net/http" + "regexp" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var affinityGroupDataSourceBetaCheckDone bool + +var ( + _ datasource.DataSource = &affinityGroupDatasource{} + _ datasource.DataSourceWithConfigure = &affinityGroupDatasource{} +) + +func NewAffinityGroupDatasource() datasource.DataSource { + return &affinityGroupDatasource{} +} + +type affinityGroupDatasource struct { + client *iaas.APIClient +} + +func (d *affinityGroupDatasource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !affinityGroupDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_affinity_group", "data source") + if resp.Diagnostics.HasError() { + return + } + affinityGroupDataSourceBetaCheckDone = true + } + + var apiClient *iaas.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuratio", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "iaas client configured") +} + +func (d *affinityGroupDatasource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_affinity_group" +} + +func (d *affinityGroupDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptionMain := "Affinity Group schema. Must have a `region` specified in the provider configuration." + resp.Schema = schema.Schema{ + Description: descriptionMain, + MarkdownDescription: descriptionMain, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. It is structured as \"`project_id`,`affinity_group_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT Project ID to which the affinity group is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "affinity_group_id": schema.StringAttribute{ + Description: "The affinity group ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the affinity group.", + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "policy": schema.StringAttribute{ + Description: "The policy of the affinity group.", + Computed: true, + }, + "members": schema.ListAttribute{ + Description: descriptionMain, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + validate.UUID(), + ), + }, + }, + }, + } +} + +func (d *affinityGroupDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + affinityGroupId := model.AffinityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId) + + affinityGroupResp, err := d.client.GetAffinityGroupExecute(ctx, projectId, affinityGroupId) + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Call API: %v", err)) + return + } + + err = mapFields(ctx, affinityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Processing API payload: %v", err)) + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Affinity group read") +} diff --git a/stackit/internal/services/iaas/affinitygroup/resource.go b/stackit/internal/services/iaas/affinitygroup/resource.go new file mode 100644 index 00000000..83eab5ff --- /dev/null +++ b/stackit/internal/services/iaas/affinitygroup/resource.go @@ -0,0 +1,363 @@ +package affinitygroup + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +// affinityGroupResourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var affinityGroupResourceBetaCheckDone bool + +var ( + _ resource.Resource = &affinityGroupResource{} + _ resource.ResourceWithConfigure = &affinityGroupResource{} + _ resource.ResourceWithImportState = &affinityGroupResource{} +) + +// Model is the provider's internal model +type Model struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + AffinityGroupId types.String `tfsdk:"affinity_group_id"` + Name types.String `tfsdk:"name"` + Policy types.String `tfsdk:"policy"` + Members types.List `tfsdk:"members"` +} + +func NewAffinityGroupResource() resource.Resource { + return &affinityGroupResource{} +} + +// affinityGroupResource is the resource implementation. +type affinityGroupResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *affinityGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_affinity_group" +} + +// Configure adds the provider configured client to the resource. +func (r *affinityGroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderDate, got %T", req.ProviderData)) + return + } + + if !affinityGroupResourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_affinity_group", "resource") + if resp.Diagnostics.HasError() { + return + } + affinityGroupResourceBetaCheckDone = true + } + + var apiClient *iaas.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuratio", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "iaas client configured") +} + +func (r *affinityGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + description := "Affinity Group schema. Must have a `region` specified in the provider configuration." + resp.Schema = schema.Schema{ + Description: description, + MarkdownDescription: features.AddBetaDescription(description + "\n\n" + exampleUsageWithServer + policies), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource identifier. It is structured as \"`project_id`,`affinity_group_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT Project ID to which the affinity group is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "affinity_group_id": schema.StringAttribute{ + Description: "The affinity group ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the affinity group.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "policy": schema.StringAttribute{ + Description: "The policy of the affinity group.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{}, + }, + "members": schema.ListAttribute{ + Description: "The servers that are part of the affinity group.", + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + validate.UUID(), + ), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *affinityGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Create new affinityGroup + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating affinity group", fmt.Sprintf("Creating API payload: %v", err)) + return + } + affinityGroupResp, err := r.client.CreateAffinityGroup(ctx, projectId).CreateAffinityGroupPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating affinity group", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupResp.Id) + + // Map response body to schema + err = mapFields(ctx, affinityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating affinity group", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Affinity group created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *affinityGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + affinityGroupId := model.AffinityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId) + + affinityGroupResp, err := r.client.GetAffinityGroupExecute(ctx, projectId, affinityGroupId) + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Call API: %v", err)) + return + } + + err = mapFields(ctx, affinityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading affinity group", fmt.Sprintf("Processing API payload: %v", err)) + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Affinity group read") +} + +func (r *affinityGroupResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update is not supported, all fields require replace +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *affinityGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + affinityGroupId := model.AffinityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId) + + // Delete existing affinity group + err := r.client.DeleteAffinityGroupExecute(ctx, projectId, affinityGroupId) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting affinity group", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Affinity group deleted") +} + +func (r *affinityGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing affinity group", + fmt.Sprintf("Expected import indentifier with format: [project_id],[affinity_group_id], got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + affinityGroupId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "affinity_group_id", affinityGroupId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("affinity_group_id"), affinityGroupId)...) + tflog.Info(ctx, "affinity group state imported") +} + +func toCreatePayload(model *Model) (*iaas.CreateAffinityGroupPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + nameValue := conversion.StringValueToPointer(model.Name) + policyValue := conversion.StringValueToPointer(model.Policy) + + return &iaas.CreateAffinityGroupPayload{ + Name: nameValue, + Policy: policyValue, + }, nil +} + +func mapFields(ctx context.Context, affinityGroupResp *iaas.AffinityGroup, model *Model) error { + if affinityGroupResp == nil { + return fmt.Errorf("response input is nil") + } + + if model == nil { + return fmt.Errorf("nil model") + } + + var affinityGroupId string + if model.AffinityGroupId.ValueString() != "" { + affinityGroupId = model.AffinityGroupId.ValueString() + } else if affinityGroupResp.Id != nil { + affinityGroupId = *affinityGroupResp.Id + } else { + return fmt.Errorf("affinity group id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + affinityGroupId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + if affinityGroupResp.Members != nil && len(*affinityGroupResp.Members) > 0 { + members, diags := types.ListValueFrom(ctx, types.StringType, *affinityGroupResp.Members) + if diags.HasError() { + return fmt.Errorf("convert members to StringValue list: %w", core.DiagsToError(diags)) + } + model.Members = members + } else if model.Members.IsNull() { + model.Members = types.ListNull(types.StringType) + } + + model.AffinityGroupId = types.StringValue(affinityGroupId) + + model.Name = types.StringPointerValue(affinityGroupResp.Name) + model.Policy = types.StringPointerValue(affinityGroupResp.Policy) + + return nil +} diff --git a/stackit/internal/services/iaas/affinitygroup/resource_test.go b/stackit/internal/services/iaas/affinitygroup/resource_test.go new file mode 100644 index 00000000..a4e20391 --- /dev/null +++ b/stackit/internal/services/iaas/affinitygroup/resource_test.go @@ -0,0 +1,114 @@ +package affinitygroup + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.AffinityGroup + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + AffinityGroupId: types.StringValue("aid"), + }, + &iaas.AffinityGroup{ + Id: utils.Ptr("aid"), + }, + Model{ + Id: types.StringValue("pid,aid"), + ProjectId: types.StringValue("pid"), + AffinityGroupId: types.StringValue("aid"), + Name: types.StringNull(), + Policy: types.StringNull(), + Members: types.ListNull(types.StringType), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_affinity_group_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.AffinityGroup{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed") + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %v", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.CreateAffinityGroupPayload + isValid bool + }{ + { + "default", + &Model{ + ProjectId: types.StringValue("pid"), + Name: types.StringValue("name"), + Policy: types.StringValue("policy"), + }, + &iaas.CreateAffinityGroupPayload{ + Name: utils.Ptr("name"), + Policy: utils.Ptr("policy"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/server/resource.go b/stackit/internal/services/iaas/server/resource.go index a1c86e07..66dbeace 100644 --- a/stackit/internal/services/iaas/server/resource.go +++ b/stackit/internal/services/iaas/server/resource.go @@ -980,6 +980,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateServerPaylo } return &iaas.CreateServerPayload{ + AffinityGroup: conversion.StringValueToPointer(model.AffinityGroup), AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), BootVolume: bootVolumePayload, ImageId: conversion.StringValueToPointer(model.ImageId), diff --git a/stackit/provider.go b/stackit/provider.go index cfe4d1ac..8709bdcd 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -14,6 +14,7 @@ import ( argusScrapeConfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/argus/scrapeconfig" dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone" + iaasAffinityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/affinitygroup" iaasImage "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/image" iaasKeyPair "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/keypair" iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network" @@ -413,6 +414,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource argusScrapeConfig.NewScrapeConfigDataSource, dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, + iaasAffinityGroup.NewAffinityGroupDatasource, iaasImage.NewImageDataSource, iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, @@ -467,6 +469,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { argusScrapeConfig.NewScrapeConfigResource, dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource, + iaasAffinityGroup.NewAffinityGroupResource, iaasImage.NewImageResource, iaasNetwork.NewNetworkResource, iaasNetworkArea.NewNetworkAreaResource,