From c1996e30aa088f8a16b330df6b60cf7e712d989b Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Fri, 28 Aug 2026 15:46:49 +0530 Subject: [PATCH 1/3] Mark storage_network_ip_range netmask/start_ip/end_ip as ForceNew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudStack's updateStorageNetworkIpRange API validates a new IP range against the record's own current start/end IPs without excluding the record being updated, so any in-place edit of these fields fails with a self-overlap error (errorcode 530). Switching the update call to only send changed fields (d.HasChange instead of d.GetOk) did not help — the failure is identical even when only the genuinely-changed field is sent, confirming this is a server-side validation bug rather than something the provider can work around. Mark these fields ForceNew so the plan matches reality: Terraform now proposes a replacement instead of an update that is guaranteed to fail. Reproduced and reverified against ACS 4.23.0.0. --- ...rce_cloudstack_storage_network_ip_range.go | 19 +++++++++++-------- .../r/storage_network_ip_range.html.markdown | 11 ++++++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/cloudstack/resource_cloudstack_storage_network_ip_range.go b/cloudstack/resource_cloudstack_storage_network_ip_range.go index 709108d9..24d317e8 100644 --- a/cloudstack/resource_cloudstack_storage_network_ip_range.go +++ b/cloudstack/resource_cloudstack_storage_network_ip_range.go @@ -47,6 +47,7 @@ func resourceCloudStackStorageNetworkIpRange() *schema.Resource { Description: "the netmask for the storage network IP range", Type: schema.TypeString, Required: true, + ForceNew: true, }, "pod_id": { Description: "the Pod ID for the storage network IP range", @@ -58,12 +59,14 @@ func resourceCloudStackStorageNetworkIpRange() *schema.Resource { Description: "the beginning IP address in the storage network IP range", Type: schema.TypeString, Required: true, + ForceNew: true, }, "end_ip": { Description: "the ending IP address in the storage network IP range", Type: schema.TypeString, Optional: true, Computed: true, + ForceNew: true, }, "vlan": { Description: "the optional VLAN of the storage network IP range", @@ -136,17 +139,17 @@ func resourceCloudStackStorageNetworkIpRangeUpdate(d *schema.ResourceData, meta p := cs.Network.NewUpdateStorageNetworkIpRangeParams(d.Id()) - if v, ok := d.GetOk("netmask"); ok { - p.SetNetmask(v.(string)) + if d.HasChange("netmask") { + p.SetNetmask(d.Get("netmask").(string)) } - if v, ok := d.GetOk("start_ip"); ok { - p.SetStartip(v.(string)) + if d.HasChange("start_ip") { + p.SetStartip(d.Get("start_ip").(string)) } - if v, ok := d.GetOk("end_ip"); ok { - p.SetEndip(v.(string)) + if d.HasChange("end_ip") { + p.SetEndip(d.Get("end_ip").(string)) } - if v, ok := d.GetOk("vlan"); ok { - p.SetVlan(v.(int)) + if d.HasChange("vlan") { + p.SetVlan(d.Get("vlan").(int)) } _, err := cs.Network.UpdateStorageNetworkIpRange(p) diff --git a/website/docs/r/storage_network_ip_range.html.markdown b/website/docs/r/storage_network_ip_range.html.markdown index 9deb7895..5d1cf7ee 100644 --- a/website/docs/r/storage_network_ip_range.html.markdown +++ b/website/docs/r/storage_network_ip_range.html.markdown @@ -33,11 +33,20 @@ The following arguments are supported: this forces a new resource to be created. - `gateway` - (Required) The gateway for the storage network IP range. Changing this forces a new resource to be created. -- `netmask` - (Required) The netmask for the storage network IP range. +- `netmask` - (Required) The netmask for the storage network IP range. Changing + this forces a new resource to be created. - `start_ip` - (Required) The beginning IP address in the storage network IP range. + Changing this forces a new resource to be created. - `end_ip` - (Optional) The ending IP address in the storage network IP range. + Changing this forces a new resource to be created. - `vlan` - (Optional) The optional VLAN of the storage network IP range. +`netmask`, `start_ip`, and `end_ip` force replacement rather than an in-place +update: CloudStack's `updateStorageNetworkIpRange` API validates a new range +against the record's own current IPs without excluding the record being +updated, so any in-place edit of these fields fails with an IP overlap error +against itself. + ## Attributes Reference The following attributes are exported: From 19a2d8a71ed516bd192e50c23db22658935706f2 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Fri, 28 Aug 2026 15:54:23 +0530 Subject: [PATCH 2/3] Document ForceNew-field footgun after cloudstack_service_offering import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the reported destroy+recreate-on-import issue (#305): the importer's resulting state is actually fully hydrated correctly, because Terraform automatically calls Read (visible as "Refreshing state...") right after the custom importer function runs, which overwrites whatever partial state the importer itself set. So populating cpu_number/cpu_speed/ memory inside resourceCloudStackServiceOfferingImport would be a no-op — verified by importing a real lab offering with the unmodified importer and confirming cpu_number/cpu_speed/memory were already correct in state. The actual destroy+recreate happens because Terraform diffs the *config* (which a minimal post-import .tf typically leaves blank for these fields) against the now-correct state; since these fields are ForceNew, the config's implicit zero value differs from the real value and forces replacement. This is a doc/workflow gap, not a code bug: documented that all ForceNew fields must be fully specified in config after import. Verified empty terraform plan after import with a fully-specified config, and unchanged (still-forcing) plan with a minimal one, matching this explanation. --- website/docs/r/service_offering.html.markdown | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/website/docs/r/service_offering.html.markdown b/website/docs/r/service_offering.html.markdown index 6a5754f1..f8feadf6 100644 --- a/website/docs/r/service_offering.html.markdown +++ b/website/docs/r/service_offering.html.markdown @@ -109,3 +109,14 @@ $ terraform import cloudstack_service_offering.example *NOTE: The importer looks up the service offering by ID and resolves the required `name` attribute from it, so it does not need to be set in the configuration beforehand.* + +*WARNING: After import, the resource's state is refreshed with the imported +offering's actual `cpu_number`, `cpu_speed`, `memory`, and other `ForceNew` +attribute values. If your `.tf` configuration for the imported resource +leaves any of those `ForceNew` attributes unset, `terraform plan` will show +a destroy-and-recreate of the offering, since an unset attribute is treated +as its zero value and compared against the real imported value. After +importing, write out the full set of `ForceNew` attributes in your +configuration (matching the values shown by `terraform state show`) before +running `terraform plan`, or the plan may propose replacing a live, possibly +in-use offering.* From 992f58ea1c7cf97992b5c0fbcb3ab4d75e521dee Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Fri, 28 Aug 2026 15:59:07 +0530 Subject: [PATCH 3/3] Add snake_case display_text field to cloudstack_project, deprecate displaytext Every other resource in this provider uses display_text; cloudstack_project was the outlier still on displaytext, even though its own docs already described display_text as the field name. Add display_text additively (displaytext is a real field in existing users' state files and can't be renamed outright) and mark displaytext Deprecated. Create/Update/Read resolve the effective value via projectDisplayText(), preferring display_text when both are set. Read only refreshes whichever of the two fields is actually in use (config already had displaytext set and display_text unset), matching the existing conditional pattern this file already uses for account/accountid/userid. Setting both unconditionally caused a permanent diff for display_text-only configs, since Read would keep populating the deprecated field the config never referenced. Verified against the lab with two standalone configs (one using display_text, one using the legacy displaytext) against a locally-built dev-override binary: both create cleanly, both produce an empty terraform plan, and the legacy field shows the expected deprecation warning. Both test projects destroyed after verification. --- cloudstack/resource_cloudstack_project.go | 39 +++++++++++++++++++---- website/docs/r/project.html.markdown | 3 ++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/cloudstack/resource_cloudstack_project.go b/cloudstack/resource_cloudstack_project.go index 9c028465..ea747434 100644 --- a/cloudstack/resource_cloudstack_project.go +++ b/cloudstack/resource_cloudstack_project.go @@ -47,6 +47,12 @@ func resourceCloudStackProject() *schema.Resource { }, "displaytext": { + Type: schema.TypeString, + Optional: true, + Deprecated: "use display_text instead", + }, + + "display_text": { Type: schema.TypeString, Optional: true, }, @@ -76,12 +82,22 @@ func resourceCloudStackProject() *schema.Resource { } } +// projectDisplayText resolves the effective display text from the new +// display_text field and the deprecated displaytext field. display_text +// wins when both are set, since it's the field new configs should use. +func projectDisplayText(d *schema.ResourceData) string { + if v, ok := d.GetOk("display_text"); ok { + return v.(string) + } + return d.Get("displaytext").(string) +} + func resourceCloudStackProjectCreate(d *schema.ResourceData, meta any) error { cs := meta.(*cloudstack.CloudStackClient) // Get the name and displaytext name := d.Get("name").(string) - displaytext := d.Get("displaytext").(string) + displaytext := projectDisplayText(d) // Get domain if provided var domain string @@ -101,7 +117,6 @@ func resourceCloudStackProjectCreate(d *schema.ResourceData, meta any) error { // Set the basic attributes to match the existing project d.Set("name", existingProject.Name) - d.Set("displaytext", existingProject.Displaytext) d.Set("domain", existingProject.Domain) return resourceCloudStackProjectRead(d, meta) @@ -327,9 +342,19 @@ func resourceCloudStackProjectRead(d *schema.ResourceData, meta any) error { // Set the basic attributes d.Set("name", project.Name) - d.Set("displaytext", project.Displaytext) d.Set("domain", project.Domain) + // Only refresh whichever of displaytext (deprecated) / display_text the + // config is actually using, so a config that only sets one of them + // doesn't see a perpetual diff on the other. + _, displaytextOk := d.GetOk("displaytext") + _, displayTextOk := d.GetOk("display_text") + if displaytextOk && !displayTextOk { + d.Set("displaytext", project.Displaytext) + } else { + d.Set("display_text", project.Displaytext) + } + // Handle owner information more safely // Only set the account, accountid, and userid if they were explicitly set in the configuration // and if the owner information is available @@ -397,7 +422,7 @@ func resourceCloudStackProjectUpdate(d *schema.ResourceData, meta any) error { cs := meta.(*cloudstack.CloudStackClient) // Check if the name or displaytext is changed - if d.HasChange("name") || d.HasChange("displaytext") { + if d.HasChange("name") || d.HasChange("displaytext") || d.HasChange("display_text") { // Create a new parameter struct p := cs.Project.NewUpdateProjectParams(d.Id()) @@ -409,8 +434,8 @@ func resourceCloudStackProjectUpdate(d *schema.ResourceData, meta any) error { p.SetName(d.Get("name").(string)) } - if d.HasChange("displaytext") { - p.SetDisplaytext(d.Get("displaytext").(string)) + if d.HasChange("displaytext") || d.HasChange("display_text") { + p.SetDisplaytext(projectDisplayText(d)) } log.Printf("[DEBUG] Updating project %s", d.Id()) @@ -490,7 +515,7 @@ func resourceCloudStackProjectUpdate(d *schema.ResourceData, meta any) error { return retry.RetryableError(fmt.Errorf("project name not updated yet")) } - if d.HasChange("displaytext") && project.Displaytext != d.Get("displaytext").(string) { + if (d.HasChange("displaytext") || d.HasChange("display_text")) && project.Displaytext != projectDisplayText(d) { log.Printf("[DEBUG] Project %s displaytext not updated yet, retrying...", d.Id()) return retry.RetryableError(fmt.Errorf("project displaytext not updated yet")) } diff --git a/website/docs/r/project.html.markdown b/website/docs/r/project.html.markdown index 8e0bfe98..64a61c75 100644 --- a/website/docs/r/project.html.markdown +++ b/website/docs/r/project.html.markdown @@ -37,6 +37,9 @@ The following arguments are supported: * `name` - (Required) The name of the project. * `display_text` - (Required) The display text of the project. Required for API version 4.18 and lower compatibility. This requirement will be removed when support for API versions older than 4.18 is dropped. +* `displaytext` - (Optional, **Deprecated**) Use `display_text` instead. Retained for + backwards compatibility with existing state files; if both `displaytext` and + `display_text` are set, `display_text` takes precedence. * `domain` - (Optional) The domain where the project will be created. This cannot be changed after the project is created. * `account` - (Optional) The account who will be Admin for the project. Requires `domain` to be set. This can be updated after the project is created. * `accountid` - (Optional) The ID of the account owning the project. This can be updated after the project is created.