diff --git a/fwprovider/test/test_environment.go b/fwprovider/test/test_environment.go index 18fc48ca..fd9e9960 100644 --- a/fwprovider/test/test_environment.go +++ b/fwprovider/test/test_environment.go @@ -123,14 +123,19 @@ func (e *Environment) AddTemplateVars(vars map[string]any) { } } +// RandomVMID returns a random VM ID. +func (e *Environment) RandomVMID() int { + return gofakeit.IntRange(100_000, 999_999) +} + // RenderConfig renders the given configuration with for the current test environment using template engine. func (e *Environment) RenderConfig(cfg string) string { tmpl, err := template.New("config").Parse("{{.ProviderConfig}}" + cfg) require.NoError(e.t, err) - e.templateVars["RandomVMID"] = gofakeit.IntRange(100_000, 999_999) - e.templateVars["RandomVMID1"] = gofakeit.IntRange(100_000, 999_999) - e.templateVars["RandomVMID2"] = gofakeit.IntRange(100_000, 999_999) + e.templateVars["RandomVMID"] = e.RandomVMID() + e.templateVars["RandomVMID1"] = e.RandomVMID() + e.templateVars["RandomVMID2"] = e.RandomVMID() var buf bytes.Buffer err = tmpl.Execute(&buf, e.templateVars) diff --git a/fwprovider/vm/cloudinit/model.go b/fwprovider/vm/cloudinit/model.go index 91cadc99..e16901bd 100644 --- a/fwprovider/vm/cloudinit/model.go +++ b/fwprovider/vm/cloudinit/model.go @@ -7,33 +7,17 @@ package cloudinit import ( - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" ) -// Model represents the CPU model. +// Model represents the cloud-init model. type Model struct { - DatastoreId types.String `tfsdk:"datastore_id"` + DatastoreID types.String `tfsdk:"datastore_id"` Interface types.String `tfsdk:"interface"` - DNS DNSValue `tfsdk:"dns"` + DNS *ModelDNS `tfsdk:"dns"` } type ModelDNS struct { Domain types.String `tfsdk:"domain"` 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}, - } -} diff --git a/fwprovider/vm/cloudinit/resource.go b/fwprovider/vm/cloudinit/resource.go index 3a4b1ef2..c14cd250 100644 --- a/fwprovider/vm/cloudinit/resource.go +++ b/fwprovider/vm/cloudinit/resource.go @@ -14,33 +14,28 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute" customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types" "github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr" "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. -func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags *diag.Diagnostics) Value { - cloudinit := Model{} +func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags *diag.Diagnostics) *Model { + ci := Model{} devices := config.CustomStorageDevices.Filter(func(device *vms.CustomStorageDevice) bool { return device.IsCloudInitDrive(vmID) }) if len(devices) != 1 { - return types.ObjectNull(attributeTypes()) + return nil } for iface, device := range devices { - cloudinit.Interface = types.StringValue(iface) - cloudinit.DatastoreId = types.StringValue(device.GetDatastoreID()) + ci.Interface = types.StringValue(iface) + ci.DatastoreID = types.StringValue(device.GetDatastoreID()) dns := ModelDNS{} dns.Domain = types.StringPointerValue(config.CloudInitDNSDomain) @@ -56,41 +51,25 @@ func NewValue(ctx context.Context, config *vms.GetResponseData, vmID int, diags } if !reflect.DeepEqual(dns, ModelDNS{}) { - dnsObj, d := types.ObjectValueFrom(ctx, attributeTypesDNS(), dns) - diags.Append(d...) - - cloudinit.DNS = dnsObj + ci.DNS = &dns } - obj, d := types.ObjectValueFrom(ctx, attributeTypes(), cloudinit) - diags.Append(d...) - - return obj + return &ci } - return types.ObjectNull(attributeTypes()) + return nil } -func FillCreateBody(ctx context.Context, planValue Value, body *vms.CreateRequestBody, diags *diag.Diagnostics) { - var plan Model - - if planValue.IsNull() || planValue.IsUnknown() { - return - } - - d := planValue.As(ctx, &plan, basetypes.ObjectAsOptions{}) - diags.Append(d...) - - if d.HasError() { +// FillCreateBody fills the CreateRequestBody with the Cloud-Init settings from the Value. +func FillCreateBody(ctx context.Context, plan *Model, body *vms.CreateRequestBody) { + if plan == nil { return } ci := vms.CustomCloudInitConfig{} - if !plan.DNS.IsUnknown() { - var dns ModelDNS - - plan.DNS.As(ctx, &dns, basetypes.ObjectAsOptions{}) + if plan.DNS != nil { + dns := *plan.DNS if !dns.Domain.IsUnknown() { ci.SearchDomain = dns.Domain.ValueStringPointer() @@ -98,6 +77,7 @@ func FillCreateBody(ctx context.Context, planValue Value, body *vms.CreateReques if !dns.Servers.IsUnknown() { var servers []string + dns.Servers.ElementsAs(ctx, &servers, false) ci.Nameserver = ptr.Ptr(strings.Join(servers, " ")) @@ -108,9 +88,67 @@ func FillCreateBody(ctx context.Context, planValue Value, body *vms.CreateReques device := vms.CustomStorageDevice{ Enabled: true, - FileVolume: fmt.Sprintf("%s:cloudinit", plan.DatastoreId.ValueString()), + FileVolume: fmt.Sprintf("%s:cloudinit", plan.DatastoreID.ValueString()), Media: ptr.Ptr("cdrom"), } body.AddCustomStorageDevice(plan.Interface.ValueString(), device) } + +// FillUpdateBody fills the UpdateRequestBody with the CPU settings from the Value. +func FillUpdateBody( + ctx context.Context, + plan, state *Model, + updateBody *vms.UpdateRequestBody, + isClone bool, + diags *diag.Diagnostics, +) { + if plan == nil || reflect.DeepEqual(plan, state) { + return + } + + del := func(field ...string) { + updateBody.Delete = append(updateBody.Delete, field...) + } + + // TODO: migrate cloud init to another datastore + + if !reflect.DeepEqual(plan.DNS, state.DNS) { + if plan.DNS == nil && state.DNS != nil && !isClone { + del("searchdomain", "nameserver") + } else if plan.DNS != nil { + ci := vms.CustomCloudInitConfig{} + + planDNS := plan.DNS + stateDNS := state.DNS + + if !planDNS.Domain.Equal(stateDNS.Domain) { + if attribute.ShouldBeRemoved(planDNS.Domain, stateDNS.Domain, isClone) { + del("searchdomain") + } else if attribute.IsDefined(planDNS.Domain) { + ci.SearchDomain = planDNS.Domain.ValueStringPointer() + } + } + + if !planDNS.Servers.Equal(stateDNS.Servers) { + if attribute.ShouldBeRemoved(planDNS.Servers, stateDNS.Servers, isClone) { + del("nameserver") + } else if attribute.IsDefined(planDNS.Servers) { + // TODO: duplicates code from FillCreateBody + var servers []string + + 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, " ")) + //} + } + } + + updateBody.CloudInitConfig = &ci + } + } +} diff --git a/fwprovider/vm/cloudinit/resource_schema.go b/fwprovider/vm/cloudinit/resource_schema.go index 8878281d..999d31d8 100644 --- a/fwprovider/vm/cloudinit/resource_schema.go +++ b/fwprovider/vm/cloudinit/resource_schema.go @@ -9,7 +9,9 @@ package cloudinit import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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" customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types" @@ -21,7 +23,6 @@ func ResourceSchema() schema.Attribute { return schema.SingleNestedAttribute{ Description: "The cloud-init configuration.", Optional: true, - Computed: true, Attributes: map[string]schema.Attribute{ "datastore_id": schema.StringAttribute{ Description: "The identifier for the datastore to create the cloud-init disk in (defaults to `local-lvm`)", @@ -31,6 +32,10 @@ func ResourceSchema() schema.Attribute { Validators: []validator.String{ stringvalidator.LengthAtLeast(1), }, + // TODO: add support for datastore migration + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "interface": schema.StringAttribute{ Description: "The hardware interface to connect the cloud-init image to.", @@ -45,22 +50,22 @@ func ResourceSchema() schema.Attribute { Validators: []validator.String{ validators.CDROMInterface(), }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "dns": schema.SingleNestedAttribute{ Description: "The DNS configuration.", Optional: true, - Computed: true, Attributes: map[string]schema.Attribute{ "domain": schema.StringAttribute{ Description: "The domain name to use for the VM.", Optional: true, - Computed: true, }, "servers": schema.ListAttribute{ Description: "The list of DNS servers to use.", ElementType: customtypes.IPAddrType{}, Optional: true, - Computed: true, }, }, }, diff --git a/fwprovider/vm/cloudinit/resource_test.go b/fwprovider/vm/cloudinit/resource_test.go index 1ce756e4..ce4958ec 100644 --- a/fwprovider/vm/cloudinit/resource_test.go +++ b/fwprovider/vm/cloudinit/resource_test.go @@ -20,6 +20,9 @@ func TestAccResourceVM2CloudInit(t *testing.T) { t.Parallel() te := test.InitEnvironment(t) + te.AddTemplateVars(map[string]interface{}{ + "UpdateVMID": te.RandomVMID(), + }) tests := []struct { name string @@ -37,7 +40,129 @@ func TestAccResourceVM2CloudInit(t *testing.T) { } } }`), + Check: resource.ComposeTestCheckFunc( + test.ResourceAttributes("proxmox_virtual_environment_vm2.test_vm", map[string]string{ + "initialization.datastore_id": te.DatastoreID, + "initialization.interface": "ide2", + }), + ), }}}, + {"update VM with cloud-init", []resource.TestStep{ + //{ + // Config: te.RenderConfig(` + // resource "proxmox_virtual_environment_vm2" "test_vm" { + // node_name = "{{.NodeName}}" + // id = {{.UpdateVMID}} + // name = "test-cloudinit" + // initialization = { + // dns = { + // domain = "example.com" + // } + // } + // }`), + // Destroy: false, + //}, + //{ + // Config: te.RenderConfig(` + // resource "proxmox_virtual_environment_vm2" "test_vm" { + // node_name = "{{.NodeName}}" + // id = {{.UpdateVMID}} + // name = "test-cloudinit" + // initialization = { + // dns = { + // domain = "example.com" + // servers = [ + // "1.1.1.1", + // "8.8.8.8" + // ] + // } + // } + // }`), + // Destroy: false, + //}, + //{ + // Config: te.RenderConfig(` + // resource "proxmox_virtual_environment_vm2" "test_vm" { + // node_name = "{{.NodeName}}" + // id = {{.UpdateVMID}} + // name = "test-cloudinit" + // initialization = { + // dns = { + // domain = "another.domain.com" + // servers = [ + // "8.8.8.8", + // "1.1.1.1" + // ] + // } + // } + // }`), + // Destroy: false, + //}, + { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_vm2" "test_vm" { + node_name = "{{.NodeName}}" + id = {{.UpdateVMID}} + name = "test-cloudinit" + initialization = { + dns = { + servers = [ + "1.1.1.1" + ] + } + } + }`), + Destroy: false, + Check: resource.ComposeTestCheckFunc( + test.NoResourceAttributesSet("proxmox_virtual_environment_vm2.test_vm", []string{ + "initialization.dns.domain", + }), + test.ResourceAttributes("proxmox_virtual_environment_vm2.test_vm", map[string]string{ + "initialization.dns.servers.#": "1", + }), + ), + }, + { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_vm2" "test_vm" { + node_name = "{{.NodeName}}" + id = {{.UpdateVMID}} + name = "test-cloudinit" + initialization = { + dns = { + //servers = [] + } + } + }`), + Destroy: false, + Check: resource.ComposeTestCheckFunc( + test.NoResourceAttributesSet("proxmox_virtual_environment_vm2.test_vm", []string{ + "initialization.dns.servers", + }), + ), + }, + { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_vm2" "test_vm" { + node_name = "{{.NodeName}}" + id = {{.UpdateVMID}} + name = "test-cloudinit" + initialization = { + dns = {} + } + }`), + Destroy: false, + }, + { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_vm2" "test_vm" { + node_name = "{{.NodeName}}" + id = {{.UpdateVMID}} + name = "test-cloudinit" + initialization = {} + }`), + }, + }}, } for _, tt := range tests { diff --git a/fwprovider/vm/cpu/resource.go b/fwprovider/vm/cpu/resource.go index 9b715850..5dbbecfe 100644 --- a/fwprovider/vm/cpu/resource.go +++ b/fwprovider/vm/cpu/resource.go @@ -66,8 +66,6 @@ func NewValue(ctx context.Context, config *vms.GetResponseData, diags *diag.Diag } // FillCreateBody fills the CreateRequestBody with the CPU settings from the Value. -// -// In the 'create' context, v is the plan. func FillCreateBody(ctx context.Context, planValue Value, body *vms.CreateRequestBody, diags *diag.Diagnostics) { var plan Model @@ -128,8 +126,6 @@ func FillCreateBody(ctx context.Context, planValue Value, body *vms.CreateReques } // FillUpdateBody fills the UpdateRequestBody with the CPU settings from the Value. -// -// In the 'update' context, v is the plan and stateValue is the current state. func FillUpdateBody( ctx context.Context, planValue, stateValue Value, diff --git a/fwprovider/vm/model.go b/fwprovider/vm/model.go index 88f395da..5398000d 100644 --- a/fwprovider/vm/model.go +++ b/fwprovider/vm/model.go @@ -35,15 +35,16 @@ type Model struct { ID types.Int64 `tfsdk:"id"` Retries types.Int64 `tfsdk:"retries"` } `tfsdk:"clone"` - CloudInit cloudinit.Value `tfsdk:"initialization"` - CPU cpu.Value `tfsdk:"cpu"` - ID types.Int64 `tfsdk:"id"` - Name types.String `tfsdk:"name"` - NodeName types.String `tfsdk:"node_name"` - StopOnDestroy types.Bool `tfsdk:"stop_on_destroy"`Tags stringset.Value `tfsdk:"tags"` - Template types.Bool `tfsdk:"template"` - Timeouts timeouts.Value `tfsdk:"timeouts"` - VGA vga.Value `tfsdk:"vga"` + CloudInit *cloudinit.Model `tfsdk:"initialization"` + CPU cpu.Value `tfsdk:"cpu"` + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + NodeName types.String `tfsdk:"node_name"` + StopOnDestroy types.Bool `tfsdk:"stop_on_destroy"` + Tags stringset.Value `tfsdk:"tags"` + Template types.Bool `tfsdk:"template"` + Timeouts timeouts.Value `tfsdk:"timeouts"` + VGA vga.Value `tfsdk:"vga"` } // read retrieves the current state of the resource from the API and updates the state. diff --git a/fwprovider/vm/resource.go b/fwprovider/vm/resource.go index 6c7cf284..33795640 100644 --- a/fwprovider/vm/resource.go +++ b/fwprovider/vm/resource.go @@ -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 cdrom.FillCreateBody(ctx, plan.CDROM, createBody, diags) - cloudinit.FillCreateBody(ctx, plan.CloudInit, createBody, diags) + cloudinit.FillCreateBody(ctx, plan.CloudInit, createBody) cpu.FillCreateBody(ctx, plan.CPU, createBody, diags) vga.FillCreateBody(ctx, plan.VGA, createBody, diags) @@ -334,6 +334,7 @@ func (r *Resource) update(ctx context.Context, plan, state Model, isClone bool, // fill out update body fields with values from other resource blocks cdrom.FillUpdateBody(ctx, plan.CDROM, state.CDROM, updateBody, isClone, diags) + cloudinit.FillUpdateBody(ctx, plan.CloudInit, state.CloudInit, updateBody, isClone, diags) cpu.FillUpdateBody(ctx, plan.CPU, state.CPU, updateBody, isClone, diags) vga.FillUpdateBody(ctx, plan.VGA, state.VGA, updateBody, isClone, diags)