0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-06-30 02:31:10 +00:00

chore(vm2): experimental support for clone and inherited attributes (#1235)

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
Pavel Boldyrev 2024-04-23 22:00:11 -04:00 committed by GitHub
parent ee939a38a3
commit 7209fe0321
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 302 additions and 75 deletions

View File

@ -9,9 +9,19 @@ description: |-
# Resource: proxmox_virtual_environment_vm2
~> **DO NOT USE**
!> **DO NOT USE**
This is an experimental implementation of a Proxmox VM resource using Plugin Framework.<br><br>It is a Proof of Concept, highly experimental and **will** change in future. It does not support all features of the Proxmox API for VMs and **MUST NOT** be used in production.
-> Note: Many attributes are marked as **optional** _and_ **computed** in the schema,
hence you may seem added to the plan with "(known after apply)" status, even if they are not set in the configuration.
This is done to support the `clone` operation, when a VM is created from an existing one,
and attributes of the original VM are copied to the new one.
Computed attributes allow the provider to set those attributes without user input.
The attributes are marked as optional to allow the user to set (or overwrite) them if needed.
In order to remove the computed attribute from the plan, you can set it to an empty value (e.g. `""` for string, `[]` for collection).
<!-- schema generated by tfplugindocs -->
@ -23,12 +33,26 @@ This is an experimental implementation of a Proxmox VM resource using Plugin Fra
### Optional
- `clone` (Attributes) The cloning configuration. (see [below for nested schema](#nestedatt--clone))
- `description` (String) The description of the VM.
- `id` (Number) The unique identifier of the VM in the Proxmox cluster.
- `name` (String) The name of the VM. Doesn't have to be unique.
- `tags` (Set of String) The tags assigned to the resource.
- `template` (Boolean) Set to true to create a VM template.
- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts))
<a id="nestedatt--clone"></a>
### Nested Schema for `clone`
Required:
- `id` (Number) The ID of the VM to clone.
Optional:
- `retries` (Number) The number of retries to perform when cloning the VM (default: 3).
<a id="nestedatt--timeouts"></a>
### Nested Schema for `timeouts`

View File

@ -121,7 +121,6 @@ func TestAccResourceVM2(t *testing.T) {
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
name = "test-tags"
tags = ["tag1"]
}`),
@ -134,28 +133,31 @@ func TestAccResourceVM2(t *testing.T) {
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
name = "test-tags"
// no tags
}`),
Check: testNoResourceAttributesSet("proxmox_virtual_environment_vm2.test_vm", []string{
"tags",
}),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("proxmox_virtual_environment_vm2.test_vm", "tags.#", "1"),
resource.TestCheckTypeSetElemAttr("proxmox_virtual_environment_vm2.test_vm", "tags.*", "tag1"),
),
},
{
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
name = "test-tags"
tags = []
}`),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("proxmox_virtual_environment_vm2.test_vm", "tags.#", "0"),
),
},
}},
{"a VM can't have empty tags set", []resource.TestStep{{
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
tags = []
}`),
ExpectError: regexp.MustCompile(`tags set must contain at least 1 elements`),
}}},
{"a VM can't have empty tags", []resource.TestStep{{
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
tags = ["", "tag1"]
}`),
ExpectError: regexp.MustCompile(`string length must be at least 1, got: 0`),
@ -164,7 +166,7 @@ func TestAccResourceVM2(t *testing.T) {
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
tags = [" ", "tag1"]
}`),
ExpectError: regexp.MustCompile(`must be a non-empty and non-whitespace string`),
@ -198,3 +200,75 @@ func TestAccResourceVM2(t *testing.T) {
})
}
}
func TestAccResourceVM2Clone(t *testing.T) {
t.Parallel()
te := initTestEnvironment(t)
vmID := gofakeit.IntRange(90000, 100000)
te.addTemplateVars(map[string]any{
"VMID": vmID,
})
tests := []struct {
name string
steps []resource.TestStep
}{
{"create a clone from template", []resource.TestStep{{
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
name = "template"
description = "template description"
template = true
}
resource "proxmox_virtual_environment_vm2" "test_vm_clone" {
node_name = "{{.NodeName}}"
name = "clone"
clone = {
id = proxmox_virtual_environment_vm2.test_vm.id
}
}`),
Check: resource.ComposeTestCheckFunc(
testResourceAttributes("proxmox_virtual_environment_vm2.test_vm", map[string]string{
"template": "true",
}),
testResourceAttributes("proxmox_virtual_environment_vm2.test_vm_clone", map[string]string{
// name is overwritten
"name": "clone",
}),
testNoResourceAttributesSet("proxmox_virtual_environment_vm2.test_vm_clone", []string{
// description is not copied
"description",
}),
),
}}},
{"tags are copied to the clone", []resource.TestStep{{
Config: te.renderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
template = true
tags = ["tag1", "tag2"]
}
resource "proxmox_virtual_environment_vm2" "test_vm_clone" {
node_name = "{{.NodeName}}"
clone = {
id = proxmox_virtual_environment_vm2.test_vm.id
}
}`),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckTypeSetElemAttr("proxmox_virtual_environment_vm2.test_vm_clone", "tags.*", "tag1"),
resource.TestCheckTypeSetElemAttr("proxmox_virtual_environment_vm2.test_vm_clone", "tags.*", "tag2"),
),
}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: te.accProviders,
Steps: tt.steps,
})
})
}
}

