Skip to content

Commit 7ce0b5f

Browse files
committed
feat(rdb): accept standalone IP addresses in private network configuration
1 parent 51063ed commit 7ce0b5f

File tree

7 files changed

+3805
-19
lines changed

7 files changed

+3805
-19
lines changed

internal/services/rdb/instance.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,14 @@ func ResourceInstance() *schema.Resource {
177177
Computed: true,
178178
Description: "The endpoint ID",
179179
},
180-
"ip_net": {
181-
Type: schema.TypeString,
182-
Optional: true,
183-
Computed: true,
184-
ValidateFunc: validation.IsCIDR,
185-
Description: "The IP with the given mask within the private subnet",
186-
},
180+
"ip_net": {
181+
Type: schema.TypeString,
182+
Optional: true,
183+
Computed: true,
184+
ValidateDiagFunc: verify.IsStandaloneIPorCIDR(),
185+
DiffSuppressFunc: dsf.DiffSuppressFuncStandaloneIPandCIDR,
186+
Description: "The IP with the given mask within the private subnet",
187+
},
187188
"ip": {
188189
Type: schema.TypeString,
189190
Computed: true,

internal/services/rdb/instance_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,6 +1704,157 @@ func TestAccInstance_EngineUpgrade(t *testing.T) {
17041704
})
17051705
}
17061706

