From 03c9b36b86914583c1709e99db305682b7b7dc99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Beno=C3=AEt?= Date: Sun, 20 Aug 2023 23:42:12 +0200 Subject: [PATCH] feat(ha): add support for Proxmox High Availability objects (#498) * chore: fix a pair of typos in comments * feat(api): list High Availability groups * New clients created for HA and HA groups (via `Cluster().HA().Groups()`) * `List(ctx)` method that lists the cluster's High Availability groups * feat(ha): added the `proxmox_virtual_environment_hagroups` data source * This data source returns the list of HA groups in its value's `group_ids` field * fix(api): changed incorrect copy-pasted error message * feat(api): get a HA group's full information * Added a `Get()` method to the HA group client, which fetches a single group's information based on its identifier. * feat(ha): added the `proxmox_virtual_environment_hagroup` data source * This data source can read information about a single Proxmox High Availabillity group from the cluster. * chore(ha): fixed linter error * test(ha): added schema tests for the HA groups data sources * fix(ha): use -1 as a node's priority when no priority is defined * It used to default to 0, which is a valid value for priorities. * chore(ha): converted the `hagroups` datasource to the Terraform plugin SDK * chore(refactoring): common definition for `id` attributes * chore(ha): ported the HA group datasource to the Terraform plugin framework * feat(ha): return HA group identifiers as a set rather than a list * docs(ha): added examples for the hagroups/hagroup datasources * docs(ha): added documentation for the hagroup{,s} datasources * chore(ha): fixed linter errors * chore(ha): workaround for the linter's split personality disorder * fix(ha): fixed reading the restricted flag * chore(refactoring): use `ExpandPath` for paths to the HA groups API Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> * feat: CustomBool to Terraform attribute value conversion method * chore(refactoring): use `CustomBool` for boolean fields in the API data * chore(refactoring): renamed "members" to "nodes" in the HA group datasource * fix: typo in comment * chore(refactoring): split HA group API data and added the update request body * fix(api): fixed copy-pasted error message * feat(api): method to create/update a HA group * feat(api): HA group deletion method * fix(api): made the digest optional for HA groups * feat(ha): added unimplemented hagroup resource * fix(ha): fixed copy-pasted comment * feat(ha): schema definition for the HA group resource * feat: helper function that converts string attr values to string pointers * fix(ha): ensure node priorities are <= 1000 in HA groups * fix(ha): add the digest attribute to the schema * feat(ha): model definition for the HA group resource * fix(api): fixed incorrect error message * fix(api): fixed HA group creation / update * I had somehow misunderstood the Proxmox API doc and thought creation and update went through the same endpoint. This has been fixed by adding separate data structures and separate methods for both actions. * feat: Terraform/Proxmox API conversion utilities * chore(refactoring): HA group model and reading code moved to separate file * feat(ha): HA group creation * fix(api): renamed method (missed during previous refactor) * feat(ha): `Read()` method implemented for the `hagroup` resource * chore(refactoring): more consistent variable naming * fix(ha): fixed the behaviour of `Read()` when the resource is deleted externally * feat(ha): implement HA group deletion * feat(ha): HA group update implemented * fix(ha): prevent empty or untrimmed HA group comments * feat(ha): HA group import * docs(ha): HA group resource examples * docs(ha): generated documentation for the `hagroup` resource * chore(ha): fixed linter errors * chore(refactoring): updated the code based on changes to the datasource PR * fix(api): fixed boolean fields in the HA group create/update structures * fix(ha): removed digest from the HA group resource and datasource * The digest is generated by Proxmox from the *whole* HA groups configuration, so any update to one group causes changes in all other groups. * Because of that, using it causes failures when updating two or more HA groups. * It is also a pretty useless value to have in the datasource, as it is global and not actually related to the individual data items * chore(refactoring): removed obsolete type conversion code * chore(refactoring): use `ExpandPath` in the HA groups API client * feat(ha): custom type for HA resource states * feat(ha): custom type for HA resource types * fix(api): fixed JSON decoding for HA resource states and types * Values were being decoded directly from the raw bytes. * Added tests for JSON marshaling/unmarshaling * feat(api): custom type for HA resource identifiers * Structure with a type and name * Conversion to/from strings * Marshaling to/Unmarshaling from JSON * URL encoding * feat(api): list and get HA resources * feat(ha): HA resources list datasource * feat(ha): added method that converts HA resource data to Terraform values * fix(api): HA resource max relocation/restarts are optional * feat(ha): Terraform validator for HA resource IDs * feat(ha): HA resource datasource * chore(refactoring): moved HA resource model to separate file * feat(api): data structures for HA resource creation and update * feat(api): HA resource creation, update and deletion * fix(api): incorrect mapping in common HA resource data * feat: utility function to create attribute validators based on parse functions * feat: validators for HA resource identifiers, states and types * fix(api): incorrect comment for the update request body * feat(ha): Terraform resource for Proxmox HA resources * chore(reafactoring): removed old HA resource ID validator * docs: examples related to HA resources added * docs: added documentation related to HA resources management * fix: update doc generation, fix minor typos * fix: rename & split utils package, replace `iota` --------- Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- .../virtual_environment_hagroup.md | 44 +++ .../virtual_environment_hagroups.md | 30 ++ .../virtual_environment_haresource.md | 46 +++ .../virtual_environment_haresources.md | 43 ++ docs/resources/virtual_environment_hagroup.md | 59 +++ .../virtual_environment_haresource.md | 56 +++ .../data-source.tf | 12 + .../data-source.tf | 5 + .../data-source.tf | 12 + .../data-source.tf | 14 + .../import.sh | 3 + .../resource.tf | 14 + .../import.sh | 3 + .../resource.tf | 9 + internal/cluster/datasource_hagroup.go | 131 ++++++ internal/cluster/datasource_hagroups.go | 119 ++++++ internal/cluster/datasource_haresource.go | 144 +++++++ internal/cluster/datasource_haresources.go | 163 ++++++++ internal/cluster/hagroup.go | 84 ++++ internal/cluster/haresource_model.go | 114 ++++++ internal/cluster/resource_hagroup.go | 339 ++++++++++++++++ internal/cluster/resource_haresource.go | 372 ++++++++++++++++++ internal/provider/provider.go | 7 + internal/structure/attribute.go | 23 ++ internal/types/common_types.go | 12 + internal/types/ha_resource_id.go | 125 ++++++ internal/types/ha_resource_id_test.go | 128 ++++++ internal/types/ha_resource_state.go | 127 ++++++ internal/types/ha_resource_state_test.go | 131 ++++++ internal/types/ha_resource_type.go | 108 +++++ internal/types/ha_resource_type_test.go | 122 ++++++ internal/validators/parse_validator.go | 59 +++ proxmox/client.go | 4 +- proxmox/cluster/client.go | 6 + proxmox/cluster/ha/client.go | 35 ++ proxmox/cluster/ha/groups/client.go | 23 ++ proxmox/cluster/ha/groups/hagroups.go | 86 ++++ proxmox/cluster/ha/groups/hagroups_types.go | 67 ++++ proxmox/cluster/ha/resources/client.go | 23 ++ proxmox/cluster/ha/resources/resources.go | 92 +++++ .../cluster/ha/resources/resources_types.go | 68 ++++ tools/tools.go | 12 +- 42 files changed, 3069 insertions(+), 5 deletions(-) create mode 100644 docs/data-sources/virtual_environment_hagroup.md create mode 100644 docs/data-sources/virtual_environment_hagroups.md create mode 100644 docs/data-sources/virtual_environment_haresource.md create mode 100644 docs/data-sources/virtual_environment_haresources.md create mode 100644 docs/resources/virtual_environment_hagroup.md create mode 100644 docs/resources/virtual_environment_haresource.md create mode 100644 examples/data-sources/proxmox_virtual_environment_hagroup/data-source.tf create mode 100644 examples/data-sources/proxmox_virtual_environment_hagroups/data-source.tf create mode 100644 examples/data-sources/proxmox_virtual_environment_haresource/data-source.tf create mode 100644 examples/data-sources/proxmox_virtual_environment_haresources/data-source.tf create mode 100644 examples/resources/proxmox_virtual_environment_hagroup/import.sh create mode 100644 examples/resources/proxmox_virtual_environment_hagroup/resource.tf create mode 100644 examples/resources/proxmox_virtual_environment_haresource/import.sh create mode 100644 examples/resources/proxmox_virtual_environment_haresource/resource.tf create mode 100644 internal/cluster/datasource_hagroup.go create mode 100644 internal/cluster/datasource_hagroups.go create mode 100644 internal/cluster/datasource_haresource.go create mode 100644 internal/cluster/datasource_haresources.go create mode 100644 internal/cluster/hagroup.go create mode 100644 internal/cluster/haresource_model.go create mode 100644 internal/cluster/resource_hagroup.go create mode 100644 internal/cluster/resource_haresource.go create mode 100644 internal/structure/attribute.go create mode 100644 internal/types/ha_resource_id.go create mode 100644 internal/types/ha_resource_id_test.go create mode 100644 internal/types/ha_resource_state.go create mode 100644 internal/types/ha_resource_state_test.go create mode 100644 internal/types/ha_resource_type.go create mode 100644 internal/types/ha_resource_type_test.go create mode 100644 internal/validators/parse_validator.go create mode 100644 proxmox/cluster/ha/client.go create mode 100644 proxmox/cluster/ha/groups/client.go create mode 100644 proxmox/cluster/ha/groups/hagroups.go create mode 100644 proxmox/cluster/ha/groups/hagroups_types.go create mode 100644 proxmox/cluster/ha/resources/client.go create mode 100644 proxmox/cluster/ha/resources/resources.go create mode 100644 proxmox/cluster/ha/resources/resources_types.go 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/