View File

@ -20,9 +20,10 @@ func ResourceAttribute() schema.SetAttribute {
},
Description: "The tags assigned to the resource.",
Optional: true,
Computed: true,
ElementType: types.StringType,
Validators: []validator.Set{
setvalidator.SizeAtLeast(1),
// NOTE: we allow empty list to remove all previously set tags
setvalidator.ValueStringsAre(
stringvalidator.RegexMatches(
regexp.MustCompile(`(.|\s)*\S(.|\s)*`),

View File

@ -41,8 +41,8 @@ func (v Value) Equal(o attr.Value) bool {
}
// ValueStringPointer returns a pointer to the string representation of tags set value.
func (v Value) ValueStringPointer(ctx context.Context, diags diag.Diagnostics) *string {
if v.IsNull() {
func (v Value) ValueStringPointer(ctx context.Context, diags *diag.Diagnostics) *string {
if v.IsNull() || v.IsUnknown() || len(v.Elements()) == 0 {
return nil
}
@ -71,7 +71,7 @@ func (v Value) ValueStringPointer(ctx context.Context, diags diag.Diagnostics) *
}
// SetValue converts a string of tags to a tags set value.
func SetValue(tagsStr *string, diags diag.Diagnostics) Value {
func SetValue(tagsStr *string, diags *diag.Diagnostics) Value {
if tagsStr == nil {
return Value{types.SetNull(types.StringType)}
}

View File

@ -94,33 +94,24 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
vmID := int(plan.ID.ValueInt64())
if vmID == 0 {
if plan.ID.ValueInt64() == 0 {
id, err := r.client.Cluster().GetVMID(ctx)
if err != nil {
resp.Diagnostics.AddError("Failed to get VM ID", err.Error())
return
}
vmID = *id
}
vmAPI := r.client.Node(plan.NodeName.ValueString()).VM(vmID)
createBody := &vms.CreateRequestBody{
Description: plan.Description.ValueStringPointer(),
Name: plan.Name.ValueStringPointer(),
Tags: plan.Tags.ValueStringPointer(ctx, resp.Diagnostics),
VMID: &vmID,
plan.ID = types.Int64Value(int64(*id))
}
if resp.Diagnostics.HasError() {
return
}
err := vmAPI.CreateVM(ctx, createBody)
if err != nil {
resp.Diagnostics.AddError("Failed to create VM", err.Error())
if plan.Clone != nil {
r.clone(ctx, plan, &resp.Diagnostics)
} else {
r.create(ctx, plan, &resp.Diagnostics)
}
if resp.Diagnostics.HasError() {
@ -128,7 +119,7 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res
}
// read back the VM from the PVE API to populate computed fields
exists := r.read(ctx, vmAPI, &plan, resp.Diagnostics)
exists := r.read(ctx, &plan, &resp.Diagnostics)
if !exists {
resp.Diagnostics.AddError("VM does not exist after creation", "")
}
@ -141,6 +132,70 @@ func (r *vmResource) Create(ctx context.Context, req resource.CreateRequest, res
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *vmResource) create(ctx context.Context, plan vmModel, diags *diag.Diagnostics) {
createBody := &vms.CreateRequestBody{
Description: plan.Description.ValueStringPointer(),
Name: plan.Name.ValueStringPointer(),
Tags: plan.Tags.ValueStringPointer(ctx, diags),
Template: proxmoxtypes.CustomBoolPtr(plan.Template.ValueBoolPointer()),
VMID: int(plan.ID.ValueInt64()),
}
if diags.HasError() {
return
}
// .VM(0) is used to create a new VM, the VM ID is not used in the API URL
vmAPI := r.client.Node(plan.NodeName.ValueString()).VM(0)
err := vmAPI.CreateVM(ctx, createBody)
if err != nil {
diags.AddError("Failed to create VM", err.Error())
}
}
func (r *vmResource) clone(ctx context.Context, plan vmModel, diags *diag.Diagnostics) {
if plan.Clone == nil {
diags.AddError("Clone configuration is missing", "")
return
}
sourceID := int(plan.Clone.ID.ValueInt64())
vmAPI := r.client.Node(plan.NodeName.ValueString()).VM(sourceID)
// name and description for the clone are optional, but they are not copied from the source VM.
cloneBody := &vms.CloneRequestBody{
Description: plan.Description.ValueStringPointer(),
Name: plan.Name.ValueStringPointer(),
VMIDNew: int(plan.ID.ValueInt64()),
}
err := vmAPI.CloneVM(ctx, int(plan.Clone.Retries.ValueInt64()), cloneBody)
if err != nil {
diags.AddError("Failed to clone VM", err.Error())
}
if diags.HasError() {
return
}
// now load the clone's configuration into a temporary model and update what is needed comparing to the plan
clone := vmModel{
ID: plan.ID,
Name: plan.Name,
Description: plan.Description,
NodeName: plan.NodeName,
}
r.read(ctx, &clone, diags)
if diags.HasError() {
return
}
r.update(ctx, plan, clone, diags)
}
func (r *vmResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state vmModel
@ -156,9 +211,7 @@ func (r *vmResource) Read(ctx context.Context, req resource.ReadRequest, resp *r
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
vmAPI := r.client.Node(state.NodeName.ValueString()).VM(int(state.ID.ValueInt64()))
exists := r.read(ctx, vmAPI, &state, resp.Diagnostics)
exists := r.read(ctx, &state, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
@ -193,10 +246,27 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
r.update(ctx, plan, state, &resp.Diagnostics)
// read back the VM from the PVE API to populate computed fields
exists := r.read(ctx, &plan, &resp.Diagnostics)
if !exists {
resp.Diagnostics.AddError("VM does not exist after update", "")
}
if resp.Diagnostics.HasError() {
return
}
// set state to the updated plan data
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *vmResource) update(ctx context.Context, plan, state vmModel, diags *diag.Diagnostics) {
vmAPI := r.client.Node(plan.NodeName.ValueString()).VM(int(plan.ID.ValueInt64()))
updateBody := &vms.UpdateRequestBody{
VMID: proxmoxtypes.Int64PtrToIntPtr(plan.ID.ValueInt64Pointer()),
VMID: int(plan.ID.ValueInt64()),
}
var errs []error
@ -221,35 +291,25 @@ func (r *vmResource) Update(ctx context.Context, req resource.UpdateRequest, res
}
}
if !plan.Tags.Equal(state.Tags) {
// For optional computed fields only:
// The first condition is for the clone case, where the tags (captured in `state.Tags`)
// have already been copied from the source VM to the clone during the cloning process.
// Then, if the clone config does not have tags, we keep the cloned ones in the VM.
// Otherwise, if the clone config has empty tags we remove them from the VM.
// And finally, if the clone config has tags we update them in th VM
if !plan.Tags.Equal(state.Tags) && !plan.Tags.IsUnknown() {
if plan.Tags.IsNull() || len(plan.Tags.Elements()) == 0 {
del("Tags")
} else {
updateBody.Tags = plan.Tags.ValueStringPointer(ctx, resp.Diagnostics)
updateBody.Tags = plan.Tags.ValueStringPointer(ctx, diags)
}
}
err := vmAPI.UpdateVM(ctx, updateBody)
if err != nil {
resp.Diagnostics.AddError("Failed to update VM", err.Error())
}
if resp.Diagnostics.HasError() {
diags.AddError("Failed to update VM", err.Error())
return
}
// read back the VM from the PVE API to populate computed fields
exists := r.read(ctx, vmAPI, &plan, resp.Diagnostics)
if !exists {
resp.Diagnostics.AddError("VM does not exist after update", "")
}
if resp.Diagnostics.HasError() {
return
}
// set state to the updated plan data
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *vmResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
@ -326,8 +386,6 @@ func (r *vmResource) ImportState(
return
}
vmAPI := r.client.Node(nodeName).VM(id)
var ts timeouts.Value
resp.Diagnostics.Append(resp.State.GetAttribute(ctx, path.Root("timeouts"), &ts)...)
@ -342,7 +400,7 @@ func (r *vmResource) ImportState(
Timeouts: ts,
}
exists := r.read(ctx, vmAPI, &state, resp.Diagnostics)
exists := r.read(ctx, &state, &resp.Diagnostics)
if !exists {
resp.Diagnostics.AddError(fmt.Sprintf("VM %d does not exist on node %s", id, nodeName), "")
}
@ -357,7 +415,9 @@ func (r *vmResource) ImportState(
// read retrieves the current state of the resource from the API and updates the state.
// Returns false if the resource does not exist, so the caller can remove it from the state if necessary.
func (r *vmResource) read(ctx context.Context, vmAPI *vms.Client, model *vmModel, diags diag.Diagnostics) bool {
func (r *vmResource) read(ctx context.Context, model *vmModel, diags *diag.Diagnostics) bool {
vmAPI := r.client.Node(model.NodeName.ValueString()).VM(int(model.ID.ValueInt64()))
// Retrieve the entire configuration in order to compare it to the state.
config, err := vmAPI.GetVM(ctx)
if err != nil {
@ -388,7 +448,12 @@ func (r *vmResource) read(ctx context.Context, vmAPI *vms.Client, model *vmModel
// Optional fields can be removed from the model, use StringPointerValue to handle removal on nil
model.Description = types.StringPointerValue(config.Description)
model.Name = types.StringPointerValue(config.Name)
model.Tags = tags.SetValue(config.Tags, diags)
if model.Tags.IsNull() || model.Tags.IsUnknown() { // only for computed
model.Tags = tags.SetValue(config.Tags, diags)
}
model.Template = types.BoolPointerValue(config.Template.PointerBool())
return true
}

View File

@ -8,10 +8,15 @@ import (
)
type vmModel struct {
Description types.String `tfsdk:"description"`
ID types.Int64 `tfsdk:"id"`
Name types.String `tfsdk:"name"`
NodeName types.String `tfsdk:"node_name"`
Tags tags.Value `tfsdk:"tags"`
Timeouts timeouts.Value `tfsdk:"timeouts"`
Description types.String `tfsdk:"description"`
Clone *struct {
ID types.Int64 `tfsdk:"id"`
Retries types.Int64 `tfsdk:"retries"`
} `tfsdk:"clone"`
ID types.Int64 `tfsdk:"id"`
Name types.String `tfsdk:"name"`
NodeName types.String `tfsdk:"node_name"`
Tags tags.Value `tfsdk:"tags"`
Template types.Bool `tfsdk:"template"`
Timeouts timeouts.Value `tfsdk:"timeouts"`
}

View File

@ -8,7 +8,10 @@ import (
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
@ -27,6 +30,25 @@ func (r *vmResource) Schema(
"<br><br>It is a Proof of Concept, highly experimental and **will** change in future. " +
"It does not support all features of the Proxmox API for VMs and **MUST NOT** be used in production.",
Attributes: map[string]schema.Attribute{
"clone": schema.SingleNestedAttribute{
Description: "The cloning configuration.",
Optional: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplace(),
},
Attributes: map[string]schema.Attribute{
"id": schema.Int64Attribute{
Description: "The ID of the VM to clone.",
Required: true,
},
"retries": schema.Int64Attribute{
Description: "The number of retries to perform when cloning the VM (default: 3).",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(3),
},
},
},
"description": schema.StringAttribute{
Description: "The description of the VM.",
Optional: true,
@ -56,6 +78,13 @@ func (r *vmResource) Schema(
Required: true,
},
"tags": tags.ResourceAttribute(),
"template": schema.BoolAttribute{
Description: "Set to true to create a VM template.",
Optional: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.RequiresReplace(),
},
},
"timeouts": timeouts.Attributes(ctx, timeouts.Opts{
Create: true,
Read: true,

View File

@ -72,7 +72,17 @@ func (c *Client) CreateVM(ctx context.Context, d *CreateRequestBody) error {
func (c *Client) CreateVMAsync(ctx context.Context, d *CreateRequestBody) (*string, error) {
resBody := &CreateResponseBody{}
err := c.DoRequest(ctx, http.MethodPost, c.basePath(), d, resBody)
err := retry.Do(
func() error {
return c.DoRequest(ctx, http.MethodPost, c.basePath(), d, resBody)
},
retry.Context(ctx),
retry.RetryIf(func(err error) bool {
return strings.Contains(err.Error(), "Reason: got no worker upid")
}),
retry.LastErrorOnly(true),
retry.Attempts(3),
)
if err != nil {
return nil, fmt.Errorf("error creating VM: %w", err)
}

View File

@ -290,7 +290,7 @@ type CreateRequestBody struct {
VirtualCPUCount *int `json:"vcpus,omitempty" url:"vcpus,omitempty"`
VirtualIODevices CustomStorageDevices `json:"virtio,omitempty" url:"virtio,omitempty"`
VMGenerationID *string `json:"vmgenid,omitempty" url:"vmgenid,omitempty"`
VMID *int `json:"vmid,omitempty" url:"vmid,omitempty"`
VMID int `json:"vmid,omitempty" url:"vmid,omitempty"`
VMStateDatastoreID *string `json:"vmstatestorage,omitempty" url:"vmstatestorage,omitempty"`
WatchdogDevice *CustomWatchdogDevice `json:"watchdog,omitempty" url:"watchdog,omitempty"`
}

View File

@ -38,6 +38,15 @@ type CustomPrivileges []string
// CustomTimestamp allows a JSON boolean value to also be a unix timestamp.
type CustomTimestamp time.Time
// CustomBoolPtr creates a CustomBool pointer from a boolean pointer.
func CustomBoolPtr(v *bool) *CustomBool {
if v == nil {
return nil
}
return BoolPtr(*v)
}
// MarshalJSON converts a boolean to a JSON value.
func (r CustomBool) MarshalJSON() ([]byte, error) {
buffer := new(bytes.Buffer)

View File

@ -2669,7 +2669,7 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{})
Template: &template,
USBDevices: usbDeviceObjects,
VGADevice: vgaDevice,
VMID: &vmID,
VMID: vmID,
}
if sataDeviceObjects != nil {

View File

@ -9,9 +9,19 @@ description: |-
# {{.Type}}: {{.Name}}
~> **DO NOT USE**
!> **DO NOT USE**
{{ .Description | trimspace }}
-> Note: Many attributes are marked as **optional** _and_ **computed** in the schema,
hence you may seem added to the plan with "(known after apply)" status, even if they are not set in the configuration.
This is done to support the `clone` operation, when a VM is created from an existing one,
and attributes of the original VM are copied to the new one.
Computed attributes allow the provider to set those attributes without user input.
The attributes are marked as optional to allow the user to set (or overwrite) them if needed.
In order to remove the computed attribute from the plan, you can set it to an empty value (e.g. `""` for string, `[]` for collection).
{{ if .HasExample -}}
## Example Usage