1707+
func TestAccInstance_PrivateNetworkWithStandaloneIP(t *testing.T) {
1708+
tt := acctest.NewTestTools(t)
1709+
defer tt.Cleanup()
1710+
1711+
latestEngineVersion := rdbchecks.GetLatestEngineVersion(tt, postgreSQLEngineName)
1712+
1713+
resource.ParallelTest(t, resource.TestCase{
1714+
PreCheck: func() { acctest.PreCheck(t) },
1715+
ProtoV6ProviderFactories: tt.ProviderFactories,
1716+
CheckDestroy: resource.ComposeTestCheckFunc(
1717+
rdbchecks.IsInstanceDestroyed(tt),
1718+
vpcchecks.CheckPrivateNetworkDestroy(tt),
1719+
),
1720+
Steps: []resource.TestStep{
1721+
// Test with standalone IP address (without CIDR notation)
1722+
{
1723+
Config: fmt.Sprintf(`
1724+
resource "scaleway_vpc" "main" {
1725+
name = "test-rdb-standalone-ip"
1726+
}
1727+
1728+
resource "scaleway_vpc_private_network" "pn" {
1729+
name = "test-pn-standalone-ip"
1730+
vpc_id = scaleway_vpc.main.id
1731+
ipv4_subnet {
1732+
subnet = "10.213.254.0/24"
1733+
}
1734+
}
1735+
1736+
resource "scaleway_rdb_instance" "main" {
1737+
name = "test-rdb-standalone-ip"
1738+
node_type = "db-dev-s"
1739+
engine = %q
1740+
is_ha_cluster = false
1741+
disable_backup = true
1742+
user_name = "test_user"
1743+
password = "thiZ_is_v&ry_s3cret"
1744+
tags = ["terraform-test", "rdb-standalone-ip"]
1745+
volume_type = "sbs_5k"
1746+
volume_size_in_gb = 10
1747+
1748+
private_network {
1749+
ip_net = "10.213.254.4/28"
1750+
pn_id = scaleway_vpc_private_network.pn.id
1751+
port = 5432
1752+
}
1753+
}
1754+
`, latestEngineVersion),
1755+
Check: resource.ComposeTestCheckFunc(
1756+
vpcchecks.IsPrivateNetworkPresent(tt, "scaleway_vpc_private_network.pn"),
1757+
isInstancePresent(tt, "scaleway_rdb_instance.main"),
1758+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.#", "1"),
1759+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.enable_ipam", "false"),
1760+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.port", "5432"),
1761+
resource.TestCheckResourceAttrPair("scaleway_rdb_instance.main", "private_network.0.pn_id", "scaleway_vpc_private_network.pn", "id"),
1762+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.ip_net", "10.213.254.4/28"),
1763+
resource.TestCheckResourceAttrSet("scaleway_rdb_instance.main", "private_network.0.ip"),
1764+
),
1765+
},
1766+
// Test with explicit CIDR notation /28
1767+
{
1768+
Config: fmt.Sprintf(`
1769+
resource "scaleway_vpc" "main" {
1770+
name = "test-rdb-standalone-ip"
1771+
}
1772+
1773+
resource "scaleway_vpc_private_network" "pn" {
1774+
name = "test-pn-standalone-ip"
1775+
vpc_id = scaleway_vpc.main.id
1776+
ipv4_subnet {
1777+
subnet = "10.213.254.0/24"
1778+
}
1779+
}
1780+
1781+
resource "scaleway_rdb_instance" "main" {
1782+
name = "test-rdb-standalone-ip"
1783+
node_type = "db-dev-s"
1784+
engine = %q
1785+
is_ha_cluster = false
1786+
disable_backup = true
1787+
user_name = "test_user"
1788+
password = "thiZ_is_v&ry_s3cret"
1789+
tags = ["terraform-test", "rdb-standalone-ip"]
1790+
volume_type = "sbs_5k"
1791+
volume_size_in_gb = 10
1792+
1793+
private_network {
1794+
ip_net = "10.213.254.20/28"
1795+
pn_id = scaleway_vpc_private_network.pn.id
1796+
port = 5432
1797+
}
1798+
}
1799+
`, latestEngineVersion),
1800+
Check: resource.ComposeTestCheckFunc(
1801+
vpcchecks.IsPrivateNetworkPresent(tt, "scaleway_vpc_private_network.pn"),
1802+
isInstancePresent(tt, "scaleway_rdb_instance.main"),
1803+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.#", "1"),
1804+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.enable_ipam", "false"),
1805+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.port", "5432"),
1806+
resource.TestCheckResourceAttrPair("scaleway_rdb_instance.main", "private_network.0.pn_id", "scaleway_vpc_private_network.pn", "id"),
1807+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.ip_net", "10.213.254.20/28"),
1808+
resource.TestCheckResourceAttrSet("scaleway_rdb_instance.main", "private_network.0.ip"),
1809+
),
1810+
},
1811+
// Test update with a different CIDR
1812+
{
1813+
Config: fmt.Sprintf(`
1814+
resource "scaleway_vpc" "main" {
1815+
name = "test-rdb-standalone-ip"
1816+
}
1817+
1818+
resource "scaleway_vpc_private_network" "pn" {
1819+
name = "test-pn-standalone-ip"
1820+
vpc_id = scaleway_vpc.main.id
1821+
ipv4_subnet {
1822+
subnet = "10.213.254.0/24"
1823+
}
1824+
}
1825+
1826+
resource "scaleway_rdb_instance" "main" {
1827+
name = "test-rdb-standalone-ip"
1828+
node_type = "db-dev-s"
1829+
engine = %q
1830+
is_ha_cluster = false
1831+
disable_backup = true
1832+
user_name = "test_user"
1833+
password = "thiZ_is_v&ry_s3cret"
1834+
tags = ["terraform-test", "rdb-standalone-ip"]
1835+
volume_type = "sbs_5k"
1836+
volume_size_in_gb = 10
1837+
1838+
private_network {
1839+
ip_net = "10.213.254.36/28"
1840+
pn_id = scaleway_vpc_private_network.pn.id
1841+
port = 5432
1842+
}
1843+
}
1844+
`, latestEngineVersion),
1845+
Check: resource.ComposeTestCheckFunc(
1846+
vpcchecks.IsPrivateNetworkPresent(tt, "scaleway_vpc_private_network.pn"),
1847+
isInstancePresent(tt, "scaleway_rdb_instance.main"),
1848+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.#", "1"),
1849+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.enable_ipam", "false"),
1850+
resource.TestCheckResourceAttr("scaleway_rdb_instance.main", "private_network.0.ip_net", "10.213.254.36/28"),
1851+
resource.TestCheckResourceAttrSet("scaleway_rdb_instance.main", "private_network.0.ip"),
1852+
),
1853+
},
1854+
},
1855+
})
1856+
}
1857+
17071858
func isInstancePresent(tt *acctest.TestTools, n string) resource.TestCheckFunc {
17081859
return func(s *terraform.State) error {
17091860
rs, ok := s.RootModule().Resources[n]

internal/services/rdb/read_replica.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/hashicorp/terraform-plugin-log/tflog"
88
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
99
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
10-
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1110
"github.com/scaleway/scaleway-sdk-go/api/rdb/v1"
1211
"github.com/scaleway/scaleway-sdk-go/scw"
1312
"github.com/scaleway/terraform-provider-scaleway/v2/internal/cdf"
@@ -106,13 +105,14 @@ func ResourceReadReplica() *schema.Resource {
106105
DiffSuppressFunc: dsf.Locality,
107106
Required: true,
108107
},
109-
"service_ip": {
110-
Type: schema.TypeString,
111-
Description: "The IP network address within the private subnet",
112-
Optional: true,
113-
Computed: true,
114-
ValidateFunc: validation.IsCIDR,
115-
},
108+
"service_ip": {
109+
Type: schema.TypeString,
110+
Description: "The IP network address within the private subnet",
111+
Optional: true,
112+
Computed: true,
113+
ValidateDiagFunc: verify.IsStandaloneIPorCIDR(),
114+
DiffSuppressFunc: dsf.DiffSuppressFuncStandaloneIPandCIDR,
115+
},
116116
"enable_ipam": {
117117
Type: schema.TypeBool,
118118
Optional: true,

internal/services/rdb/testdata/instance-private-network-with-standalone-ip.cassette.yaml

Lines changed: 3488 additions & 0 deletions
Large diffs are not rendered by default.

internal/services/rdb/types.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,15 @@ func expandPrivateNetwork(data any, exist bool, ipamConfig *bool, staticConfig *
5353
}
5454

5555
if staticConfig != nil {
56-
ip, err := types.ExpandIPNet(*staticConfig)
56+
// Normalize IP to CIDR notation if needed (e.g., 10.0.0.1 -> 10.0.0.1/32)
57+
normalizedIP, err := types.NormalizeIPToCIDR(*staticConfig)
5758
if err != nil {
58-
return nil, append(diags, diag.FromErr(fmt.Errorf("failed to parse private_network ip_net (%s): %w", r["ip_net"], err))...)
59+
return nil, append(diags, diag.FromErr(fmt.Errorf("failed to normalize private_network ip_net (%s): %w", r["ip_net"], err))...)
60+
}
61+
62+
ip, err := types.ExpandIPNet(normalizedIP)
63+
if err != nil {
64+
return nil, append(diags, diag.FromErr(fmt.Errorf("failed to parse private_network ip_net (%s): %w", normalizedIP, err))...)
5965
}
6066

6167
spec.PrivateNetwork.ServiceIP = &ip
@@ -175,9 +181,15 @@ func expandReadReplicaEndpointsSpecPrivateNetwork(data any, ipamConfig *bool, st
175181
}
176182

177183
if staticConfig != nil {
178-
ipNet, err := types.ExpandIPNet(*staticConfig)
184+
// Normalize IP to CIDR notation if needed (e.g., 10.0.0.1 -> 10.0.0.1/32)
185+
normalizedIP, err := types.NormalizeIPToCIDR(*staticConfig)
186+
if err != nil {
187+
return nil, append(diags, diag.FromErr(fmt.Errorf("failed to normalize private_network service_ip (%s): %w", rawEndpoint["service_ip"], err))...)
188+
}
189+
190+
ipNet, err := types.ExpandIPNet(normalizedIP)
179191
if err != nil {
180-
return nil, append(diags, diag.FromErr(fmt.Errorf("failed to parse private_network service_ip (%s): %w", rawEndpoint["service_ip"], err))...)
192+
return nil, append(diags, diag.FromErr(fmt.Errorf("failed to parse private_network service_ip (%s): %w", normalizedIP, err))...)
181193
}
182194

183195
endpoint.PrivateNetwork.ServiceIP = &ipNet

internal/types/ip.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,32 @@ func FlattenIPNet(ipNet scw.IPNet) (string, error) {
4343

4444
return string(raw[1 : len(raw)-1]), nil // remove quotes
4545
}
46+
47+
// NormalizeIPToCIDR converts a standalone IP address to CIDR notation with default mask
48+
// If the input is already in CIDR notation, it returns it unchanged
49+
// IPv4 addresses get /32 mask, IPv6 addresses get /128 mask
50+
func NormalizeIPToCIDR(raw string) (string, error) {
51+
if raw == "" {
52+
return "", nil
53+
}
54+
55+
// Check if it's already a valid CIDR
56+
if _, _, err := net.ParseCIDR(raw); err == nil {
57+
return raw, nil
58+
}
59+
60+
// Try to parse as standalone IP
61+
ip := net.ParseIP(raw)
62+
if ip == nil {
63+
return "", fmt.Errorf("invalid IP address or CIDR notation: %s", raw)
64+
}
65+
66+
// Add default mask based on IP version
67+
if ip.To4() != nil {
68+
// IPv4 address
69+
return raw + "/32", nil
70+
}
71+
72+
// IPv6 address
73+
return raw + "/128", nil
74+
}

internal/types/ip_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package types_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/types"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestNormalizeIPToCIDR(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
input string
15+
expected string
16+
expectError bool
17+
}{
18+
{
19+
name: "IPv4 address without mask",
20+
input: "10.213.254.3",
21+
expected: "10.213.254.3/32",
22+
expectError: false,
23+
},
24+
{
25+
name: "IPv4 address with /32 mask",
26+
input: "10.213.254.3/32",
27+
expected: "10.213.254.3/32",
28+
expectError: false,
29+
},
30+
{
31+
name: "IPv4 address with /24 mask",
32+
input: "192.168.1.0/24",
33+
expected: "192.168.1.0/24",
34+
expectError: false,
35+
},
36+
{
37+
name: "IPv4 address with /16 mask",
38+
input: "10.0.0.0/16",
39+
expected: "10.0.0.0/16",
40+
expectError: false,
41+
},
42+
{
43+
name: "IPv6 address without mask",
44+
input: "2001:db8::1",
45+
expected: "2001:db8::1/128",
46+
expectError: false,
47+
},
48+
{
49+
name: "IPv6 address with /128 mask",
50+
input: "2001:db8::1/128",
51+
expected: "2001:db8::1/128",
52+
expectError: false,
53+
},
54+
{
55+
name: "IPv6 address with /64 mask",
56+
input: "2001:db8::/64",
57+
expected: "2001:db8::/64",
58+
expectError: false,
59+
},
60+
{
61+
name: "empty string",
62+
input: "",
63+
expected: "",
64+
expectError: false,
65+
},
66+
{
67+
name: "invalid IP",
68+
input: "not-an-ip",
69+
expected: "",
70+
expectError: true,
71+
},
72+
{
73+
name: "invalid CIDR",
74+
input: "10.0.0.1/33",
75+
expected: "",
76+
expectError: true,
77+
},
78+
{
79+
name: "localhost IPv4",
80+
input: "127.0.0.1",
81+
expected: "127.0.0.1/32",
82+
expectError: false,
83+
},
84+
{
85+
name: "localhost IPv6",
86+
input: "::1",
87+
expected: "::1/128",
88+
expectError: false,
89+
},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
result, err := types.NormalizeIPToCIDR(tt.input)
95+
96+
if tt.expectError {
97+
require.Error(t, err)
98+
} else {
99+
require.NoError(t, err)
100+
assert.Equal(t, tt.expected, result)
101+
}
102+
})
103+
}
104+
}
105+

0 commit comments

Comments
 (0)