diff --git a/docs/data-sources/virtual_environment_hagroup.md b/docs/data-sources/virtual_environment_hagroup.md new file mode 100644 index 00000000..43776c03 --- /dev/null +++ b/docs/data-sources/virtual_environment_hagroup.md @@ -0,0 +1,44 @@ +--- +layout: page +title: proxmox_virtual_environment_hagroup +parent: Data Sources +subcategory: Virtual Environment +description: |- + Retrieves information about a specific High Availability group. +--- + +# Data Source: proxmox_virtual_environment_hagroup + +Retrieves information about a specific High Availability group. + +## Example Usage + +```terraform +// This will fetch the set of HA group identifiers... +data "proxmox_virtual_environment_hagroups" "all" {} + +// ...which we will go through in order to fetch the whole data on each group. +data "proxmox_virtual_environment_hagroup" "example" { + for_each = data.proxmox_virtual_environment_hagroups.all.group_ids + group = each.value +} + +output "proxmox_virtual_environment_hagroups_full" { + value = data.proxmox_virtual_environment_hagroup.example +} +``` + + +## Schema + +### Required + +- `group` (String) The identifier of the High Availability group to read. + +### Read-Only + +- `comment` (String) The comment associated with this group +- `id` (String) The ID of this resource. +- `no_failback` (Boolean) A flag that indicates that failing back to a higher priority node is disabled for this HA group. +- `nodes` (Map of Number) The member nodes for this group. They are provided as a map, where the keys are the node names and the values represent their priority: integers for known priorities or `null` for unset priorities. +- `restricted` (Boolean) A flag that indicates that other nodes may not be used to run resources associated to this HA group. diff --git a/docs/data-sources/virtual_environment_hagroups.md b/docs/data-sources/virtual_environment_hagroups.md new file mode 100644 index 00000000..214203be --- /dev/null +++ b/docs/data-sources/virtual_environment_hagroups.md @@ -0,0 +1,30 @@ +--- +layout: page +title: proxmox_virtual_environment_hagroups +parent: Data Sources +subcategory: Virtual Environment +description: |- + Retrieves the list of High Availability groups. +--- + +# Data Source: proxmox_virtual_environment_hagroups + +Retrieves the list of High Availability groups. + +## Example Usage + +```terraform +data "proxmox_virtual_environment_hagroups" "example" {} + +output "data_proxmox_virtual_environment_hagroups" { + value = data.proxmox_virtual_environment_hagroups.example.group_ids +} +``` + + +## Schema + +### Read-Only + +- `group_ids` (Set of String) The identifiers of the High Availability groups. +- `id` (String) The ID of this resource. diff --git a/docs/data-sources/virtual_environment_haresource.md b/docs/data-sources/virtual_environment_haresource.md new file mode 100644 index 00000000..a4d58a61 --- /dev/null +++ b/docs/data-sources/virtual_environment_haresource.md @@ -0,0 +1,46 @@ +--- +layout: page +title: proxmox_virtual_environment_haresource +parent: Data Sources +subcategory: Virtual Environment +description: |- + Retrieves the list of High Availability resources. +--- + +# Data Source: proxmox_virtual_environment_haresource + +Retrieves the list of High Availability resources. + +## Example Usage + +```terraform +// This will fetch the set of all HA resource identifiers... +data "proxmox_virtual_environment_haresources" "all" {} + +// ...which we will go through in order to fetch the whole record for each resource. +data "proxmox_virtual_environment_haresource" "example" { + for_each = data.proxmox_virtual_environment_haresources.all.resource_ids + resource_id = each.value +} + +output "proxmox_virtual_environment_haresources_full" { + value = data.proxmox_virtual_environment_haresource.example +} +``` + + +## Schema + +### Required + +- `resource_id` (String) The identifier of the Proxmox HA resource to read. + +### Read-Only + +- `comment` (String) The comment associated with this resource. +- `group` (String) The identifier of the High Availability group this resource is a member of. +- `id` (String) The ID of this resource. +- `max_relocate` (Number) The maximal number of relocation attempts. +- `max_restart` (Number) The maximal number of restart attempts. +- `state` (String) The desired state of the resource. +- `type` (String) The type of High Availability resource (`vm` or `ct`). diff --git a/docs/data-sources/virtual_environment_haresources.md b/docs/data-sources/virtual_environment_haresources.md new file mode 100644 index 00000000..d6aeb75d --- /dev/null +++ b/docs/data-sources/virtual_environment_haresources.md @@ -0,0 +1,43 @@ +--- +layout: page +title: proxmox_virtual_environment_haresources +parent: Data Sources +subcategory: Virtual Environment +description: |- + Retrieves the list of High Availability resources. +--- + +# Data Source: proxmox_virtual_environment_haresources + +Retrieves the list of High Availability resources. + +## Example Usage + +```terraform +// This will fetch the set of all HA resource identifiers. +data "proxmox_virtual_environment_haresources" "example_all" {} + +// This will fetch the set of HA resource identifiers that correspond to virtual machines. +data "proxmox_virtual_environment_haresources" "example_vm" { + type = "vm" +} + +output "data_proxmox_virtual_environment_haresources" { + value = { + all = data.proxmox_virtual_environment_haresources.example_all.resource_ids + vms = data.proxmox_virtual_environment_haresources.example_vm.resource_ids + } +} +``` + + +## Schema + +### Optional + +- `type` (String) The type of High Availability resources to fetch (`vm` or `ct`). All resources will be fetched if this option is unset. + +### Read-Only + +- `id` (String) The ID of this resource. +- `resource_ids` (Set of String) The identifiers of the High Availability resources. diff --git a/docs/resources/virtual_environment_hagroup.md b/docs/resources/virtual_environment_hagroup.md new file mode 100644 index 00000000..83b176c0 --- /dev/null +++ b/docs/resources/virtual_environment_hagroup.md @@ -0,0 +1,59 @@ +--- +layout: page +title: proxmox_virtual_environment_hagroup +parent: Resources +subcategory: Virtual Environment +description: |- + Manages a High Availability group in a Proxmox VE cluster. +--- + +# Resource: proxmox_virtual_environment_hagroup + +Manages a High Availability group in a Proxmox VE cluster. + +## Example Usage + +```terraform +resource "proxmox_virtual_environment_hagroup" "example" { + group = "example" + comment = "This is a comment." + + # Member nodes, with or without priority. + nodes = { + node1 = null + node2 = 2 + node3 = 1 + } + + restricted = true + no_failback = false +} +``` + + +## Schema + +### Required + +- `group` (String) The identifier of the High Availability group to manage. +- `nodes` (Map of Number) The member nodes for this group. They are provided as a map, where the keys are the node names and the values represent their priority: integers for known priorities or `null` for unset priorities. + +### Optional + +- `comment` (String) The comment associated with this group +- `no_failback` (Boolean) A flag that indicates that failing back to a higher priority node is disabled for this HA group. Defaults to `false`. +- `restricted` (Boolean) A flag that indicates that other nodes may not be used to run resources associated to this HA group. Defaults to `false`. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +#!/usr/bin/env sh +# HA groups can be imported using their name, e.g.: +terraform import proxmox_virtual_environment_hagroup.example example +``` diff --git a/docs/resources/virtual_environment_haresource.md b/docs/resources/virtual_environment_haresource.md new file mode 100644 index 00000000..fdaaf4e0 --- /dev/null +++ b/docs/resources/virtual_environment_haresource.md @@ -0,0 +1,56 @@ +--- +layout: page +title: proxmox_virtual_environment_haresource +parent: Resources +subcategory: Virtual Environment +description: |- + Manages Proxmox HA resources. +--- + +# Resource: proxmox_virtual_environment_haresource + +Manages Proxmox HA resources. + +## Example Usage + +```terraform +resource "proxmox_virtual_environment_haresource" "example" { + depends_on = [ + proxmox_virtual_environment_hagroup.example + ] + resource_id = "vm:123" + state = "started" + group = "example" + comment = "Managed by Terraform" +} +``` + + +## Schema + +### Required + +- `resource_id` (String) The Proxmox HA resource identifier + +### Optional + +- `comment` (String) The comment associated with this resource. +- `group` (String) The identifier of the High Availability group this resource is a member of. +- `max_relocate` (Number) The maximal number of relocation attempts. +- `max_restart` (Number) The maximal number of restart attempts. +- `state` (String) The desired state of the resource. +- `type` (String) The type of HA resources to create. If unset, it will be deduced from the `resource_id`. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +#!/usr/bin/env sh +# HA resources can be imported using their identifiers, e.g.: +terraform import proxmox_virtual_environment_haresource.example vm:123 +``` diff --git a/examples/data-sources/proxmox_virtual_environment_hagroup/data-source.tf b/examples/data-sources/proxmox_virtual_environment_hagroup/data-source.tf new file mode 100644 index 00000000..fc7d5741 --- /dev/null +++ b/examples/data-sources/proxmox_virtual_environment_hagroup/data-source.tf @@ -0,0 +1,12 @@ +// This will fetch the set of HA group identifiers... +data "proxmox_virtual_environment_hagroups" "all" {} + +// ...which we will go through in order to fetch the whole data on each group. +data "proxmox_virtual_environment_hagroup" "example" { + for_each = data.proxmox_virtual_environment_hagroups.all.group_ids + group = each.value +} + +output "proxmox_virtual_environment_hagroups_full" { + value = data.proxmox_virtual_environment_hagroup.example +} diff --git a/examples/data-sources/proxmox_virtual_environment_hagroups/data-source.tf b/examples/data-sources/proxmox_virtual_environment_hagroups/data-source.tf new file mode 100644 index 00000000..9b44bb8a --- /dev/null +++ b/examples/data-sources/proxmox_virtual_environment_hagroups/data-source.tf @@ -0,0 +1,5 @@ +data "proxmox_virtual_environment_hagroups" "example" {} + +output "data_proxmox_virtual_environment_hagroups" { + value = data.proxmox_virtual_environment_hagroups.example.group_ids +} diff --git a/examples/data-sources/proxmox_virtual_environment_haresource/data-source.tf b/examples/data-sources/proxmox_virtual_environment_haresource/data-source.tf new file mode 100644 index 00000000..951a98f2 --- /dev/null +++ b/examples/data-sources/proxmox_virtual_environment_haresource/data-source.tf @@ -0,0 +1,12 @@ +// This will fetch the set of all HA resource identifiers... +data "proxmox_virtual_environment_haresources" "all" {} + +// ...which we will go through in order to fetch the whole record for each resource. +data "proxmox_virtual_environment_haresource" "example" { + for_each = data.proxmox_virtual_environment_haresources.all.resource_ids + resource_id = each.value +} + +output "proxmox_virtual_environment_haresources_full" { + value = data.proxmox_virtual_environment_haresource.example +} diff --git a/examples/data-sources/proxmox_virtual_environment_haresources/data-source.tf b/examples/data-sources/proxmox_virtual_environment_haresources/data-source.tf new file mode 100644 index 00000000..fcb773bf --- /dev/null +++ b/examples/data-sources/proxmox_virtual_environment_haresources/data-source.tf @@ -0,0 +1,14 @@ +// This will fetch the set of all HA resource identifiers. +data "proxmox_virtual_environment_haresources" "example_all" {} + +// This will fetch the set of HA resource identifiers that correspond to virtual machines. +data "proxmox_virtual_environment_haresources" "example_vm" { + type = "vm" +} + +output "data_proxmox_virtual_environment_haresources" { + value = { + all = data.proxmox_virtual_environment_haresources.example_all.resource_ids + vms = data.proxmox_virtual_environment_haresources.example_vm.resource_ids + } +} diff --git a/examples/resources/proxmox_virtual_environment_hagroup/import.sh b/examples/resources/proxmox_virtual_environment_hagroup/import.sh new file mode 100644 index 00000000..fe3846ca --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_hagroup/import.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +# HA groups can be imported using their name, e.g.: +terraform import proxmox_virtual_environment_hagroup.example example diff --git a/examples/resources/proxmox_virtual_environment_hagroup/resource.tf b/examples/resources/proxmox_virtual_environment_hagroup/resource.tf new file mode 100644 index 00000000..9dc91bde --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_hagroup/resource.tf @@ -0,0 +1,14 @@ +resource "proxmox_virtual_environment_hagroup" "example" { + group = "example" + comment = "This is a comment." + + # Member nodes, with or without priority. + nodes = { + node1 = null + node2 = 2 + node3 = 1 + } + + restricted = true + no_failback = false +} diff --git a/examples/resources/proxmox_virtual_environment_haresource/import.sh b/examples/resources/proxmox_virtual_environment_haresource/import.sh new file mode 100644 index 00000000..45d0acfc --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_haresource/import.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +# HA resources can be imported using their identifiers, e.g.: +terraform import proxmox_virtual_environment_haresource.example vm:123 diff --git a/examples/resources/proxmox_virtual_environment_haresource/resource.tf b/examples/resources/proxmox_virtual_environment_haresource/resource.tf new file mode 100644 index 00000000..54949bf1 --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_haresource/resource.tf @@ -0,0 +1,9 @@ +resource "proxmox_virtual_environment_haresource" "example" { + depends_on = [ + proxmox_virtual_environment_hagroup.example + ] + resource_id = "vm:123" + state = "started" + group = "example" + comment = "Managed by Terraform" +} diff --git a/internal/cluster/datasource_hagroup.go b/internal/cluster/datasource_hagroup.go new file mode 100644 index 00000000..fb52cd8e --- /dev/null +++ b/internal/cluster/datasource_hagroup.go @@ -0,0 +1,131 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/internal/structure" + "github.com/bpg/terraform-provider-proxmox/proxmox" + hagroups "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/groups" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &hagroupDatasource{} + _ datasource.DataSourceWithConfigure = &hagroupDatasource{} +) + +// NewHAGroupDataSource is a helper function to simplify the provider implementation. +func NewHAGroupDataSource() datasource.DataSource { + return &hagroupDatasource{} +} + +// hagroupDatasource is the data source implementation for full information about +// specific High Availability groups. +type hagroupDatasource struct { + client *hagroups.Client +} + +// Metadata returns the data source type name. +func (d *hagroupDatasource) Metadata( + _ context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_hagroup" +} + +// Schema returns the schema for the data source. +func (d *hagroupDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieves information about a specific High Availability group.", + Attributes: map[string]schema.Attribute{ + "id": structure.IDAttribute(), + "group": schema.StringAttribute{ + Description: "The identifier of the High Availability group to read.", + Required: true, + }, + "comment": schema.StringAttribute{ + Description: "The comment associated with this group", + Computed: true, + }, + "nodes": schema.MapAttribute{ + Description: "The member nodes for this group. They are provided as a map, where the keys are the node " + + "names and the values represent their priority: integers for known priorities or `null` for unset " + + "priorities.", + Computed: true, + ElementType: types.Int64Type, + }, + "no_failback": schema.BoolAttribute{ + Description: "A flag that indicates that failing back to a higher priority node is disabled for this HA group.", + Computed: true, + }, + "restricted": schema.BoolAttribute{ + Description: "A flag that indicates that other nodes may not be used to run resources associated to this HA group.", + Computed: true, + }, + }, + } +} + +// Configure adds the provider-configured client to the data source. +func (d *hagroupDatasource) Configure( + _ context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData), + ) + + return + } + + d.client = client.Cluster().HA().Groups() +} + +// Read fetches the list of HA groups from the Proxmox cluster then converts it to a list of strings. +func (d *hagroupDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state hagroupModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + groupID := state.Group.ValueString() + + group, err := d.client.Get(ctx, groupID) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to read High Availability group '%s'", groupID), + err.Error(), + ) + + return + } + + state.ID = types.StringValue(groupID) + + resp.Diagnostics.Append(state.importFromAPI(*group)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/cluster/datasource_hagroups.go b/internal/cluster/datasource_hagroups.go new file mode 100644 index 00000000..538c7a80 --- /dev/null +++ b/internal/cluster/datasource_hagroups.go @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/internal/structure" + "github.com/bpg/terraform-provider-proxmox/proxmox" + hagroups "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/groups" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &hagroupsDatasource{} + _ datasource.DataSourceWithConfigure = &hagroupsDatasource{} +) + +// NewHAGroupsDataSource is a helper function to simplify the provider implementation. +func NewHAGroupsDataSource() datasource.DataSource { + return &hagroupsDatasource{} +} + +// hagroupsDatasource is the data source implementation for High Availability groups. +type hagroupsDatasource struct { + client *hagroups.Client +} + +// hagroupsModel maps the schema data for the High Availability groups data source. +type hagroupsModel struct { + Groups types.Set `tfsdk:"group_ids"` + ID types.String `tfsdk:"id"` +} + +// Metadata returns the data source type name. +func (d *hagroupsDatasource) Metadata( + _ context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_hagroups" +} + +// Schema returns the schema for the data source. +func (d *hagroupsDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieves the list of High Availability groups.", + Attributes: map[string]schema.Attribute{ + "id": structure.IDAttribute(), + "group_ids": schema.SetAttribute{ + Description: "The identifiers of the High Availability groups.", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +// Configure adds the provider-configured client to the data source. +func (d *hagroupsDatasource) Configure( + _ context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData), + ) + + return + } + + d.client = client.Cluster().HA().Groups() +} + +// Read fetches the list of HA groups from the Proxmox cluster then converts it to a list of strings. +func (d *hagroupsDatasource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + var state hagroupsModel + + list, err := d.client.List(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Unable to read High Availability groups", + err.Error(), + ) + + return + } + + groups := make([]attr.Value, len(list)) + for i, v := range list { + groups[i] = types.StringValue(v.ID) + } + + groupsValue, diags := types.SetValue(types.StringType, groups) + resp.Diagnostics.Append(diags...) + + state.ID = types.StringValue("hagroups") + state.Groups = groupsValue + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/cluster/datasource_haresource.go b/internal/cluster/datasource_haresource.go new file mode 100644 index 00000000..90fe20da --- /dev/null +++ b/internal/cluster/datasource_haresource.go @@ -0,0 +1,144 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "context" + "fmt" + + "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/bpg/terraform-provider-proxmox/internal/structure" + customtypes "github.com/bpg/terraform-provider-proxmox/internal/types" + "github.com/bpg/terraform-provider-proxmox/proxmox" + haresources "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/resources" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &haresourceDatasource{} + _ datasource.DataSourceWithConfigure = &haresourceDatasource{} +) + +// NewHAResourceDataSource is a helper function to simplify the provider implementation. +func NewHAResourceDataSource() datasource.DataSource { + return &haresourceDatasource{} +} + +// haresourceDatasource is the data source implementation for High Availability resources. +type haresourceDatasource struct { + client *haresources.Client +} + +// Metadata returns the data source type name. +func (d *haresourceDatasource) Metadata( + _ context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_haresource" +} + +// Schema returns the schema for the data source. +func (d *haresourceDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieves the list of High Availability resources.", + Attributes: map[string]schema.Attribute{ + "id": structure.IDAttribute(), + "resource_id": schema.StringAttribute{ + Description: "The identifier of the Proxmox HA resource to read.", + Required: true, + Validators: []validator.String{ + customtypes.HAResourceIDValidator(), + }, + }, + "type": schema.StringAttribute{ + Description: "The type of High Availability resource (`vm` or `ct`).", + Computed: true, + }, + "comment": schema.StringAttribute{ + Description: "The comment associated with this resource.", + Computed: true, + }, + "group": schema.StringAttribute{ + Description: "The identifier of the High Availability group this resource is a member of.", + Computed: true, + }, + "max_relocate": schema.Int64Attribute{ + Description: "The maximal number of relocation attempts.", + Computed: true, + }, + "max_restart": schema.Int64Attribute{ + Description: "The maximal number of restart attempts.", + Computed: true, + }, + "state": schema.StringAttribute{ + Description: "The desired state of the resource.", + Computed: true, + }, + }, + } +} + +// Configure adds the provider-configured client to the data source. +func (d *haresourceDatasource) Configure( + _ context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + if ok { + d.client = client.Cluster().HA().Resources() + } else { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData), + ) + } +} + +// Read fetches the specified HA resource. +func (d *haresourceDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data haresourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + resID, err := customtypes.ParseHAResourceID(data.ResourceID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected error parsing Proxmox HA resource identifier", + fmt.Sprintf("Couldn't parse configuration into a valid HA resource identifier: %s. "+ + "Please report this issue to the provider developers.", err.Error()), + ) + + return + } + + resource, err := d.client.Get(ctx, resID) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to read High Availability resource %v", resID), + err.Error(), + ) + + return + } + + data.importFromAPI(resource) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/cluster/datasource_haresources.go b/internal/cluster/datasource_haresources.go new file mode 100644 index 00000000..3c590284 --- /dev/null +++ b/internal/cluster/datasource_haresources.go @@ -0,0 +1,163 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/bpg/terraform-provider-proxmox/internal/structure" + customtypes "github.com/bpg/terraform-provider-proxmox/internal/types" + "github.com/bpg/terraform-provider-proxmox/proxmox" + haresources "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/resources" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &haresourcesDatasource{} + _ datasource.DataSourceWithConfigure = &haresourcesDatasource{} +) + +// NewHAResourcesDataSource is a helper function to simplify the provider implementation. +func NewHAResourcesDataSource() datasource.DataSource { + return &haresourcesDatasource{} +} + +// haresourcesDatasource is the data source implementation for High Availability resources. +type haresourcesDatasource struct { + client *haresources.Client +} + +// haresourcesModel maps the schema data for the High Availability resources data source. +type haresourcesModel struct { + // The Terraform resource identifier + ID types.String `tfsdk:"id"` + // The type of HA resources to fetch. If unset, all resources will be fetched. + Type types.String `tfsdk:"type"` + // The set of HA resource identifiers + Resources types.Set `tfsdk:"resource_ids"` +} + +// Metadata returns the data source type name. +func (d *haresourcesDatasource) Metadata( + _ context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_haresources" +} + +// Schema returns the schema for the data source. +func (d *haresourcesDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieves the list of High Availability resources.", + Attributes: map[string]schema.Attribute{ + "id": structure.IDAttribute(), + "type": schema.StringAttribute{ + Description: "The type of High Availability resources to fetch (`vm` or `ct`). All resources " + + "will be fetched if this option is unset.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("ct", "vm"), + }, + }, + "resource_ids": schema.SetAttribute{ + Description: "The identifiers of the High Availability resources.", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +// Configure adds the provider-configured client to the data source. +func (d *haresourcesDatasource) Configure( + _ context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + if ok { + d.client = client.Cluster().HA().Resources() + } else { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData), + ) + } +} + +// Read fetches the list of HA resources from the Proxmox cluster then converts it to a list of strings. +func (d *haresourcesDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var ( + data haresourcesModel + fetchType *customtypes.HAResourceType + ) + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + if data.Type.IsNull() { + data.ID = types.StringValue("haresources") + } else { + confType, err := customtypes.ParseHAResourceType(data.Type.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected HA resource type", + fmt.Sprintf( + "Couldn't parse configuration into a valid HA resource type: %s. Please report this issue to the "+ + "provider developers.", err.Error(), + ), + ) + + return + } + + fetchType = &confType + data.ID = types.StringValue(fmt.Sprintf("haresources:%v", confType)) + } + + list, err := d.client.List(ctx, fetchType) + if err != nil { + resp.Diagnostics.AddError( + "Unable to read High Availability resources", + err.Error(), + ) + + return + } + + resources := make([]attr.Value, len(list)) + for i, v := range list { + resources[i] = types.StringValue(v.ID.String()) + } + + resourcesValue, diags := types.SetValue(types.StringType, resources) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + data.Resources = resourcesValue + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/cluster/hagroup.go b/internal/cluster/hagroup.go new file mode 100644 index 00000000..b25a4ad0 --- /dev/null +++ b/internal/cluster/hagroup.go @@ -0,0 +1,84 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + + hagroups "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/groups" +) + +// hagroupModel is the model used to represent a High Availability group. +type hagroupModel struct { + ID types.String `tfsdk:"id"` // Identifier used by Terrraform + Group types.String `tfsdk:"group"` // HA group name + Comment types.String `tfsdk:"comment"` // Comment, if present + Nodes types.Map `tfsdk:"nodes"` // Map of member nodes associated with their priorities + NoFailback types.Bool `tfsdk:"no_failback"` // Flag that disables failback + Restricted types.Bool `tfsdk:"restricted"` // Flag that prevents execution on other member nodes +} + +// Import the contents of a HA group model from the API's response data. +func (m *hagroupModel) importFromAPI(group hagroups.HAGroupGetResponseData) diag.Diagnostics { + m.Comment = types.StringPointerValue(group.Comment) + m.NoFailback = group.NoFailback.ToValue() + m.Restricted = group.Restricted.ToValue() + + return m.parseHAGroupNodes(group.Nodes) +} + +// Parse the list of member nodes. The list is received from the Proxmox API as a string. It must +// be converted into a map value. Errors will be returned as Terraform diagnostics. +func (m *hagroupModel) parseHAGroupNodes(nodes string) diag.Diagnostics { + var diags diag.Diagnostics + + nodesIn := strings.Split(nodes, ",") + nodesOut := make(map[string]attr.Value) + + for _, nodeDescStr := range nodesIn { + nodeDesc := strings.Split(nodeDescStr, ":") + if len(nodeDesc) > 2 { + diags.AddWarning( + "Could not parse HA group node", + fmt.Sprintf("Received group node '%s' for HA group '%s'", + nodeDescStr, m.Group.ValueString()), + ) + + continue + } + + priority := types.Int64Null() + + if len(nodeDesc) == 2 { + prio, err := strconv.Atoi(nodeDesc[1]) + if err == nil { + priority = types.Int64Value(int64(prio)) + } else { + diags.AddWarning( + "Could not parse HA group node priority", + fmt.Sprintf("Node priority string '%s' for node %s of HA group '%s'", + nodeDesc[1], nodeDesc[0], m.Group.ValueString()), + ) + } + } + + nodesOut[nodeDesc[0]] = priority + } + + value, mbDiags := types.MapValue(types.Int64Type, nodesOut) + diags.Append(mbDiags...) + + m.Nodes = value + + return diags +} diff --git a/internal/cluster/haresource_model.go b/internal/cluster/haresource_model.go new file mode 100644 index 00000000..62578ab6 --- /dev/null +++ b/internal/cluster/haresource_model.go @@ -0,0 +1,114 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "fmt" + + customtypes "github.com/bpg/terraform-provider-proxmox/internal/types" + haresources "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/resources" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// haresourceModel maps the schema data for the High Availability resource data source. +type haresourceModel struct { + // The Terraform resource identifier + ID types.String `tfsdk:"id"` + // The Proxmox HA resource identifier + ResourceID types.String `tfsdk:"resource_id"` + // The type of HA resources to fetch. If unset, all resources will be fetched. + Type types.String `tfsdk:"type"` + // The desired state of the resource. + State types.String `tfsdk:"state"` + // The comment associated with this resource. + Comment types.String `tfsdk:"comment"` + // The identifier of the High Availability group this resource is a member of. + Group types.String `tfsdk:"group"` + // The maximal number of relocation attempts. + MaxRelocate types.Int64 `tfsdk:"max_relocate"` + // The maximal number of restart attempts. + MaxRestart types.Int64 `tfsdk:"max_restart"` +} + +// importFromAPI imports the contents of a HA resource model from the API's response data. +func (d *haresourceModel) importFromAPI(data *haresources.HAResourceGetResponseData) { + d.ID = data.ID.ToValue() + d.ResourceID = data.ID.ToValue() + d.Type = data.Type.ToValue() + d.State = data.State.ToValue() + d.Comment = types.StringPointerValue(data.Comment) + d.Group = types.StringPointerValue(data.Group) + d.MaxRelocate = types.Int64PointerValue(data.MaxRelocate) + d.MaxRestart = types.Int64PointerValue(data.MaxRestart) +} + +// toRequestBase builds the common request data structure for HA resource creation or update API calls. +func (d haresourceModel) toRequestBase() haresources.HAResourceDataBase { + var state customtypes.HAResourceState + + if d.State.IsNull() { + state = customtypes.HAResourceStateStarted + } else { + var err error + + state, err = customtypes.ParseHAResourceState(d.State.ValueString()) + if err != nil { + panic(fmt.Errorf( + "state string '%s' wrongly assumed to be valid; error: %w", + d.State.ValueString(), err, + )) + } + } + + return haresources.HAResourceDataBase{ + State: state, + Comment: d.Comment.ValueStringPointer(), + Group: d.Group.ValueStringPointer(), + MaxRelocate: d.MaxRelocate.ValueInt64Pointer(), + MaxRestart: d.MaxRestart.ValueInt64Pointer(), + } +} + +// toCreateRequest builds the request data structure for creating a new HA resource. +func (d haresourceModel) toCreateRequest(resID customtypes.HAResourceID) *haresources.HAResourceCreateRequestBody { + return &haresources.HAResourceCreateRequestBody{ + ID: resID, + Type: &resID.Type, + HAResourceDataBase: d.toRequestBase(), + } +} + +// toUpdateRequest builds the request data structure for updating an existing HA resource. +func (d haresourceModel) toUpdateRequest(state *haresourceModel) *haresources.HAResourceUpdateRequestBody { + del := []string{} + + if d.Comment.IsNull() && !state.Comment.IsNull() { + del = append(del, "comment") + } + + if d.Group.IsNull() && !state.Group.IsNull() { + del = append(del, "group") + } + + if d.MaxRelocate.IsNull() && !state.MaxRelocate.IsNull() { + del = append(del, "max_relocate") + } + + if d.MaxRestart.IsNull() && !state.MaxRestart.IsNull() { + del = append(del, "max_restart") + } + + if len(del) == 0 { + del = nil + } + + return &haresources.HAResourceUpdateRequestBody{ + HAResourceDataBase: d.toRequestBase(), + Delete: del, + } +} diff --git a/internal/cluster/resource_hagroup.go b/internal/cluster/resource_hagroup.go new file mode 100644 index 00000000..21464827 --- /dev/null +++ b/internal/cluster/resource_hagroup.go @@ -0,0 +1,339 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/internal/structure" + "github.com/bpg/terraform-provider-proxmox/proxmox" + hagroups "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/groups" +) + +var ( + _ resource.Resource = &hagroupResource{} + _ resource.ResourceWithConfigure = &hagroupResource{} + _ resource.ResourceWithImportState = &hagroupResource{} +) + +// NewHAGroupResource creates a new resource for managing Linux Bridge network interfaces. +func NewHAGroupResource() resource.Resource { + return &hagroupResource{} +} + +// hagroupResource contains the resource's internal data. +type hagroupResource struct { + // The HA groups API client + client hagroups.Client +} + +// Metadata defines the name of the resource. +func (r *hagroupResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_hagroup" +} + +// Schema defines the schema for the resource. +func (r *hagroupResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: "Manages a High Availability group in a Proxmox VE cluster.", + Attributes: map[string]schema.Attribute{ + "id": structure.IDAttribute(), + "group": schema.StringAttribute{ + Description: "The identifier of the High Availability group to manage.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9\-_\.]*[a-zA-Z0-9]$`), + "must start with a letter, end with a letter or number, be composed of "+ + "letters, numbers, '-', '_' and '.', and must be at least 2 characters long", + ), + }, + }, + "comment": schema.StringAttribute{ + Description: "The comment associated with this group", + Optional: true, + Validators: []validator.String{ + stringvalidator.UTF8LengthAtLeast(1), + stringvalidator.RegexMatches(regexp.MustCompile(`^[^\s]|^$`), "must not start with whitespace"), + stringvalidator.RegexMatches(regexp.MustCompile(`[^\s]$|^$`), "must not end with whitespace"), + }, + }, + "nodes": schema.MapAttribute{ + Description: "The member nodes for this group. They are provided as a map, where the keys are the node " + + "names and the values represent their priority: integers for known priorities or `null` for unset " + + "priorities.", + Required: true, + ElementType: types.Int64Type, + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + mapvalidator.KeysAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$`), + "must be a valid Proxmox node name", + ), + ), + mapvalidator.ValueInt64sAre(int64validator.Between(0, 1000)), + }, + }, + "no_failback": schema.BoolAttribute{ + Description: "A flag that indicates that failing back to a higher priority node is disabled for this HA " + + "group. Defaults to `false`.", + Computed: true, + Optional: true, + Default: booldefault.StaticBool(false), + }, + "restricted": schema.BoolAttribute{ + Description: "A flag that indicates that other nodes may not be used to run resources associated to this HA " + + "group. Defaults to `false`.", + Computed: true, + Optional: true, + Default: booldefault.StaticBool(false), + }, + }, + } +} + +// Configure accesses the provider-configured Proxmox API client on behalf of the resource. +func (r *hagroupResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + if ok { + r.client = *client.Cluster().HA().Groups() + } else { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData), + ) + } +} + +// Create creates a new HA group on the Proxmox cluster. +func (r *hagroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data hagroupModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + groupID := data.Group.ValueString() + createRequest := &hagroups.HAGroupCreateRequestBody{} + createRequest.ID = groupID + createRequest.Comment = data.Comment.ValueStringPointer() + createRequest.Nodes = r.groupNodesToString(data.Nodes) + createRequest.NoFailback.FromValue(data.NoFailback) + createRequest.Restricted.FromValue(data.Restricted) + createRequest.Type = "group" + + err := r.client.Create(ctx, createRequest) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Could not create HA group '%s'.", groupID), + err.Error(), + ) + + return + } + + data.ID = types.StringValue(groupID) + + r.readBack(ctx, &data, &resp.Diagnostics, &resp.State) +} + +// Read reads a HA group definition from the Proxmox cluster. +func (r *hagroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data hagroupModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + found, diags := r.read(ctx, &data) + resp.Diagnostics.Append(diags...) + + if !resp.Diagnostics.HasError() { + if found { + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) + } else { + resp.State.RemoveResource(ctx) + } + } +} + +// Update updates a HA group definition on the Proxmox cluster. +func (r *hagroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data, state hagroupModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + updateRequest := &hagroups.HAGroupUpdateRequestBody{} + updateRequest.Comment = data.Comment.ValueStringPointer() + updateRequest.Nodes = r.groupNodesToString(data.Nodes) + updateRequest.NoFailback.FromValue(data.NoFailback) + updateRequest.Restricted.FromValue(data.Restricted) + + if updateRequest.Comment == nil && !state.Comment.IsNull() { + updateRequest.Delete = "comment" + } + + err := r.client.Update(ctx, state.Group.ValueString(), updateRequest) + if err == nil { + r.readBack(ctx, &data, &resp.Diagnostics, &resp.State) + } else { + resp.Diagnostics.AddError( + "Error updating HA group", + fmt.Sprintf("Could not update HA group '%s', unexpected error: %s", + state.Group.ValueString(), err.Error()), + ) + } +} + +// Delete deletes a HA group definition. +func (r *hagroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data hagroupModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + groupID := data.Group.ValueString() + + err := r.client.Delete(ctx, groupID) + if err != nil { + if strings.Contains(err.Error(), "no such ha group") { + resp.Diagnostics.AddWarning( + "HA group does not exist", + fmt.Sprintf( + "Could not delete HA group '%s', it does not exist or has been deleted outside of Terraform.", + groupID, + ), + ) + } else { + resp.Diagnostics.AddError( + "Error deleting HA group", + fmt.Sprintf("Could not delete HA group '%s', unexpected error: %s", + groupID, err.Error()), + ) + } + } +} + +// ImportState imports a HA group from the Proxmox cluster. +func (r *hagroupResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + reqID := req.ID + data := hagroupModel{ + ID: types.StringValue(reqID), + Group: types.StringValue(reqID), + } + r.readBack(ctx, &data, &resp.Diagnostics, &resp.State) +} + +// readBack reads information about a created or modified HA group from the cluster then updates the response +// state accordingly. It is assumed that the `state`'s identifier is set. +func (r *hagroupResource) readBack( + ctx context.Context, + data *hagroupModel, + respDiags *diag.Diagnostics, + respState *tfsdk.State, +) { + found, diags := r.read(ctx, data) + + respDiags.Append(diags...) + + if !found { + respDiags.AddError( + "HA group not found after update", + "Failed to find the group when trying to read back the updated HA group's data.", + ) + } + + if !respDiags.HasError() { + respDiags.Append(respState.Set(ctx, *data)...) + } +} + +// read reads information about a HA group from the cluster. The group identifier must have been set in the +// `data`. +func (r *hagroupResource) read(ctx context.Context, data *hagroupModel) (bool, diag.Diagnostics) { + name := data.Group.ValueString() + + group, err := r.client.Get(ctx, name) + if err != nil { + var diags diag.Diagnostics + + if !strings.Contains(err.Error(), "no such ha group") { + diags.AddError("Could not read HA group", err.Error()) + } + + return false, diags + } + + return true, data.importFromAPI(*group) +} + +// groupNodesToString converts the map of group member nodes into a string. +func (r *hagroupResource) groupNodesToString(nodes types.Map) string { + mbElements := nodes.Elements() + mbNodes := make([]string, len(mbElements)) + i := 0 + + for name, value := range mbElements { + if value.IsNull() { + mbNodes[i] = name + } else { + mbNodes[i] = fmt.Sprintf("%s:%d", name, value.(types.Int64).ValueInt64()) + } + + i++ + } + + return strings.Join(mbNodes, ",") +} diff --git a/internal/cluster/resource_haresource.go b/internal/cluster/resource_haresource.go new file mode 100644 index 00000000..63261e27 --- /dev/null +++ b/internal/cluster/resource_haresource.go @@ -0,0 +1,372 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package cluster + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/bpg/terraform-provider-proxmox/internal/structure" + customtypes "github.com/bpg/terraform-provider-proxmox/internal/types" + "github.com/bpg/terraform-provider-proxmox/proxmox" + haresources "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/resources" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// haresourceResource contains the resource's internal data. +type haresourceResource struct { + // The HA resources API client + client haresources.Client +} + +// Ensure the resource implements the expected interfaces. +var ( + _ resource.Resource = &haresourceResource{} + _ resource.ResourceWithConfigure = &haresourceResource{} + _ resource.ResourceWithImportState = &haresourceResource{} +) + +// NewHAResourceResource returns a new resource for managing High Availability resources. +func NewHAResourceResource() resource.Resource { + return &haresourceResource{} +} + +// Metadata defines the name of the resource. +func (r *haresourceResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_haresource" +} + +// Schema defines the schema for the resource. +func (r *haresourceResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: "Manages Proxmox HA resources.", + Attributes: map[string]schema.Attribute{ + "id": structure.IDAttribute(), + "resource_id": schema.StringAttribute{ + Description: "The Proxmox HA resource identifier", + Required: true, + Validators: []validator.String{ + customtypes.HAResourceIDValidator(), + }, + }, + "state": schema.StringAttribute{ + Description: "The desired state of the resource.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("started"), + Validators: []validator.String{ + customtypes.HAResourceStateValidator(), + }, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The type of HA resources to create. If unset, it will be deduced from the `resource_id`.", + Computed: true, + Optional: true, + Validators: []validator.String{ + customtypes.HAResourceTypeValidator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "comment": schema.StringAttribute{ + Description: "The comment associated with this resource.", + Optional: true, + Validators: []validator.String{ + stringvalidator.UTF8LengthAtLeast(1), + stringvalidator.RegexMatches(regexp.MustCompile(`^[^\s]|^$`), "must not start with whitespace"), + stringvalidator.RegexMatches(regexp.MustCompile(`[^\s]$|^$`), "must not end with whitespace"), + }, + }, + "group": schema.StringAttribute{ + Description: "The identifier of the High Availability group this resource is a member of.", + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9\-_\.]*[a-zA-Z0-9]$`), + "must start with a letter, end with a letter or number, be composed of "+ + "letters, numbers, '-', '_' and '.', and must be at least 2 characters long", + ), + }, + }, + "max_relocate": schema.Int64Attribute{ + Description: "The maximal number of relocation attempts.", + Optional: true, + Validators: []validator.Int64{ + int64validator.Between(0, 10), + }, + }, + "max_restart": schema.Int64Attribute{ + Description: "The maximal number of restart attempts.", + Optional: true, + Validators: []validator.Int64{ + int64validator.Between(0, 10), + }, + }, + }, + } +} + +// Configure adds the provider-configured client to the resource. +func (r *haresourceResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + if ok { + r.client = *client.Cluster().HA().Resources() + } else { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData), + ) + } +} + +// Create creates a new HA resource. +func (r *haresourceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data haresourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + resID, err := customtypes.ParseHAResourceID(data.ResourceID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected error parsing Proxmox HA resource identifier", + fmt.Sprintf("Couldn't parse the Terraform resource ID into a valid HA resource identifier: %s. "+ + "Please report this issue to the provider developers.", err), + ) + + return + } + + createRequest := data.toCreateRequest(resID) + + err = r.client.Create(ctx, createRequest) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Could not create HA resource '%v'.", resID), + err.Error(), + ) + + return + } + + data.ID = types.StringValue(resID.String()) + + r.readBack(ctx, &data, &resp.Diagnostics, &resp.State) +} + +// Update updates an existing HA resource. +func (r *haresourceResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var data, state haresourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + resID, err := customtypes.ParseHAResourceID(state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected error parsing Proxmox HA resource identifier", + fmt.Sprintf("Couldn't parse the Terraform resource ID into a valid HA resource identifier: %s. "+ + "Please report this issue to the provider developers.", err), + ) + + return + } + + updateRequest := data.toUpdateRequest(&state) + + err = r.client.Update(ctx, resID, updateRequest) + if err == nil { + r.readBack(ctx, &data, &resp.Diagnostics, &resp.State) + } else { + resp.Diagnostics.AddError( + "Error updating HA resource", + fmt.Sprintf("Could not update HA resource '%s', unexpected error: %s", + state.Group.ValueString(), err.Error()), + ) + } +} + +// Delete deletes an existing HA resource. +func (r *haresourceResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var data haresourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + resID, err := customtypes.ParseHAResourceID(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected error parsing Proxmox HA resource identifier", + fmt.Sprintf("Couldn't parse the Terraform resource ID into a valid HA resource identifier: %s. "+ + "Please report this issue to the provider developers.", err), + ) + + return + } + + err = r.client.Delete(ctx, resID) + if err != nil { + if strings.Contains(err.Error(), "no such resource") { + resp.Diagnostics.AddWarning( + "HA resource does not exist", + fmt.Sprintf( + "Could not delete HA resource '%v', it does not exist or has been deleted outside of Terraform.", + resID, + ), + ) + } else { + resp.Diagnostics.AddError( + "Error deleting HA resource", + fmt.Sprintf("Could not delete HA resource '%v', unexpected error: %s", + resID, err.Error()), + ) + } + } +} + +// Read reads the HA resource. +func (r *haresourceResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var data haresourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + found, diags := r.read(ctx, &data) + resp.Diagnostics.Append(diags...) + + if !resp.Diagnostics.HasError() { + if found { + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) + } else { + resp.State.RemoveResource(ctx) + } + } +} + +// ImportState imports a HA resource from the Proxmox cluster. +func (r *haresourceResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + reqID := req.ID + data := haresourceModel{ + ID: types.StringValue(reqID), + ResourceID: types.StringValue(reqID), + } + r.readBack(ctx, &data, &resp.Diagnostics, &resp.State) +} + +// read reads information about a HA resource from the cluster. The Terraform resource identifier must have been set +// in the model before this function is called. +func (r *haresourceResource) read(ctx context.Context, data *haresourceModel) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + resID, err := customtypes.ParseHAResourceID(data.ID.ValueString()) + if err != nil { + diags.AddError( + "Unexpected error parsing Proxmox HA resource identifier", + fmt.Sprintf("Couldn't parse the Terraform resource ID into a valid HA resource identifier: %s. "+ + "Please report this issue to the provider developers.", err), + ) + + return false, diags + } + + resource, err := r.client.Get(ctx, resID) + if err != nil { + if !strings.Contains(err.Error(), "no such resource") { + diags.AddError("Could not read HA resource", err.Error()) + } + + return false, diags + } + + data.importFromAPI(resource) + + return true, nil +} + +// readBack reads information about a created or modified HA resource from the cluster then updates the response +// state accordingly. It is assumed that the `state`'s identifier is set. +func (r *haresourceResource) readBack( + ctx context.Context, + data *haresourceModel, + respDiags *diag.Diagnostics, + respState *tfsdk.State, +) { + found, diags := r.read(ctx, data) + + respDiags.Append(diags...) + + if !found { + respDiags.AddError( + "HA resource not found after update", + "Failed to find the resource when trying to read back the updated HA resource's data.", + ) + } + + if !respDiags.HasError() { + respDiags.Append(respState.Set(ctx, *data)...) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3f96677a..168b4355 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/bpg/terraform-provider-proxmox/internal/cluster" "github.com/bpg/terraform-provider-proxmox/internal/network" "github.com/bpg/terraform-provider-proxmox/proxmox" "github.com/bpg/terraform-provider-proxmox/proxmox/api" @@ -348,6 +349,8 @@ func (p *proxmoxProvider) Configure( func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ + cluster.NewHAGroupResource, + cluster.NewHAResourceResource, network.NewLinuxBridgeResource, network.NewLinuxVLANResource, } @@ -356,6 +359,10 @@ func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resourc func (p *proxmoxProvider) DataSources(_ context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewVersionDataSource, + cluster.NewHAGroupsDataSource, + cluster.NewHAGroupDataSource, + cluster.NewHAResourcesDataSource, + cluster.NewHAResourceDataSource, } } diff --git a/internal/structure/attribute.go b/internal/structure/attribute.go new file mode 100644 index 00000000..f6068a5f --- /dev/null +++ b/internal/structure/attribute.go @@ -0,0 +1,23 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package structure + +import ( + "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" +) + +// IDAttribute generates an attribute definition suitable for the always-present `id` attribute. +func IDAttribute() schema.StringAttribute { + return schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + } +} diff --git a/internal/types/common_types.go b/internal/types/common_types.go index 0ec037cb..1907e8cf 100644 --- a/internal/types/common_types.go +++ b/internal/types/common_types.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/hashicorp/terraform-plugin-framework/types" ) // CustomBool allows a JSON boolean value to also be an integer. @@ -63,6 +65,16 @@ func (r *CustomBool) PointerBool() *bool { return (*bool)(r) } +// ToValue returns a Terraform attribute value. +func (r CustomBool) ToValue() types.Bool { + return types.BoolValue(bool(r)) +} + +// FromValue sets the numeric boolean based on the value of a Terraform attribute. +func (r *CustomBool) FromValue(tfValue types.Bool) { + *r = CustomBool(tfValue.ValueBool()) +} + // MarshalJSON converts a boolean to a JSON value. func (r *CustomCommaSeparatedList) MarshalJSON() ([]byte, error) { s := strings.Join(*r, ",") diff --git a/internal/types/ha_resource_id.go b/internal/types/ha_resource_id.go new file mode 100644 index 00000000..744a9a37 --- /dev/null +++ b/internal/types/ha_resource_id.go @@ -0,0 +1,125 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package types + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/google/go-querystring/query" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/internal/validators" +) + +// NOTE: the linter believes the `HAResourceID` structure below should be tagged with `json:` due to some values of it +// being passed to a JSON marshaler in the tests. As far as I can tell this is unnecessary, so I'm silencing the lint. + +// HAResourceID represents a HA resource identifier, composed of a resource type and identifier. +// +//nolint:musttag +type HAResourceID struct { + Type HAResourceType // The type of this HA resource. + Name string // The name of the element the HA resource refers to. +} + +// Ensure the HA resource identifier type implements various interfaces. +var ( + _ fmt.Stringer = &HAResourceID{} + _ json.Marshaler = &HAResourceID{} + _ json.Unmarshaler = &HAResourceID{} + _ query.Encoder = &HAResourceID{} +) + +// ParseHAResourceID parses a string that represents a HA resource identifier into a value of `HAResourceID`. +func ParseHAResourceID(input string) (HAResourceID, error) { + resID := HAResourceID{} + + inParts := strings.SplitN(input, ":", 2) + if len(inParts) < 2 { + return resID, fmt.Errorf("'%s' is not a valid HA resource identifier", input) + } + + resType, err := ParseHAResourceType(inParts[0]) + if err != nil { + return resID, fmt.Errorf("could not extract type from HA resource identifier '%s': %w", input, err) + } + + // For types VM and Container, we know the resource "name" should be a valid integer between 100 + // and 999_999_999. + if resType == HAResourceTypeVM || resType == HAResourceTypeContainer { + id, err := strconv.Atoi(inParts[1]) + if err != nil { + return resID, fmt.Errorf("invalid %s HA resource name '%s': %w", resType, inParts[1], err) + } + + if id < 100 { + return resID, fmt.Errorf("invalid %s HA resource name '%s': minimum value is 100", resType, inParts[1]) + } + + if id > 999_999_999 { + return resID, fmt.Errorf("invalid %s HA resource name '%s': maximum value is 999999999", resType, inParts[1]) + } + } + + resID.Type = resType + resID.Name = inParts[1] + + return resID, nil +} + +// HAResourceIDValidator returns a new HA resource identifier validator. +func HAResourceIDValidator() validator.String { + return validators.NewParseValidator(ParseHAResourceID, "value must be a valid HA resource identifier") +} + +// String converts a HAResourceID value into a string. +func (rid HAResourceID) String() string { + return fmt.Sprintf("%s:%s", rid.Type, rid.Name) +} + +// MarshalJSON marshals a HA resource identifier into JSON value. +func (rid HAResourceID) MarshalJSON() ([]byte, error) { + bytes, err := json.Marshal(rid.String()) + if err != nil { + return nil, fmt.Errorf("cannot marshal HA resource identifier: %w", err) + } + + return bytes, nil +} + +// UnmarshalJSON unmarshals a Proxmox HA resource identifier. +func (rid *HAResourceID) UnmarshalJSON(b []byte) error { + var ridString string + + err := json.Unmarshal(b, &ridString) + if err != nil { + return fmt.Errorf("cannot unmarshal HA resource type: %w", err) + } + + resType, err := ParseHAResourceID(ridString) + if err == nil { + *rid = resType + } + + return err +} + +// EncodeValues encodes a HA resource ID field into an URL-encoded set of values. +func (rid HAResourceID) EncodeValues(key string, v *url.Values) error { + v.Add(key, rid.String()) + return nil +} + +// ToValue converts a HA resource ID into a Terraform value. +func (rid HAResourceID) ToValue() types.String { + return types.StringValue(rid.String()) +} diff --git a/internal/types/ha_resource_id_test.go b/internal/types/ha_resource_id_test.go new file mode 100644 index 00000000..5a1b35a8 --- /dev/null +++ b/internal/types/ha_resource_id_test.go @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package types + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestParseHAResourceID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want HAResourceID + wantErr bool + }{ + {"VM value", "vm:123", HAResourceID{HAResourceTypeVM, "123"}, false}, + {"container value", "ct:123", HAResourceID{HAResourceTypeContainer, "123"}, false}, + {"no semicolon", "ct", HAResourceID{}, true}, + {"invalid type", "blah:123", HAResourceID{}, true}, + {"invalid VM name", "vm:moo", HAResourceID{}, true}, + {"invalid container name", "ct:moo", HAResourceID{}, true}, + {"VM name too low", "vm:99", HAResourceID{}, true}, + {"VM name too high", "vm:1000000000", HAResourceID{}, true}, + {"container name too low", "ct:99", HAResourceID{}, true}, + {"container name too high", "ct:1000000000", HAResourceID{}, true}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseHAResourceID(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseHAResourceID() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("ParseHAResourceID() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceIDToString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state HAResourceID + want string + }{ + {"stringify VM", HAResourceID{HAResourceTypeVM, "123"}, "vm:123"}, + {"stringify CT", HAResourceID{HAResourceTypeContainer, "123"}, "ct:123"}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.state.String(); got != tt.want { + t.Errorf("HAResourceID.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceIDToJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state HAResourceID + want string + }{ + {"jsonify VM", HAResourceID{HAResourceTypeVM, "123"}, `"vm:123"`}, + {"jsonify CT", HAResourceID{HAResourceTypeContainer, "123"}, `"ct:123"`}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := json.Marshal(tt.state) + if err != nil { + t.Errorf("json.Marshal(HAResourceID): err = %v", err) + } else if !bytes.Equal(got, []byte(tt.want)) { + t.Errorf("json.Marshal(HAResourceID) = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceIDFromJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + want HAResourceID + wantErr bool + }{ + {"VM", `"vm:123"`, HAResourceID{HAResourceTypeVM, "123"}, false}, + {"container", `"ct:123"`, HAResourceID{HAResourceTypeContainer, "123"}, false}, + {"invalid JSON", `\\/yo`, HAResourceID{}, true}, + {"incompatible type", `["yo"]`, HAResourceID{}, true}, + {"invalid content", `"nope:notatall"`, HAResourceID{}, true}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got HAResourceID + + err := json.Unmarshal([]byte(tt.json), &got) + if (err != nil) != tt.wantErr { + t.Errorf("json.Unmarshal(HAResourceID) error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("json.Unmarshal(HAResourceID) got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/types/ha_resource_state.go b/internal/types/ha_resource_state.go new file mode 100644 index 00000000..7d07cfee --- /dev/null +++ b/internal/types/ha_resource_state.go @@ -0,0 +1,127 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package types + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/google/go-querystring/query" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/internal/validators" +) + +// HAResourceState represents the requested state of a HA resource. +type HAResourceState int + +// Ensure various interfaces are supported by the HA resource state type. +// NOTE: the global variable created here is only meant to be used in this block. There is, to my knowledge, no +// other way to enforce interface implementation at compile time unless the value is wrapped into a struct. Because +// of this, the linter is disabled. +var ( + //nolint:gochecknoglobals + _haResourceStateValue HAResourceState + _ fmt.Stringer = &_haResourceStateValue + _ json.Marshaler = &_haResourceStateValue + _ json.Unmarshaler = &_haResourceStateValue + _ query.Encoder = &_haResourceStateValue +) + +const ( + // HAResourceStateStarted indicates that a HA resource should be started. + HAResourceStateStarted HAResourceState = 0 + // HAResourceStateStopped indicates that a HA resource should be stopped, but that it should still be relocated + // on node failure. + HAResourceStateStopped HAResourceState = 1 + // HAResourceStateDisabled indicates that a HA resource should be stopped. No relocation should occur on node failure. + HAResourceStateDisabled HAResourceState = 2 + // HAResourceStateIgnored indicates that a HA resource is not managed by the cluster resource manager. No relocation + // or status change will occur. + HAResourceStateIgnored HAResourceState = 3 +) + +// ParseHAResourceState converts the string representation of a HA resource state into the corresponding +// enum value. An error is returned if the input string does not match any known state. This function also +// parses the `enabled` value which is an alias for `started`. +func ParseHAResourceState(input string) (HAResourceState, error) { + switch input { + case "started": + return HAResourceStateStarted, nil + case "enabled": + return HAResourceStateStarted, nil + case "stopped": + return HAResourceStateStopped, nil + case "disabled": + return HAResourceStateDisabled, nil + case "ignored": + return HAResourceStateIgnored, nil + default: + return HAResourceStateIgnored, fmt.Errorf("illegal HA resource state '%s'", input) + } +} + +// HAResourceStateValidator returns a new HA resource state validator. +func HAResourceStateValidator() validator.String { + return validators.NewParseValidator(ParseHAResourceState, "value must be a valid HA resource state") +} + +// String converts a HAResourceState value into a string. +func (s HAResourceState) String() string { + switch s { + case HAResourceStateStarted: + return "started" + case HAResourceStateStopped: + return "stopped" + case HAResourceStateDisabled: + return "disabled" + case HAResourceStateIgnored: + return "ignored" + default: + panic(fmt.Sprintf("unknown HA resource state value: %d", s)) + } +} + +// MarshalJSON marshals a HA resource state into JSON value. +func (s HAResourceState) MarshalJSON() ([]byte, error) { + bytes, err := json.Marshal(s.String()) + if err != nil { + return nil, fmt.Errorf("cannot marshal HA resource state: %w", err) + } + + return bytes, nil +} + +// UnmarshalJSON unmarshals a Proxmox HA resource state. +func (s *HAResourceState) UnmarshalJSON(b []byte) error { + var stateString string + + err := json.Unmarshal(b, &stateString) + if err != nil { + return fmt.Errorf("cannot unmarshal HA resource state: %w", err) + } + + state, err := ParseHAResourceState(stateString) + if err == nil { + *s = state + } + + return err +} + +// EncodeValues encodes a HA resource state field into an URL-encoded set of values. +func (s HAResourceState) EncodeValues(key string, v *url.Values) error { + v.Add(key, s.String()) + return nil +} + +// ToValue converts a HA resource state into a Terraform value. +func (s HAResourceState) ToValue() types.String { + return types.StringValue(s.String()) +} diff --git a/internal/types/ha_resource_state_test.go b/internal/types/ha_resource_state_test.go new file mode 100644 index 00000000..e3fe1fea --- /dev/null +++ b/internal/types/ha_resource_state_test.go @@ -0,0 +1,131 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package types + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestParseHAResourceState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want HAResourceState + wantErr bool + }{ + {"valid value started", "started", HAResourceStateStarted, false}, + {"valid value enabled", "enabled", HAResourceStateStarted, false}, + {"valid value stopped", "stopped", HAResourceStateStopped, false}, + {"valid value disabled", "disabled", HAResourceStateDisabled, false}, + {"valid value ignored", "ignored", HAResourceStateIgnored, false}, + {"empty value", "", HAResourceStateIgnored, true}, + {"invalid value", "blah", HAResourceStateIgnored, true}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseHAResourceState(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseHAResourceState() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("ParseHAResourceState() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceStateToString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state HAResourceState + want string + }{ + {"stringify started", HAResourceStateStarted, "started"}, + {"stringify stopped", HAResourceStateStopped, "stopped"}, + {"stringify disabled", HAResourceStateDisabled, "disabled"}, + {"stringify ignored", HAResourceStateIgnored, "ignored"}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.state.String(); got != tt.want { + t.Errorf("HAResourceState.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceStateToJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state HAResourceState + want string + }{ + {"jsonify started", HAResourceStateStarted, `"started"`}, + {"jsonify stopped", HAResourceStateStopped, `"stopped"`}, + {"jsonify disabled", HAResourceStateDisabled, `"disabled"`}, + {"jsonify ignored", HAResourceStateIgnored, `"ignored"`}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := json.Marshal(tt.state) + if err != nil { + t.Errorf("json.Marshal(HAResourceState): err = %v", err) + } else if !bytes.Equal(got, []byte(tt.want)) { + t.Errorf("json.Marshal(HAResourceState) = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceStateFromJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + want HAResourceState + wantErr bool + }{ + {"started", `"started"`, HAResourceStateStarted, false}, + {"stopped", `"stopped"`, HAResourceStateStopped, false}, + {"disabled", `"disabled"`, HAResourceStateDisabled, false}, + {"ignored", `"ignored"`, HAResourceStateIgnored, false}, + {"invalid JSON", `\\/yo`, HAResourceStateIgnored, true}, + {"incompatible type", `["yo"]`, HAResourceStateIgnored, true}, + {"invalid content", `"nope"`, HAResourceStateIgnored, true}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got HAResourceState + + err := json.Unmarshal([]byte(tt.json), &got) + if (err != nil) != tt.wantErr { + t.Errorf("json.Unmarshal(HAResourceState) error = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Errorf("json.Unmarshal(HAResourceState) got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/types/ha_resource_type.go b/internal/types/ha_resource_type.go new file mode 100644 index 00000000..1f0ecc68 --- /dev/null +++ b/internal/types/ha_resource_type.go @@ -0,0 +1,108 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package types + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/google/go-querystring/query" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/internal/validators" +) + +// HAResourceType represents the type of a HA resource. +type HAResourceType int + +// Ensure various interfaces are supported by the HA resource type type. +// NOTE: to my knowledge, this "global" here is required for the static type checks to work. +var ( + //nolint:gochecknoglobals + _haResourceTypeValue HAResourceType + _ fmt.Stringer = &_haResourceTypeValue + _ json.Marshaler = &_haResourceTypeValue + _ json.Unmarshaler = &_haResourceTypeValue + _ query.Encoder = &_haResourceTypeValue +) + +const ( + // HAResourceTypeVM indicates that a HA resource refers to a virtual machine. + HAResourceTypeVM HAResourceType = 0 + // HAResourceTypeContainer indicates that a HA resource refers to a container. + HAResourceTypeContainer HAResourceType = 1 +) + +// ParseHAResourceType converts the string representation of a HA resource type into the corresponding +// enum value. An error is returned if the input string does not match any known type. +func ParseHAResourceType(input string) (HAResourceType, error) { + switch input { + case "vm": + return HAResourceTypeVM, nil + case "ct": + return HAResourceTypeContainer, nil + default: + return _haResourceTypeValue, fmt.Errorf("illegal HA resource type '%s'", input) + } +} + +// HAResourceTypeValidator returns a new HA resource type validator. +func HAResourceTypeValidator() validator.String { + return validators.NewParseValidator(ParseHAResourceType, "value must be a valid HA resource type") +} + +// String converts a HAResourceType value into a string. +func (t HAResourceType) String() string { + switch t { + case HAResourceTypeVM: + return "vm" + case HAResourceTypeContainer: + return "ct" + default: + panic(fmt.Sprintf("unknown HA resource type value: %d", t)) + } +} + +// MarshalJSON marshals a HA resource type into JSON value. +func (t HAResourceType) MarshalJSON() ([]byte, error) { + bytes, err := json.Marshal(t.String()) + if err != nil { + return nil, fmt.Errorf("cannot marshal HA resource type: %w", err) + } + + return bytes, nil +} + +// UnmarshalJSON unmarshals a Proxmox HA resource type. +func (t *HAResourceType) UnmarshalJSON(b []byte) error { + var rtString string + + err := json.Unmarshal(b, &rtString) + if err != nil { + return fmt.Errorf("cannot unmarshal HA resource type: %w", err) + } + + resType, err := ParseHAResourceType(rtString) + if err == nil { + *t = resType + } + + return err +} + +// EncodeValues encodes a HA resource type field into an URL-encoded set of values. +func (t HAResourceType) EncodeValues(key string, v *url.Values) error { + v.Add(key, t.String()) + return nil +} + +// ToValue converts a HA resource type into a Terraform value. +func (t HAResourceType) ToValue() types.String { + return types.StringValue(t.String()) +} diff --git a/internal/types/ha_resource_type_test.go b/internal/types/ha_resource_type_test.go new file mode 100644 index 00000000..b513bf68 --- /dev/null +++ b/internal/types/ha_resource_type_test.go @@ -0,0 +1,122 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package types + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestParseHAResourceType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want HAResourceType + wantErr bool + }{ + {"valid value vm", "vm", HAResourceTypeVM, false}, + {"valid value ct", "ct", HAResourceTypeContainer, false}, + {"empty value", "", _haResourceTypeValue, true}, + {"invalid value", "blah", _haResourceTypeValue, true}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseHAResourceType(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseHAResourceType() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("ParseHAResourceType() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceTypeToString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resType HAResourceType + want string + }{ + {"stringify vm", HAResourceTypeVM, "vm"}, + {"stringify ct", HAResourceTypeContainer, "ct"}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.resType.String(); got != tt.want { + t.Errorf("HAResourceType.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceTypeToJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state HAResourceType + want string + }{ + {"jsonify vm", HAResourceTypeVM, `"vm"`}, + {"jsonify container", HAResourceTypeContainer, `"ct"`}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := json.Marshal(tt.state) + if err != nil { + t.Errorf("json.Marshal(HAResourceType): err = %v", err) + } else if !bytes.Equal(got, []byte(tt.want)) { + t.Errorf("json.Marshal(HAResourceType) = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHAResourceTypeFromJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + want HAResourceType + wantErr bool + }{ + {"started", `"vm"`, HAResourceTypeVM, false}, + {"container", `"ct"`, HAResourceTypeContainer, false}, + {"invalid JSON", `\\/yo`, HAResourceTypeVM, true}, + {"incompatible type", `["yo"]`, HAResourceTypeVM, true}, + {"invalid content", `"nope"`, HAResourceTypeVM, true}, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got HAResourceType + + err := json.Unmarshal([]byte(tt.json), &got) + if (err != nil) != tt.wantErr { + t.Errorf("json.Unmarshal(HAResourceType) error = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Errorf("json.Unmarshal(HAResourceType) got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/validators/parse_validator.go b/internal/validators/parse_validator.go new file mode 100644 index 00000000..f073e044 --- /dev/null +++ b/internal/validators/parse_validator.go @@ -0,0 +1,59 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package validators + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// NewParseValidator creates a validator which uses a parsing function to validate a string. The function is expected +// to return a value of type `T` and an error. If the error is non-nil, the validator will fail. The `description` +// argument should contain a description of the validator's effect. +func NewParseValidator[T any](parseFunction func(string) (T, error), description string) validator.String { + return &parseValidator[T]{ + parseFunction: parseFunction, + description: description, + } +} + +// parseValidator is a validator which uses a parsing function to validate a string. +type parseValidator[T any] struct { + parseFunction func(string) (T, error) + description string +} + +func (val *parseValidator[T]) Description(_ context.Context) string { + return val.description +} + +func (val *parseValidator[T]) MarkdownDescription(_ context.Context) string { + return val.description +} + +func (val *parseValidator[T]) ValidateString( + ctx context.Context, + request validator.StringRequest, + response *validator.StringResponse, +) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + _, err := val.parseFunction(value.ValueString()) + if err != nil { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + val.Description(ctx), + value.String(), + )) + } +} diff --git a/proxmox/client.go b/proxmox/client.go index fe437144..637018b8 100644 --- a/proxmox/client.go +++ b/proxmox/client.go @@ -37,10 +37,10 @@ type Client interface { // Version returns a client for getting the version of the Proxmox Virtual Environment API. Version() *version.Client - // API returns a lower-lever REST API client. + // API returns a lower-level REST API client. API() api.Client - // SSH returns a lower-lever SSH client. + // SSH returns a lower-level SSH client. SSH() ssh.Client } diff --git a/proxmox/cluster/client.go b/proxmox/cluster/client.go index 7b2278ae..cce4ea58 100644 --- a/proxmox/cluster/client.go +++ b/proxmox/cluster/client.go @@ -11,6 +11,7 @@ import ( "github.com/bpg/terraform-provider-proxmox/proxmox/api" clusterfirewall "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/firewall" + "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha" "github.com/bpg/terraform-provider-proxmox/proxmox/firewall" ) @@ -30,3 +31,8 @@ func (c *Client) Firewall() clusterfirewall.API { Client: firewall.Client{Client: c}, } } + +// HA returns a client for managing the cluster's High Availability features. +func (c *Client) HA() *ha.Client { + return &ha.Client{Client: c} +} diff --git a/proxmox/cluster/ha/client.go b/proxmox/cluster/ha/client.go new file mode 100644 index 00000000..1de355a1 --- /dev/null +++ b/proxmox/cluster/ha/client.go @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ha + +import ( + "fmt" + + "github.com/bpg/terraform-provider-proxmox/proxmox/api" + hagroups "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/groups" + haresources "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha/resources" +) + +// Client is an interface for accessing the Proxmox High Availability API. +type Client struct { + api.Client +} + +// ExpandPath expands a relative path to a full cluster API path. +func (c *Client) ExpandPath(path string) string { + return fmt.Sprintf("cluster/ha/%s", path) +} + +// Groups returns a client for managing the cluster's High Availability groups. +func (c *Client) Groups() *hagroups.Client { + return &hagroups.Client{Client: c.Client} +} + +// Resources returns a client for managing the cluster's High Availability resources. +func (c *Client) Resources() *haresources.Client { + return &haresources.Client{Client: c.Client} +} diff --git a/proxmox/cluster/ha/groups/client.go b/proxmox/cluster/ha/groups/client.go new file mode 100644 index 00000000..a64be2a8 --- /dev/null +++ b/proxmox/cluster/ha/groups/client.go @@ -0,0 +1,23 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package groups + +import ( + "fmt" + + "github.com/bpg/terraform-provider-proxmox/proxmox/api" +) + +// Client is an interface for accessing the Proxmox High Availability groups API. +type Client struct { + api.Client +} + +// ExpandPath expands a relative path to the HA groups management API path. +func (c *Client) ExpandPath(path string) string { + return fmt.Sprintf("cluster/ha/groups/%s", path) +} diff --git a/proxmox/cluster/ha/groups/hagroups.go b/proxmox/cluster/ha/groups/hagroups.go new file mode 100644 index 00000000..2d9d9ada --- /dev/null +++ b/proxmox/cluster/ha/groups/hagroups.go @@ -0,0 +1,86 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package groups + +import ( + "context" + "fmt" + "net/http" + "net/url" + "sort" + + "github.com/bpg/terraform-provider-proxmox/proxmox/api" +) + +// List retrieves the list of HA groups. +func (c *Client) List(ctx context.Context) ([]*HAGroupListResponseData, error) { + resBody := &HAGroupListResponseBody{} + + err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(""), nil, resBody) + if err != nil { + return nil, fmt.Errorf("error listing HA groups: %w", err) + } + + if resBody.Data == nil { + return nil, api.ErrNoDataObjectInResponse + } + + sort.Slice(resBody.Data, func(i, j int) bool { + return resBody.Data[i].ID < resBody.Data[j].ID + }) + + return resBody.Data, nil +} + +// Get retrieves a single HA group based on its identifier. +func (c *Client) Get(ctx context.Context, groupID string) (*HAGroupGetResponseData, error) { + resBody := &HAGroupGetResponseBody{} + + err := c.DoRequest( + ctx, http.MethodGet, + c.ExpandPath(url.PathEscape(groupID)), nil, resBody, + ) + if err != nil { + return nil, fmt.Errorf("error reading HA group: %w", err) + } + + if resBody.Data == nil { + return nil, api.ErrNoDataObjectInResponse + } + + return resBody.Data, nil +} + +// Create creates a new HA group. +func (c *Client) Create(ctx context.Context, data *HAGroupCreateRequestBody) error { + err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(""), data, nil) + if err != nil { + return fmt.Errorf("error creating HA group: %w", err) + } + + return nil +} + +// Update updates a HA group's configuration. +func (c *Client) Update(ctx context.Context, groupID string, data *HAGroupUpdateRequestBody) error { + err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(url.PathEscape(groupID)), data, nil) + if err != nil { + return fmt.Errorf("error updating HA group: %w", err) + } + + return nil +} + +// Delete deletes a HA group. +func (c *Client) Delete(ctx context.Context, groupID string) error { + err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(url.PathEscape(groupID)), nil, nil) + if err != nil { + return fmt.Errorf("error deleting HA group: %w", err) + } + + return nil +} diff --git a/proxmox/cluster/ha/groups/hagroups_types.go b/proxmox/cluster/ha/groups/hagroups_types.go new file mode 100644 index 00000000..5e9c5343 --- /dev/null +++ b/proxmox/cluster/ha/groups/hagroups_types.go @@ -0,0 +1,67 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package groups + +import "github.com/bpg/terraform-provider-proxmox/internal/types" + +// HAGroupListResponseBody contains the body from a HA group list response. +type HAGroupListResponseBody struct { + Data []*HAGroupListResponseData `json:"data,omitempty"` +} + +// HAGroupListResponseData contains the data from a HA group list response. +type HAGroupListResponseData struct { + ID string `json:"group"` +} + +// HAGroupGetResponseBody contains the body from a HA group get response. +type HAGroupGetResponseBody struct { + Data *HAGroupGetResponseData `json:"data,omitempty"` +} + +// HAGroupDataBase contains fields which are both received from and send to the HA group API. +type HAGroupDataBase struct { + // A SHA1 digest of the group's configuration. + Digest *string `json:"digest,omitempty" url:"digest,omitempty"` + // The group's comment, if defined + Comment *string `json:"comment,omitempty" url:"comment,omitempty"` + // A comma-separated list of node fields. Each node field contains a node name, and may + // include a priority, with a semicolon acting as a separator. + Nodes string `json:"nodes" url:"nodes"` + // A boolean (0/1) indicating that failing back to the highest priority node is disabled. + NoFailback types.CustomBool `json:"nofailback" url:"nofailback,int"` + // A boolean (0/1) indicating that associated resources cannot run on other nodes. + Restricted types.CustomBool `json:"restricted" url:"restricted,int"` +} + +// HAGroupGetResponseData contains the data from a HA group get response. +type HAGroupGetResponseData struct { + // The group's data + HAGroupDataBase + // The group's identifier + ID string `json:"group"` + // The type. Always set to `group`. + Type string `json:"type"` +} + +// HAGroupCreateRequestBody contains the data which must be sent when creating a HA group. +type HAGroupCreateRequestBody struct { + // The group's data + HAGroupDataBase + // The group's identifier + ID string `url:"group"` + // The type. Always set to `group`. + Type string `url:"type"` +} + +// HAGroupUpdateRequestBody contains the data which must be sent when updating a HA group. +type HAGroupUpdateRequestBody struct { + // The group's data + HAGroupDataBase + // A list of settings to delete + Delete string `url:"delete"` +} diff --git a/proxmox/cluster/ha/resources/client.go b/proxmox/cluster/ha/resources/client.go new file mode 100644 index 00000000..5f4a7eb7 --- /dev/null +++ b/proxmox/cluster/ha/resources/client.go @@ -0,0 +1,23 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package resources + +import ( + "fmt" + + "github.com/bpg/terraform-provider-proxmox/proxmox/api" +) + +// Client is an interface for accessing the Proxmox High Availability resources management API. +type Client struct { + api.Client +} + +// ExpandPath expands a relative path to the HA resources management API path. +func (c *Client) ExpandPath(path string) string { + return fmt.Sprintf("cluster/ha/resources/%s", path) +} diff --git a/proxmox/cluster/ha/resources/resources.go b/proxmox/cluster/ha/resources/resources.go new file mode 100644 index 00000000..a37e9b63 --- /dev/null +++ b/proxmox/cluster/ha/resources/resources.go @@ -0,0 +1,92 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package resources + +import ( + "context" + "fmt" + "net/http" + "net/url" + "sort" + + "github.com/bpg/terraform-provider-proxmox/internal/types" + "github.com/bpg/terraform-provider-proxmox/proxmox/api" +) + +type haResourceTypeListQuery struct { + ResType *types.HAResourceType `url:"type"` +} + +// List retrieves the list of HA resources. If the `resType` argument is `nil`, all resources will be returned; +// otherwise resources will be filtered by the specified type (either `ct` or `vm`). +func (c *Client) List(ctx context.Context, resType *types.HAResourceType) ([]*HAResourceListResponseData, error) { + options := &haResourceTypeListQuery{resType} + resBody := &HAResourceListResponseBody{} + + err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(""), options, resBody) + if err != nil { + return nil, fmt.Errorf("error listing HA resources: %w", err) + } + + if resBody.Data == nil { + return nil, api.ErrNoDataObjectInResponse + } + + sort.Slice(resBody.Data, func(i, j int) bool { + return resBody.Data[i].ID.Type < resBody.Data[j].ID.Type || + (resBody.Data[i].ID.Type == resBody.Data[j].ID.Type && + resBody.Data[i].ID.Name < resBody.Data[j].ID.Name) + }) + + return resBody.Data, nil +} + +// Get retrieves the configuration of a single HA resource. +func (c *Client) Get(ctx context.Context, id types.HAResourceID) (*HAResourceGetResponseData, error) { + resBody := &HAResourceGetResponseBody{} + + err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(url.PathEscape(id.String())), nil, resBody) + if err != nil { + return nil, fmt.Errorf("error reading HA resource: %w", err) + } + + if resBody.Data == nil { + return nil, api.ErrNoDataObjectInResponse + } + + return resBody.Data, nil +} + +// Create creates a new HA resource. +func (c *Client) Create(ctx context.Context, data *HAResourceCreateRequestBody) error { + err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(""), data, nil) + if err != nil { + return fmt.Errorf("error creating HA resource: %w", err) + } + + return nil +} + +// Update updates an existing HA resource. +func (c *Client) Update(ctx context.Context, id types.HAResourceID, data *HAResourceUpdateRequestBody) error { + err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(url.PathEscape(id.String())), data, nil) + if err != nil { + return fmt.Errorf("error updating HA resource %v: %w", id, err) + } + + return nil +} + +// Delete deletes a HA resource. +func (c *Client) Delete(ctx context.Context, id types.HAResourceID) error { + err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(url.PathEscape(id.String())), nil, nil) + if err != nil { + return fmt.Errorf("error deleting HA resource %v: %w", id, err) + } + + return nil +} diff --git a/proxmox/cluster/ha/resources/resources_types.go b/proxmox/cluster/ha/resources/resources_types.go new file mode 100644 index 00000000..b86d63ce --- /dev/null +++ b/proxmox/cluster/ha/resources/resources_types.go @@ -0,0 +1,68 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package resources + +import "github.com/bpg/terraform-provider-proxmox/internal/types" + +// HAResourceListResponseBody contains the body from a HA resource list response. +type HAResourceListResponseBody struct { + Data []*HAResourceListResponseData `json:"data,omitempty"` +} + +// HAResourceListResponseData contains the data from a HA resource list response. +type HAResourceListResponseData struct { + ID types.HAResourceID `json:"sid"` +} + +// HAResourceGetResponseBody contains the body from a HA resource get response. +type HAResourceGetResponseBody struct { + Data *HAResourceGetResponseData `json:"data,omitempty"` +} + +// HAResourceDataBase contains data common to all HA resource API calls. +type HAResourceDataBase struct { + // Resource comment, if defined + Comment *string `json:"comment,omitempty" url:"comment,omitempty"` + // HA group identifier, if the resource is part of one. + Group *string `json:"group,omitempty" url:"group,omitempty"` + // Maximal number of service relocation attempts. + MaxRelocate *int64 `json:"max_relocate,omitempty" url:"max_relocate,omitempty"` + // Maximal number of service restart attempts. + MaxRestart *int64 `json:"max_restart" url:"max_restart,omitempty"` + // Requested resource state. + State types.HAResourceState `json:"state" url:"state"` +} + +// HAResourceGetResponseData contains data received from the HA resource API when requesting information about a single +// HA resource. +type HAResourceGetResponseData struct { + HAResourceDataBase + // Identifier of this resource + ID types.HAResourceID `json:"sid"` + // Type of this resource + Type types.HAResourceType `json:"type"` + // SHA-1 digest of the resources' configuration. + Digest *string `json:"digest,omitempty"` +} + +// HAResourceCreateRequestBody contains data received from the HA resource API when creating a new HA resource. +type HAResourceCreateRequestBody struct { + HAResourceDataBase + // Identifier of this resource + ID types.HAResourceID `url:"sid"` + // Type of this resource + Type *types.HAResourceType `url:"type,omitempty"` + // SHA-1 digest of the resources' configuration. + Digest *string `url:"comment,omitempty"` +} + +// HAResourceUpdateRequestBody contains data received from the HA resource API when updating an existing HA resource. +type HAResourceUpdateRequestBody struct { + HAResourceDataBase + // Settings that must be deleted from the resource's configuration + Delete []string `url:"delete,omitempty,comma"` +} diff --git a/tools/tools.go b/tools/tools.go index 419f6756..235b4c44 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -1,12 +1,12 @@ -//go:build tools -// +build tools - /* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +//go:build tools +// +build tools + package tools // Manage tool dependencies via go.mod. @@ -29,5 +29,11 @@ import ( // Temporary: while migrating to the TF framework, we need to copy the generated docs to the right place // for the resources / data sources that have been migrated. //go:generate cp ../build/docs-gen/data-sources/virtual_environment_version.md ../docs/data-sources/ +//go:generate cp ../build/docs-gen/data-sources/virtual_environment_hagroup.md ../docs/data-sources/ +//go:generate cp ../build/docs-gen/data-sources/virtual_environment_hagroups.md ../docs/data-sources/ +//go:generate cp ../build/docs-gen/data-sources/virtual_environment_haresource.md ../docs/data-sources/ +//go:generate cp ../build/docs-gen/data-sources/virtual_environment_haresources.md ../docs/data-sources/ //go:generate cp ../build/docs-gen/resources/virtual_environment_network_linux_bridge.md ../docs/resources/ //go:generate cp ../build/docs-gen/resources/virtual_environment_network_linux_vlan.md ../docs/resources/ +//go:generate cp ../build/docs-gen/resources/virtual_environment_hagroup.md ../docs/resources/ +//go:generate cp ../build/docs-gen/resources/virtual_environment_haresource.md ../docs/resources/