0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-07-01 02:52:58 +00:00

clone works

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
Pavel Boldyrev 2024-07-02 21:37:54 -04:00
parent 9f64647ed2
commit d91a559fb5
No known key found for this signature in database
GPG Key ID: 637146A2A6804C59
7 changed files with 305 additions and 55 deletions

View File

@ -10,13 +10,18 @@ import (
"context" "context"
"github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
) )
// UseUnknownForNullConfigList returns a plan modifier sets the value of an attribute // UseUnknownForNullConfigList returns a plan modifier that sets the value of an attribute
// to Unknown if the attribute is missing from the plan and the config is null. // to Unknown if the attribute is missing from the plan and the config is null AND the resource is not a clone.
// Use this for optional computed attributes that can be reset / removed by the user. //
// Use this for optional computed attributes that can be reset / removed by the user. If the resource is a clone,
// the value will be copied from the prior state (e.g. the clone source).
// //
// The behavior for Terraform for Optional + Computed attributes is to copy the prior state // The behavior for Terraform for Optional + Computed attributes is to copy the prior state
// if there is no configuration for it. This plan modifier will instead set the value to Unknown, // if there is no configuration for it. This plan modifier will instead set the value to Unknown,
@ -43,11 +48,26 @@ func (m useUnknownForNullConfigList) MarkdownDescription(_ context.Context) stri
// PlanModifyList implements the plan modification logic. // PlanModifyList implements the plan modification logic.
func (m useUnknownForNullConfigList) PlanModifyList( func (m useUnknownForNullConfigList) PlanModifyList(
_ context.Context, ctx context.Context,
req planmodifier.ListRequest, req planmodifier.ListRequest,
resp *planmodifier.ListResponse, resp *planmodifier.ListResponse,
) { ) {
if !req.PlanValue.IsNull() && req.ConfigValue.IsNull() { if !m.isClone(ctx, req) {
if req.PlanValue.IsNull() {
return
}
if !req.ConfigValue.IsNull() {
return
}
resp.PlanValue = types.ListUnknown(m.elementType) resp.PlanValue = types.ListUnknown(m.elementType)
} }
} }
func (m useUnknownForNullConfigList) isClone(ctx context.Context, req planmodifier.ListRequest) bool {
var cloneID types.Int64
_ = req.Plan.GetAttribute(ctx, path.Root("clone").AtName("id"), &cloneID)
return attribute.IsDefined(cloneID)
}

View File

@ -9,13 +9,18 @@ package planmodifiers
import ( import (
"context" "context"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
) )
// UseUnknownForNullConfigString returns a plan modifier sets the value of an attribute // UseUnknownForNullConfigString returns a plan modifier that sets the value of an attribute
// to Unknown if the attribute is missing from the plan and the config is null. // to Unknown if the attribute is missing from the plan and the config is null AND the resource is not a clone.
// Use this for optional computed attributes that can be reset / removed by the user. //
// Use this for optional computed attributes that can be reset / removed by the user. If the resource is a clone,
// the value will be copied from the prior state (e.g. the clone source).
// //
// The behavior for Terraform for Optional + Computed attributes is to copy the prior state // The behavior for Terraform for Optional + Computed attributes is to copy the prior state
// if there is no configuration for it. This plan modifier will instead set the value to Unknown, // if there is no configuration for it. This plan modifier will instead set the value to Unknown,
@ -40,11 +45,26 @@ func (m useUnknownForNullConfigString) MarkdownDescription(_ context.Context) st
// PlanModifyString implements the plan modification logic. // PlanModifyString implements the plan modification logic.
func (m useUnknownForNullConfigString) PlanModifyString( func (m useUnknownForNullConfigString) PlanModifyString(
_ context.Context, ctx context.Context,
req planmodifier.StringRequest, req planmodifier.StringRequest,
resp *planmodifier.StringResponse, resp *planmodifier.StringResponse,
) { ) {
if !req.PlanValue.IsNull() && req.ConfigValue.IsNull() { if !m.isClone(ctx, req) {
if req.PlanValue.IsNull() {
return
}
if !req.ConfigValue.IsNull() {
return
}
resp.PlanValue = types.StringUnknown() resp.PlanValue = types.StringUnknown()
} }
} }
func (m useUnknownForNullConfigString) isClone(ctx context.Context, req planmodifier.StringRequest) bool {
var cloneID types.Int64
_ = req.Plan.GetAttribute(ctx, path.Root("clone").AtName("id"), &cloneID)
return attribute.IsDefined(cloneID)
}

View File

