From 47df0074950830ca127c533da127ba1d6d594b0e Mon Sep 17 00:00:00 2001 From: Alain Kaeslin Date: Fri, 26 Jun 2026 10:54:36 +0200 Subject: [PATCH 1/6] test: add unit tests for dataSourceResourceRead filter logic --- cloudscale/datasources_test.go | 207 +++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 cloudscale/datasources_test.go diff --git a/cloudscale/datasources_test.go b/cloudscale/datasources_test.go new file mode 100644 index 00000000..febc7115 --- /dev/null +++ b/cloudscale/datasources_test.go @@ -0,0 +1,207 @@ +package cloudscale + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// testDSSchema is a minimal schema for dataSourceResourceRead unit tests. +var testDSSchema = map[string]*schema.Schema{ + "name": {Type: schema.TypeString, Optional: true}, + "tags": &TagsSchema, +} + +func mockFetch(rows ...ResourceDataRaw) func(*schema.ResourceData, any) ([]ResourceDataRaw, error) { + return func(_ *schema.ResourceData, _ any) ([]ResourceDataRaw, error) { + return rows, nil + } +} + +func TestDataSourceRead_SingleMatch(t *testing.T) { + // two resources with distinct names; the name filter selects exactly one + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{}}, + {"id": "bbb", "name": "beta", "tags": map[string]interface{}{}}, + } + filter := ResourceDataRaw{"name": "alpha"} + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if diags.HasError() { + t.Fatalf("unexpected error: %s", diags[0].Summary) + } + if resourceData.Id() != "aaa" { + t.Errorf("got id=%q, want aaa", resourceData.Id()) + } +} + +func TestDataSourceRead_NoMatch(t *testing.T) { + // name filter matches none of the available resources; returns a zero-match diagnostic + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{}}, + } + filter := ResourceDataRaw{"name": "gamma"} + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if !diags.HasError() { + t.Fatal("expected error, got none") + } + if diags[0].Summary != "Found zero things" { + t.Errorf("unexpected error: %s", diags[0].Summary) + } +} + +func TestDataSourceRead_MultipleMatches(t *testing.T) { + // no filter attributes set; every resource qualifies and triggers an ambiguity diagnostic + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{}}, + {"id": "bbb", "name": "beta", "tags": map[string]interface{}{}}, + } + filter := ResourceDataRaw{} // no filter → all match + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if !diags.HasError() { + t.Fatal("expected error, got none") + } + if diags[0].Summary != "Found 2 things, expected one" { + t.Errorf("unexpected error: %s", diags[0].Summary) + } +} + +// TestDataSourceRead_TagSubsetMatch is the regression test for the fix in 575d73ef. +// +// Before that commit the filter loop used `m[key] != attr` for all field types. +// Comparing two interface{} values whose dynamic type is map[string]interface{} +// causes a runtime panic ("comparing uncomparable type") in Go. Rebase this commit +// onto the parent of 575d73ef to see the panic; on the fixed code it passes cleanly. +func TestDataSourceRead_TagSubsetMatch(t *testing.T) { + // filter holds a strict subset of a resource's tags; the resource still qualifies + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{"env": "prod", "team": "infra"}}, + {"id": "bbb", "name": "beta", "tags": map[string]interface{}{"env": "dev"}}, + } + filter := ResourceDataRaw{"tags": map[string]interface{}{"env": "prod"}} // strict subset of "aaa"'s tags + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if diags.HasError() { + t.Fatalf("unexpected error: %s", diags[0].Summary) + } + if resourceData.Id() != "aaa" { + t.Errorf("got id=%q, want aaa", resourceData.Id()) + } +} + +func TestDataSourceRead_TagExactMatch(t *testing.T) { + // filter tags match a resource's tags exactly; the resource qualifies + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{"env": "prod"}}, + {"id": "bbb", "name": "beta", "tags": map[string]interface{}{"env": "dev"}}, + } + filter := ResourceDataRaw{"tags": map[string]interface{}{"env": "prod"}} + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if diags.HasError() { + t.Fatalf("unexpected error: %s", diags[0].Summary) + } + if resourceData.Id() != "aaa" { + t.Errorf("got id=%q, want aaa", resourceData.Id()) + } +} + +func TestDataSourceRead_TagSupersetNoMatch(t *testing.T) { + // filter demands more tags than the resource carries; nothing qualifies + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{"env": "prod"}}, + } + filter := ResourceDataRaw{"tags": map[string]interface{}{"env": "prod", "team": "infra"}} // resource only has one of these + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if !diags.HasError() { + t.Fatal("expected error, got none") + } + if diags[0].Summary != "Found zero things" { + t.Errorf("unexpected error: %s", diags[0].Summary) + } +} + +func TestDataSourceRead_TagMismatch(t *testing.T) { + // filter and resource disagree on the value of a shared tag key; nothing qualifies + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{"env": "prod"}}, + } + filter := ResourceDataRaw{"tags": map[string]interface{}{"env": "dev"}} + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if !diags.HasError() { + t.Fatal("expected error, got none") + } + if diags[0].Summary != "Found zero things" { + t.Errorf("unexpected error: %s", diags[0].Summary) + } +} + +func TestDataSourceRead_EmptyTagFilter(t *testing.T) { + // empty tag map is a no-op filter; all resources qualify and trigger an ambiguity diagnostic + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "tags": map[string]interface{}{"env": "prod"}}, + {"id": "bbb", "name": "beta", "tags": map[string]interface{}{"env": "dev"}}, + } + filter := ResourceDataRaw{"tags": map[string]interface{}{}} // empty tags = no filter + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if !diags.HasError() { + t.Fatal("expected ambiguity error, got none") + } + if diags[0].Summary != "Found 2 things, expected one" { + t.Errorf("unexpected error: %s", diags[0].Summary) + } +} From b609e9a927485b97c38c97582f54e5d6af4fbe5c Mon Sep 17 00:00:00 2001 From: Alain Kaeslin Date: Fri, 8 May 2026 14:04:30 +0200 Subject: [PATCH 2/6] fix: use subset semantics for tag filtering in data sources The old filter loop used `m[key] != attr` for all attribute types. For TypeMap (tags), comparing two interface{} values whose dynamic type is map[string]interface{} panics in Go: maps are not comparable. In practice this panic was masked: gather functions did not include "tags" in ResourceDataRaw, so m["tags"] was always nil. Comparing nil != someMap is safe and evaluates to true (no match), so the real provider silently returned "Found zero" rather than crashing. This commit fixes both: it wires up TagsToState in every gather function so tags are actually returned, and replaces the generic != comparison with reflect.DeepEqual for scalars and subset-walk logic for TypeMap fields. --- .../datasource_cloudscale_server_test.go | 94 +++++++++++++++++++ cloudscale/datasources.go | 26 ++++- .../resource_cloudscale_custom_image.go | 5 +- cloudscale/resource_cloudscale_floating_ip.go | 2 +- .../resource_cloudscale_load_balancer.go | 7 +- ...cloudscale_load_balancer_health_monitor.go | 7 +- ...ource_cloudscale_load_balancer_listener.go | 2 +- .../resource_cloudscale_load_balancer_pool.go | 5 +- ...ce_cloudscale_load_balancer_pool_member.go | 7 +- cloudscale/resource_cloudscale_network.go | 2 +- .../resource_cloudscale_objects_user.go | 5 +- cloudscale/resource_cloudscale_server.go | 2 +- .../resource_cloudscale_server_group.go | 2 +- cloudscale/resource_cloudscale_subnet.go | 2 +- cloudscale/resource_cloudscale_volume.go | 2 +- .../resource_cloudscale_volume_snapshot.go | 2 +- cloudscale/util.go | 15 ++- 17 files changed, 157 insertions(+), 30 deletions(-) diff --git a/cloudscale/datasource_cloudscale_server_test.go b/cloudscale/datasource_cloudscale_server_test.go index d3c0fc7e..e9300037 100644 --- a/cloudscale/datasource_cloudscale_server_test.go +++ b/cloudscale/datasource_cloudscale_server_test.go @@ -116,6 +116,73 @@ func TestAccCloudscaleServer_DS_NotExisting(t *testing.T) { }) } +// Tag filtering is implemented generically in dataSourceResourceRead (datasources.go), +// so this test covers all data sources; no need to duplicate it per resource. +func TestAccCloudscaleServer_DS_FilterByTags(t *testing.T) { + var server cloudscale.Server + rInt := acctest.RandInt() + name := fmt.Sprintf("terraform-%d", rInt) + config := serverConfig_baselineWithTag(rInt) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudscaleServerDestroy, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + Config: config + testAccCheckCloudscaleServerConfig_tags(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudscaleServerExists("data.cloudscale_server.foo", &server), + resource.TestCheckResourceAttrPtr( + "cloudscale_server.basic", "id", &server.UUID), + resource.TestCheckResourceAttrPtr( + "data.cloudscale_server.foo", "id", &server.UUID), + resource.TestCheckResourceAttr( + "data.cloudscale_server.foo", "name", name), + ), + }, + { + Config: config + testAccCheckCloudscaleServerConfig_tags(rInt+1), + ExpectError: regexp.MustCompile(`Found zero servers`), + }, + }, + }) +} + +func TestAccCloudscaleServer_DS_FilterByTags_NoTags(t *testing.T) { + rInt := acctest.RandInt() + config := serverConfig_baseline(2, rInt) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudscaleServerDestroy, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + // No filter at all: matches all servers. + Config: config + "\n" + `data "cloudscale_server" "foo" {}`, + ExpectError: regexp.MustCompile(`Found \d+ servers, expected one`), + }, + { + // tags = {} is identical: it also matches all servers without tags. + Config: config + "\n" + `data "cloudscale_server" "foo" { tags = {} }`, + ExpectError: regexp.MustCompile(`Found \d+ servers, expected one`), + }, + { + // A server with no tags must not match a non-empty tag filter. + Config: config + testAccCheckCloudscaleServerConfig_tags(rInt), + ExpectError: regexp.MustCompile(`Found zero servers`), + }, + }, + }) +} + func testAccCheckCloudscaleServerConfig_name(name string) string { return fmt.Sprintf(` data "cloudscale_server" "foo" { @@ -154,3 +221,30 @@ resource "cloudscale_server" "basic" { ssh_keys = ["ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFEepRNW5hDct4AdJ8oYsb4lNP5E9XY5fnz3ZvgNCEv7m48+bhUjJXUPuamWix3zigp2lgJHC6SChI/okJ41GUY=", "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFEepRNW5hDct4AdJ8oYsb4lNP5E9XY5fnz3ZvgNCEv7m48+bhUjJXUPuamWix3zigp2lgJHC6SChI/okJ41GUY="] }`, count, rInt, DefaultImageSlug) } + +func serverConfig_baselineWithTag(rInt int) string { + return fmt.Sprintf(` +resource "cloudscale_server" "basic" { + name = "terraform-%d" + flavor_slug = "flex-4-1" + allow_stopping_for_update = true + image_slug = "%s" + volume_size_gb = 10 + zone_slug = "rma1" + ssh_keys = ["ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFEepRNW5hDct4AdJ8oYsb4lNP5E9XY5fnz3ZvgNCEv7m48+bhUjJXUPuamWix3zigp2lgJHC6SChI/okJ41GUY=", "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFEepRNW5hDct4AdJ8oYsb4lNP5E9XY5fnz3ZvgNCEv7m48+bhUjJXUPuamWix3zigp2lgJHC6SChI/okJ41GUY="] + tags = { + test-id = "%d" + provider = "cloudscale-tf-test" + } +}`, rInt, DefaultImageSlug, rInt) +} + +func testAccCheckCloudscaleServerConfig_tags(rInt int) string { + return fmt.Sprintf(` +data "cloudscale_server" "foo" { + tags = { + test-id = "%d" + } +} +`, rInt) +} diff --git a/cloudscale/datasources.go b/cloudscale/datasources.go index b1a8cb34..f3f97026 100644 --- a/cloudscale/datasources.go +++ b/cloudscale/datasources.go @@ -2,6 +2,7 @@ package cloudscale import ( "context" + "reflect" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -29,14 +30,29 @@ func dataSourceResourceRead( } var foundItems []map[string]any + // Filter resources: each set attribute must match (maps use subset semantics). for _, m := range resources { match := true - for key := range sourceSchema { - if attr, ok := d.GetOk(key); ok { - if m[key] != attr { - match = false - break + for key, schemaEntry := range sourceSchema { + attr, ok := d.GetOk(key) + if !ok { + continue // not a filter criterion + } + if schemaEntry.Type == schema.TypeMap { + // Tags: all filter key-value pairs must be present in the resource (subset, not exact). + filterMap := attr.(map[string]interface{}) + resourceMap, _ := m[key].(map[string]interface{}) + for fk, fv := range filterMap { + if resourceMap[fk] != fv { + match = false + break // one tag mismatch is sufficient + } } + } else if !reflect.DeepEqual(m[key], attr) { + match = false + } + if !match { + break // skip remaining attributes } } if match { diff --git a/cloudscale/resource_cloudscale_custom_image.go b/cloudscale/resource_cloudscale_custom_image.go index 62834e7c..fd1a1b57 100644 --- a/cloudscale/resource_cloudscale_custom_image.go +++ b/cloudscale/resource_cloudscale_custom_image.go @@ -3,11 +3,12 @@ package cloudscale import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "log" "math" "time" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -190,7 +191,7 @@ func gatherCustomImageResourceData(customImage *cloudscale.CustomImage) Resource m["user_data_handling"] = customImage.UserDataHandling m["firmware_type"] = customImage.FirmwareType m["checksums"] = customImage.Checksums - m["tags"] = customImage.Tags + m["tags"] = TagsToRaw(customImage.Tags) zoneSlugs := make([]string, 0, len(customImage.Zones)) for _, zone := range customImage.Zones { diff --git a/cloudscale/resource_cloudscale_floating_ip.go b/cloudscale/resource_cloudscale_floating_ip.go index 77c0e864..8bac133b 100644 --- a/cloudscale/resource_cloudscale_floating_ip.go +++ b/cloudscale/resource_cloudscale_floating_ip.go @@ -153,7 +153,7 @@ func gatherFloatingIPResourceData(floatingIP *cloudscale.FloatingIP) ResourceDat m["next_hop"] = floatingIP.NextHop m["reverse_ptr"] = floatingIP.ReversePointer m["type"] = floatingIP.Type - m["tags"] = floatingIP.Tags + m["tags"] = TagsToRaw(floatingIP.Tags) if floatingIP.Server != nil { m["server"] = floatingIP.Server.UUID } else { diff --git a/cloudscale/resource_cloudscale_load_balancer.go b/cloudscale/resource_cloudscale_load_balancer.go index e255afd5..e62ad30d 100644 --- a/cloudscale/resource_cloudscale_load_balancer.go +++ b/cloudscale/resource_cloudscale_load_balancer.go @@ -3,11 +3,12 @@ package cloudscale import ( "context" "fmt" + "log" + "time" + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "log" - "time" ) const loadBalancerHumanName = "load balancer" @@ -190,7 +191,7 @@ func gatherLoadBalancerResourceData(loadbalancer *cloudscale.LoadBalancer) Resou m["flavor_slug"] = loadbalancer.Flavor.Slug m["zone_slug"] = loadbalancer.Zone.Slug m["status"] = loadbalancer.Status - m["tags"] = loadbalancer.Tags + m["tags"] = TagsToRaw(loadbalancer.Tags) if addrss := len(loadbalancer.VIPAddresses); addrss > 0 { vipAddressesMap := make([]map[string]any, 0, addrss) diff --git a/cloudscale/resource_cloudscale_load_balancer_health_monitor.go b/cloudscale/resource_cloudscale_load_balancer_health_monitor.go index ecb44ff2..c28e1c3d 100644 --- a/cloudscale/resource_cloudscale_load_balancer_health_monitor.go +++ b/cloudscale/resource_cloudscale_load_balancer_health_monitor.go @@ -3,9 +3,10 @@ package cloudscale import ( "context" "fmt" + "log" + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "log" ) const healthMonitorHumanName = "load balancer health monitor" @@ -232,7 +233,7 @@ func gatherLoadBalancerHealthMonitorUpdateRequests(d *schema.ResourceData) []*cl httpOpts.Method = d.Get(attribute).(string) } else if attribute == "http_url_path" { httpOpts.UrlPath = d.Get(attribute).(string) - }else if attribute == "http_host" { + } else if attribute == "http_host" { if attr, ok := d.GetOk(attribute); ok { s := attr.(string) httpOpts.Host = &s @@ -267,7 +268,7 @@ func gatherLoadBalancerHealthMonitorResourceData(loadBalancerHealthMonitor *clou } else { m["http_expected_codes"] = nil } - m["tags"] = loadBalancerHealthMonitor.Tags + m["tags"] = TagsToRaw(loadBalancerHealthMonitor.Tags) return m } diff --git a/cloudscale/resource_cloudscale_load_balancer_listener.go b/cloudscale/resource_cloudscale_load_balancer_listener.go index c0934351..beeb911f 100644 --- a/cloudscale/resource_cloudscale_load_balancer_listener.go +++ b/cloudscale/resource_cloudscale_load_balancer_listener.go @@ -167,7 +167,7 @@ func gatherLoadBalancerListenerResourceData(loadbalancerlistener *cloudscale.Loa m["timeout_member_connect_ms"] = loadbalancerlistener.TimeoutMemberConnectMS m["timeout_member_data_ms"] = loadbalancerlistener.TimeoutMemberDataMS m["allowed_cidrs"] = loadbalancerlistener.AllowedCIDRs - m["tags"] = loadbalancerlistener.Tags + m["tags"] = TagsToRaw(loadbalancerlistener.Tags) return m } diff --git a/cloudscale/resource_cloudscale_load_balancer_pool.go b/cloudscale/resource_cloudscale_load_balancer_pool.go index 0b62c3a4..247eb06d 100644 --- a/cloudscale/resource_cloudscale_load_balancer_pool.go +++ b/cloudscale/resource_cloudscale_load_balancer_pool.go @@ -3,9 +3,10 @@ package cloudscale import ( "context" "fmt" + "log" + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "log" ) const poolHumanName = "load balancer pool" @@ -117,7 +118,7 @@ func gatherLoadBalancerPoolResourceData(loadbalancerpool *cloudscale.LoadBalance m["load_balancer_href"] = loadbalancerpool.LoadBalancer.HREF m["algorithm"] = loadbalancerpool.Algorithm m["protocol"] = loadbalancerpool.Protocol - m["tags"] = loadbalancerpool.Tags + m["tags"] = TagsToRaw(loadbalancerpool.Tags) return m } diff --git a/cloudscale/resource_cloudscale_load_balancer_pool_member.go b/cloudscale/resource_cloudscale_load_balancer_pool_member.go index b37771eb..2e75227f 100644 --- a/cloudscale/resource_cloudscale_load_balancer_pool_member.go +++ b/cloudscale/resource_cloudscale_load_balancer_pool_member.go @@ -3,10 +3,11 @@ package cloudscale import ( "context" "fmt" - "github.com/cloudscale-ch/cloudscale-go-sdk/v9" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "log" "strings" + + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) const poolMemberHumanName = "load balancer pool member" @@ -195,7 +196,7 @@ func gatherLoadBalancerPoolMemberResourceData(loadbalancerPoolMember *cloudscale m["monitor_port"] = loadbalancerPoolMember.MonitorPort m["address"] = loadbalancerPoolMember.Address m["monitor_status"] = loadbalancerPoolMember.MonitorStatus - m["tags"] = loadbalancerPoolMember.Tags + m["tags"] = TagsToRaw(loadbalancerPoolMember.Tags) return m } diff --git a/cloudscale/resource_cloudscale_network.go b/cloudscale/resource_cloudscale_network.go index 775280a6..00d17bf9 100644 --- a/cloudscale/resource_cloudscale_network.go +++ b/cloudscale/resource_cloudscale_network.go @@ -144,7 +144,7 @@ func gatherNetworkResourceData(network *cloudscale.Network) ResourceDataRaw { subnets = append(subnets, g) } m["subnets"] = subnets - m["tags"] = network.Tags + m["tags"] = TagsToRaw(network.Tags) return m } diff --git a/cloudscale/resource_cloudscale_objects_user.go b/cloudscale/resource_cloudscale_objects_user.go index 83d0a4b0..6cec5003 100644 --- a/cloudscale/resource_cloudscale_objects_user.go +++ b/cloudscale/resource_cloudscale_objects_user.go @@ -3,9 +3,10 @@ package cloudscale import ( "context" "fmt" + "log" + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "log" ) const objectsUserHumanName = "Objects User" @@ -104,7 +105,7 @@ func gatherObjectsUserResourceData(objectsUser *cloudscale.ObjectsUser) Resource m["href"] = objectsUser.HREF m["user_id"] = objectsUser.ID m["display_name"] = objectsUser.DisplayName - m["tags"] = objectsUser.Tags + m["tags"] = TagsToRaw(objectsUser.Tags) keys := make([]map[string]string, 0, len(objectsUser.Keys)) for _, keyEntry := range objectsUser.Keys { diff --git a/cloudscale/resource_cloudscale_server.go b/cloudscale/resource_cloudscale_server.go index aa083b72..488d6800 100644 --- a/cloudscale/resource_cloudscale_server.go +++ b/cloudscale/resource_cloudscale_server.go @@ -483,7 +483,7 @@ func gatherServerResourceData(server *cloudscale.Server) ResourceDataRaw { m["image_slug"] = server.Image.Slug m["zone_slug"] = server.Zone.Slug m["status"] = server.Status - m["tags"] = server.Tags + m["tags"] = TagsToRaw(server.Tags) if volumes := len(server.Volumes); volumes > 0 { volumesMaps := make([]map[string]any, 0, volumes) diff --git a/cloudscale/resource_cloudscale_server_group.go b/cloudscale/resource_cloudscale_server_group.go index 892efc88..372aa227 100644 --- a/cloudscale/resource_cloudscale_server_group.go +++ b/cloudscale/resource_cloudscale_server_group.go @@ -103,7 +103,7 @@ func gatherServerGroupResourceData(serverGroup *cloudscale.ServerGroup) Resource m["name"] = serverGroup.Name m["type"] = serverGroup.Type m["zone_slug"] = serverGroup.Zone.Slug - m["tags"] = serverGroup.Tags + m["tags"] = TagsToRaw(serverGroup.Tags) return m } diff --git a/cloudscale/resource_cloudscale_subnet.go b/cloudscale/resource_cloudscale_subnet.go index 4185670f..80e32def 100644 --- a/cloudscale/resource_cloudscale_subnet.go +++ b/cloudscale/resource_cloudscale_subnet.go @@ -148,7 +148,7 @@ func gatherSubnetResourceData(subnet *cloudscale.Subnet) ResourceDataRaw { m["network_name"] = subnet.Network.Name m["gateway_address"] = subnet.GatewayAddress m["dns_servers"] = subnet.DNSServers - m["tags"] = subnet.Tags + m["tags"] = TagsToRaw(subnet.Tags) return m } diff --git a/cloudscale/resource_cloudscale_volume.go b/cloudscale/resource_cloudscale_volume.go index a63b608b..cfa02686 100644 --- a/cloudscale/resource_cloudscale_volume.go +++ b/cloudscale/resource_cloudscale_volume.go @@ -186,7 +186,7 @@ func gatherVolumeResourceData(volume *cloudscale.Volume) ResourceDataRaw { m["type"] = volume.Type m["zone_slug"] = volume.Zone.Slug m["server_uuids"] = volume.ServerUUIDs - m["tags"] = volume.Tags + m["tags"] = TagsToRaw(volume.Tags) return m } diff --git a/cloudscale/resource_cloudscale_volume_snapshot.go b/cloudscale/resource_cloudscale_volume_snapshot.go index 9ed5bc54..859fe3a9 100644 --- a/cloudscale/resource_cloudscale_volume_snapshot.go +++ b/cloudscale/resource_cloudscale_volume_snapshot.go @@ -160,7 +160,7 @@ func gatherVolumeSnapshotResourceData(snap *cloudscale.VolumeSnapshot) ResourceD m["source_volume_href"] = snap.SourceVolume.HREF m["size_gb"] = snap.SizeGB m["status"] = snap.Status - m["tags"] = snap.Tags + m["tags"] = TagsToRaw(snap.Tags) return m } diff --git a/cloudscale/util.go b/cloudscale/util.go index f0dd7f5b..6b582449 100644 --- a/cloudscale/util.go +++ b/cloudscale/util.go @@ -2,9 +2,10 @@ package cloudscale import ( "fmt" + "net/http" + "github.com/cloudscale-ch/cloudscale-go-sdk/v9" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "net/http" ) var ( @@ -14,9 +15,10 @@ var ( Type: schema.TypeString, }, Optional: true, - }; + } ) +// CopyTags converts Terraform state tags (map[string]interface{}) to the SDK type. func CopyTags(d *schema.ResourceData) *cloudscale.TagMap { newTags := make(cloudscale.TagMap) @@ -27,6 +29,15 @@ func CopyTags(d *schema.ResourceData) *cloudscale.TagMap { return &newTags } +// TagsToRaw is the inverse of CopyTags: converts SDK tags to Terraform's map type. +func TagsToRaw(tags cloudscale.TagMap) map[string]interface{} { + result := make(map[string]interface{}, len(tags)) + for k, v := range tags { + result[k] = v + } + return result +} + // CheckDeleted checks the error to see if it's a 404 (Not Found) and, if so, // sets the resource ID to the empty string instead of throwing an error. func CheckDeleted(d *schema.ResourceData, err error, msg string) error { From 45e960ef5c99606b5ccf61bd3de2d60ffa28fb86 Mon Sep 17 00:00:00 2001 From: Alain Kaeslin Date: Fri, 26 Jun 2026 08:59:56 +0200 Subject: [PATCH 3/6] docs: document subset-based tag filtering in data sources. --- docs/data-sources/custom_image.md | 1 + docs/data-sources/floating_ip.md | 1 + docs/data-sources/load_balancer.md | 1 + docs/data-sources/load_balancer_pool.md | 1 + docs/data-sources/load_balancer_pool_health_monitor.md | 1 + docs/data-sources/load_balancer_pool_listener.md | 1 + docs/data-sources/load_balancer_pool_member.md | 1 + docs/data-sources/network.md | 2 +- docs/data-sources/objects_user.md | 1 + docs/data-sources/server.md | 1 + docs/data-sources/server_group.md | 1 + docs/data-sources/subnet.md | 2 +- docs/data-sources/volume.md | 2 +- docs/data-sources/volume_snapshot.md | 2 +- 14 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/custom_image.md b/docs/data-sources/custom_image.md index 29fd3dca..226e0556 100644 --- a/docs/data-sources/custom_image.md +++ b/docs/data-sources/custom_image.md @@ -41,6 +41,7 @@ The following arguments can be used to look up a custom image: * `id` - (Optional) The UUID of a custom image. * `name` - (Optional) The human-readable name of a custom image. * `slug` - (Optional) A string identifying a custom image. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/floating_ip.md b/docs/data-sources/floating_ip.md index fd9316c8..bfb91320 100644 --- a/docs/data-sources/floating_ip.md +++ b/docs/data-sources/floating_ip.md @@ -29,6 +29,7 @@ The following arguments can be used to look up a Floating IP: * `ip_version` - (Optional) `4` or `6`, for an IPv4 or IPv6 address or network respectively. * `region_slug` - (Optional) The slug of the region in which a Regional Floating IP is assigned. * `type` - (Optional) Options include `regional` and `global`. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/load_balancer.md b/docs/data-sources/load_balancer.md index 5bb05a97..9506c1a3 100644 --- a/docs/data-sources/load_balancer.md +++ b/docs/data-sources/load_balancer.md @@ -20,6 +20,7 @@ The following arguments can be used to look up a load balancer: * `id` - (Optional) The UUID of the load balancer. * `zone_slug` - (Optional) The slug of the zone in which the load balancer exists. Options include `lpg1` and `rma1`. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/load_balancer_pool.md b/docs/data-sources/load_balancer_pool.md index 4efdcbc5..7d28e0ef 100644 --- a/docs/data-sources/load_balancer_pool.md +++ b/docs/data-sources/load_balancer_pool.md @@ -21,6 +21,7 @@ The following arguments can be used to look up a load balancer pool: * `id` - (Optional) The UUID of the load balancer pool. * `name` - (Optional) Name of the load balancer pool. * `load_balancer_uuid` - (Optional) The load balancer of the pool. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/load_balancer_pool_health_monitor.md b/docs/data-sources/load_balancer_pool_health_monitor.md index d2e261c4..6369c981 100644 --- a/docs/data-sources/load_balancer_pool_health_monitor.md +++ b/docs/data-sources/load_balancer_pool_health_monitor.md @@ -20,6 +20,7 @@ The following arguments can be used to look up a load balancer listener: * `id` - (Optional) The UUID of the load balancer health monitor. * `pool_uuid` - (Optional) The UUID of the pool this health monitor belongs to. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/load_balancer_pool_listener.md b/docs/data-sources/load_balancer_pool_listener.md index 19948290..5c1f2cac 100644 --- a/docs/data-sources/load_balancer_pool_listener.md +++ b/docs/data-sources/load_balancer_pool_listener.md @@ -21,6 +21,7 @@ The following arguments can be used to look up a load balancer listener: * `id` - (Optional) The UUID of the load balancer listener. * `name` - (Optional) Name of the load balancer listener. * `pool_uuid` - (Optional) The UUID of the pool this listener belongs to. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/load_balancer_pool_member.md b/docs/data-sources/load_balancer_pool_member.md index 7f4bc7b6..66742a3f 100644 --- a/docs/data-sources/load_balancer_pool_member.md +++ b/docs/data-sources/load_balancer_pool_member.md @@ -21,6 +21,7 @@ The following arguments can be used to look up a load balancer pool member: * `pool_uuid` - (Required) The UUID of the pool this member belongs to. * `id` - (Optional) The UUID of the load balancer pool. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/network.md b/docs/data-sources/network.md index caba9433..e7b37497 100644 --- a/docs/data-sources/network.md +++ b/docs/data-sources/network.md @@ -40,7 +40,7 @@ The following arguments can be used to look up a network: * `id` - (Optional) The UUID of a network. * `name` - (Optional) The name of a network. * `zone_slug` - (Optional) The zone slug of a network. Options include `lpg1` and `rma1`. - +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/objects_user.md b/docs/data-sources/objects_user.md index 223ade8e..4b4072d6 100644 --- a/docs/data-sources/objects_user.md +++ b/docs/data-sources/objects_user.md @@ -26,6 +26,7 @@ The following arguments can be used to look up an Objects User: * `id` - (Optional) The unique identifier of the Objects User. * `display_name` - (Optional) The display name of the Objects User. * `user_id` - (Optional) The unique identifier of the Objects User. (Exactly the same as `id`) +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/server.md b/docs/data-sources/server.md index e6acb6ac..b2b2279d 100644 --- a/docs/data-sources/server.md +++ b/docs/data-sources/server.md @@ -21,6 +21,7 @@ The following arguments can be used to look up a server: * `id` - (Optional) The UUID of a server. * `name` - (Optional) Name of the server. * `zone_slug` - (Optional) The slug of the zone in which the server exists. Options include `lpg1` and `rma1`. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/server_group.md b/docs/data-sources/server_group.md index f53183be..324e30d4 100644 --- a/docs/data-sources/server_group.md +++ b/docs/data-sources/server_group.md @@ -31,6 +31,7 @@ The following arguments can be used to look up a server group: * `id` - (Optional) The UUID of a server group. * `name` - (Optional) Name of the server group. * `zone_slug` - (Optional) The slug of the zone in which the server group exists. Options include `lpg1` and `rma1`. +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/subnet.md b/docs/data-sources/subnet.md index a338e059..7fd033d1 100644 --- a/docs/data-sources/subnet.md +++ b/docs/data-sources/subnet.md @@ -43,7 +43,7 @@ The following arguments can be used to look up a subnet: * `network_uuid` - (Optional) The network UUID of the subnet. * `network_name` - (Optional) The network name of the subnet. * `gateway_address` - (Optional) The gateway address of the subnet. - +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/volume.md b/docs/data-sources/volume.md index eb6f7900..a53a769f 100644 --- a/docs/data-sources/volume.md +++ b/docs/data-sources/volume.md @@ -23,7 +23,7 @@ The following arguments can be used to look up a volume: * `zone_slug` - (Optional) The slug of the zone in which the new volume will be created. Options include `lpg1` and `rma1`. * `type` - (Optional) For SSD/NVMe volumes "ssd" (default); or "bulk" for our HDD cluster with NVMe caching. * `size_gb` - (Optional) The volume size in GB. Valid values are multiples of 1 for type "ssd" and multiples of 100 for type "bulk". -* `tags` - (Optional) Tags allow you to assign custom metadata to resources. Tags are always strings (both keys and values). +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference diff --git a/docs/data-sources/volume_snapshot.md b/docs/data-sources/volume_snapshot.md index c753aad5..c6a138e9 100644 --- a/docs/data-sources/volume_snapshot.md +++ b/docs/data-sources/volume_snapshot.md @@ -21,7 +21,7 @@ The following arguments can be used to look up a volume snapshot: * `id` - (Optional) The UUID of the volume snapshot. * `name` - (Optional) The name of the volume snapshot. * `source_volume_uuid` - (Optional) The UUID of the source volume. -* `tags` - (Optional) Tags allow you to assign custom metadata to resources. Tags are always strings (both keys and values). +* `tags` - (Optional) Filter by tags; the resource must have at least the specified key-value pairs (subset match). Tags are always strings (both keys and values). ## Attributes Reference From ba35e44527c1a75335b82aca0570896d824fdfb3 Mon Sep 17 00:00:00 2001 From: Alain Kaeslin Date: Fri, 26 Jun 2026 10:33:43 +0200 Subject: [PATCH 4/6] refactor: rename CopyTags/TagsToRaw to TagsFromState/TagsToState Makes it more clear what input and output of the funcs actually are. --- cloudscale/resource_cloudscale_custom_image.go | 6 +++--- cloudscale/resource_cloudscale_floating_ip.go | 6 +++--- cloudscale/resource_cloudscale_load_balancer.go | 6 +++--- .../resource_cloudscale_load_balancer_health_monitor.go | 6 +++--- cloudscale/resource_cloudscale_load_balancer_listener.go | 6 +++--- cloudscale/resource_cloudscale_load_balancer_pool.go | 6 +++--- .../resource_cloudscale_load_balancer_pool_member.go | 6 +++--- cloudscale/resource_cloudscale_network.go | 6 +++--- cloudscale/resource_cloudscale_objects_user.go | 6 +++--- cloudscale/resource_cloudscale_server.go | 6 +++--- cloudscale/resource_cloudscale_server_group.go | 6 +++--- cloudscale/resource_cloudscale_subnet.go | 6 +++--- cloudscale/resource_cloudscale_volume.go | 6 +++--- cloudscale/resource_cloudscale_volume_snapshot.go | 6 +++--- cloudscale/util.go | 8 ++++---- 15 files changed, 46 insertions(+), 46 deletions(-) diff --git a/cloudscale/resource_cloudscale_custom_image.go b/cloudscale/resource_cloudscale_custom_image.go index fd1a1b57..4b319ead 100644 --- a/cloudscale/resource_cloudscale_custom_image.go +++ b/cloudscale/resource_cloudscale_custom_image.go @@ -127,7 +127,7 @@ func resourceCustomImageCreate(d *schema.ResourceData, meta any) error { UserDataHandling: d.Get("user_data_handling").(string), Zones: nil, } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) zoneSlugs := d.Get("zone_slugs").(*schema.Set).List() z := make([]string, len(zoneSlugs)) for i := range zoneSlugs { @@ -191,7 +191,7 @@ func gatherCustomImageResourceData(customImage *cloudscale.CustomImage) Resource m["user_data_handling"] = customImage.UserDataHandling m["firmware_type"] = customImage.FirmwareType m["checksums"] = customImage.Checksums - m["tags"] = TagsToRaw(customImage.Tags) + m["tags"] = TagsToState(customImage.Tags) zoneSlugs := make([]string, 0, len(customImage.Zones)) for _, zone := range customImage.Zones { @@ -227,7 +227,7 @@ func gatherCustomImageUpdateRequest(d *schema.ResourceData) []*cloudscale.Custom } else if attribute == "user_data_handling" { opts.UserDataHandling = cloudscale.UserDataHandling(d.Get(attribute).(string)) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_floating_ip.go b/cloudscale/resource_cloudscale_floating_ip.go index 8bac133b..c0cde703 100644 --- a/cloudscale/resource_cloudscale_floating_ip.go +++ b/cloudscale/resource_cloudscale_floating_ip.go @@ -125,7 +125,7 @@ func resourceFloatingIPCreate(d *schema.ResourceData, meta any) error { if attr, ok := d.GetOk("type"); ok { opts.Type = attr.(string) } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] FloatingIP create configuration: %#v", opts) @@ -153,7 +153,7 @@ func gatherFloatingIPResourceData(floatingIP *cloudscale.FloatingIP) ResourceDat m["next_hop"] = floatingIP.NextHop m["reverse_ptr"] = floatingIP.ReversePointer m["type"] = floatingIP.Type - m["tags"] = TagsToRaw(floatingIP.Tags) + m["tags"] = TagsToState(floatingIP.Tags) if floatingIP.Server != nil { m["server"] = floatingIP.Server.UUID } else { @@ -205,7 +205,7 @@ func gatherFloatingIPUpdateRequest(d *schema.ResourceData) []*cloudscale.Floatin opts.LoadBalancer = loadBalancerUUID } } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_load_balancer.go b/cloudscale/resource_cloudscale_load_balancer.go index e62ad30d..f4d83cb5 100644 --- a/cloudscale/resource_cloudscale_load_balancer.go +++ b/cloudscale/resource_cloudscale_load_balancer.go @@ -122,7 +122,7 @@ func resourceCloudscaleLoadBalancerCreate(d *schema.ResourceData, meta any) erro opts.VIPAddresses = &vipAddressRequests } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] LoadBalancer create configuration: %#v", opts) @@ -191,7 +191,7 @@ func gatherLoadBalancerResourceData(loadbalancer *cloudscale.LoadBalancer) Resou m["flavor_slug"] = loadbalancer.Flavor.Slug m["zone_slug"] = loadbalancer.Zone.Slug m["status"] = loadbalancer.Status - m["tags"] = TagsToRaw(loadbalancer.Tags) + m["tags"] = TagsToState(loadbalancer.Tags) if addrss := len(loadbalancer.VIPAddresses); addrss > 0 { vipAddressesMap := make([]map[string]any, 0, addrss) @@ -235,7 +235,7 @@ func gatherLoadBalancerUpdateRequest(d *schema.ResourceData) []*cloudscale.LoadB if attribute == "name" { opts.Name = d.Get(attribute).(string) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_load_balancer_health_monitor.go b/cloudscale/resource_cloudscale_load_balancer_health_monitor.go index c28e1c3d..6ea2ba89 100644 --- a/cloudscale/resource_cloudscale_load_balancer_health_monitor.go +++ b/cloudscale/resource_cloudscale_load_balancer_health_monitor.go @@ -159,7 +159,7 @@ func resourceCloudscaleLoadBalancerHealthMonitorCreate(d *schema.ResourceData, m opts.HTTP = &httpOpts } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] LoadBalancerHealthMonitor create configuration: %#v", opts) @@ -218,7 +218,7 @@ func gatherLoadBalancerHealthMonitorUpdateRequests(d *schema.ResourceData) []*cl } else if attribute == "down_threshold" { opts.DownThreshold = d.Get(attribute).(int) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } monitorType := d.Get("type").(string) @@ -268,7 +268,7 @@ func gatherLoadBalancerHealthMonitorResourceData(loadBalancerHealthMonitor *clou } else { m["http_expected_codes"] = nil } - m["tags"] = TagsToRaw(loadBalancerHealthMonitor.Tags) + m["tags"] = TagsToState(loadBalancerHealthMonitor.Tags) return m } diff --git a/cloudscale/resource_cloudscale_load_balancer_listener.go b/cloudscale/resource_cloudscale_load_balancer_listener.go index beeb911f..ec08fe76 100644 --- a/cloudscale/resource_cloudscale_load_balancer_listener.go +++ b/cloudscale/resource_cloudscale_load_balancer_listener.go @@ -128,7 +128,7 @@ func resourceCloudscaleLoadBalancerListenerCreate(d *schema.ResourceData, meta a } opts.AllowedCIDRs = &s - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] LoadBalancerListener create configuration: %#v", opts) @@ -167,7 +167,7 @@ func gatherLoadBalancerListenerResourceData(loadbalancerlistener *cloudscale.Loa m["timeout_member_connect_ms"] = loadbalancerlistener.TimeoutMemberConnectMS m["timeout_member_data_ms"] = loadbalancerlistener.TimeoutMemberDataMS m["allowed_cidrs"] = loadbalancerlistener.AllowedCIDRs - m["tags"] = TagsToRaw(loadbalancerlistener.Tags) + m["tags"] = TagsToState(loadbalancerlistener.Tags) return m } @@ -215,7 +215,7 @@ func gatherLoadBalancerListenerUpdateRequest(d *schema.ResourceData) []*cloudsca } opts.AllowedCIDRs = &s } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_load_balancer_pool.go b/cloudscale/resource_cloudscale_load_balancer_pool.go index 247eb06d..6a8e5b42 100644 --- a/cloudscale/resource_cloudscale_load_balancer_pool.go +++ b/cloudscale/resource_cloudscale_load_balancer_pool.go @@ -89,7 +89,7 @@ func resourceCloudscaleLoadBalancerPoolCreate(d *schema.ResourceData, meta any) Protocol: d.Get("protocol").(string), } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] LoadBalancerPool create configuration: %#v", opts) @@ -118,7 +118,7 @@ func gatherLoadBalancerPoolResourceData(loadbalancerpool *cloudscale.LoadBalance m["load_balancer_href"] = loadbalancerpool.LoadBalancer.HREF m["algorithm"] = loadbalancerpool.Algorithm m["protocol"] = loadbalancerpool.Protocol - m["tags"] = TagsToRaw(loadbalancerpool.Tags) + m["tags"] = TagsToState(loadbalancerpool.Tags) return m } @@ -144,7 +144,7 @@ func gatherLoadBalancerPoolUpdateRequest(d *schema.ResourceData) []*cloudscale.L if attribute == "name" { opts.Name = d.Get(attribute).(string) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_load_balancer_pool_member.go b/cloudscale/resource_cloudscale_load_balancer_pool_member.go index 2e75227f..d3d37172 100644 --- a/cloudscale/resource_cloudscale_load_balancer_pool_member.go +++ b/cloudscale/resource_cloudscale_load_balancer_pool_member.go @@ -160,7 +160,7 @@ func resourceCloudscaleLoadBalancerPoolMemberCreate(d *schema.ResourceData, meta val := attr.(bool) opts.Enabled = &val } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] LoadBalancerPoolMember create configuration: %#v", opts) @@ -196,7 +196,7 @@ func gatherLoadBalancerPoolMemberResourceData(loadbalancerPoolMember *cloudscale m["monitor_port"] = loadbalancerPoolMember.MonitorPort m["address"] = loadbalancerPoolMember.Address m["monitor_status"] = loadbalancerPoolMember.MonitorStatus - m["tags"] = TagsToRaw(loadbalancerPoolMember.Tags) + m["tags"] = TagsToState(loadbalancerPoolMember.Tags) return m } @@ -225,7 +225,7 @@ func gatherLoadBalancerPoolMemberUpdateRequest(d *schema.ResourceData) []*clouds v := d.Get(attribute).(bool) opts.Enabled = &v } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_network.go b/cloudscale/resource_cloudscale_network.go index 00d17bf9..d8323bfe 100644 --- a/cloudscale/resource_cloudscale_network.go +++ b/cloudscale/resource_cloudscale_network.go @@ -108,7 +108,7 @@ func resourceCloudscaleNetworkCreate(d *schema.ResourceData, meta any) error { val := attr.(bool) opts.AutoCreateIPV4Subnet = &val } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] Network create configuration: %#v", opts) @@ -144,7 +144,7 @@ func gatherNetworkResourceData(network *cloudscale.Network) ResourceDataRaw { subnets = append(subnets, g) } m["subnets"] = subnets - m["tags"] = TagsToRaw(network.Tags) + m["tags"] = TagsToState(network.Tags) return m } @@ -172,7 +172,7 @@ func gatherNetworkUpdateRequest(d *schema.ResourceData) []*cloudscale.NetworkUpd } else if attribute == "mtu" { opts.MTU = d.Get(attribute).(int) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_objects_user.go b/cloudscale/resource_cloudscale_objects_user.go index 6cec5003..e592f593 100644 --- a/cloudscale/resource_cloudscale_objects_user.go +++ b/cloudscale/resource_cloudscale_objects_user.go @@ -81,7 +81,7 @@ func resourceCloudscaleObjectsUserCreate(d *schema.ResourceData, meta any) error opts := &cloudscale.ObjectsUserRequest{ DisplayName: d.Get("display_name").(string), } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) objectsUser, err := client.ObjectsUsers.Create(context.Background(), opts) if err != nil { @@ -105,7 +105,7 @@ func gatherObjectsUserResourceData(objectsUser *cloudscale.ObjectsUser) Resource m["href"] = objectsUser.HREF m["user_id"] = objectsUser.ID m["display_name"] = objectsUser.DisplayName - m["tags"] = TagsToRaw(objectsUser.Tags) + m["tags"] = TagsToState(objectsUser.Tags) keys := make([]map[string]string, 0, len(objectsUser.Keys)) for _, keyEntry := range objectsUser.Keys { @@ -140,7 +140,7 @@ func gatherObjectsUserUpdateRequest(d *schema.ResourceData) []*cloudscale.Object if attribute == "display_name" { opts.DisplayName = d.Get(attribute).(string) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_server.go b/cloudscale/resource_cloudscale_server.go index 488d6800..c93b07e0 100644 --- a/cloudscale/resource_cloudscale_server.go +++ b/cloudscale/resource_cloudscale_server.go @@ -360,7 +360,7 @@ func resourceCloudscaleServerCreate(d *schema.ResourceData, meta any) error { if attr, ok := d.GetOk("status"); ok { originalStatus = attr.(string) } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] Server create configuration: %#v", opts) @@ -483,7 +483,7 @@ func gatherServerResourceData(server *cloudscale.Server) ResourceDataRaw { m["image_slug"] = server.Image.Slug m["zone_slug"] = server.Zone.Slug m["status"] = server.Status - m["tags"] = TagsToRaw(server.Tags) + m["tags"] = TagsToState(server.Tags) if volumes := len(server.Volumes); volumes > 0 { volumesMaps := make([]map[string]any, 0, volumes) @@ -665,7 +665,7 @@ func resourceCloudscaleServerUpdate(d *schema.ResourceData, meta any) error { if d.HasChange("tags") { updateRequest := &cloudscale.ServerUpdateRequest{} - updateRequest.Tags = CopyTags(d) + updateRequest.Tags = TagsFromState(d) err := client.Servers.Update(context.Background(), id, updateRequest) if err != nil { return fmt.Errorf("Error tagging the Server (%s) status (%s) ", id, err) diff --git a/cloudscale/resource_cloudscale_server_group.go b/cloudscale/resource_cloudscale_server_group.go index 372aa227..928b22db 100644 --- a/cloudscale/resource_cloudscale_server_group.go +++ b/cloudscale/resource_cloudscale_server_group.go @@ -76,7 +76,7 @@ func resourceCloudscaleServerGroupCreate(d *schema.ResourceData, meta any) error if attr, ok := d.GetOk("zone_slug"); ok { opts.Zone = attr.(string) } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] ServerGroup create configuration: %#v", opts) @@ -103,7 +103,7 @@ func gatherServerGroupResourceData(serverGroup *cloudscale.ServerGroup) Resource m["name"] = serverGroup.Name m["type"] = serverGroup.Type m["zone_slug"] = serverGroup.Zone.Slug - m["tags"] = TagsToRaw(serverGroup.Tags) + m["tags"] = TagsToState(serverGroup.Tags) return m } @@ -129,7 +129,7 @@ func gatherServerGroupUpdateRequest(d *schema.ResourceData) []*cloudscale.Server if attribute == "name" { opts.Name = d.Get(attribute).(string) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_subnet.go b/cloudscale/resource_cloudscale_subnet.go index 80e32def..142569a4 100644 --- a/cloudscale/resource_cloudscale_subnet.go +++ b/cloudscale/resource_cloudscale_subnet.go @@ -117,7 +117,7 @@ func resourceCloudscaleSubnetCreate(d *schema.ResourceData, meta any) error { } } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] Subnet create configuration: %#v", opts) @@ -148,7 +148,7 @@ func gatherSubnetResourceData(subnet *cloudscale.Subnet) ResourceDataRaw { m["network_name"] = subnet.Network.Name m["gateway_address"] = subnet.GatewayAddress m["dns_servers"] = subnet.DNSServers - m["tags"] = TagsToRaw(subnet.Tags) + m["tags"] = TagsToState(subnet.Tags) return m } @@ -190,7 +190,7 @@ func gatherSubnetUpdateRequests(d *schema.ResourceData) []*cloudscale.SubnetUpda } } } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_volume.go b/cloudscale/resource_cloudscale_volume.go index cfa02686..6e26f72f 100644 --- a/cloudscale/resource_cloudscale_volume.go +++ b/cloudscale/resource_cloudscale_volume.go @@ -108,7 +108,7 @@ func resourceCloudscaleVolumeCreate(d *schema.ResourceData, meta any) error { opts := &cloudscale.VolumeCreateRequest{ Name: d.Get("name").(string), } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) snapshotUUID, fromSnapshot := d.GetOk("volume_snapshot_uuid") if fromSnapshot { @@ -186,7 +186,7 @@ func gatherVolumeResourceData(volume *cloudscale.Volume) ResourceDataRaw { m["type"] = volume.Type m["zone_slug"] = volume.Zone.Slug m["server_uuids"] = volume.ServerUUIDs - m["tags"] = TagsToRaw(volume.Tags) + m["tags"] = TagsToState(volume.Tags) return m } @@ -222,7 +222,7 @@ func gatherVolumeUpdateRequests(d *schema.ResourceData) []*cloudscale.VolumeUpda } else if attribute == "size_gb" { opts.SizeGB = d.Get(attribute).(int) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/resource_cloudscale_volume_snapshot.go b/cloudscale/resource_cloudscale_volume_snapshot.go index 859fe3a9..a730545a 100644 --- a/cloudscale/resource_cloudscale_volume_snapshot.go +++ b/cloudscale/resource_cloudscale_volume_snapshot.go @@ -102,7 +102,7 @@ func resourceCloudscaleVolumeSnapshotCreate(d *schema.ResourceData, meta any) er Name: d.Get("name").(string), SourceVolume: sourceVolumeUUID, } - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) log.Printf("[DEBUG] VolumeSnapshot create configuration: %#v", opts) @@ -160,7 +160,7 @@ func gatherVolumeSnapshotResourceData(snap *cloudscale.VolumeSnapshot) ResourceD m["source_volume_href"] = snap.SourceVolume.HREF m["size_gb"] = snap.SizeGB m["status"] = snap.Status - m["tags"] = TagsToRaw(snap.Tags) + m["tags"] = TagsToState(snap.Tags) return m } @@ -186,7 +186,7 @@ func gatherVolumeSnapshotUpdateRequest(d *schema.ResourceData) []*cloudscale.Vol if attribute == "name" { opts.Name = d.Get(attribute).(string) } else if attribute == "tags" { - opts.Tags = CopyTags(d) + opts.Tags = TagsFromState(d) } } } diff --git a/cloudscale/util.go b/cloudscale/util.go index 6b582449..f2fb0a12 100644 --- a/cloudscale/util.go +++ b/cloudscale/util.go @@ -18,8 +18,8 @@ var ( } ) -// CopyTags converts Terraform state tags (map[string]interface{}) to the SDK type. -func CopyTags(d *schema.ResourceData) *cloudscale.TagMap { +// TagsFromState reads the "tags" attribute from Terraform state and converts it to the SDK type. +func TagsFromState(d *schema.ResourceData) *cloudscale.TagMap { newTags := make(cloudscale.TagMap) for k, v := range d.Get("tags").(map[string]any) { @@ -29,8 +29,8 @@ func CopyTags(d *schema.ResourceData) *cloudscale.TagMap { return &newTags } -// TagsToRaw is the inverse of CopyTags: converts SDK tags to Terraform's map type. -func TagsToRaw(tags cloudscale.TagMap) map[string]interface{} { +// TagsToState converts SDK tags to the map type used in Terraform state. +func TagsToState(tags cloudscale.TagMap) map[string]interface{} { result := make(map[string]interface{}, len(tags)) for k, v := range tags { result[k] = v From 50548a110d6e0daebb8a29f802c59ae1abe8cebd Mon Sep 17 00:00:00 2001 From: Alain Kaeslin Date: Fri, 26 Jun 2026 14:16:31 +0200 Subject: [PATCH 5/6] test: failing test for TypeList filter type mismatch --- cloudscale/datasources_test.go | 52 ++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/cloudscale/datasources_test.go b/cloudscale/datasources_test.go index febc7115..4dd7e3ff 100644 --- a/cloudscale/datasources_test.go +++ b/cloudscale/datasources_test.go @@ -9,8 +9,9 @@ import ( // testDSSchema is a minimal schema for dataSourceResourceRead unit tests. var testDSSchema = map[string]*schema.Schema{ - "name": {Type: schema.TypeString, Optional: true}, - "tags": &TagsSchema, + "name": {Type: schema.TypeString, Optional: true}, + "ssh_keys": {Type: schema.TypeList, Optional: true, Elem: &schema.Schema{Type: schema.TypeString}}, + "tags": &TagsSchema, } func mockFetch(rows ...ResourceDataRaw) func(*schema.ResourceData, any) ([]ResourceDataRaw, error) { @@ -205,3 +206,50 @@ func TestDataSourceRead_EmptyTagFilter(t *testing.T) { t.Errorf("unexpected error: %s", diags[0].Summary) } } + +func TestDataSourceRead_ListMatch(t *testing.T) { + // gather returns []string (from the SDK struct); d.GetOk returns []interface{}. + // The filter must normalise the types before comparing, otherwise reflect.DeepEqual + // returns false and the resource is silently excluded. + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "ssh_keys": []string{"key-a"}, "tags": map[string]interface{}{}}, + {"id": "bbb", "name": "beta", "ssh_keys": []string{"key-b"}, "tags": map[string]interface{}{}}, + } + filter := ResourceDataRaw{"ssh_keys": []interface{}{"key-a"}} + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if diags.HasError() { + t.Fatalf("unexpected error: %s", diags[0].Summary) + } + if resourceData.Id() != "aaa" { + t.Errorf("got id=%q, want aaa", resourceData.Id()) + } +} + +func TestDataSourceRead_ListNoMatch(t *testing.T) { + // list filter with a value that matches no resource returns a zero-match diagnostic + + // Arrange + rows := []ResourceDataRaw{ + {"id": "aaa", "name": "alpha", "ssh_keys": []string{"key-a"}, "tags": map[string]interface{}{}}, + } + filter := ResourceDataRaw{"ssh_keys": []interface{}{"key-z"}} + resourceData := schema.TestResourceDataRaw(t, testDSSchema, filter) + + // Act + diags := dataSourceResourceRead("things", testDSSchema, mockFetch(rows...))(context.Background(), resourceData, nil) + + // Assert + if !diags.HasError() { + t.Fatal("expected error, got none") + } + if diags[0].Summary != "Found zero things" { + t.Errorf("unexpected error: %s", diags[0].Summary) + } +} From 18ad9098b211f9f5173976faa2bae35bb8dd3500 Mon Sep 17 00:00:00 2001 From: Alain Kaeslin Date: Fri, 26 Jun 2026 14:17:21 +0200 Subject: [PATCH 6/6] fix: normalise TypeList/TypeSet values before filter comparison Gather functions return []string directly from the SDK struct, but d.GetOk on a schema.TypeList always returns []interface{}. The previous reflect.DeepEqual call compared incompatible dynamic types and always returned false, causing any TypeList filter to silently match nothing. Add a TypeList/TypeSet branch that converts []string to []interface{} via toInterfaceSlice before comparing. --- cloudscale/datasources.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cloudscale/datasources.go b/cloudscale/datasources.go index f3f97026..76de8113 100644 --- a/cloudscale/datasources.go +++ b/cloudscale/datasources.go @@ -48,6 +48,12 @@ func dataSourceResourceRead( break // one tag mismatch is sufficient } } + } else if schemaEntry.Type == schema.TypeList || schemaEntry.Type == schema.TypeSet { + // Gather functions return []string from the SDK struct; d.GetOk returns []interface{}. + // Normalise before comparing so reflect.DeepEqual sees the same dynamic type. + if !reflect.DeepEqual(toInterfaceSlice(m[key]), attr) { + match = false + } } else if !reflect.DeepEqual(m[key], attr) { match = false } @@ -73,6 +79,21 @@ func dataSourceResourceRead( } } +func toInterfaceSlice(v any) []interface{} { + switch s := v.(type) { + case []string: + result := make([]interface{}, len(s)) + for i, str := range s { + result[i] = str + } + return result + case []interface{}: + return s + default: + return nil + } +} + func getFetchFunc[TResource any]( listFunc func(d *schema.ResourceData, meta any) ([]TResource, error), gatherFunc func(resource *TResource) ResourceDataRaw,