@ -7,17 +7,33 @@
package cloudinit package cloudinit
import ( import (
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
) )
// Model represents the cloud-init model. // Model represents the Cloud-Init model.
type Model struct { type Model struct {
DatastoreID types.String `tfsdk:"datastore_id"` DatastoreID types.String `tfsdk:"datastore_id"`
Interface types.String `tfsdk:"interface"` Interface types.String `tfsdk:"interface"`
DNS *ModelDNS `tfsdk:"dns"` DNS DNSValue `tfsdk:"dns"`
} }
type ModelDNS struct { type ModelDNS struct {
Domain types.String `tfsdk:"domain"` Domain types.String `tfsdk:"domain"`
Servers types.List `tfsdk:"servers"` Servers types.List `tfsdk:"servers"`
} }
func attributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"datastore_id": types.StringType,
"interface": types.StringType,
"dns": types.ObjectType{}.WithAttributeTypes(attributeTypesDNS()),
}
}
func attributeTypesDNS() map[string]attr.Type {
return map[string]attr.Type{
"domain": types.StringType,
"servers": types.ListType{ElemType: types.StringType},
}
}

View File

@ -14,6 +14,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/bpg/terraform-provider-proxmox/fwprovider/attribute" "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types" customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types"
@ -21,8 +22,13 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmox/nodes/vms" "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/vms"
) )
// Value represents the type for CPU settings.
type Value = types.Object
type DNSValue = types.Object
// NewValue returns a new Value with the given CPU settings from the PVE API. // NewValue returns a new Value with the given CPU settings from the PVE API.
func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags *diag.Diagnostics) *Model { func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags *diag.Diagnostics) Value {
ci := Model{} ci := Model{}
devices := config.CustomStorageDevices.Filter(func(device *vms.CustomStorageDevice) bool { devices := config.CustomStorageDevices.Filter(func(device *vms.CustomStorageDevice) bool {
@ -30,7 +36,7 @@ func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags
}) })
if len(devices) != 1 { if len(devices) != 1 {
return nil types.ObjectNull(attributeTypes())
} }
for iface, device := range devices { for iface, device := range devices {
@ -51,25 +57,43 @@ func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags
} }
if !reflect.DeepEqual(dns, ModelDNS{}) { if !reflect.DeepEqual(dns, ModelDNS{}) {
ci.DNS = &dns dnsObj, d := types.ObjectValueFrom(ctx, attributeTypesDNS(), dns)
diags.Append(d...)
ci.DNS = dnsObj
} }
return &ci obj, d := types.ObjectValueFrom(ctx, attributeTypes(), ci)
diags.Append(d...)
return obj
} }
return nil return types.ObjectNull(attributeTypes())
} }
// FillCreateBody fills the CreateRequestBody with the Cloud-Init settings from the Value. // FillCreateBody fills the CreateRequestBody with the Cloud-Init settings from the Value.
func FillCreateBody(ctx context.Context, plan *Model, body *vms.CreateRequestBody) { func FillCreateBody(ctx context.Context, planValue Value, body *vms.CreateRequestBody, diags *diag.Diagnostics) {
if plan == nil { var plan Model
if planValue.IsNull() || planValue.IsUnknown() {
return
}
d := planValue.As(ctx, &plan, basetypes.ObjectAsOptions{})
diags.Append(d...)
if d.HasError() {
return return
} }
ci := vms.CustomCloudInitConfig{} ci := vms.CustomCloudInitConfig{}
if plan.DNS != nil { // TODO: should we check for !null?
dns := *plan.DNS if !plan.DNS.IsUnknown() {
var dns ModelDNS
plan.DNS.As(ctx, &dns, basetypes.ObjectAsOptions{})
if !dns.Domain.IsUnknown() { if !dns.Domain.IsUnknown() {
ci.SearchDomain = dns.Domain.ValueStringPointer() ci.SearchDomain = dns.Domain.ValueStringPointer()
@ -98,12 +122,23 @@ func FillCreateBody(ctx context.Context, plan *Model, body *vms.CreateRequestBod
// FillUpdateBody fills the UpdateRequestBody with the Cloud-Init settings from the Value. // FillUpdateBody fills the UpdateRequestBody with the Cloud-Init settings from the Value.
func FillUpdateBody( func FillUpdateBody(
ctx context.Context, ctx context.Context,
plan, state *Model, planValue, stateValue Value,
updateBody *vms.UpdateRequestBody, updateBody *vms.UpdateRequestBody,
isClone bool, isClone bool,
diags *diag.Diagnostics, diags *diag.Diagnostics,
) { ) {
if plan == nil || reflect.DeepEqual(plan, state) { var plan, state Model
if planValue.IsNull() || planValue.IsUnknown() || planValue.Equal(stateValue) {
return
}
d := planValue.As(ctx, &plan, basetypes.ObjectAsOptions{})
diags.Append(d...)
d = stateValue.As(ctx, &state, basetypes.ObjectAsOptions{})
diags.Append(d...)
if diags.HasError() {
return return
} }
@ -114,13 +149,20 @@ func FillUpdateBody(
// TODO: migrate cloud init to another datastore // TODO: migrate cloud init to another datastore
if !reflect.DeepEqual(plan.DNS, state.DNS) { if !reflect.DeepEqual(plan.DNS, state.DNS) {
if plan.DNS == nil && state.DNS != nil && !isClone { if attribute.ShouldBeRemoved(plan.DNS, state.DNS, isClone) {
del("searchdomain", "nameserver") del("searchdomain", "nameserver")
} else if plan.DNS != nil { } else if attribute.IsDefined(plan.DNS) {
ci := vms.CustomCloudInitConfig{} ci := vms.CustomCloudInitConfig{}
planDNS := plan.DNS var planDNS, stateDNS ModelDNS
stateDNS := state.DNS d = plan.DNS.As(ctx, &planDNS, basetypes.ObjectAsOptions{})
diags.Append(d...)
d = state.DNS.As(ctx, &stateDNS, basetypes.ObjectAsOptions{})
diags.Append(d...)
if diags.HasError() {
return
}
if !planDNS.Domain.Equal(stateDNS.Domain) { if !planDNS.Domain.Equal(stateDNS.Domain) {
if attribute.ShouldBeRemoved(planDNS.Domain, stateDNS.Domain, isClone) { if attribute.ShouldBeRemoved(planDNS.Domain, stateDNS.Domain, isClone) {
@ -134,17 +176,11 @@ func FillUpdateBody(
if attribute.ShouldBeRemoved(planDNS.Servers, stateDNS.Servers, isClone) { if attribute.ShouldBeRemoved(planDNS.Servers, stateDNS.Servers, isClone) {
del("nameserver") del("nameserver")
} else if attribute.IsDefined(planDNS.Servers) { } else if attribute.IsDefined(planDNS.Servers) {
// TODO: duplicates code from FillCreateBody
var servers []string var servers []string
planDNS.Servers.ElementsAs(ctx, &servers, false) planDNS.Servers.ElementsAs(ctx, &servers, false)
//// special case for the servers list, if we want to remove them during update
//if len(servers) == 0 {
// del("nameserver")
//} else {
ci.Nameserver = ptr.Ptr(strings.Join(servers, " ")) ci.Nameserver = ptr.Ptr(strings.Join(servers, " "))
//}
} }
} }

View File

@ -253,17 +253,175 @@ func TestResource_VM2_CloudInit_Update(t *testing.T) {
), ),
}, },
}}, }},
// { {"delete dns block", []resource.TestStep{
// // step 9: update the VM: remove the dns block {
// Config: te.RenderConfig(` Config: te.RenderConfig(`
// resource "proxmox_virtual_environment_vm2" "test_vm" { resource "proxmox_virtual_environment_vm2" "test_vm" {
// node_name = "{{.NodeName}}" node_name = "{{.NodeName}}"
// name = "test-ci" id = {{.RandomVMID}}
// initialization = {} name = "test-ci"
// }`), initialization = {
// }, dns = {
//}}, domain = "another.domain.com"
servers = [
"1.1.1.1"
]
}
}
}`),
},
{
Config: te.RenderConfig(`
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
name = "test-ci"
initialization = {}
}`),
Check: resource.ComposeTestCheckFunc(
test.NoResourceAttributesSet("proxmox_virtual_environment_vm2.test_vm", []string{
"initialization.dns.domain",
}),
resource.TestCheckResourceAttr("proxmox_virtual_environment_vm2.test_vm", "initialization.dns.servers.#", "0"),
),
},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: te.AccProviders,
Steps: tt.steps,
})
})
}
}
func TestResource_VM2_CloudInit_Clone(t *testing.T) {
t.Parallel()
te := test.InitEnvironment(t)
tests := []struct {
name string
steps []resource.TestStep
}{
{"clone dns block in full", []resource.TestStep{
{
Config: te.RenderConfig(`
resource "proxmox_virtual_environment_vm2" "test_template" {
node_name = "{{.NodeName}}"
id = {{.RandomVMID1}}
name = "test-ci-template"
initialization = {
dns = {
domain = "example.com"
servers = [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
id = {{.RandomVMID2}}
name = "test-ci"
clone = {
id = proxmox_virtual_environment_vm2.test_template.id
}
}`),
Check: test.ResourceAttributes("proxmox_virtual_environment_vm2.test_vm", map[string]string{
"initialization.datastore_id": te.DatastoreID,
"initialization.interface": "ide2",
"initialization.dns.domain": "example.com",
"initialization.dns.servers.#": "2",
"initialization.dns.servers.0": "1.1.1.1",
"initialization.dns.servers.1": "8.8.8.8",
}),
},
}},
{"clone dns block overwriting domain", []resource.TestStep{
{
Config: te.RenderConfig(`
resource "proxmox_virtual_environment_vm2" "test_template" {
node_name = "{{.NodeName}}"
id = {{.RandomVMID1}}
name = "test-ci-template"
initialization = {
dns = {
domain = "example.com"
servers = [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
id = {{.RandomVMID2}}
name = "test-ci"
clone = {
id = proxmox_virtual_environment_vm2.test_template.id
}
initialization = {
dns = {
domain = "another.domain.com"
}
}
}`),
Check: test.ResourceAttributes("proxmox_virtual_environment_vm2.test_vm", map[string]string{
"initialization.datastore_id": te.DatastoreID,
"initialization.interface": "ide2",
"initialization.dns.domain": "another.domain.com",
"initialization.dns.servers.#": "2",
"initialization.dns.servers.0": "1.1.1.1",
"initialization.dns.servers.1": "8.8.8.8",
}),
},
}},
{"clone dns block overwriting servers", []resource.TestStep{
{
Config: te.RenderConfig(`
resource "proxmox_virtual_environment_vm2" "test_template" {
node_name = "{{.NodeName}}"
id = {{.RandomVMID1}}
name = "test-ci-template"
initialization = {
dns = {
domain = "example.com"
servers = [
"1.1.1.1",
"8.8.8.8"
]
}
}
}
resource "proxmox_virtual_environment_vm2" "test_vm" {
node_name = "{{.NodeName}}"
id = {{.RandomVMID2}}
name = "test-ci"
clone = {
id = proxmox_virtual_environment_vm2.test_template.id
}
initialization = {
dns = {
servers = [
"4.4.4.4"
]
}
}
}`),
Check: test.ResourceAttributes("proxmox_virtual_environment_vm2.test_vm", map[string]string{
"initialization.datastore_id": te.DatastoreID,
"initialization.interface": "ide2",
"initialization.dns.domain": "example.com",
"initialization.dns.servers.#": "1",
"initialization.dns.servers.0": "4.4.4.4",
}),
},
}},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -35,16 +35,16 @@ type Model struct {
ID types.Int64 `tfsdk:"id"` ID types.Int64 `tfsdk:"id"`
Retries types.Int64 `tfsdk:"retries"` Retries types.Int64 `tfsdk:"retries"`
} `tfsdk:"clone"` } `tfsdk:"clone"`
CloudInit *cloudinit.Model `tfsdk:"initialization"` CloudInit cloudinit.Value `tfsdk:"initialization"`
CPU cpu.Value `tfsdk:"cpu"` CPU cpu.Value `tfsdk:"cpu"`
ID types.Int64 `tfsdk:"id"` ID types.Int64 `tfsdk:"id"`
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
NodeName types.String `tfsdk:"node_name"` NodeName types.String `tfsdk:"node_name"`
StopOnDestroy types.Bool `tfsdk:"stop_on_destroy"` StopOnDestroy types.Bool `tfsdk:"stop_on_destroy"`
Tags stringset.Value `tfsdk:"tags"` Tags stringset.Value `tfsdk:"tags"`
Template types.Bool `tfsdk:"template"` Template types.Bool `tfsdk:"template"`
Timeouts timeouts.Value `tfsdk:"timeouts"` Timeouts timeouts.Value `tfsdk:"timeouts"`
VGA vga.Value `tfsdk:"vga"` VGA vga.Value `tfsdk:"vga"`
} }
// read retrieves the current state of the resource from the API and updates the state. // read retrieves the current state of the resource from the API and updates the state.

View File

@ -155,7 +155,7 @@ func (r *Resource) create(ctx context.Context, plan Model, diags *diag.Diagnosti
// fill out create body fields with values from other resource blocks // fill out create body fields with values from other resource blocks
cdrom.FillCreateBody(ctx, plan.CDROM, createBody, diags) cdrom.FillCreateBody(ctx, plan.CDROM, createBody, diags)
cloudinit.FillCreateBody(ctx, plan.CloudInit, createBody) cloudinit.FillCreateBody(ctx, plan.CloudInit, createBody, diags)
cpu.FillCreateBody(ctx, plan.CPU, createBody, diags) cpu.FillCreateBody(ctx, plan.CPU, createBody, diags)
vga.FillCreateBody(ctx, plan.VGA, createBody, diags) vga.FillCreateBody(ctx, plan.VGA, createBody, diags)