From 58ff2ff240d6c07b1a6c347dd23991e54ab66cc7 Mon Sep 17 00:00:00 2001
From: MacherelR <64424331+MacherelR@users.noreply.github.com>
Date: Fri, 30 May 2025 14:06:08 +0200
Subject: [PATCH 1/2] feat(sdn)!: add SDN support for zones, vnets, subnets
with validation and tests
BREAKING CHANGE: introduces sdn support.
Signed-off-by: MacherelR <64424331+MacherelR@users.noreply.github.com>
---
.../virtual_environment_sdn_subnet.md | 41 +++
.../virtual_environment_sdn_vnet.md | 32 ++
.../virtual_environment_sdn_zone.md | 45 +++
.../virtual_environment_sdn_subnet.md | 44 +++
.../resources/virtual_environment_sdn_vnet.md | 35 ++
.../resources/virtual_environment_sdn_zone.md | 60 ++++
.../resource_virtual_environment_container.tf | 6 +-
...ource_virtual_environment_download_file.tf | 4 +-
example/resource_virtual_environment_sdn.tf | 108 ++++++
example/variables.tf | 12 +
examples/guides/clone-vm/clone.tf | 2 +-
.../cluster/sdn/datasource_sdn_subnets.go | 137 +++++++
.../cluster/sdn/datasource_sdn_vnets.go | 119 ++++++
.../cluster/sdn/datasource_sdn_zones.go | 98 +++++
.../cluster/sdn/resource_sdn_subnets.go | 340 ++++++++++++++++++
fwprovider/cluster/sdn/resource_sdn_vnets.go | 313 ++++++++++++++++
fwprovider/cluster/sdn/resource_sdn_zones.go | 315 ++++++++++++++++
fwprovider/cluster/sdn/sdn_subnet_model.go | 89 +++++
fwprovider/cluster/sdn/sdn_vnet_model.go | 53 +++
fwprovider/cluster/sdn/sdn_zone_model.go | 89 +++++
.../helpers/ptrConversion/ptr_conversion.go | 33 ++
fwprovider/provider.go | 7 +
fwprovider/test/datasource_sdn_subnet_test.go | 64 ++++
fwprovider/test/datasource_sdn_vnet_test.go | 54 +++
fwprovider/test/datasource_sdn_zone_test.go | 54 +++
fwprovider/test/resource_sdn_test.go | 157 ++++++++
fwprovider/test/test_environment.go | 12 +
proxmox/cluster/client.go | 18 +
proxmox/cluster/sdn/sdn_test.go | 196 ++++++++++
proxmox/cluster/sdn/subnets/api.go | 13 +
proxmox/cluster/sdn/subnets/client.go | 17 +
proxmox/cluster/sdn/subnets/subnets.go | 71 ++++
proxmox/cluster/sdn/subnets/subnets_types.go | 87 +++++
proxmox/cluster/sdn/vnets/api.go | 16 +
proxmox/cluster/sdn/vnets/client.go | 21 ++
proxmox/cluster/sdn/vnets/vnets.go | 82 +++++
proxmox/cluster/sdn/vnets/vnets_types.go | 49 +++
proxmox/cluster/sdn/zones/api.go | 13 +
proxmox/cluster/sdn/zones/client.go | 17 +
proxmox/cluster/sdn/zones/zones.go | 75 ++++
proxmox/cluster/sdn/zones/zones_types.go | 57 +++
proxmox/helpers/ptr/ptr.go | 22 ++
proxmoxtf/resource/cluster/sdn/subnets.go | 34 ++
43 files changed, 3105 insertions(+), 6 deletions(-)
create mode 100644 docs/data-sources/virtual_environment_sdn_subnet.md
create mode 100644 docs/data-sources/virtual_environment_sdn_vnet.md
create mode 100644 docs/data-sources/virtual_environment_sdn_zone.md
create mode 100644 docs/resources/virtual_environment_sdn_subnet.md
create mode 100644 docs/resources/virtual_environment_sdn_vnet.md
create mode 100644 docs/resources/virtual_environment_sdn_zone.md
create mode 100644 example/resource_virtual_environment_sdn.tf
create mode 100644 fwprovider/cluster/sdn/datasource_sdn_subnets.go
create mode 100644 fwprovider/cluster/sdn/datasource_sdn_vnets.go
create mode 100644 fwprovider/cluster/sdn/datasource_sdn_zones.go
create mode 100644 fwprovider/cluster/sdn/resource_sdn_subnets.go
create mode 100644 fwprovider/cluster/sdn/resource_sdn_vnets.go
create mode 100644 fwprovider/cluster/sdn/resource_sdn_zones.go
create mode 100644 fwprovider/cluster/sdn/sdn_subnet_model.go
create mode 100644 fwprovider/cluster/sdn/sdn_vnet_model.go
create mode 100644 fwprovider/cluster/sdn/sdn_zone_model.go
create mode 100644 fwprovider/helpers/ptrConversion/ptr_conversion.go
create mode 100644 fwprovider/test/datasource_sdn_subnet_test.go
create mode 100644 fwprovider/test/datasource_sdn_vnet_test.go
create mode 100644 fwprovider/test/datasource_sdn_zone_test.go
create mode 100644 fwprovider/test/resource_sdn_test.go
create mode 100644 proxmox/cluster/sdn/sdn_test.go
create mode 100644 proxmox/cluster/sdn/subnets/api.go
create mode 100644 proxmox/cluster/sdn/subnets/client.go
create mode 100644 proxmox/cluster/sdn/subnets/subnets.go
create mode 100644 proxmox/cluster/sdn/subnets/subnets_types.go
create mode 100644 proxmox/cluster/sdn/vnets/api.go
create mode 100644 proxmox/cluster/sdn/vnets/client.go
create mode 100644 proxmox/cluster/sdn/vnets/vnets.go
create mode 100644 proxmox/cluster/sdn/vnets/vnets_types.go
create mode 100644 proxmox/cluster/sdn/zones/api.go
create mode 100644 proxmox/cluster/sdn/zones/client.go
create mode 100644 proxmox/cluster/sdn/zones/zones.go
create mode 100644 proxmox/cluster/sdn/zones/zones_types.go
create mode 100644 proxmoxtf/resource/cluster/sdn/subnets.go
diff --git a/docs/data-sources/virtual_environment_sdn_subnet.md b/docs/data-sources/virtual_environment_sdn_subnet.md
new file mode 100644
index 00000000..f66e241a
--- /dev/null
+++ b/docs/data-sources/virtual_environment_sdn_subnet.md
@@ -0,0 +1,41 @@
+---
+layout: page
+title: proxmox_virtual_environment_sdn_subnet
+parent: Data Sources
+subcategory: Virtual Environment
+description: |-
+ Retrieve details about a specific SDN Subnet in Proxmox VE.
+---
+
+# Data Source: proxmox_virtual_environment_sdn_subnet
+
+Retrieve details about a specific SDN Subnet in Proxmox VE.
+
+
+
+
+## Schema
+
+### Required
+
+- `subnet` (String)
+- `vnet` (String) The VNet this subnet belongs to.
+
+### Read-Only
+
+- `canonical_name` (String)
+- `dhcp_dns_server` (String) The DNS server used for DHCP.
+- `dhcp_range` (Attributes List) List of DHCP ranges (start and end IPs). (see [below for nested schema](#nestedatt--dhcp_range))
+- `dnszoneprefix` (String) Prefix used for DNS zone delegation.
+- `gateway` (String) The gateway address for the subnet.
+- `id` (String) The full ID in the format 'vnet-id/subnet-id'.
+- `snat` (Boolean) Whether SNAT is enabled for the subnet.
+- `type` (String)
+
+
+### Nested Schema for `dhcp_range`
+
+Read-Only:
+
+- `end_address` (String) End of the DHCP range.
+- `start_address` (String) Start of the DHCP range.
diff --git a/docs/data-sources/virtual_environment_sdn_vnet.md b/docs/data-sources/virtual_environment_sdn_vnet.md
new file mode 100644
index 00000000..af09546d
--- /dev/null
+++ b/docs/data-sources/virtual_environment_sdn_vnet.md
@@ -0,0 +1,32 @@
+---
+layout: page
+title: proxmox_virtual_environment_sdn_vnet
+parent: Data Sources
+subcategory: Virtual Environment
+description: |-
+ Retrieves information about an existing SDN Vnet in Proxmox VE.
+---
+
+# Data Source: proxmox_virtual_environment_sdn_vnet
+
+Retrieves information about an existing SDN Vnet in Proxmox VE.
+
+
+
+
+## Schema
+
+### Required
+
+- `name` (String) The name of the vnet.
+
+### Read-Only
+
+- `alias` (String) - An alias for this vnet.
+- `id` (String) - The ID of the vnet (usually the name).
+- `isolate_ports` (Boolean) - Whether ports are isolated.
+- `tag` (Number) - VLAN/VXLAN tag.
+- `type` (String) - Type of the vnet.
+- `vlanaware` (Boolean) - Whether this vnet is VLAN aware.
+- `zone` (String) - The zone associated with the vnet.
+- `zonetype` (String) - The type of the zone associated with this vnet.
diff --git a/docs/data-sources/virtual_environment_sdn_zone.md b/docs/data-sources/virtual_environment_sdn_zone.md
new file mode 100644
index 00000000..0c782485
--- /dev/null
+++ b/docs/data-sources/virtual_environment_sdn_zone.md
@@ -0,0 +1,45 @@
+---
+layout: page
+title: proxmox_virtual_environment_sdn_zone
+parent: Data Sources
+subcategory: Virtual Environment
+description: |-
+ Fetch a Proxmox SDN Zone by name.
+---
+
+# Data Source: proxmox_virtual_environment_sdn_zone
+
+
+This data source allows you to fetch information about an existing SDN zone in a Proxmox Virtual Environment (PVE) cluster by its name.
+
+
+
+
+## Schema
+
+### Required
+
+- `name` (String) Name (ID) of the SDN zone.
+
+### Read-Only
+
+- `advertise_subnets` (Boolean) - Whether to advertise subnets to the zone.
+- `bridge` (String) – Linux bridge device used (if applicable).
+- `controller` (String) – Controller for EVPN zones.
+- `disable_arp_nd_suppression` (Boolean) – Whether ARP/ND suppression is disabled.
+- `dns` (String) – DNS server configured for the zone.
+- `dns_zone` (String) – The DNS zone name used by this SDN zone.
+- `exit_nodes` (String) – Nodes designated as exit points.
+- `exit_nodes_local_routing` (Boolean) – Whether local routing is enabled for exit nodes.
+- `id` (String) - The ID of the SDN zone.
+- `ipam` (String) – The IP Address Management (IPAM) method used in the zone.
+- `mtu` (Number) – Maximum Transmission Unit for this zone.
+- `nodes` (String) – Comma-separated list of node names associated with the zone.
+- `peers` (String) – Peers used for some zone types only.
+- `primary_exit_node` (String) – The main exit node.
+- `reversedns` (String) – Reverse DNS server for the zone.
+- `rt_import` (String) – Route targets to import.
+- `tag` (Number) – VLAN tag or other numeric identifier.
+- `type` (String) – The SDN zone type (e.g., `simple`, `vlan`, `vxlan`, `evpn`).
+- `vlan_protocol` (String) – VLAN protocol used.
+- `vrf_vxlan` (Number) – VXLAN ID associated with VRF zones.
diff --git a/docs/resources/virtual_environment_sdn_subnet.md b/docs/resources/virtual_environment_sdn_subnet.md
new file mode 100644
index 00000000..0d14c016
--- /dev/null
+++ b/docs/resources/virtual_environment_sdn_subnet.md
@@ -0,0 +1,44 @@
+---
+layout: page
+title: proxmox_virtual_environment_sdn_subnet
+parent: Resources
+subcategory: Virtual Environment
+description: |-
+ Manages SDN Subnets in Proxmox VE.
+---
+
+# Resource: proxmox_virtual_environment_sdn_subnet
+
+Manages SDN Subnets in Proxmox VE.
+
+
+
+
+## Schema
+
+### Required
+
+- `subnet` (String) The name/ID of the subnet.
+- `vnet` (String) The VNet to which this subnet belongs.
+
+### Optional
+
+- `dhcp_dns_server` (String) The DNS server used for DHCP.
+- `dhcp_range` (Attributes List) List of DHCP ranges (start and end IPs). (see [below for nested schema](#nestedatt--dhcp_range))
+- `dnszoneprefix` (String) Prefix used for DNS zone delegation.
+- `gateway` (String) The gateway address for the subnet.
+- `snat` (Boolean) Whether SNAT is enabled for the subnet.
+
+### Read-Only
+
+- `canonical_name` (String) Canonical name of the subnet (e.g. zoneM-10.10.0.0-24).
+- `id` (String) The unique identifier of this resource.
+- `type` (String) Subnet type (set default at 'subnet')
+
+
+### Nested Schema for `dhcp_range`
+
+Required:
+
+- `end_address` (String) End of the DHCP range.
+- `start_address` (String) Start of the DHCP range.
diff --git a/docs/resources/virtual_environment_sdn_vnet.md b/docs/resources/virtual_environment_sdn_vnet.md
new file mode 100644
index 00000000..6698a48d
--- /dev/null
+++ b/docs/resources/virtual_environment_sdn_vnet.md
@@ -0,0 +1,35 @@
+---
+layout: page
+title: proxmox_virtual_environment_sdn_vnet
+parent: Resources
+subcategory: Virtual Environment
+description: |-
+ Manages Proxmox VE SDN vnet.
+---
+
+# Resource: proxmox_virtual_environment_sdn_vnet
+
+Manages Proxmox VE SDN vnet.
+
+
+
+
+## Schema
+
+### Required
+
+- `name` (String) Unique identifier for the vnet.
+- `zone` (String) The zone to which this vnet belongs.
+- `zonetype` (String) Parent's zone type. MUST be specified.
+
+### Optional
+
+- `alias` (String) An optional alias for this vnet.
+- `isolate_ports` (Boolean) Whether to isolate ports within this vnet.
+- `tag` (Number) Tag value for VLAN/VXLAN (depends on zone type).
+- `vlanaware` (Boolean) Whether this vnet is VLAN aware.
+
+### Read-Only
+
+- `id` (String) The unique identifier of this resource.
+- `type` (String) Type of vnet (e.g. 'vnet').
diff --git a/docs/resources/virtual_environment_sdn_zone.md b/docs/resources/virtual_environment_sdn_zone.md
new file mode 100644
index 00000000..5501ad07
--- /dev/null
+++ b/docs/resources/virtual_environment_sdn_zone.md
@@ -0,0 +1,60 @@
+---
+layout: page
+title: proxmox_virtual_environment_sdn_zone
+parent: Resources
+subcategory: Virtual Environment
+description: |-
+ Manages SDN Zones in Proxmox VE.
+---
+
+# Resource: proxmox_virtual_environment_sdn_zone
+
+Manages SDN Zones in Proxmox VE.
+Some attributes in the `proxmox_virtual_environment_sdn_zone` resource or data source are only applicable to certain zone types. For example:
+
+ `bridge` is relevant only for `vlan` zones.
+
+ `peers`, `controller`, `vrf_vxlan`, and related attributes are specific to `vxlan` and `evpn` zone types.
+
+ `service_vlan` and `vlan_protocol` apply to `qinq` zones.
+
+While the Proxmox API does not explicitly document these constraints, they are enforced by the Proxmox backend and have been validated manually through API experimentation.
+
+The Terraform provider implements field-level validation to ensure that only compatible attributes are used with each zone type. If incompatible attributes are set, Terraform will raise a configuration error during plan or apply to prevent invalid requests to the Proxmox API.
+
+This design helps ensure correctness and avoids unexpected API failures when managing SDN zones across different zone types.
+
+
+
+
+## Schema
+
+### Required
+
+- `name` (String) The unique ID of the SDN zone.
+- `type` (String) Zone type (e.g. simple, vlan, qinq, vxlan, evpn).
+
+### Optional
+
+- `advertise_subnets` (Boolean) Enable subnet advertisement for EVPN.
+- `bridge` (String) Bridge interface for VLAN/QinQ.
+- `controller` (String) EVPN controller address.
+- `disable_arp_nd_suppression` (Boolean) Disable ARP/ND suppression for EVPN.
+- `dns` (String) DNS server address.
+- `dns_zone` (String) DNS zone name.
+- `exit_nodes` (String) Comma-separated list of exit nodes for EVPN.
+- `exit_nodes_local_routing` (Boolean) Enable local routing for EVPN exit nodes.
+- `ipam` (String) IP Address Management system.
+- `mtu` (Number) MTU value for the zone.
+- `nodes` (String) Comma-separated list of Proxmox node names.
+- `peers` (String) Peers list for VXLAN.
+- `primary_exit_node` (String) Primary exit node for EVPN.
+- `reversedns` (String) Reverse DNS settings.
+- `rt_import` (String) Route target import for EVPN.
+- `tag` (Number) Service VLAN tag for QinQ.
+- `vlan_protocol` (String) Service VLAN protocol for QinQ.
+- `vrf_vxlan` (Number) EVPN VRF VXLAN ID.
+
+### Read-Only
+
+- `id` (String) The unique identifier of this resource.
diff --git a/example/resource_virtual_environment_container.tf b/example/resource_virtual_environment_container.tf
index 705d79e8..bc53f49e 100644
--- a/example/resource_virtual_environment_container.tf
+++ b/example/resource_virtual_environment_container.tf
@@ -4,13 +4,13 @@ resource "proxmox_virtual_environment_container" "example_template" {
start_on_boot = "true"
disk {
- datastore_id = "local-lvm"
+ datastore_id = var.virtual_environment_storage
size = 4
}
mount_point {
// volume mount
- volume = "local-lvm"
+ volume = var.virtual_environment_storage
size = "4G"
path = "mnt/local"
}
@@ -66,7 +66,7 @@ resource "proxmox_virtual_environment_container" "example_template" {
resource "proxmox_virtual_environment_container" "example" {
disk {
- datastore_id = "local-lvm"
+ datastore_id = var.virtual_environment_storage
}
clone {
diff --git a/example/resource_virtual_environment_download_file.tf b/example/resource_virtual_environment_download_file.tf
index 53bc1ef9..ecb817c9 100644
--- a/example/resource_virtual_environment_download_file.tf
+++ b/example/resource_virtual_environment_download_file.tf
@@ -3,7 +3,7 @@
resource "proxmox_virtual_environment_download_file" "release_20240725_ubuntu_24_noble_lxc_img" {
content_type = "vztmpl"
datastore_id = "local"
- node_name = "pve"
+ node_name = var.virtual_environment_node_name
url = var.release_20240725_ubuntu_24_noble_lxc_img_url
checksum = var.release_20240725_ubuntu_24_noble_lxc_img_checksum
checksum_algorithm = "sha256"
@@ -15,7 +15,7 @@ resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_
content_type = "iso"
datastore_id = "local"
file_name = "debian-12-generic-amd64.img"
- node_name = "pve"
+ node_name = var.virtual_environment_node_name
url = var.latest_debian_12_bookworm_qcow2_img_url
overwrite = true
overwrite_unmanaged = true
diff --git a/example/resource_virtual_environment_sdn.tf b/example/resource_virtual_environment_sdn.tf
new file mode 100644
index 00000000..e381bf4e
--- /dev/null
+++ b/example/resource_virtual_environment_sdn.tf
@@ -0,0 +1,108 @@
+# --- SDN Zones ---
+
+resource "proxmox_virtual_environment_sdn_zone" "zone_simple" {
+ name = "zoneS"
+ type = "simple"
+ nodes = var.virtual_environment_node_name
+ mtu = 1496
+}
+
+resource "proxmox_virtual_environment_sdn_zone" "zone_vlan" {
+ name = "zoneVLAN"
+ type = "vlan"
+ nodes = var.virtual_environment_node_name
+ mtu = 1500
+ bridge = "vmbr0"
+}
+
+# --- SDN Vnets ---
+
+resource "proxmox_virtual_environment_sdn_vnet" "vnet_simple" {
+ name = "vnetM"
+ zone = proxmox_virtual_environment_sdn_zone.zone_simple.name
+ alias = "vnet in zoneM"
+ isolate_ports = "0"
+ vlanaware = "0"
+ zonetype = proxmox_virtual_environment_sdn_zone.zone_simple.type
+}
+
+resource "proxmox_virtual_environment_sdn_vnet" "vnet_vlan" {
+ name = "vnetVLAN"
+ zone = proxmox_virtual_environment_sdn_zone.zone_vlan.name
+ alias = "vnet in zoneVLAN"
+ tag = 1000
+ zonetype = proxmox_virtual_environment_sdn_zone.zone_vlan.type
+}
+
+# --- SDN Subnets ---
+
+resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple" {
+ subnet = "10.10.0.0/24"
+ vnet = proxmox_virtual_environment_sdn_vnet.vnet_simple.name
+ dhcp_dns_server = "10.10.0.53"
+ dhcp_range = [
+ {
+ start_address = "10.10.0.10"
+ end_address = "10.10.0.100"
+ }
+ ]
+ gateway = "10.10.0.1"
+ snat = true
+}
+
+resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple2" {
+ subnet = "10.40.0.0/24"
+ vnet = proxmox_virtual_environment_sdn_vnet.vnet_simple.name
+ dhcp_dns_server = "10.40.0.53"
+ dhcp_range = [
+ {
+ start_address = "10.40.0.10"
+ end_address = "10.40.0.100"
+ }
+ ]
+ gateway = "10.40.0.1"
+ snat = true
+}
+
+resource "proxmox_virtual_environment_sdn_subnet" "subnet_vlan" {
+ subnet = "10.20.0.0/24"
+ vnet = proxmox_virtual_environment_sdn_vnet.vnet_vlan.name
+ dhcp_dns_server = "10.20.0.53"
+ dhcp_range = [
+ {
+ start_address = "10.20.0.10"
+ end_address = "10.20.0.100"
+ }
+ ]
+ gateway = "10.20.0.100"
+ snat = false
+}
+
+# --- Data Sources ---
+
+data "proxmox_virtual_environment_sdn_zone" "zone_ex" {
+ name = "ZoneEx"
+}
+
+data "proxmox_virtual_environment_sdn_vnet" "vnet_ex" {
+ name = "VnetEx"
+}
+
+data "proxmox_virtual_environment_sdn_subnet" "subnet_ex" {
+ subnet = "ZoneEx-100.100.0.0-24"
+ vnet = data.proxmox_virtual_environment_sdn_vnet.vnet_ex.id
+}
+
+# --- Outputs ---
+
+output "sdn_zone" {
+ value = data.proxmox_virtual_environment_sdn_zone.zone_ex
+}
+
+output "sdn_vnet" {
+ value = data.proxmox_virtual_environment_sdn_vnet.vnet_ex
+}
+
+output "sdn_subnet" {
+ value = data.proxmox_virtual_environment_sdn_subnet.subnet_ex
+}
diff --git a/example/variables.tf b/example/variables.tf
index eb47415c..71a6911f 100644
--- a/example/variables.tf
+++ b/example/variables.tf
@@ -13,6 +13,18 @@ variable "virtual_environment_ssh_username" {
description = "The username for the Proxmox Virtual Environment API"
}
+variable "virtual_environment_node_name" {
+ description = "Name of the Proxmox node"
+ type = string
+ default = "pve"
+}
+
+variable "virtual_environment_storage" {
+ description = "Name of the Proxmox storage"
+ type = string
+ default = "local-lvm"
+}
+
variable "latest_debian_12_bookworm_qcow2_img_url" {
type = string
description = "The URL for the latest Debian 12 Bookworm qcow2 image"
diff --git a/examples/guides/clone-vm/clone.tf b/examples/guides/clone-vm/clone.tf
index e881eb20..4f3f14af 100644
--- a/examples/guides/clone-vm/clone.tf
+++ b/examples/guides/clone-vm/clone.tf
@@ -1,6 +1,6 @@
resource "proxmox_virtual_environment_vm" "ubuntu_clone" {
name = "ubuntu-clone"
- node_name = "pve"
+ node_name = var.virtual_environment_node_name
clone {
vm_id = proxmox_virtual_environment_vm.ubuntu_template.id
diff --git a/fwprovider/cluster/sdn/datasource_sdn_subnets.go b/fwprovider/cluster/sdn/datasource_sdn_subnets.go
new file mode 100644
index 00000000..8602f6d3
--- /dev/null
+++ b/fwprovider/cluster/sdn/datasource_sdn_subnets.go
@@ -0,0 +1,137 @@
+package sdn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/subnets"
+)
+
+var (
+ _ datasource.DataSource = &sdnSubnetDataSource{}
+ _ datasource.DataSourceWithConfigure = &sdnSubnetDataSource{}
+)
+
+type sdnSubnetDataSource struct {
+ client *subnets.Client
+}
+
+func NewSDNSubnetDataSource() datasource.DataSource {
+ return &sdnSubnetDataSource{}
+}
+
+func (d *sdnSubnetDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_sdn_subnet"
+}
+
+func (d *sdnSubnetDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ cfg, ok := req.ProviderData.(config.DataSource)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Provider Configuration",
+ fmt.Sprintf("Expected config.DataSource, got: %T", req.ProviderData),
+ )
+ return
+ }
+
+ d.client = cfg.Client.Cluster().SDNSubnets()
+}
+
+func (d *sdnSubnetDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: "Retrieve details about a specific SDN Subnet in Proxmox VE.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ Description: "The full ID in the format 'vnet-id/subnet-id'.",
+ },
+ "subnet": schema.StringAttribute{
+ Required: true,
+ },
+ "canonical_name": schema.StringAttribute{
+ Computed: true,
+ },
+ "type": schema.StringAttribute{
+ Computed: true,
+ },
+ "vnet": schema.StringAttribute{
+ Required: true,
+ Description: "The VNet this subnet belongs to.",
+ },
+ "dhcp_dns_server": schema.StringAttribute{
+ Computed: true,
+ Description: "The DNS server used for DHCP.",
+ },
+ "dhcp_range": schema.ListNestedAttribute{
+ Optional: false,
+ Computed: true,
+ Description: "List of DHCP ranges (start and end IPs).",
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "start_address": schema.StringAttribute{
+ Computed: true,
+ Description: "Start of the DHCP range.",
+ },
+ "end_address": schema.StringAttribute{
+ Computed: true,
+ Description: "End of the DHCP range.",
+ },
+ },
+ },
+ },
+ "dnszoneprefix": schema.StringAttribute{
+ Computed: true,
+ Description: "Prefix used for DNS zone delegation.",
+ },
+ "gateway": schema.StringAttribute{
+ Computed: true,
+ Description: "The gateway address for the subnet.",
+ },
+ "snat": schema.BoolAttribute{
+ Computed: true,
+ Description: "Whether SNAT is enabled for the subnet.",
+ },
+ },
+ }
+}
+
+func (d *sdnSubnetDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config sdnSubnetModel
+
+ resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ subnet, err := d.client.GetSubnet(ctx, config.Vnet.ValueString(), config.Subnet.ValueString())
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Subnet not found", err.Error())
+ return
+ }
+ resp.Diagnostics.AddError("Failed to retrieve subnet", err.Error())
+ return
+ }
+
+ // Set the state
+ state := &sdnSubnetModel{}
+ state.Subnet = config.Subnet
+ state.Vnet = config.Vnet
+ state.importFromAPI(config.Subnet.ValueString(), subnet)
+
+ // Set canonical name and ID (both = user-supplied subnet)
+ state.ID = config.Subnet
+ state.CanonicalName = config.Subnet
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
diff --git a/fwprovider/cluster/sdn/datasource_sdn_vnets.go b/fwprovider/cluster/sdn/datasource_sdn_vnets.go
new file mode 100644
index 00000000..68d49116
--- /dev/null
+++ b/fwprovider/cluster/sdn/datasource_sdn_vnets.go
@@ -0,0 +1,119 @@
+package sdn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/vnets"
+)
+
+var (
+ _ datasource.DataSource = &sdnVnetDataSource{}
+ _ datasource.DataSourceWithConfigure = &sdnVnetDataSource{}
+)
+
+type sdnVnetDataSource struct {
+ client *vnets.Client
+}
+
+func NewSDNVnetDataSource() datasource.DataSource {
+ return &sdnVnetDataSource{}
+}
+
+func (d *sdnVnetDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_sdn_vnet"
+}
+
+func (d *sdnVnetDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ cfg, ok := req.ProviderData.(config.DataSource)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Provider Data",
+ fmt.Sprintf("Expected config.DataSource, got: %T", req.ProviderData),
+ )
+ return
+ }
+
+ d.client = cfg.Client.Cluster().SDNVnets()
+}
+
+func (d *sdnVnetDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: "Retrieves information about an existing SDN Vnet in Proxmox VE.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: "The ID of the vnet (usually the name).",
+ Computed: true,
+ },
+ "name": schema.StringAttribute{
+ Required: true,
+ Description: "The name of the vnet.",
+ },
+ "zone": schema.StringAttribute{
+ Computed: true,
+ Description: "The zone associated with the vnet.",
+ },
+ "zonetype": schema.StringAttribute{
+ Computed: true,
+ Description: "The type of the zone associated with this vnet.",
+ },
+ "alias": schema.StringAttribute{
+ Computed: true,
+ Description: "An alias for this vnet.",
+ },
+ "isolate_ports": schema.BoolAttribute{
+ Computed: true,
+ Description: "Whether ports are isolated.",
+ },
+ "tag": schema.Int64Attribute{
+ Computed: true,
+ Description: "VLAN/VXLAN tag.",
+ },
+ "type": schema.StringAttribute{
+ Computed: true,
+ Description: "Type of the vnet.",
+ },
+ "vlanaware": schema.BoolAttribute{
+ Computed: true,
+ Description: "Whether this vnet is VLAN aware.",
+ },
+ },
+ }
+}
+
+func (d *sdnVnetDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config sdnVnetModel
+
+ resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ vnetID := config.Name.ValueString()
+ vnet, err := d.client.GetVnet(ctx, vnetID)
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Vnet not found", fmt.Sprintf("No vnet with ID %q exists", vnetID))
+ return
+ }
+ resp.Diagnostics.AddError("Error retrieving vnet", err.Error())
+ return
+ }
+
+ state := sdnVnetModel{}
+ state.importFromAPI(vnetID, vnet)
+ state.ID = types.StringValue(vnetID)
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+}
diff --git a/fwprovider/cluster/sdn/datasource_sdn_zones.go b/fwprovider/cluster/sdn/datasource_sdn_zones.go
new file mode 100644
index 00000000..0dde0d88
--- /dev/null
+++ b/fwprovider/cluster/sdn/datasource_sdn_zones.go
@@ -0,0 +1,98 @@
+package sdn
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
+)
+
+var _ datasource.DataSource = &sdnZoneDataSource{}
+var _ datasource.DataSourceWithConfigure = &sdnZoneDataSource{}
+
+type sdnZoneDataSource struct {
+ client *zones.Client
+}
+
+func NewSDNZoneDataSource() datasource.DataSource {
+ return &sdnZoneDataSource{}
+}
+
+func (d *sdnZoneDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_sdn_zone"
+}
+
+func (d *sdnZoneDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ cfg, ok := req.ProviderData.(config.DataSource)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Provider Configuration",
+ fmt.Sprintf("Expected config.DataSource but got: %T", req.ProviderData),
+ )
+ return
+ }
+
+ d.client = cfg.Client.Cluster().SDNZones()
+}
+
+func (d *sdnZoneDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: "Fetch a Proxmox SDN Zone by name.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ Description: "The ID of the SDN zone.",
+ },
+ "name": schema.StringAttribute{
+ Required: true,
+ Description: "Name (ID) of the SDN zone.",
+ },
+ "type": schema.StringAttribute{Computed: true},
+ "ipam": schema.StringAttribute{Computed: true},
+ "dns": schema.StringAttribute{Computed: true},
+ "reversedns": schema.StringAttribute{Computed: true},
+ "dns_zone": schema.StringAttribute{Computed: true},
+ "nodes": schema.StringAttribute{Computed: true},
+ "mtu": schema.Int64Attribute{Computed: true},
+ "bridge": schema.StringAttribute{Computed: true},
+ "tag": schema.Int64Attribute{Computed: true},
+ "vlan_protocol": schema.StringAttribute{Computed: true},
+ "peers": schema.StringAttribute{Computed: true},
+ "controller": schema.StringAttribute{Computed: true},
+ "vrf_vxlan": schema.Int64Attribute{Computed: true},
+ "exit_nodes": schema.StringAttribute{Computed: true},
+ "primary_exit_node": schema.StringAttribute{Computed: true},
+ "exit_nodes_local_routing": schema.BoolAttribute{Computed: true},
+ "advertise_subnets": schema.BoolAttribute{Computed: true},
+ "disable_arp_nd_suppression": schema.BoolAttribute{Computed: true},
+ "rt_import": schema.StringAttribute{Computed: true},
+ },
+ }
+}
+
+func (d *sdnZoneDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var data sdnZoneModel
+
+ resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ zone, err := d.client.GetZone(ctx, data.Name.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddError("Unable to fetch SDN Zone", err.Error())
+ return
+ }
+
+ readModel := &sdnZoneModel{}
+ readModel.importFromAPI(zone.ID, zone)
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
diff --git a/fwprovider/cluster/sdn/resource_sdn_subnets.go b/fwprovider/cluster/sdn/resource_sdn_subnets.go
new file mode 100644
index 00000000..38e42eda
--- /dev/null
+++ b/fwprovider/cluster/sdn/resource_sdn_subnets.go
@@ -0,0 +1,340 @@
+package sdn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "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/types"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/subnets"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+var (
+ _ resource.Resource = &sdnSubnetResource{}
+ _ resource.ResourceWithConfigure = &sdnSubnetResource{}
+ _ resource.ResourceWithImportState = &sdnSubnetResource{}
+)
+
+type sdnSubnetResource struct {
+ client *subnets.Client
+}
+
+func NewSDNSubnetResource() resource.Resource {
+ return &sdnSubnetResource{}
+}
+
+func (r *sdnSubnetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_sdn_subnet"
+}
+
+func (r *sdnSubnetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ cfg, ok := req.ProviderData.(config.Resource)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Resource Configure Type",
+ fmt.Sprintf("Expected config.Resource, got: %T", req.ProviderData),
+ )
+ return
+ }
+
+ r.client = cfg.Client.Cluster().SDNSubnets()
+}
+
+func (r *sdnSubnetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ Description: "Manages SDN Subnets in Proxmox VE.",
+ Attributes: map[string]schema.Attribute{
+ "id": attribute.ResourceID(),
+ "subnet": schema.StringAttribute{
+ Required: true,
+ Description: "The name/ID of the subnet.",
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "canonical_name": schema.StringAttribute{
+ Computed: true,
+ Description: "Canonical name of the subnet (e.g. zoneM-10.10.0.0-24).",
+ },
+ "type": schema.StringAttribute{
+ Computed: true,
+ Description: "Subnet type (set default at 'subnet')",
+ Default: stringdefault.StaticString("subnet"),
+ },
+ "vnet": schema.StringAttribute{
+ Required: true,
+ Description: "The VNet to which this subnet belongs.",
+ },
+ "dhcp_dns_server": schema.StringAttribute{
+ Optional: true,
+ Description: "The DNS server used for DHCP.",
+ },
+ "dhcp_range": schema.ListNestedAttribute{
+ Optional: true,
+ Description: "List of DHCP ranges (start and end IPs).",
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "start_address": schema.StringAttribute{
+ Required: true,
+ Description: "Start of the DHCP range.",
+ },
+ "end_address": schema.StringAttribute{
+ Required: true,
+ Description: "End of the DHCP range.",
+ },
+ },
+ },
+ },
+ "dnszoneprefix": schema.StringAttribute{
+ Optional: true,
+ Description: "Prefix used for DNS zone delegation.",
+ },
+ "gateway": schema.StringAttribute{
+ Optional: true,
+ Description: "The gateway address for the subnet.",
+ },
+ "snat": schema.BoolAttribute{
+ Optional: true,
+ Description: "Whether SNAT is enabled for the subnet.",
+ },
+ },
+ }
+}
+
+func (r *sdnSubnetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan sdnSubnetModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ if plan.Vnet.IsNull() || plan.Vnet.IsUnknown() {
+ resp.Diagnostics.AddAttributeError(
+ path.Root("vnet"),
+ "missing required field",
+ "Missing the parent vnet's ID attribute, which is required to define a subnet")
+ return
+ }
+ err := r.client.CreateSubnet(ctx, plan.Vnet.ValueString(), plan.toAPIRequestBody())
+ if err != nil {
+ resp.Diagnostics.AddError("Error creating subnet", err.Error())
+ return
+ }
+
+ tflog.Debug(ctx, "Created object's ID", map[string]any{"plan name:": plan.Subnet})
+ plan.ID = plan.Subnet
+
+ // Because proxmox API doesn't return the created object's properties and the subnet's name gets modified by proxmox internally
+ // Read it back to get the canonical-ID from proxmox
+ canonicalID, err := resolveCanonicalSubnetID(ctx, r.client, plan.Vnet.ValueString(), plan.Subnet.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddError("Error resolving canonical subnet ID", err.Error())
+ return
+ }
+
+ plan.ID = types.StringValue(canonicalID)
+ plan.CanonicalName = types.StringValue(canonicalID)
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *sdnSubnetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state sdnSubnetModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ subnet, err := r.client.GetSubnet(ctx, state.Vnet.ValueString(), state.ID.ValueString())
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ resp.Diagnostics.AddError("Error reading subnet", err.Error())
+ return
+ }
+
+ readModel := &sdnSubnetModel{}
+ readModel.Subnet = state.Subnet
+ readModel.importFromAPI(state.ID.ValueString(), subnet)
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
+
+func (r *sdnSubnetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ var plan sdnSubnetModel
+ // var state sdnSubnetModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ // resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ reqData := plan.toAPIRequestBody()
+ // reqData.Delete = toDelete
+
+ if plan.Vnet.IsNull() || plan.Vnet.IsUnknown() {
+ resp.Diagnostics.AddAttributeError(
+ path.Root("vnet"),
+ "missing required field",
+ "Missing the parent vnet's ID attribute, which is required to define a subnet")
+ return
+ }
+ err := r.client.UpdateSubnet(ctx, plan.Vnet.ValueString(), reqData)
+ if err != nil {
+ resp.Diagnostics.AddError("Error updating subnet", err.Error())
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *sdnSubnetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state sdnSubnetModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ err := r.client.DeleteSubnet(ctx, state.Vnet.ValueString(), state.ID.ValueString())
+ if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Error deleting subnet", err.Error())
+ }
+}
+
+func (r *sdnSubnetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ // Expect ID format: "vnet/subnet"
+ parts := strings.Split(req.ID, "/")
+ if len(parts) != 2 {
+ resp.Diagnostics.AddError(
+ "Unexpected Import Identifier",
+ "Expected import identifier in format 'vnet-id/subnet-id'.",
+ )
+ return
+ }
+ vnetID := parts[0]
+ subnetID := parts[1]
+ subnet, err := r.client.GetSubnet(ctx, vnetID, subnetID)
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Subnet does not exist", err.Error())
+ return
+ }
+
+ resp.Diagnostics.AddError("Unable to import subnet", err.Error())
+ return
+ }
+
+ readModel := &sdnSubnetModel{}
+ readModel.importFromAPI(req.ID, subnet)
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
+
+func resolveCanonicalSubnetID(ctx context.Context, client *subnets.Client, vnet string, originalID string) (string, error) {
+ subnets, err := client.GetSubnets(ctx, vnet)
+ if err != nil {
+ return "", fmt.Errorf("failed to list subnets for canonical name resolution: %w", err)
+ }
+
+ for _, subnet := range subnets {
+ if subnet.ID == originalID {
+ return subnet.ID, nil // Already canonical
+ }
+
+ // Proxmox canonical format is usually zone-prefixed:
+ // e.g., zoneM-10-10-0-0-24 instead of 10.10.0.0/24
+ if strings.HasSuffix(subnet.ID, strings.ReplaceAll(originalID, "/", "-")) {
+ return subnet.ID, nil
+ }
+ }
+
+ return "", fmt.Errorf("could not resolve canonical subnet ID for %s", originalID)
+}
+
+// ValidateConfig checks that the subnet's field are correctly set. Particularly that gateway, dhcp and dns are within CIDR
+func (r *sdnSubnetResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+ var config sdnSubnetModel
+ diags := req.Config.Get(ctx, &config)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ _, ipnet, err := net.ParseCIDR(config.Subnet.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddAttributeError(
+ path.Root("subnet"),
+ "Invalid Subnet",
+ fmt.Sprintf("Could not parse subnet: %s", err),
+ )
+ return
+ }
+
+ checkIPInCIDR := func(attrName string, ipVal types.String) {
+ if !ipVal.IsNull() {
+ ip := net.ParseIP(ipVal.ValueString())
+ if ip == nil {
+ resp.Diagnostics.AddAttributeError(
+ path.Root(attrName),
+ "Invalid IP Address",
+ fmt.Sprintf("Could not parse IP address: %s", ipVal.ValueString()),
+ )
+ return
+ }
+
+ if !ipnet.Contains(ip) {
+ resp.Diagnostics.AddAttributeError(
+ path.Root(attrName),
+ "Invalid IP for Subnet",
+ fmt.Sprintf("%s must be within the subnet %s", ipVal.ValueString(), config.Subnet.ValueString()),
+ )
+ }
+ }
+ }
+
+ checkIPInCIDR("gateway", config.Gateway)
+ checkIPInCIDR("dhcp_dns_server", config.DhcpDnsServer)
+
+ for i, r := range config.DhcpRange {
+ if !r.StartAddress.IsNull() {
+ ip := net.ParseIP(r.StartAddress.ValueString())
+ if !ipnet.Contains(ip) {
+ resp.Diagnostics.AddAttributeError(
+ path.Root("dhcp_range").AtListIndex(i).AtMapKey("start_address"),
+ "Invalid DHCP Range Start Address",
+ fmt.Sprintf("Start address %s must be within the subnet %s", ip, config.Subnet.ValueString()),
+ )
+ }
+ }
+
+ if !r.EndAddress.IsNull() {
+ ip := net.ParseIP(r.EndAddress.ValueString())
+ if !ipnet.Contains(ip) {
+ resp.Diagnostics.AddAttributeError(
+ path.Root("dhcp_range").AtListIndex(i).AtMapKey("end_address"),
+ "Invalid DHCP Range End Address",
+ fmt.Sprintf("End address %s must be within the subnet %s", ip, config.Subnet.ValueString()),
+ )
+ }
+ }
+ }
+}
diff --git a/fwprovider/cluster/sdn/resource_sdn_vnets.go b/fwprovider/cluster/sdn/resource_sdn_vnets.go
new file mode 100644
index 00000000..6f30322e
--- /dev/null
+++ b/fwprovider/cluster/sdn/resource_sdn_vnets.go
@@ -0,0 +1,313 @@
+package sdn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "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-log/tflog"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/vnets"
+)
+
+var (
+ _ resource.Resource = &sdnVnetResource{}
+ _ resource.ResourceWithConfigure = &sdnVnetResource{}
+ _ resource.ResourceWithImportState = &sdnVnetResource{}
+)
+
+type sdnVnetResource struct {
+ client *vnets.Client
+}
+
+func NewSDNVnetResource() resource.Resource {
+ return &sdnVnetResource{}
+}
+
+func (r *sdnVnetResource) Metadata(
+ _ context.Context,
+ req resource.MetadataRequest,
+ resp *resource.MetadataResponse,
+) {
+ resp.TypeName = req.ProviderTypeName + "_sdn_vnet"
+}
+
+func (r *sdnVnetResource) Configure(
+ _ context.Context,
+ req resource.ConfigureRequest,
+ resp *resource.ConfigureResponse,
+) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ cfg, ok := req.ProviderData.(config.Resource)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Resource Configure Type",
+ fmt.Sprintf("Expected config.Resource, got: %T", req.ProviderData),
+ )
+ return
+ }
+
+ r.client = cfg.Client.Cluster().SDNVnets()
+}
+
+func (r *sdnVnetResource) Schema(
+ _ context.Context,
+ _ resource.SchemaRequest,
+ resp *resource.SchemaResponse,
+) {
+ resp.Schema = schema.Schema{
+ Description: "Manages Proxmox VE SDN vnet.",
+ Attributes: map[string]schema.Attribute{
+ "id": attribute.ResourceID(),
+ "name": schema.StringAttribute{
+ Description: "Unique identifier for the vnet.",
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "zonetype": schema.StringAttribute{
+ Required: true,
+ Description: "Parent's zone type. MUST be specified.",
+ },
+ "zone": schema.StringAttribute{
+ Description: "The zone to which this vnet belongs.",
+ Required: true,
+ },
+ "alias": schema.StringAttribute{
+ Optional: true,
+ Description: "An optional alias for this vnet.",
+ },
+ "isolate_ports": schema.BoolAttribute{
+ Optional: true,
+ Description: "Whether to isolate ports within this vnet.",
+ },
+ "tag": schema.Int64Attribute{
+ Optional: true,
+ Description: "Tag value for VLAN/VXLAN (depends on zone type).",
+ },
+ "type": schema.StringAttribute{
+ Computed: true,
+ Description: "Type of vnet (e.g. 'vnet').",
+ Default: stringdefault.StaticString("vnet"),
+ },
+ "vlanaware": schema.BoolAttribute{
+ Optional: true,
+ Description: "Whether this vnet is VLAN aware.",
+ },
+ },
+ }
+}
+
+func (r *sdnVnetResource) Create(
+ ctx context.Context,
+ req resource.CreateRequest,
+ resp *resource.CreateResponse,
+) {
+ var plan sdnVnetModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ err := r.client.CreateVnet(ctx, plan.toAPIRequestBody())
+ if err != nil {
+ resp.Diagnostics.AddError("Error creating vnet", err.Error())
+ return
+ }
+
+ plan.ID = plan.Name
+ tflog.Info(ctx, "ZONETYPE value", map[string]any{"zonetype": plan.ZoneType.ValueString()})
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *sdnVnetResource) Read(
+ ctx context.Context,
+ req resource.ReadRequest,
+ resp *resource.ReadResponse,
+) {
+ var state sdnVnetModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ data, err := r.client.GetVnet(ctx, state.ID.ValueString())
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ resp.Diagnostics.AddError("Error reading vnet", err.Error())
+ return
+ }
+
+ readModel := &sdnVnetModel{}
+ readModel.importFromAPI(state.ID.ValueString(), data)
+ // Preserve provider-only field
+ readModel.ZoneType = state.ZoneType
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
+
+func (r *sdnVnetResource) Update(
+ ctx context.Context,
+ req resource.UpdateRequest,
+ resp *resource.UpdateResponse,
+) {
+ var plan sdnVnetModel
+ var state sdnVnetModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ var toDelete []string
+ checkDelete(plan.Alias, state.Alias, &toDelete, "alias")
+ checkDelete(plan.IsolatePorts, state.IsolatePorts, &toDelete, "isolate-ports")
+ checkDelete(plan.Tag, state.Tag, &toDelete, "tag")
+ checkDelete(plan.Type, state.Type, &toDelete, "type")
+ checkDelete(plan.VlanAware, state.VlanAware, &toDelete, "vlanaware")
+
+ reqData := plan.toAPIRequestBody()
+ reqData.Delete = toDelete
+
+ err := r.client.UpdateVnet(ctx, reqData)
+ if err != nil {
+ resp.Diagnostics.AddError("Error updating vnet", err.Error())
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *sdnVnetResource) Delete(
+ ctx context.Context,
+ req resource.DeleteRequest,
+ resp *resource.DeleteResponse,
+) {
+ var state sdnVnetModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ err := r.client.DeleteVnet(ctx, state.ID.ValueString())
+ if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Error deleting vnet", err.Error())
+ }
+}
+
+func (r *sdnVnetResource) ImportState(
+ ctx context.Context,
+ req resource.ImportStateRequest,
+ resp *resource.ImportStateResponse,
+) {
+ data, err := r.client.GetVnet(ctx, req.ID)
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Resource does not exist", err.Error())
+ return
+ }
+ resp.Diagnostics.AddError("Failed to import resource", err.Error())
+ return
+ }
+
+ readModel := &sdnVnetModel{}
+ readModel.importFromAPI(req.ID, data)
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
+
+func checkDelete(planField, stateField attr.Value, toDelete *[]string, apiName string) {
+ if planField.IsNull() && !stateField.IsNull() {
+ *toDelete = append(*toDelete, apiName)
+ }
+}
+
+func (r *sdnVnetResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+ var data sdnVnetModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if data.Zone.IsNull() || data.Zone.IsUnknown() {
+ return
+ }
+
+ if data.ZoneType.IsNull() || data.ZoneType.IsUnknown() {
+ resp.Diagnostics.AddAttributeError(
+ path.Root("zonetype"),
+ "Missing Required Field",
+ "No Zone linked to this Vnet, please set the 'zonetype' property. \nEither from a created zone or a datasource import.")
+ return
+ }
+
+ zoneType := data.ZoneType.ValueString()
+
+ required := map[string][]string{
+ "simple": {"name", "zone"},
+ "vlan": {"name", "zone", "tag"},
+ "qinq": {"name", "zone"},
+ "vxlan": {"name", "zone", "tag"},
+ "evpn": {"name", "zone", "tag"},
+ }
+
+ authorized := map[string]map[string]bool{
+ "simple": {"name": true, "alias": true, "zone": true, "isolate_ports": true, "vlanaware": true},
+ "vlan": {"name": true, "alias": true, "zone": true, "tag": true, "isolate_ports": true, "vlanaware": true},
+ "qinq": {"name": true, "alias": true, "zone": true, "tag": true, "isolate_ports": true, "vlanaware": true},
+ "vxlan": {"name": true, "alias": true, "zone": true, "tag": true, "isolate_ports": true, "vlanaware": true},
+ "evpn": {"name": true, "alias": true, "zone": true, "tag": true, "isolate_ports": true},
+ }
+
+ fieldMap := map[string]attr.Value{
+ "name": data.Name,
+ "zone": data.Zone,
+ "alias": data.Alias,
+ "tag": data.Tag,
+ "isolate_ports": data.IsolatePorts,
+ "vlanaware": data.VlanAware,
+ "type": data.Type,
+ }
+
+ // Check required fields
+ for _, field := range required[zoneType] {
+ if val, ok := fieldMap[field]; ok {
+ if val.IsNull() || val.IsUnknown() {
+ resp.Diagnostics.AddAttributeError(
+ path.Root(field),
+ "Missing Required Attribute",
+ fmt.Sprintf("The attribute %q is required for SDN VNETs in a %q zone.", field, zoneType),
+ )
+ }
+ }
+ }
+
+ for fieldName, val := range fieldMap {
+ if !authorized[zoneType][fieldName] && !val.IsNull() && !val.IsUnknown() {
+ resp.Diagnostics.AddAttributeError(
+ path.Root(fieldName),
+ "Unauthorized Attribute for Zone Type",
+ fmt.Sprintf("The attribute %q is not allowed in VNETs under a %q zone.", fieldName, zoneType),
+ )
+ }
+ }
+
+}
diff --git a/fwprovider/cluster/sdn/resource_sdn_zones.go b/fwprovider/cluster/sdn/resource_sdn_zones.go
new file mode 100644
index 00000000..4c37df38
--- /dev/null
+++ b/fwprovider/cluster/sdn/resource_sdn_zones.go
@@ -0,0 +1,315 @@
+package sdn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "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/stringplanmodifier"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+)
+
+var (
+ _ resource.Resource = &sdnZoneResource{}
+ _ resource.ResourceWithConfigure = &sdnZoneResource{}
+ _ resource.ResourceWithImportState = &sdnZoneResource{}
+)
+
+type sdnZoneResource struct {
+ client *zones.Client
+}
+
+func NewSDNZoneResource() resource.Resource {
+ return &sdnZoneResource{}
+}
+
+func (r *sdnZoneResource) Metadata(
+ _ context.Context,
+ req resource.MetadataRequest,
+ resp *resource.MetadataResponse,
+) {
+ resp.TypeName = req.ProviderTypeName + "_sdn_zone"
+}
+
+func (r *sdnZoneResource) Configure(
+ _ context.Context,
+ req resource.ConfigureRequest,
+ resp *resource.ConfigureResponse,
+) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ cfg, ok := req.ProviderData.(config.Resource)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Resource Configure Type",
+ fmt.Sprintf("Expected config.Resource, got: %T", req.ProviderData),
+ )
+ return
+ }
+
+ r.client = cfg.Client.Cluster().SDNZones()
+}
+
+func (r *sdnZoneResource) Schema(
+ _ context.Context,
+ _ resource.SchemaRequest,
+ resp *resource.SchemaResponse,
+) {
+ resp.Schema = schema.Schema{
+ Description: "Manages SDN Zones in Proxmox VE.",
+ Attributes: map[string]schema.Attribute{
+ "id": attribute.ResourceID(),
+ "name": schema.StringAttribute{
+ Description: "The unique ID of the SDN zone.",
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "type": schema.StringAttribute{
+ Description: "Zone type (e.g. simple, vlan, qinq, vxlan, evpn).",
+ Required: true,
+ },
+ "ipam": schema.StringAttribute{
+ Optional: true,
+ Description: "IP Address Management system.",
+ },
+ "dns": schema.StringAttribute{
+ Optional: true,
+ Description: "DNS server address.",
+ },
+ "reversedns": schema.StringAttribute{
+ Optional: true,
+ Description: "Reverse DNS settings.",
+ },
+ "dns_zone": schema.StringAttribute{
+ Optional: true,
+ Description: "DNS zone name.",
+ },
+ "nodes": schema.StringAttribute{
+ Optional: true,
+ Description: "Comma-separated list of Proxmox node names.",
+ },
+ "mtu": schema.Int64Attribute{
+ Optional: true,
+ Description: "MTU value for the zone.",
+ },
+ "bridge": schema.StringAttribute{
+ Optional: true,
+ Description: "Bridge interface for VLAN/QinQ.",
+ },
+ "tag": schema.Int64Attribute{
+ Optional: true,
+ Description: "Service VLAN tag for QinQ.",
+ },
+ "vlan_protocol": schema.StringAttribute{
+ Optional: true,
+ Description: "Service VLAN protocol for QinQ.",
+ },
+ "peers": schema.StringAttribute{
+ Optional: true,
+ Description: "Peers list for VXLAN.",
+ },
+ "controller": schema.StringAttribute{
+ Optional: true,
+ Description: "EVPN controller address.",
+ },
+ "vrf_vxlan": schema.Int64Attribute{
+ Optional: true,
+ Description: "EVPN VRF VXLAN ID.",
+ },
+ "exit_nodes": schema.StringAttribute{
+ Optional: true,
+ Description: "Comma-separated list of exit nodes for EVPN.",
+ },
+ "primary_exit_node": schema.StringAttribute{
+ Optional: true,
+ Description: "Primary exit node for EVPN.",
+ },
+ "exit_nodes_local_routing": schema.BoolAttribute{
+ Optional: true,
+ Description: "Enable local routing for EVPN exit nodes.",
+ },
+ "advertise_subnets": schema.BoolAttribute{
+ Optional: true,
+ Description: "Enable subnet advertisement for EVPN.",
+ },
+ "disable_arp_nd_suppression": schema.BoolAttribute{
+ Optional: true,
+ Description: "Disable ARP/ND suppression for EVPN.",
+ },
+ "rt_import": schema.StringAttribute{
+ Optional: true,
+ Description: "Route target import for EVPN.",
+ },
+ },
+ }
+}
+
+func (r *sdnZoneResource) Create(
+ ctx context.Context,
+ req resource.CreateRequest,
+ resp *resource.CreateResponse,
+) {
+ var plan sdnZoneModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ reqData := plan.toAPIRequestBody()
+ err := r.client.CreateZone(ctx, reqData)
+ if err != nil {
+ resp.Diagnostics.AddError("Unable to Create SDN Zone", err.Error())
+ return
+ }
+
+ plan.ID = plan.Name
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *sdnZoneResource) Read(
+ ctx context.Context,
+ req resource.ReadRequest,
+ resp *resource.ReadResponse,
+) {
+ var state sdnZoneModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ zone, err := r.client.GetZone(ctx, state.ID.ValueString())
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ resp.Diagnostics.AddError("Unable to Read SDN Zone", err.Error())
+ return
+ }
+
+ readModel := &sdnZoneModel{}
+ readModel.importFromAPI(zone.ID, zone)
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
+
+func (r *sdnZoneResource) Update(
+ ctx context.Context,
+ req resource.UpdateRequest,
+ resp *resource.UpdateResponse,
+) {
+ var plan sdnZoneModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ reqData := plan.toAPIRequestBody()
+ err := r.client.UpdateZone(ctx, reqData)
+ if err != nil {
+ resp.Diagnostics.AddError("Unable to Update SDN Zone", err.Error())
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *sdnZoneResource) Delete(
+ ctx context.Context,
+ req resource.DeleteRequest,
+ resp *resource.DeleteResponse,
+) {
+ var state sdnZoneModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ err := r.client.DeleteZone(ctx, state.ID.ValueString())
+ if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Unable to Delete SDN Zone", err.Error())
+ }
+}
+
+func (r *sdnZoneResource) ImportState(
+ ctx context.Context,
+ req resource.ImportStateRequest,
+ resp *resource.ImportStateResponse,
+) {
+ zone, err := r.client.GetZone(ctx, req.ID)
+ if err != nil {
+ if errors.Is(err, api.ErrResourceDoesNotExist) {
+ resp.Diagnostics.AddError("Zone does not exist", err.Error())
+ return
+ }
+
+ resp.Diagnostics.AddError("Unable to Import SDN Zone", err.Error())
+ return
+ }
+
+ readModel := &sdnZoneModel{}
+ readModel.importFromAPI(zone.ID, zone)
+ resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
+}
+
+func (r *sdnZoneResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+ var data sdnZoneModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Check the type field
+ if data.Type.IsNull() || data.Type.IsUnknown() {
+ return
+ }
+
+ required := map[string][]string{
+ "vlan": {"bridge"},
+ "qinq": {"bridge", "service_vlan"},
+ "vxlan": {"peers"},
+ "evpn": {"controller", "vrf_vxlan"},
+ }
+
+ zoneType := data.Type.ValueString()
+
+ // Extracts required fields and at the same time checks zone type validity
+ fields, ok := required[zoneType]
+ if !ok {
+ return
+ }
+
+ // Map of field names to their values from data
+ fieldMap := map[string]attr.Value{
+ "bridge": data.Bridge,
+ "service_vlan": data.ServiceVLAN,
+ "peers": data.Peers,
+ "controller": data.Controller,
+ "vrf_vxlan": data.VRFVXLANID,
+ }
+
+ for _, field := range fields {
+ val, exists := fieldMap[field]
+ if !exists || val.IsNull() || val.IsUnknown() {
+ resp.Diagnostics.AddAttributeError(
+ path.Root(field),
+ "Missing Required Field",
+ fmt.Sprintf("Attribute %q is required when type is %q.", field, zoneType),
+ )
+ }
+ }
+}
diff --git a/fwprovider/cluster/sdn/sdn_subnet_model.go b/fwprovider/cluster/sdn/sdn_subnet_model.go
new file mode 100644
index 00000000..fbd4e20e
--- /dev/null
+++ b/fwprovider/cluster/sdn/sdn_subnet_model.go
@@ -0,0 +1,89 @@
+package sdn
+
+/*
+--------------------------------- Subnet Model Terraform ---------------------------------
+
+Note: Currently in the API there are Delete and Digest options which are not available
+in the UI so the choice was made to remove them temporary, waiting for a fix.
+Also, it is not really in the way of working with terraform to use such parameters.
+----------------------------------------------------------------------------------------
+*/
+import (
+ "context"
+ "fmt"
+
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/helpers/ptrConversion"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/subnets"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+type sdnSubnetModel struct {
+ ID types.String `tfsdk:"id"`
+ Subnet types.String `tfsdk:"subnet"`
+ CanonicalName types.String `tfsdk:"canonical_name"`
+ Type types.String `tfsdk:"type"`
+ Vnet types.String `tfsdk:"vnet"`
+ DhcpDnsServer types.String `tfsdk:"dhcp_dns_server"`
+ DhcpRange []dhcpRangeModel `tfsdk:"dhcp_range"`
+ DnsZonePrefix types.String `tfsdk:"dnszoneprefix"`
+ Gateway types.String `tfsdk:"gateway"`
+ Snat types.Bool `tfsdk:"snat"`
+}
+
+type dhcpRangeModel struct {
+ StartAddress types.String `tfsdk:"start_address"`
+ EndAddress types.String `tfsdk:"end_address"`
+}
+
+func (m *sdnSubnetModel) importFromAPI(name string, data *subnets.SubnetData) {
+ m.ID = types.StringValue(name)
+ m.CanonicalName = types.StringValue(name)
+
+ m.Type = types.StringPointerValue(data.Type)
+ m.Vnet = types.StringPointerValue(data.Vnet)
+ m.DhcpDnsServer = types.StringPointerValue(data.DHCPDNSServer)
+ if data.DHCPRange != nil {
+ var ranges []dhcpRangeModel
+ for _, r := range data.DHCPRange {
+ ranges = append(ranges, dhcpRangeModel{
+ StartAddress: types.StringValue(r.StartAddress),
+ EndAddress: types.StringValue(r.EndAddress),
+ })
+ }
+ m.DhcpRange = ranges
+ }
+
+ m.DnsZonePrefix = types.StringPointerValue(data.DNSZonePrefix)
+ m.Gateway = types.StringPointerValue(data.Gateway)
+ m.Snat = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.SNAT))
+}
+
+func (m *sdnSubnetModel) toAPIRequestBody() *subnets.SubnetRequestData {
+ data := &subnets.SubnetRequestData{}
+
+ // When creating the subnet it is ok to pass subnet cidr, but when updating need to pass canonical name
+ if m.CanonicalName.ValueString() == "" {
+ data.ID = m.Subnet.ValueString()
+ } else {
+ data.ID = m.CanonicalName.ValueString()
+ }
+ tflog.Warn(context.Background(), "TO API", map[string]any{
+ "canonical name": m.CanonicalName.ValueString(),
+ "ID": m.ID.ValueString(),
+ })
+ data.Type = m.Type.ValueStringPointer()
+ data.Vnet = m.Vnet.ValueStringPointer()
+ data.DHCPDNSServer = m.DhcpDnsServer.ValueStringPointer()
+ if m.DhcpRange != nil {
+ var dhcpRanges []string
+ for _, r := range m.DhcpRange {
+ dhcpRanges = append(dhcpRanges, fmt.Sprintf("start-address=%s,end-address=%s", r.StartAddress.ValueString(), r.EndAddress.ValueString()))
+ }
+ data.DHCPRange = dhcpRanges
+ }
+ data.DNSZonePrefix = m.DnsZonePrefix.ValueStringPointer()
+ data.Gateway = m.Gateway.ValueStringPointer()
+ data.SNAT = ptrConversion.BoolToInt64Ptr(m.Snat.ValueBoolPointer())
+ return data
+}
diff --git a/fwprovider/cluster/sdn/sdn_vnet_model.go b/fwprovider/cluster/sdn/sdn_vnet_model.go
new file mode 100644
index 00000000..af26c298
--- /dev/null
+++ b/fwprovider/cluster/sdn/sdn_vnet_model.go
@@ -0,0 +1,53 @@
+package sdn
+
+/*
+--------------------------------- VNET Model Terraform ---------------------------------
+
+
+----------------------------------------------------------------------------------------
+*/
+
+import (
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/helpers/ptrConversion"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/vnets"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+type sdnVnetModel struct {
+ ID types.String `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ Zone types.String `tfsdk:"zone"`
+ Alias types.String `tfsdk:"alias"`
+ IsolatePorts types.Bool `tfsdk:"isolate_ports"`
+ Tag types.Int64 `tfsdk:"tag"`
+ Type types.String `tfsdk:"type"`
+ VlanAware types.Bool `tfsdk:"vlanaware"`
+ ZoneType types.String `tfsdk:"zonetype"`
+}
+
+func (m *sdnVnetModel) importFromAPI(name string, data *vnets.VnetData) {
+ m.ID = types.StringValue(name)
+ m.Name = types.StringValue(name)
+
+ m.Zone = types.StringPointerValue(data.Zone)
+ m.Alias = types.StringPointerValue(data.Alias)
+ m.IsolatePorts = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.IsolatePorts))
+ m.Tag = types.Int64PointerValue(data.Tag)
+ m.Type = types.StringPointerValue(data.Type)
+ m.VlanAware = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.VlanAware))
+}
+
+func (m *sdnVnetModel) toAPIRequestBody() *vnets.VnetRequestData {
+ data := &vnets.VnetRequestData{}
+
+ data.ID = m.Name.ValueString()
+
+ data.Zone = m.Zone.ValueStringPointer()
+ data.Alias = m.Alias.ValueStringPointer()
+ data.IsolatePorts = ptrConversion.BoolToInt64Ptr(m.IsolatePorts.ValueBoolPointer())
+ data.Tag = m.Tag.ValueInt64Pointer()
+ data.Type = m.Type.ValueStringPointer()
+ data.VlanAware = ptrConversion.BoolToInt64Ptr(m.VlanAware.ValueBoolPointer())
+
+ return data
+}
diff --git a/fwprovider/cluster/sdn/sdn_zone_model.go b/fwprovider/cluster/sdn/sdn_zone_model.go
new file mode 100644
index 00000000..c3de2927
--- /dev/null
+++ b/fwprovider/cluster/sdn/sdn_zone_model.go
@@ -0,0 +1,89 @@
+package sdn
+
+import (
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/helpers/ptrConversion"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+type sdnZoneModel struct {
+ ID types.String `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ Type types.String `tfsdk:"type"`
+ IPAM types.String `tfsdk:"ipam"`
+ DNS types.String `tfsdk:"dns"`
+ ReverseDNS types.String `tfsdk:"reversedns"`
+ DNSZone types.String `tfsdk:"dns_zone"`
+ Nodes types.String `tfsdk:"nodes"`
+ MTU types.Int64 `tfsdk:"mtu"`
+ // VLAN
+ Bridge types.String `tfsdk:"bridge"`
+ // QinQ
+ ServiceVLAN types.Int64 `tfsdk:"tag"`
+ ServiceVLANProtocol types.String `tfsdk:"vlan_protocol"`
+ // VXLAN
+ Peers types.String `tfsdk:"peers"`
+ // EVPN
+ Controller types.String `tfsdk:"controller"`
+ ExitNodes types.String `tfsdk:"exit_nodes"`
+ PrimaryExitNode types.String `tfsdk:"primary_exit_node"`
+ RouteTargetImport types.String `tfsdk:"rt_import"`
+ VRFVXLANID types.Int64 `tfsdk:"vrf_vxlan"`
+ ExitNodesLocalRouting types.Bool `tfsdk:"exit_nodes_local_routing"`
+ AdvertiseSubnets types.Bool `tfsdk:"advertise_subnets"`
+ DisableARPNDSuppression types.Bool `tfsdk:"disable_arp_nd_suppression"`
+}
+
+func (m *sdnZoneModel) importFromAPI(name string, data *zones.ZoneData) {
+ m.ID = types.StringValue(name)
+ m.Name = types.StringValue(name)
+
+ m.Type = types.StringPointerValue(data.Type)
+ m.IPAM = types.StringPointerValue(data.IPAM)
+ m.DNS = types.StringPointerValue(data.DNS)
+ m.ReverseDNS = types.StringPointerValue(data.ReverseDNS)
+ m.DNSZone = types.StringPointerValue(data.DNSZone)
+ m.Nodes = types.StringPointerValue(data.Nodes)
+ m.MTU = types.Int64PointerValue(data.MTU)
+ m.Bridge = types.StringPointerValue(data.Bridge)
+ m.ServiceVLAN = types.Int64PointerValue(data.ServiceVLAN)
+ m.ServiceVLANProtocol = types.StringPointerValue(data.ServiceVLANProtocol)
+ m.Peers = types.StringPointerValue(data.Peers)
+ m.Controller = types.StringPointerValue(data.Controller)
+ m.ExitNodes = types.StringPointerValue(data.ExitNodes)
+ m.PrimaryExitNode = types.StringPointerValue(data.PrimaryExitNode)
+ m.RouteTargetImport = types.StringPointerValue(data.RouteTargetImport)
+ m.VRFVXLANID = types.Int64PointerValue(data.VRFVXLANID)
+ m.ExitNodesLocalRouting = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.ExitNodesLocalRouting))
+ m.AdvertiseSubnets = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.AdvertiseSubnets))
+ m.DisableARPNDSuppression = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.DisableARPNDSuppression))
+
+}
+
+func (m *sdnZoneModel) toAPIRequestBody() *zones.ZoneRequestData {
+ data := &zones.ZoneRequestData{}
+
+ data.ID = m.Name.ValueString()
+
+ data.Type = m.Type.ValueStringPointer()
+ data.IPAM = m.IPAM.ValueStringPointer()
+ data.DNS = m.DNS.ValueStringPointer()
+ data.ReverseDNS = m.ReverseDNS.ValueStringPointer()
+ data.DNSZone = m.DNSZone.ValueStringPointer()
+ data.Nodes = m.Nodes.ValueStringPointer()
+ data.MTU = m.MTU.ValueInt64Pointer()
+ data.Bridge = m.Bridge.ValueStringPointer()
+ data.ServiceVLAN = m.ServiceVLAN.ValueInt64Pointer()
+ data.ServiceVLANProtocol = m.ServiceVLANProtocol.ValueStringPointer()
+ data.Peers = m.Peers.ValueStringPointer()
+ data.Controller = m.Controller.ValueStringPointer()
+ data.ExitNodes = m.ExitNodes.ValueStringPointer()
+ data.PrimaryExitNode = m.PrimaryExitNode.ValueStringPointer()
+ data.RouteTargetImport = m.RouteTargetImport.ValueStringPointer()
+ data.VRFVXLANID = m.VRFVXLANID.ValueInt64Pointer()
+ data.ExitNodesLocalRouting = ptrConversion.BoolToInt64Ptr(m.ExitNodesLocalRouting.ValueBoolPointer())
+ data.AdvertiseSubnets = ptrConversion.BoolToInt64Ptr(m.AdvertiseSubnets.ValueBoolPointer())
+ data.DisableARPNDSuppression = ptrConversion.BoolToInt64Ptr(m.DisableARPNDSuppression.ValueBoolPointer())
+
+ return data
+}
diff --git a/fwprovider/helpers/ptrConversion/ptr_conversion.go b/fwprovider/helpers/ptrConversion/ptr_conversion.go
new file mode 100644
index 00000000..a4cc1c3e
--- /dev/null
+++ b/fwprovider/helpers/ptrConversion/ptr_conversion.go
@@ -0,0 +1,33 @@
+package ptrConversion
+
+func BoolToInt64Ptr(boolPtr *bool) *int64 {
+ if boolPtr != nil {
+ var result int64
+
+ if *boolPtr {
+ result = int64(1)
+ } else {
+ result = int64(0)
+ }
+
+ return &result
+ }
+
+ return nil
+}
+
+func Int64ToBoolPtr(int64ptr *int64) *bool {
+ if int64ptr != nil {
+ var result bool
+
+ if *int64ptr == 0 {
+ result = false
+ } else {
+ result = true
+ }
+
+ return &result
+ }
+
+ return nil
+}
diff --git a/fwprovider/provider.go b/fwprovider/provider.go
index 4304e53e..768daa78 100644
--- a/fwprovider/provider.go
+++ b/fwprovider/provider.go
@@ -30,6 +30,7 @@ import (
"github.com/bpg/terraform-provider-proxmox/fwprovider/cluster/hardwaremapping"
"github.com/bpg/terraform-provider-proxmox/fwprovider/cluster/metrics"
"github.com/bpg/terraform-provider-proxmox/fwprovider/cluster/options"
+ "github.com/bpg/terraform-provider-proxmox/fwprovider/cluster/sdn"
"github.com/bpg/terraform-provider-proxmox/fwprovider/config"
"github.com/bpg/terraform-provider-proxmox/fwprovider/nodes"
"github.com/bpg/terraform-provider-proxmox/fwprovider/nodes/apt"
@@ -515,6 +516,9 @@ func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resourc
nodes.NewDownloadFileResource,
options.NewClusterOptionsResource,
vm.NewResource,
+ sdn.NewSDNZoneResource,
+ sdn.NewSDNVnetResource,
+ sdn.NewSDNSubnetResource,
}
}
@@ -538,6 +542,9 @@ func (p *proxmoxProvider) DataSources(_ context.Context) []func() datasource.Dat
hardwaremapping.NewUSBDataSource,
metrics.NewMetricsServerDatasource,
vm.NewDataSource,
+ sdn.NewSDNZoneDataSource,
+ sdn.NewSDNVnetDataSource,
+ sdn.NewSDNSubnetDataSource,
}
}
diff --git a/fwprovider/test/datasource_sdn_subnet_test.go b/fwprovider/test/datasource_sdn_subnet_test.go
new file mode 100644
index 00000000..20c4abb6
--- /dev/null
+++ b/fwprovider/test/datasource_sdn_subnet_test.go
@@ -0,0 +1,64 @@
+//go:build acceptance || all
+
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package test
+
+import (
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAccDatasourceSDNSubnet(t *testing.T) {
+ t.Parallel()
+
+ te := InitEnvironment(t)
+
+ tests := []struct {
+ name string
+ steps []resource.TestStep
+ }{
+ {
+ "read sdn subnet attributes",
+ []resource.TestStep{{
+ Config: te.RenderConfig(`
+ data "proxmox_virtual_environment_sdn_vnet" "vnet_ex" {
+ name = "{{ .VNetName }}"
+ }
+
+ data "proxmox_virtual_environment_sdn_subnet" "subnet_ex" {
+ subnet = "{{ .SubnetName }}"
+ vnet = data.proxmox_virtual_environment_sdn_vnet.vnet_ex.id
+ }
+ `),
+ Check: resource.ComposeTestCheckFunc(
+ ResourceAttributesSet("data.proxmox_virtual_environment_sdn_subnet.subnet_ex", []string{
+ "id",
+ "subnet",
+ "canonical_name",
+ "type",
+ "vnet",
+ "dhcp_dns_server",
+ "dhcp_range.#",
+ "gateway",
+ "snat",
+ }),
+ ),
+ }},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resource.ParallelTest(t, resource.TestCase{
+ ProtoV6ProviderFactories: te.AccProviders,
+ Steps: tt.steps,
+ })
+ })
+ }
+}
diff --git a/fwprovider/test/datasource_sdn_vnet_test.go b/fwprovider/test/datasource_sdn_vnet_test.go
new file mode 100644
index 00000000..2a35c062
--- /dev/null
+++ b/fwprovider/test/datasource_sdn_vnet_test.go
@@ -0,0 +1,54 @@
+//go:build acceptance || all
+
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package test
+
+import (
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAccDatasourceSDNVNet(t *testing.T) {
+ t.Parallel()
+
+ te := InitEnvironment(t)
+
+ tests := []struct {
+ name string
+ steps []resource.TestStep
+ }{
+ {
+ "read sdn vnet attributes",
+ []resource.TestStep{{
+ Config: te.RenderConfig(`
+ data "proxmox_virtual_environment_sdn_vnet" "vnet_ex" {
+ name = "{{ .VnetName }}"
+ }
+ `),
+ Check: resource.ComposeTestCheckFunc(
+ ResourceAttributesSet("data.proxmox_virtual_environment_sdn_vnet.vnet_ex", []string{
+ "id",
+ "name",
+ "zone",
+ "type",
+ }),
+ ),
+ }},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resource.ParallelTest(t, resource.TestCase{
+ ProtoV6ProviderFactories: te.AccProviders,
+ Steps: tt.steps,
+ })
+ })
+ }
+}
diff --git a/fwprovider/test/datasource_sdn_zone_test.go b/fwprovider/test/datasource_sdn_zone_test.go
new file mode 100644
index 00000000..9309897e
--- /dev/null
+++ b/fwprovider/test/datasource_sdn_zone_test.go
@@ -0,0 +1,54 @@
+//go:build acceptance || all
+
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package test
+
+import (
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAccDatasourceSDNZone(t *testing.T) {
+ t.Parallel()
+
+ te := InitEnvironment(t)
+
+ tests := []struct {
+ name string
+ steps []resource.TestStep
+ }{
+ {
+ "read sdn zone attributes",
+ []resource.TestStep{{
+ Config: te.RenderConfig(`
+ data "proxmox_virtual_environment_sdn_zone" "zone_ex" {
+ name = "{{ .ZoneName }}"
+ }
+ `),
+ Check: resource.ComposeTestCheckFunc(
+ ResourceAttributesSet("data.proxmox_virtual_environment_sdn_zone.zone_ex", []string{
+ "id",
+ "name",
+ "type",
+ "ipam",
+ }),
+ ),
+ }},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resource.ParallelTest(t, resource.TestCase{
+ ProtoV6ProviderFactories: te.AccProviders,
+ Steps: tt.steps,
+ })
+ })
+ }
+}
diff --git a/fwprovider/test/resource_sdn_test.go b/fwprovider/test/resource_sdn_test.go
new file mode 100644
index 00000000..e763d116
--- /dev/null
+++ b/fwprovider/test/resource_sdn_test.go
@@ -0,0 +1,157 @@
+//go:build acceptance || all
+
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package test
+
+import (
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAccResourceSDN(t *testing.T) {
+ te := InitEnvironment(t)
+
+ tests := []struct {
+ name string
+ steps []resource.TestStep
+ }{
+ {"create zones, vnets and subnets", []resource.TestStep{{
+ Config: te.RenderConfig(`
+ resource "proxmox_virtual_environment_sdn_zone" "zone_simple" {
+ name = "zoneS"
+ type = "simple"
+ nodes = "weisshorn-proxmox"
+ mtu = 1496
+ }
+
+ resource "proxmox_virtual_environment_sdn_zone" "zone_vlan" {
+ name = "zoneVLAN"
+ type = "vlan"
+ nodes = "weisshorn-proxmox"
+ mtu = 1500
+ bridge = "vmbr0"
+ }
+
+ resource "proxmox_virtual_environment_sdn_vnet" "vnet_simple" {
+ name = "vnetM"
+ zone = proxmox_virtual_environment_sdn_zone.zone_simple.name
+ alias = "vnet in zoneM"
+ isolate_ports = "0"
+ vlanaware = "0"
+ zonetype = proxmox_virtual_environment_sdn_zone.zone_simple.type
+ depends_on = [proxmox_virtual_environment_sdn_zone.zone_simple]
+ }
+
+ resource "proxmox_virtual_environment_sdn_vnet" "vnet_vlan" {
+ name = "vnetVLAN"
+ zone = proxmox_virtual_environment_sdn_zone.zone_vlan.name
+ alias = "vnet in zoneVLAN"
+ tag = 1000
+ zonetype = proxmox_virtual_environment_sdn_zone.zone_vlan.type
+ depends_on = [proxmox_virtual_environment_sdn_zone.zone_vlan]
+ }
+
+ resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple" {
+ subnet = "10.10.0.0/24"
+ vnet = proxmox_virtual_environment_sdn_vnet.vnet_simple.name
+ dhcp_dns_server = "10.10.0.53"
+ dhcp_range = [
+ {
+ start_address = "10.10.0.10"
+ end_address = "10.10.0.100"
+ }
+ ]
+ gateway = "10.10.0.1"
+ snat = true
+ depends_on = [proxmox_virtual_environment_sdn_vnet.vnet_simple]
+ }
+
+ resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple2" {
+ subnet = "10.40.0.0/24"
+ vnet = proxmox_virtual_environment_sdn_vnet.vnet_simple.name
+ dhcp_dns_server = "10.40.0.53"
+ dhcp_range = [
+ {
+ start_address = "10.40.0.10"
+ end_address = "10.40.0.100"
+ }
+ ]
+ gateway = "10.40.0.1"
+ snat = true
+ depends_on = [proxmox_virtual_environment_sdn_vnet.vnet_simple]
+ }
+
+ resource "proxmox_virtual_environment_sdn_subnet" "subnet_vlan" {
+ subnet = "10.20.0.0/24"
+ vnet = proxmox_virtual_environment_sdn_vnet.vnet_vlan.name
+ dhcp_dns_server = "10.20.0.53"
+ dhcp_range = [
+ {
+ start_address = "10.20.0.10"
+ end_address = "10.20.0.100"
+ }
+ ]
+ gateway = "10.20.0.100"
+ snat = false
+ depends_on = [proxmox_virtual_environment_sdn_vnet.vnet_vlan]
+ }
+ `),
+ Check: resource.ComposeTestCheckFunc(
+ // Zones
+ ResourceAttributes("proxmox_virtual_environment_sdn_zone.zone_simple", map[string]string{
+ "name": "zoneS",
+ "type": "simple",
+ "mtu": "1496",
+ "nodes": "weisshorn-proxmox",
+ }),
+ ResourceAttributes("proxmox_virtual_environment_sdn_zone.zone_vlan", map[string]string{
+ "name": "zoneVLAN",
+ "type": "vlan",
+ "mtu": "1500",
+ "bridge": "vmbr0",
+ }),
+
+ // VNets
+ ResourceAttributes("proxmox_virtual_environment_sdn_vnet.vnet_simple", map[string]string{
+ "name": "vnetM",
+ "alias": "vnet in zoneM",
+ "zone": "zoneS",
+ "isolate_ports": "false",
+ "vlanaware": "false",
+ "zonetype": "simple",
+ }),
+ ResourceAttributes("proxmox_virtual_environment_sdn_vnet.vnet_vlan", map[string]string{
+ "name": "vnetVLAN",
+ "alias": "vnet in zoneVLAN",
+ "zone": "zoneVLAN",
+ "tag": "1000",
+ "zonetype": "vlan",
+ }),
+
+ // Subnet (only check one in detail to avoid too many long checks)
+ ResourceAttributes("proxmox_virtual_environment_sdn_subnet.subnet_simple", map[string]string{
+ "subnet": "10.10.0.0/24",
+ "vnet": "vnetM",
+ "gateway": "10.10.0.1",
+ "dhcp_dns_server": "10.10.0.53",
+ "snat": "true",
+ }),
+ ),
+ }}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resource.ParallelTest(t, resource.TestCase{
+ ProtoV6ProviderFactories: te.AccProviders,
+ Steps: tt.steps,
+ })
+ })
+ }
+}
diff --git a/fwprovider/test/test_environment.go b/fwprovider/test/test_environment.go
index 6e0b7d8e..80e53f83 100644
--- a/fwprovider/test/test_environment.go
+++ b/fwprovider/test/test_environment.go
@@ -141,6 +141,16 @@ func InitEnvironment(t *testing.T) *Environment {
nodeName = "pve"
}
+ zoneName := utils.GetAnyStringEnv("PROXMOX_VE_ACC_ZONE_NAME")
+ if zoneName == "" {
+ zoneName = "ZoneEx"
+ }
+
+ vnetName := utils.GetAnyStringEnv("PROXMOX_VE_ACC_VNET_NAME")
+ if vnetName == "" {
+ vnetName = "VnetEx"
+ }
+
const datastoreID = "local"
cloudImagesServer := utils.GetAnyStringEnv("PROXMOX_VE_ACC_CLOUD_IMAGES_SERVER")
@@ -160,6 +170,8 @@ func InitEnvironment(t *testing.T) *Environment {
"DatastoreID": datastoreID,
"CloudImagesServer": cloudImagesServer,
"ContainerImagesServer": containerImagesServer,
+ "ZoneName": zoneName,
+ "VnetName": vnetName,
},
NodeName: nodeName,
DatastoreID: datastoreID,
diff --git a/proxmox/cluster/client.go b/proxmox/cluster/client.go
index e4f2314a..6a06f1a7 100644
--- a/proxmox/cluster/client.go
+++ b/proxmox/cluster/client.go
@@ -15,6 +15,9 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/ha"
"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/mapping"
"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/metrics"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/subnets"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/vnets"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
"github.com/bpg/terraform-provider-proxmox/proxmox/firewall"
)
@@ -54,3 +57,18 @@ func (c *Client) ACME() *acme.Client {
func (c *Client) Metrics() *metrics.Client {
return &metrics.Client{Client: c}
}
+
+// SDNZones returns a client for managing the cluster's SDN zones
+func (c *Client) SDNZones() *zones.Client {
+ return &zones.Client{Client: c}
+}
+
+// SDNVnets returns a client for managing the cluster's SDN Vnets
+func (c *Client) SDNVnets() *vnets.Client {
+ return &vnets.Client{Client: c}
+}
+
+// SDNSubnets returns a client for managing the cluster's SDN Subnets
+func (c *Client) SDNSubnets() *subnets.Client {
+ return &subnets.Client{Client: c}
+}
diff --git a/proxmox/cluster/sdn/sdn_test.go b/proxmox/cluster/sdn/sdn_test.go
new file mode 100644
index 00000000..1e6c13b7
--- /dev/null
+++ b/proxmox/cluster/sdn/sdn_test.go
@@ -0,0 +1,196 @@
+package sdn
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/subnets"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/vnets"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
+)
+
+const (
+ testZoneID = "testzone"
+ testVnetID = "testvnet"
+ testSubnetCIDR = "10.10.0.0/24"
+ testSubnetCanonical = "testzone-10.10.0.0-24"
+ testGateway = "10.10.0.1"
+ testDNS = "10.10.0.53"
+ testDHCPStart = "10.10.0.10"
+ testDHCPEnd = "10.10.0.100"
+)
+
+type testClients struct {
+ zone *zones.Client
+ vnet *vnets.Client
+ subnet *subnets.Client
+}
+
+func getTestClients(t *testing.T) *testClients {
+ apiToken := os.Getenv("PVE_TOKEN")
+ url := os.Getenv("PVE_URL")
+ if apiToken == "" || url == "" {
+ t.Skip("PVE_TOKEN and PVE_URL must be set")
+ }
+ conn, err := api.NewConnection(url, true, "")
+ if err != nil {
+ t.Fatalf("connection error: %v", err)
+ }
+ creds := api.Credentials{TokenCredentials: &api.TokenCredentials{APIToken: apiToken}}
+ client, err := api.NewClient(creds, conn)
+ if err != nil {
+ t.Fatalf("client error: %v", err)
+ }
+
+ return &testClients{
+ zone: &zones.Client{Client: client},
+ vnet: &vnets.Client{Client: client},
+ subnet: &subnets.Client{Client: client},
+ }
+}
+
+func TestSDNLifecycle(t *testing.T) {
+ clients := getTestClients(t)
+
+ t.Run("Create Zone", func(t *testing.T) {
+ err := clients.zone.CreateZone(context.Background(), &zones.ZoneRequestData{
+ ZoneData: zones.ZoneData{
+ ID: testZoneID,
+ Type: ptr.Ptr("vlan"),
+ IPAM: ptr.Ptr("pve"),
+ Bridge: ptr.Ptr("vmbr0"),
+ MTU: ptr.Ptr(int64(1500)),
+ Nodes: ptr.Ptr("pvenode1"),
+ },
+ })
+ if err != nil {
+ t.Fatalf("CreateZone failed: %v", err)
+ }
+ })
+
+ t.Run("Get Zone", func(t *testing.T) {
+ zone, err := clients.zone.GetZone(context.Background(), testZoneID)
+ if err != nil {
+ t.Fatalf("GetZone failed: %v", err)
+ }
+ t.Logf("Zone: %+v", zone)
+ })
+
+ t.Run("Update Zone", func(t *testing.T) {
+ err := clients.zone.UpdateZone(context.Background(), &zones.ZoneRequestData{
+ ZoneData: zones.ZoneData{
+ ID: testZoneID,
+ Nodes: ptr.Ptr("updatednode"),
+ Bridge: ptr.Ptr("vmbr1"), // simulate a VLAN-related update
+ },
+ })
+ if err != nil {
+ t.Fatalf("UpdateZone failed: %v", err)
+ }
+ })
+
+ t.Run("Create VNet", func(t *testing.T) {
+ err := clients.vnet.CreateVnet(context.Background(), &vnets.VnetRequestData{
+ VnetData: vnets.VnetData{
+ ID: testVnetID,
+ Zone: ptr.Ptr(testZoneID),
+ Alias: ptr.Ptr("TestVNet"),
+ IsolatePorts: ptr.Ptr(int64(0)),
+ Type: ptr.Ptr("vnet"),
+ Tag: ptr.Ptr(int64(100)),
+ VlanAware: ptr.Ptr(int64(0)),
+ },
+ })
+ if err != nil {
+ t.Fatalf("CreateVnet failed: %v", err)
+ }
+ })
+
+ t.Run("Get VNet", func(t *testing.T) {
+ vnet, err := clients.vnet.GetVnet(context.Background(), testVnetID)
+ if err != nil {
+ t.Fatalf("GetVnet failed: %v", err)
+ }
+ t.Logf("VNet: %+v", vnet)
+ })
+
+ t.Run("Update VNet", func(t *testing.T) {
+ err := clients.vnet.UpdateVnet(context.Background(), &vnets.VnetRequestData{
+ VnetData: vnets.VnetData{
+ ID: testVnetID,
+ Alias: ptr.Ptr("UpdatedAlias"),
+ },
+ })
+ if err != nil {
+ t.Fatalf("UpdateVnet failed: %v", err)
+ }
+ })
+
+ t.Run("Create Subnet", func(t *testing.T) {
+ ptr := &subnets.SubnetData{
+ ID: testSubnetCIDR,
+ Vnet: ptr.Ptr(testVnetID),
+ Type: ptr.Ptr("subnet"),
+ Gateway: ptr.Ptr(testGateway),
+ DHCPDNSServer: ptr.Ptr(testDNS),
+ DHCPRange: subnets.DHCPRangeList{
+ {StartAddress: testDHCPStart, EndAddress: testDHCPEnd},
+ },
+ SNAT: ptr.Ptr(int64(1)),
+ }
+ req := &subnets.SubnetRequestData{
+ EncodedSubnetData: *ptr.ToEncoded(),
+ }
+ err := clients.subnet.CreateSubnet(context.Background(), testVnetID, req)
+ if err != nil {
+ t.Fatalf("CreateSubnet failed: %v", err)
+ }
+ })
+
+ t.Run("Get Subnet", func(t *testing.T) {
+ subnet, err := clients.subnet.GetSubnet(context.Background(), testVnetID, testSubnetCanonical)
+ if err != nil {
+ t.Fatalf("GetSubnet failed: %v", err)
+ }
+ t.Logf("Subnet: %+v", subnet)
+ })
+
+ t.Run("Update Subnet", func(t *testing.T) {
+ ptr := &subnets.SubnetData{
+ ID: testSubnetCanonical,
+ Vnet: ptr.Ptr(testVnetID),
+ Gateway: ptr.Ptr("10.10.0.254"),
+ }
+ req := &subnets.SubnetRequestData{
+ EncodedSubnetData: *ptr.ToEncoded(),
+ }
+ err := clients.subnet.UpdateSubnet(context.Background(), testVnetID, req)
+ if err != nil {
+ t.Fatalf("UpdateSubnet failed: %v", err)
+ }
+ })
+
+ t.Run("Delete Subnet", func(t *testing.T) {
+ err := clients.subnet.DeleteSubnet(context.Background(), testVnetID, testSubnetCanonical)
+ if err != nil {
+ t.Fatalf("DeleteSubnet failed: %v", err)
+ }
+ })
+
+ t.Run("Delete VNet", func(t *testing.T) {
+ err := clients.vnet.DeleteVnet(context.Background(), testVnetID)
+ if err != nil {
+ t.Fatalf("DeleteVnet failed: %v", err)
+ }
+ })
+
+ t.Run("Delete Zone", func(t *testing.T) {
+ err := clients.zone.DeleteZone(context.Background(), testZoneID)
+ if err != nil {
+ t.Fatalf("DeleteZone failed: %v", err)
+ }
+ })
+}
diff --git a/proxmox/cluster/sdn/subnets/api.go b/proxmox/cluster/sdn/subnets/api.go
new file mode 100644
index 00000000..87f49c1a
--- /dev/null
+++ b/proxmox/cluster/sdn/subnets/api.go
@@ -0,0 +1,13 @@
+package subnets
+
+import (
+ "context"
+)
+
+type API interface {
+ GetSubnets(ctx context.Context, vnetID string) ([]SubnetData, error)
+ GetSubnet(ctx context.Context, vnetID string, id string) (*SubnetData, error)
+ CreateSubnet(ctx context.Context, vnetID string, data *SubnetRequestData) error
+ UpdateSubnet(ctx context.Context, vnetID string, data *SubnetRequestData) error
+ DeleteSubnet(ctx context.Context, vnetID string, id string) error
+}
diff --git a/proxmox/cluster/sdn/subnets/client.go b/proxmox/cluster/sdn/subnets/client.go
new file mode 100644
index 00000000..72633007
--- /dev/null
+++ b/proxmox/cluster/sdn/subnets/client.go
@@ -0,0 +1,17 @@
+package subnets
+
+import (
+ "fmt"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+)
+
+// Client is a client for accessing the Proxmox SDN VNETs API.
+type Client struct {
+ api.Client
+}
+
+// ExpandPath returns the API path for SDN VNETS.
+func (c *Client) ExpandPath(vnet_id string, path string) string {
+ return fmt.Sprintf("cluster/sdn/vnets/%s/subnets/%s", vnet_id, path)
+}
diff --git a/proxmox/cluster/sdn/subnets/subnets.go b/proxmox/cluster/sdn/subnets/subnets.go
new file mode 100644
index 00000000..ec2c6458
--- /dev/null
+++ b/proxmox/cluster/sdn/subnets/subnets.go
@@ -0,0 +1,71 @@
+package subnets
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+)
+
+// GetSubnet retrieves a single Subnet by ID and containing Vnet's ID
+func (c *Client) GetSubnet(ctx context.Context, vnetID string, id string) (*SubnetData, error) {
+ resBody := &SubnetResponseBody{}
+
+ err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(vnetID, id), nil, resBody)
+ if err != nil {
+ return nil, fmt.Errorf("Error reading SDN subnet %s for Vnet %s: %w", id, vnetID, err)
+ }
+
+ if resBody.Data == nil {
+ return nil, api.ErrNoDataObjectInResponse
+ }
+
+ return resBody.Data, nil
+}
+
+// GetSubnets lists all Subnets related to a Vnet
+func (c *Client) GetSubnets(ctx context.Context, vnetID string) ([]SubnetData, error) {
+ resBody := &SubnetsResponseBody{}
+
+ err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(vnetID, ""), nil, resBody)
+ if err != nil {
+ return nil, fmt.Errorf("Error listing Subnets for Vnet %s: %w", vnetID, err)
+ }
+
+ if resBody.Data == nil {
+ return nil, api.ErrNoDataObjectInResponse
+ }
+
+ return *resBody.Data, nil
+}
+
+// CreateSubnet creates a new Subnet in the defined Vnet
+func (c *Client) CreateSubnet(ctx context.Context, vnetID string, data *SubnetRequestData) error {
+ err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(vnetID, ""), data, nil)
+ if err != nil {
+ return fmt.Errorf("Error creating subnet %s on VNet %s: %w", data.ID, vnetID, err)
+ }
+
+ return nil
+}
+
+// UpdateSubnet updates an existing subnet inside a defined vnet
+func (c *Client) UpdateSubnet(ctx context.Context, vnetID string, data *SubnetRequestData) error {
+ err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(vnetID, data.ID), data, nil)
+ if err != nil {
+ return fmt.Errorf("Error updating subnet %s on VNet %s: %w", data.ID, vnetID, err)
+ }
+
+ return nil
+}
+
+// DeleteSubnet deletes an existing subnet inside a defined vnet
+func (c *Client) DeleteSubnet(ctx context.Context, vnetID string, id string) error {
+ err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(vnetID, id), nil, nil)
+ if err != nil {
+ return fmt.Errorf("Error deleting subnet %s on VNet %s: %s", id, vnetID, err)
+ }
+
+ return nil
+}
diff --git a/proxmox/cluster/sdn/subnets/subnets_types.go b/proxmox/cluster/sdn/subnets/subnets_types.go
new file mode 100644
index 00000000..aa0af6ba
--- /dev/null
+++ b/proxmox/cluster/sdn/subnets/subnets_types.go
@@ -0,0 +1,87 @@
+package subnets
+
+import (
+ "fmt"
+)
+
+/*
+--------------------------------- SUBNETS -----------------------------------------------
+
+This part is related to the SDN component : SubNets
+Based on docs :
+https://pve.proxmox.com/pve-docs/chapter-pvesdn.html#pvesdn_config_subnet
+https://pve.proxmox.com/pve-docs/api-viewer/index.html#/cluster/sdn/vnets/{vnet}/subnets
+
+Notes:
+ 1. The Type is once again defined as an enum type in the API docs but isn't referenced
+ anywhere. Therefore no way to check what are allowed types. 'subnet' works
+ 2. Currently in the API there are Delete and Digest options which are not available
+ in the UI so the choice was made to remove them temporary, waiting for a fix.
+ 3. It is also not really in the terraform spirit to update elements like this.
+
+-----------------------------------------------------------------------------------------
+*/
+type SubnetData struct {
+ ID string `json:"subnet,omitempty" url:"subnet,omitempty"`
+ Type *string `json:"type,omitempty" url:"type,omitempty"`
+ Vnet *string `json:"vnet,omitempty" url:"vnet,omitempty"`
+ DHCPDNSServer *string `json:"dhcp-dns-server,omitempty" url:"dhcp-dns-server,omitempty"`
+ DHCPRange DHCPRangeList `json:"dhcp-range,omitempty" url:"dhcp-range,omitempty"`
+ DNSZonePrefix *string `json:"dnszoneprefix,omitempty" url:"dnszoneprefix,omitempty"`
+ Gateway *string `json:"gateway,omitempty" url:"gateway,omitempty"`
+ SNAT *int64 `json:"snat,omitempty" url:"snat,omitempty"`
+}
+
+type SubnetRequestData struct {
+ EncodedSubnetData
+ Delete []string `url:"delete,omitempty"`
+}
+
+type SubnetResponseBody struct {
+ Data *SubnetData `json:"data"`
+}
+
+type SubnetsResponseBody struct {
+ Data *[]SubnetData `json:"data"`
+}
+
+type DHCPRangeList []DHCPRangeEntry
+
+type DHCPRangeEntry struct {
+ StartAddress string `json:"start-address"`
+ EndAddress string `json:"end-address"`
+}
+
+/*
+This structure had to be defined and added after realizing a weird behavior in Proxmox's API.
+When creating or updating Subnets, the dhcpRange needs to be passed as string array.
+But when reading (GET), it arrives as an array of JSON structures.
+*/
+type EncodedSubnetData struct {
+ ID string `url:"subnet,omitempty"`
+ Type *string `url:"type,omitempty"`
+ Vnet *string `url:"vnet,omitempty"`
+ DHCPDNSServer *string `url:"dhcp-dns-server,omitempty"`
+ DHCPRange []string `url:"dhcp-range,omitempty"` // manually formatted
+ DNSZonePrefix *string `url:"dnszoneprefix,omitempty"`
+ Gateway *string `url:"gateway,omitempty"`
+ SNAT *int64 `url:"snat,omitempty"`
+}
+
+func (s *SubnetData) ToEncoded() *EncodedSubnetData {
+ var encodedRanges []string
+ for _, r := range s.DHCPRange {
+ encodedRanges = append(encodedRanges, fmt.Sprintf("start-address=%s,end-address=%s", r.StartAddress, r.EndAddress))
+ }
+
+ return &EncodedSubnetData{
+ ID: s.ID,
+ Type: s.Type,
+ Vnet: s.Vnet,
+ DHCPDNSServer: s.DHCPDNSServer,
+ DHCPRange: encodedRanges,
+ DNSZonePrefix: s.DNSZonePrefix,
+ Gateway: s.Gateway,
+ SNAT: s.SNAT,
+ }
+}
diff --git a/proxmox/cluster/sdn/vnets/api.go b/proxmox/cluster/sdn/vnets/api.go
new file mode 100644
index 00000000..16d25916
--- /dev/null
+++ b/proxmox/cluster/sdn/vnets/api.go
@@ -0,0 +1,16 @@
+package vnets
+
+import (
+ "context"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
+)
+
+type API interface {
+ GetVnets(ctx context.Context) ([]VnetData, error)
+ GetVnet(ctx context.Context, id string) (*VnetData, error)
+ CreateVnet(ctx context.Context, req *VnetRequestData) error
+ UpdateVnet(ctx context.Context, req *VnetRequestData) error
+ DeleteVnet(ctx context.Context, id string) error
+ GetParentZone(ctx context.Context, zoneId string) (*zones.ZoneData, error)
+}
diff --git a/proxmox/cluster/sdn/vnets/client.go b/proxmox/cluster/sdn/vnets/client.go
new file mode 100644
index 00000000..b5fc42c5
--- /dev/null
+++ b/proxmox/cluster/sdn/vnets/client.go
@@ -0,0 +1,21 @@
+package vnets
+
+import (
+ "fmt"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+)
+
+// Client is a client for accessing the Proxmox SDN VNETs API.
+type Client struct {
+ api.Client
+}
+
+// ExpandPath returns the API path for SDN VNETS.
+func (c *Client) ExpandPath(path string) string {
+ return fmt.Sprintf("cluster/sdn/vnets/%s", path)
+}
+
+func (c *Client) ParentPath(parentId string) string {
+ return fmt.Sprintf("cluster/sdn/zones/%s", parentId)
+}
diff --git a/proxmox/cluster/sdn/vnets/vnets.go b/proxmox/cluster/sdn/vnets/vnets.go
new file mode 100644
index 00000000..b6f194b7
--- /dev/null
+++ b/proxmox/cluster/sdn/vnets/vnets.go
@@ -0,0 +1,82 @@
+package vnets
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+ "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
+)
+
+// GetVnet retrieves a single SDN Vnet by ID
+func (c *Client) GetVnet(ctx context.Context, id string) (*VnetData, error) {
+ resBody := &VnetResponseBody{}
+
+ err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(id), nil, resBody)
+ if err != nil {
+ return nil, fmt.Errorf("Error reading SDN Vnet %s: %w", id, err)
+ }
+
+ if resBody.Data == nil {
+ return nil, api.ErrNoDataObjectInResponse
+ }
+
+ return resBody.Data, nil
+}
+
+// GetVnets lists all SDN Vnets
+func (c *Client) GetVnets(ctx context.Context) ([]VnetData, error) {
+ resBody := &VnetsResponseBody{}
+
+ err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(""), nil, resBody)
+ if err != nil {
+ return nil, fmt.Errorf("Error listing SDN Vnets: %w", err)
+ }
+
+ if resBody.Data == nil {
+ return nil, api.ErrNoDataObjectInResponse
+ }
+
+ return *resBody.Data, nil
+}
+
+// CreateVnet creates a new SDN VNET
+func (c *Client) CreateVnet(ctx context.Context, data *VnetRequestData) error {
+ err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(""), data, nil)
+ if err != nil {
+ return fmt.Errorf("Error creating SDN VNET: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateVnet Updates an existing VNet
+func (c *Client) UpdateVnet(ctx context.Context, data *VnetRequestData) error {
+ err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(data.ID), data, nil)
+ if err != nil {
+ return fmt.Errorf("Error updating SDN VNET: %w", err)
+ }
+
+ return nil
+}
+
+// DeleteVnet deletes an SDN VNET by ID
+func (c *Client) DeleteVnet(ctx context.Context, id string) error {
+ err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(id), nil, nil)
+ if err != nil {
+ return fmt.Errorf("Error deleting SDN VNET: %w", err)
+ }
+
+ return nil
+}
+
+func (c *Client) GetParentZone(ctx context.Context, zoneId string) (*zones.ZoneData, error) {
+ parentZone := zones.ZoneResponseBody{}
+ err := c.DoRequest(ctx, http.MethodGet, c.ParentPath(zoneId), nil, parentZone)
+ if err != nil {
+ return nil, fmt.Errorf("Error fetching vnet's parent zone %s: %w", zoneId, err)
+ }
+
+ return parentZone.Data, nil
+}
diff --git a/proxmox/cluster/sdn/vnets/vnets_types.go b/proxmox/cluster/sdn/vnets/vnets_types.go
new file mode 100644
index 00000000..8b622aa3
--- /dev/null
+++ b/proxmox/cluster/sdn/vnets/vnets_types.go
@@ -0,0 +1,49 @@
+package vnets
+
+/*
+--------------------------------- VNETS ---------------------------------
+
+This part is related to the SDN component : VNETS
+Based on docs :
+https://pve.proxmox.com/pve-docs/chapter-pvesdn.html#pvesdn_config_vnet
+https://pve.proxmox.com/pve-docs/api-viewer/index.html#/cluster/sdn/vnets
+
+Notes:
+
+ 1. IsolatePorts is a boolean in the docs but needs to be passed as 0 or 1
+ and is therefore defined as int.
+
+ 2. Type field can be 'vnet' but other values are unknown
+
+ 3. Tag cannot be set on Vnets created in simple Zones, might actually be
+ only usable on vlan or vxlan zones as it sets the vlan or vxlan id.
+
+ 4. Currently in the API there are Delete and Digest options which are not available
+ in the UI so the choice was made to remove them temporary, waiting for a fix.
+
+-------------------------------------------------------------------------
+*/
+type VnetData struct {
+ ID string `json:"vnet,omitempty" url:"vnet,omitempty"`
+ Zone *string `json:"zone,omitempty" url:"zone,omitempty"`
+ Alias *string `json:"alias,omitempty" url:"alias,omitempty"`
+ IsolatePorts *int64 `json:"isolate-ports,omitempty" url:"isolate-ports,omitempty"`
+ Tag *int64 `json:"tag,omitempty" url:"tag,omitempty"`
+ Type *string `json:"type,omitempty" url:"type,omitempty"`
+ VlanAware *int64 `json:"vlanaware,omitempty" url:"vlanaware,omitempty"`
+ // DeleteSettings *string `json:"delete,omitempty" url:"delete,omitempty"`
+ // Digest *string `json:"digest,omitempty" url:"digest,omitempty"`
+}
+
+type VnetRequestData struct {
+ VnetData
+ Delete []string `url:"delete,omitempty"`
+}
+
+type VnetResponseBody struct {
+ Data *VnetData `json:"data"`
+}
+
+type VnetsResponseBody struct {
+ Data *[]VnetData `json:"data"`
+}
diff --git a/proxmox/cluster/sdn/zones/api.go b/proxmox/cluster/sdn/zones/api.go
new file mode 100644
index 00000000..6a0804d6
--- /dev/null
+++ b/proxmox/cluster/sdn/zones/api.go
@@ -0,0 +1,13 @@
+package zones
+
+import (
+ "context"
+)
+
+type API interface {
+ GetZones(ctx context.Context) ([]ZoneData, error)
+ GetZone(ctx context.Context, id string) (*ZoneData, error)
+ CreateZone(ctx context.Context, req *ZoneRequestData) error
+ UpdateZone(ctx context.Context, req *ZoneRequestData) error
+ DeleteZone(ctx context.Context, id string) error
+}
diff --git a/proxmox/cluster/sdn/zones/client.go b/proxmox/cluster/sdn/zones/client.go
new file mode 100644
index 00000000..11e8942e
--- /dev/null
+++ b/proxmox/cluster/sdn/zones/client.go
@@ -0,0 +1,17 @@
+package zones
+
+import (
+ "fmt"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+)
+
+// Client is a client for accessing the Proxmox SDN Zones API.
+type Client struct {
+ api.Client
+}
+
+// ExpandPath returns the API path for SDN zones.
+func (c *Client) ExpandPath(path string) string {
+ return fmt.Sprintf("cluster/sdn/zones/%s", path)
+}
diff --git a/proxmox/cluster/sdn/zones/zones.go b/proxmox/cluster/sdn/zones/zones.go
new file mode 100644
index 00000000..b616c3af
--- /dev/null
+++ b/proxmox/cluster/sdn/zones/zones.go
@@ -0,0 +1,75 @@
+package zones
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/bpg/terraform-provider-proxmox/proxmox/api"
+)
+
+// GetZone retrieves a single SDN zone by ID.
+func (c *Client) GetZone(ctx context.Context, id string) (*ZoneData, error) {
+ resBody := &ZoneResponseBody{}
+
+ err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(id), nil, resBody)
+ if err != nil {
+ return nil, fmt.Errorf("error reading SDN zone %s: %w", id, err)
+ }
+
+ if resBody.Data == nil {
+ return nil, api.ErrNoDataObjectInResponse
+ }
+
+ return resBody.Data, nil
+}
+
+// GetZones lists all SDN zones.
+func (c *Client) GetZones(ctx context.Context) ([]ZoneData, error) {
+ resBody := &ZonesResponseBody{}
+
+ err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(""), nil, resBody)
+ if err != nil {
+ return nil, fmt.Errorf("error listing SDN zones: %w", err)
+ }
+
+ if resBody.Data == nil {
+ return nil, api.ErrNoDataObjectInResponse
+ }
+
+ return *resBody.Data, nil
+}
+
+// CreateZone creates a new SDN zone.
+func (c *Client) CreateZone(ctx context.Context, data *ZoneRequestData) error {
+ err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(""), data, nil)
+ if err != nil {
+ return fmt.Errorf("error creating SDN zone: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateZone updates an existing SDN zone.
+func (c *Client) UpdateZone(ctx context.Context, data *ZoneRequestData) error {
+ // PVE API does not allow to pass "type" in PUT requests, this doesn't makes any sense
+ // since other required params like port, server must still be there
+ // while we could spawn another struct, let's just fix it silently
+ data.Type = nil
+ err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(data.ID), data, nil)
+ if err != nil {
+ return fmt.Errorf("error updating SDN zone: %w", err)
+ }
+
+ return nil
+}
+
+// DeleteZone deletes an SDN zone by ID.
+func (c *Client) DeleteZone(ctx context.Context, id string) error {
+ err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(id), nil, nil)
+ if err != nil {
+ return fmt.Errorf("error deleting SDN zone: %w", err)
+ }
+
+ return nil
+}
diff --git a/proxmox/cluster/sdn/zones/zones_types.go b/proxmox/cluster/sdn/zones/zones_types.go
new file mode 100644
index 00000000..68e141bd
--- /dev/null
+++ b/proxmox/cluster/sdn/zones/zones_types.go
@@ -0,0 +1,57 @@
+package zones
+
+/*
+--------------------------------- ZONES ---------------------------------
+
+This part is related to the first SDN component : Zones
+Based on docs :
+https://pve.proxmox.com/pve-docs/chapter-pvesdn.html#pvesdn_config_zone
+https://pve.proxmox.com/pve-docs/api-viewer/index.html#/cluster/sdn/zones
+-------------------------------------------------------------------------
+*/
+type ZoneData struct {
+ ID string `json:"zone,omitempty" url:"zone,omitempty"`
+ Type *string `json:"type,omitempty" url:"type,omitempty"`
+ IPAM *string `json:"ipam,omitempty" url:"ipam,omitempty"`
+ DNS *string `json:"dns,omitempty" url:"dns,omitempty"`
+ ReverseDNS *string `json:"reversedns,omitempty" url:"reversedns,omitempty"`
+ DNSZone *string `json:"dnszone,omitempty" url:"dnszone,omitempty"`
+ Nodes *string `json:"nodes,omitempty" url:"nodes,omitempty"`
+ MTU *int64 `json:"mtu,omitempty" url:"mtu,omitempty"`
+
+ // VLAN
+ Bridge *string `json:"bridge,omitempty" url:"bridge,omitempty"`
+
+ // QinQ
+ ServiceVLAN *int64 `json:"tag,omitempty" url:"tag,omitempty"`
+ ServiceVLANProtocol *string `json:"vlan-protocol,omitempty" url:"vlan-protocol,omitempty"`
+
+ // VXLAN
+ Peers *string `json:"peers,omitempty" url:"peers,omitempty"`
+
+ // EVPN
+ Controller *string `json:"controller,omitempty" url:"controller,omitempty"`
+ VRFVXLANID *int64 `json:"vrf-vxlan,omitempty" url:"vrf-vxlan,omitempty"`
+ ExitNodes *string `json:"exitnodes,omitempty" url:"exitnodes,omitempty"`
+ PrimaryExitNode *string `json:"exitnodes-primary,omitempty" url:"exitnodes-primary,omitempty"`
+ ExitNodesLocalRouting *int64 `json:"exitnodes-local-routing,omitempty" url:"exitnodes-local-routing,omitempty"`
+ AdvertiseSubnets *int64 `json:"advertise-subnets,omitempty" url:"advertise-subnets,omitempty"`
+ DisableARPNDSuppression *int64 `json:"disable-arp-nd-suppression,omitempty" url:"disable-arp-nd-suppression,omitempty"`
+ RouteTargetImport *string `json:"rt-import,omitempty" url:"rt-import,omitempty"`
+}
+
+// ZoneRequestData wraps a ZoneData struct with optional delete instructions.
+type ZoneRequestData struct {
+ ZoneData
+ Delete []string `url:"delete,omitempty"`
+}
+
+// ZoneResponseBody represents the response for a single zone.
+type ZoneResponseBody struct {
+ Data *ZoneData `json:"data"`
+}
+
+// ZonesResponseBody represents the response for a list of zones.
+type ZonesResponseBody struct {
+ Data *[]ZoneData `json:"data"`
+}
diff --git a/proxmox/helpers/ptr/ptr.go b/proxmox/helpers/ptr/ptr.go
index 62213c93..9bf5ffdc 100644
--- a/proxmox/helpers/ptr/ptr.go
+++ b/proxmox/helpers/ptr/ptr.go
@@ -6,6 +6,12 @@
package ptr
+import (
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
// Ptr creates a ptr from a value to use it inline.
func Ptr[T any](val T) *T {
return &val
@@ -43,3 +49,19 @@ func UpdateIfChanged[T comparable](dst **T, src *T) bool {
return false
}
+
+// PtrOrNil safely gets a value of any type from schema.ResourceData.
+// If the key is missing, returns nil. For strings, also returns nil if empty or whitespace.
+func PtrOrNil[T any](d *schema.ResourceData, key string) *T {
+ if v, ok := d.GetOk(key); ok {
+ val := v.(T)
+
+ // Special case: skip empty/whitespace-only strings
+ if s, ok := any(val).(string); ok && strings.TrimSpace(s) == "" {
+ return nil
+ }
+
+ return &val
+ }
+ return nil
+}
diff --git a/proxmoxtf/resource/cluster/sdn/subnets.go b/proxmoxtf/resource/cluster/sdn/subnets.go
new file mode 100644
index 00000000..ae15bbd9
--- /dev/null
+++ b/proxmoxtf/resource/cluster/sdn/subnets.go
@@ -0,0 +1,34 @@
+package sdn
+
+import "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+
+const (
+ mkSubnetID = "subnet"
+ mkSubnetType = "type"
+ mkSubnetVnet = "vnet"
+ mkSubnetDhcpDnsServer = "DhcpDnsServer"
+ mkSubnetDhcpRange = "DhcpRange"
+ mkSubnetDnsZonePrefix = "DnsZonePrefix"
+ mkSubnetGateway = "gateway"
+ mkSubnetSnat = "snat"
+ mkSubnetDeleteSettings = "deleteSettings"
+ mkSubnetDigest = "digest"
+)
+
+func Subnet() *schema.Resource {
+ return &schema.Resource{
+ Schema: map[string]*schema.Schema{
+ mkSubnetID: {
+ Type: schema.TypeString,
+ Required: true,
+ ForceNew: true,
+ Description: "Subnet value",
+ },
+ mkSubnetType: {
+ Type: schema.TypeString,
+ Optional: true,
+ Description: "Subnet type",
+ },
+ },
+ }
+}
From 48bb57f0c7f77d9228d9711a239efdefbba368e3 Mon Sep 17 00:00:00 2001
From: MacherelR <64424331+MacherelR@users.noreply.github.com>
Date: Tue, 24 Jun 2025 08:19:04 +0200
Subject: [PATCH 2/2] fix(sdn): resolve linter warnings and apply gofumpt
formatting Signed-off-by: MacherelR
<64424331+MacherelR@users.noreply.github.com>
---
example/resource_virtual_environment_vm.tf | 2 +-
examples/guides/clone-vm/clone.tf | 2 +-
.../cluster/sdn/datasource_sdn_subnets.go | 33 ++++++---
.../cluster/sdn/datasource_sdn_vnets.go | 24 +++++--
.../cluster/sdn/datasource_sdn_zones.go | 15 +++-
.../cluster/sdn/resource_sdn_subnets.go | 71 ++++++++++++++-----
fwprovider/cluster/sdn/resource_sdn_vnets.go | 28 ++++++--
fwprovider/cluster/sdn/resource_sdn_zones.go | 28 ++++++--
fwprovider/cluster/sdn/sdn_subnet_model.go | 25 +++++--
fwprovider/cluster/sdn/sdn_vnet_model.go | 5 +-
fwprovider/cluster/sdn/sdn_zone_model.go | 9 ++-
proxmox/cluster/client.go | 6 +-
proxmox/cluster/sdn/sdn_test.go | 62 ++++++++++++----
proxmox/cluster/sdn/subnets/subnets.go | 20 +++---
proxmox/cluster/sdn/subnets/subnets_types.go | 24 +++----
proxmox/cluster/sdn/vnets/vnets.go | 23 +++---
proxmox/cluster/sdn/vnets/vnets_types.go | 20 +++---
proxmox/cluster/sdn/zones/zones.go | 7 +-
proxmox/cluster/sdn/zones/zones_types.go | 49 +++++++------
proxmox/helpers/ptr/ptr.go | 1 +
20 files changed, 304 insertions(+), 150 deletions(-)
diff --git a/example/resource_virtual_environment_vm.tf b/example/resource_virtual_environment_vm.tf
index 56554b7e..61a42017 100644
--- a/example/resource_virtual_environment_vm.tf
+++ b/example/resource_virtual_environment_vm.tf
@@ -1,5 +1,5 @@
locals {
- datastore_id = "local-lvm"
+ datastore_id = var.virtual_environment_storage
}
resource "proxmox_virtual_environment_vm" "example_template" {
diff --git a/examples/guides/clone-vm/clone.tf b/examples/guides/clone-vm/clone.tf
index 4f3f14af..e881eb20 100644
--- a/examples/guides/clone-vm/clone.tf
+++ b/examples/guides/clone-vm/clone.tf
@@ -1,6 +1,6 @@
resource "proxmox_virtual_environment_vm" "ubuntu_clone" {
name = "ubuntu-clone"
- node_name = var.virtual_environment_node_name
+ node_name = "pve"
clone {
vm_id = proxmox_virtual_environment_vm.ubuntu_template.id
diff --git a/fwprovider/cluster/sdn/datasource_sdn_subnets.go b/fwprovider/cluster/sdn/datasource_sdn_subnets.go
index 8602f6d3..f784dd93 100644
--- a/fwprovider/cluster/sdn/datasource_sdn_subnets.go
+++ b/fwprovider/cluster/sdn/datasource_sdn_subnets.go
@@ -13,10 +13,9 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/subnets"
)
-var (
- _ datasource.DataSource = &sdnSubnetDataSource{}
- _ datasource.DataSourceWithConfigure = &sdnSubnetDataSource{}
-)
+var _ datasource.DataSource = &sdnSubnetDataSource{}
+
+var _ datasource.DataSourceWithConfigure = &sdnSubnetDataSource{}
type sdnSubnetDataSource struct {
client *subnets.Client
@@ -26,11 +25,19 @@ func NewSDNSubnetDataSource() datasource.DataSource {
return &sdnSubnetDataSource{}
}
-func (d *sdnSubnetDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+func (d *sdnSubnetDataSource) Metadata(
+ ctx context.Context,
+ req datasource.MetadataRequest,
+ resp *datasource.MetadataResponse,
+) {
resp.TypeName = req.ProviderTypeName + "_sdn_subnet"
}
-func (d *sdnSubnetDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+func (d *sdnSubnetDataSource) Configure(
+ ctx context.Context,
+ req datasource.ConfigureRequest,
+ resp *datasource.ConfigureResponse,
+) {
if req.ProviderData == nil {
return
}
@@ -41,13 +48,18 @@ func (d *sdnSubnetDataSource) Configure(ctx context.Context, req datasource.Conf
"Unexpected Provider Configuration",
fmt.Sprintf("Expected config.DataSource, got: %T", req.ProviderData),
)
+
return
}
d.client = cfg.Client.Cluster().SDNSubnets()
}
-func (d *sdnSubnetDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+func (d *sdnSubnetDataSource) Schema(
+ ctx context.Context,
+ req datasource.SchemaRequest,
+ resp *datasource.SchemaResponse,
+) {
resp.Schema = schema.Schema{
Description: "Retrieve details about a specific SDN Subnet in Proxmox VE.",
Attributes: map[string]schema.Attribute{
@@ -109,6 +121,7 @@ func (d *sdnSubnetDataSource) Read(ctx context.Context, req datasource.ReadReque
var config sdnSubnetModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -119,17 +132,19 @@ func (d *sdnSubnetDataSource) Read(ctx context.Context, req datasource.ReadReque
resp.Diagnostics.AddError("Subnet not found", err.Error())
return
}
+
resp.Diagnostics.AddError("Failed to retrieve subnet", err.Error())
+
return
}
- // Set the state
+ // Set the state.
state := &sdnSubnetModel{}
state.Subnet = config.Subnet
state.Vnet = config.Vnet
state.importFromAPI(config.Subnet.ValueString(), subnet)
- // Set canonical name and ID (both = user-supplied subnet)
+ // Set canonical name and ID (both = user-supplied subnet).
state.ID = config.Subnet
state.CanonicalName = config.Subnet
diff --git a/fwprovider/cluster/sdn/datasource_sdn_vnets.go b/fwprovider/cluster/sdn/datasource_sdn_vnets.go
index 68d49116..d4381ef5 100644
--- a/fwprovider/cluster/sdn/datasource_sdn_vnets.go
+++ b/fwprovider/cluster/sdn/datasource_sdn_vnets.go
@@ -14,10 +14,9 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/vnets"
)
-var (
- _ datasource.DataSource = &sdnVnetDataSource{}
- _ datasource.DataSourceWithConfigure = &sdnVnetDataSource{}
-)
+var _ datasource.DataSource = &sdnVnetDataSource{}
+
+var _ datasource.DataSourceWithConfigure = &sdnVnetDataSource{}
type sdnVnetDataSource struct {
client *vnets.Client
@@ -27,11 +26,19 @@ func NewSDNVnetDataSource() datasource.DataSource {
return &sdnVnetDataSource{}
}
-func (d *sdnVnetDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+func (d *sdnVnetDataSource) Metadata(
+ ctx context.Context,
+ req datasource.MetadataRequest,
+ resp *datasource.MetadataResponse,
+) {
resp.TypeName = req.ProviderTypeName + "_sdn_vnet"
}
-func (d *sdnVnetDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+func (d *sdnVnetDataSource) Configure(
+ ctx context.Context,
+ req datasource.ConfigureRequest,
+ resp *datasource.ConfigureResponse,
+) {
if req.ProviderData == nil {
return
}
@@ -42,6 +49,7 @@ func (d *sdnVnetDataSource) Configure(ctx context.Context, req datasource.Config
"Unexpected Provider Data",
fmt.Sprintf("Expected config.DataSource, got: %T", req.ProviderData),
)
+
return
}
@@ -96,18 +104,22 @@ func (d *sdnVnetDataSource) Read(ctx context.Context, req datasource.ReadRequest
var config sdnVnetModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+
if resp.Diagnostics.HasError() {
return
}
vnetID := config.Name.ValueString()
+
vnet, err := d.client.GetVnet(ctx, vnetID)
if err != nil {
if errors.Is(err, api.ErrResourceDoesNotExist) {
resp.Diagnostics.AddError("Vnet not found", fmt.Sprintf("No vnet with ID %q exists", vnetID))
return
}
+
resp.Diagnostics.AddError("Error retrieving vnet", err.Error())
+
return
}
diff --git a/fwprovider/cluster/sdn/datasource_sdn_zones.go b/fwprovider/cluster/sdn/datasource_sdn_zones.go
index 0dde0d88..30558a0f 100644
--- a/fwprovider/cluster/sdn/datasource_sdn_zones.go
+++ b/fwprovider/cluster/sdn/datasource_sdn_zones.go
@@ -12,6 +12,7 @@ import (
)
var _ datasource.DataSource = &sdnZoneDataSource{}
+
var _ datasource.DataSourceWithConfigure = &sdnZoneDataSource{}
type sdnZoneDataSource struct {
@@ -22,11 +23,19 @@ func NewSDNZoneDataSource() datasource.DataSource {
return &sdnZoneDataSource{}
}
-func (d *sdnZoneDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+func (d *sdnZoneDataSource) Metadata(
+ _ context.Context,
+ req datasource.MetadataRequest,
+ resp *datasource.MetadataResponse,
+) {
resp.TypeName = req.ProviderTypeName + "_sdn_zone"
}
-func (d *sdnZoneDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+func (d *sdnZoneDataSource) Configure(
+ _ context.Context,
+ req datasource.ConfigureRequest,
+ resp *datasource.ConfigureResponse,
+) {
if req.ProviderData == nil {
return
}
@@ -37,6 +46,7 @@ func (d *sdnZoneDataSource) Configure(_ context.Context, req datasource.Configur
"Unexpected Provider Configuration",
fmt.Sprintf("Expected config.DataSource but got: %T", req.ProviderData),
)
+
return
}
@@ -82,6 +92,7 @@ func (d *sdnZoneDataSource) Read(ctx context.Context, req datasource.ReadRequest
var data sdnZoneModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+
if resp.Diagnostics.HasError() {
return
}
diff --git a/fwprovider/cluster/sdn/resource_sdn_subnets.go b/fwprovider/cluster/sdn/resource_sdn_subnets.go
index 38e42eda..37108306 100644
--- a/fwprovider/cluster/sdn/resource_sdn_subnets.go
+++ b/fwprovider/cluster/sdn/resource_sdn_subnets.go
@@ -40,7 +40,11 @@ func (r *sdnSubnetResource) Metadata(_ context.Context, req resource.MetadataReq
resp.TypeName = req.ProviderTypeName + "_sdn_subnet"
}
-func (r *sdnSubnetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+func (r *sdnSubnetResource) Configure(
+ _ context.Context,
+ req resource.ConfigureRequest,
+ resp *resource.ConfigureResponse,
+) {
if req.ProviderData == nil {
return
}
@@ -51,6 +55,7 @@ func (r *sdnSubnetResource) Configure(_ context.Context, req resource.ConfigureR
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected config.Resource, got: %T", req.ProviderData),
)
+
return
}
@@ -120,18 +125,23 @@ func (r *sdnSubnetResource) Schema(_ context.Context, _ resource.SchemaRequest,
func (r *sdnSubnetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan sdnSubnetModel
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+
if resp.Diagnostics.HasError() {
return
}
+
if plan.Vnet.IsNull() || plan.Vnet.IsUnknown() {
resp.Diagnostics.AddAttributeError(
path.Root("vnet"),
"missing required field",
"Missing the parent vnet's ID attribute, which is required to define a subnet")
+
return
}
- err := r.client.CreateSubnet(ctx, plan.Vnet.ValueString(), plan.toAPIRequestBody())
+
+ err := r.client.CreateSubnet(ctx, plan.Vnet.ValueString(), plan.toAPIRequestBody(ctx))
if err != nil {
resp.Diagnostics.AddError("Error creating subnet", err.Error())
return
@@ -140,8 +150,9 @@ func (r *sdnSubnetResource) Create(ctx context.Context, req resource.CreateReque
tflog.Debug(ctx, "Created object's ID", map[string]any{"plan name:": plan.Subnet})
plan.ID = plan.Subnet
- // Because proxmox API doesn't return the created object's properties and the subnet's name gets modified by proxmox internally
- // Read it back to get the canonical-ID from proxmox
+ /* Because proxmox API doesn't return the created object's properties and the subnet's name gets modified by
+ proxmox internally.
+ Read it back to get the canonical-ID from proxmox.*/
canonicalID, err := resolveCanonicalSubnetID(ctx, r.client, plan.Vnet.ValueString(), plan.Subnet.ValueString())
if err != nil {
resp.Diagnostics.AddError("Error resolving canonical subnet ID", err.Error())
@@ -156,7 +167,9 @@ func (r *sdnSubnetResource) Create(ctx context.Context, req resource.CreateReque
func (r *sdnSubnetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state sdnSubnetModel
+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -169,6 +182,7 @@ func (r *sdnSubnetResource) Read(ctx context.Context, req resource.ReadRequest,
}
resp.Diagnostics.AddError("Error reading subnet", err.Error())
+
return
}
@@ -181,24 +195,24 @@ func (r *sdnSubnetResource) Read(ctx context.Context, req resource.ReadRequest,
func (r *sdnSubnetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan sdnSubnetModel
- // var state sdnSubnetModel
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
- // resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
- reqData := plan.toAPIRequestBody()
- // reqData.Delete = toDelete
+ reqData := plan.toAPIRequestBody(ctx)
if plan.Vnet.IsNull() || plan.Vnet.IsUnknown() {
resp.Diagnostics.AddAttributeError(
path.Root("vnet"),
"missing required field",
"Missing the parent vnet's ID attribute, which is required to define a subnet")
+
return
}
+
err := r.client.UpdateSubnet(ctx, plan.Vnet.ValueString(), reqData)
if err != nil {
resp.Diagnostics.AddError("Error updating subnet", err.Error())
@@ -210,7 +224,9 @@ func (r *sdnSubnetResource) Update(ctx context.Context, req resource.UpdateReque
func (r *sdnSubnetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state sdnSubnetModel
+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -221,18 +237,25 @@ func (r *sdnSubnetResource) Delete(ctx context.Context, req resource.DeleteReque
}
}
-func (r *sdnSubnetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
- // Expect ID format: "vnet/subnet"
+func (r *sdnSubnetResource) ImportState(
+ ctx context.Context,
+ req resource.ImportStateRequest,
+ resp *resource.ImportStateResponse,
+) {
+ // Expect ID format: "vnet/subnet".
parts := strings.Split(req.ID, "/")
if len(parts) != 2 {
resp.Diagnostics.AddError(
"Unexpected Import Identifier",
"Expected import identifier in format 'vnet-id/subnet-id'.",
)
+
return
}
+
vnetID := parts[0]
subnetID := parts[1]
+
subnet, err := r.client.GetSubnet(ctx, vnetID, subnetID)
if err != nil {
if errors.Is(err, api.ErrResourceDoesNotExist) {
@@ -241,6 +264,7 @@ func (r *sdnSubnetResource) ImportState(ctx context.Context, req resource.Import
}
resp.Diagnostics.AddError("Unable to import subnet", err.Error())
+
return
}
@@ -249,7 +273,12 @@ func (r *sdnSubnetResource) ImportState(ctx context.Context, req resource.Import
resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
}
-func resolveCanonicalSubnetID(ctx context.Context, client *subnets.Client, vnet string, originalID string) (string, error) {
+func resolveCanonicalSubnetID(
+ ctx context.Context,
+ client *subnets.Client,
+ vnet string,
+ originalID string,
+) (string, error) {
subnets, err := client.GetSubnets(ctx, vnet)
if err != nil {
return "", fmt.Errorf("failed to list subnets for canonical name resolution: %w", err)
@@ -257,11 +286,11 @@ func resolveCanonicalSubnetID(ctx context.Context, client *subnets.Client, vnet
for _, subnet := range subnets {
if subnet.ID == originalID {
- return subnet.ID, nil // Already canonical
+ return subnet.ID, nil
}
- // Proxmox canonical format is usually zone-prefixed:
- // e.g., zoneM-10-10-0-0-24 instead of 10.10.0.0/24
+ // Proxmox canonical format is usually zone-prefixed.
+ // e.g., zoneM-10-10-0-0-24 instead of 10.10.0.0/24.
if strings.HasSuffix(subnet.ID, strings.ReplaceAll(originalID, "/", "-")) {
return subnet.ID, nil
}
@@ -270,11 +299,19 @@ func resolveCanonicalSubnetID(ctx context.Context, client *subnets.Client, vnet
return "", fmt.Errorf("could not resolve canonical subnet ID for %s", originalID)
}
-// ValidateConfig checks that the subnet's field are correctly set. Particularly that gateway, dhcp and dns are within CIDR
-func (r *sdnSubnetResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+/*
+ValidateConfig checks that the subnet's field are correctly set.
+Particularly that gateway, dhcp and dns are within CIDR.
+*/
+func (r *sdnSubnetResource) ValidateConfig(
+ ctx context.Context,
+ req resource.ValidateConfigRequest,
+ resp *resource.ValidateConfigResponse,
+) {
var config sdnSubnetModel
diags := req.Config.Get(ctx, &config)
resp.Diagnostics.Append(diags...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -286,6 +323,7 @@ func (r *sdnSubnetResource) ValidateConfig(ctx context.Context, req resource.Val
"Invalid Subnet",
fmt.Sprintf("Could not parse subnet: %s", err),
)
+
return
}
@@ -298,6 +336,7 @@ func (r *sdnSubnetResource) ValidateConfig(ctx context.Context, req resource.Val
"Invalid IP Address",
fmt.Sprintf("Could not parse IP address: %s", ipVal.ValueString()),
)
+
return
}
diff --git a/fwprovider/cluster/sdn/resource_sdn_vnets.go b/fwprovider/cluster/sdn/resource_sdn_vnets.go
index 6f30322e..b4d70b6d 100644
--- a/fwprovider/cluster/sdn/resource_sdn_vnets.go
+++ b/fwprovider/cluster/sdn/resource_sdn_vnets.go
@@ -57,6 +57,7 @@ func (r *sdnVnetResource) Configure(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected config.Resource, got: %T", req.ProviderData),
)
+
return
}
@@ -118,7 +119,9 @@ func (r *sdnVnetResource) Create(
resp *resource.CreateResponse,
) {
var plan sdnVnetModel
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -140,7 +143,9 @@ func (r *sdnVnetResource) Read(
resp *resource.ReadResponse,
) {
var state sdnVnetModel
+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -153,12 +158,13 @@ func (r *sdnVnetResource) Read(
}
resp.Diagnostics.AddError("Error reading vnet", err.Error())
+
return
}
readModel := &sdnVnetModel{}
readModel.importFromAPI(state.ID.ValueString(), data)
- // Preserve provider-only field
+ // Preserve provider-only field.
readModel.ZoneType = state.ZoneType
resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
}
@@ -169,7 +175,9 @@ func (r *sdnVnetResource) Update(
resp *resource.UpdateResponse,
) {
var plan sdnVnetModel
+
var state sdnVnetModel
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
@@ -178,6 +186,7 @@ func (r *sdnVnetResource) Update(
}
var toDelete []string
+
checkDelete(plan.Alias, state.Alias, &toDelete, "alias")
checkDelete(plan.IsolatePorts, state.IsolatePorts, &toDelete, "isolate-ports")
checkDelete(plan.Tag, state.Tag, &toDelete, "tag")
@@ -202,7 +211,9 @@ func (r *sdnVnetResource) Delete(
resp *resource.DeleteResponse,
) {
var state sdnVnetModel
+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -224,7 +235,9 @@ func (r *sdnVnetResource) ImportState(
resp.Diagnostics.AddError("Resource does not exist", err.Error())
return
}
+
resp.Diagnostics.AddError("Failed to import resource", err.Error())
+
return
}
@@ -239,8 +252,13 @@ func checkDelete(planField, stateField attr.Value, toDelete *[]string, apiName s
}
}
-func (r *sdnVnetResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+func (r *sdnVnetResource) ValidateConfig(
+ ctx context.Context,
+ req resource.ValidateConfigRequest,
+ resp *resource.ValidateConfigResponse,
+) {
var data sdnVnetModel
+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
@@ -255,7 +273,8 @@ func (r *sdnVnetResource) ValidateConfig(ctx context.Context, req resource.Valid
resp.Diagnostics.AddAttributeError(
path.Root("zonetype"),
"Missing Required Field",
- "No Zone linked to this Vnet, please set the 'zonetype' property. \nEither from a created zone or a datasource import.")
+ "No Zone linked, please set the 'zonetype' property. \nEither from a created zone or a datasource import.")
+
return
}
@@ -287,7 +306,7 @@ func (r *sdnVnetResource) ValidateConfig(ctx context.Context, req resource.Valid
"type": data.Type,
}
- // Check required fields
+ // Check required fields.
for _, field := range required[zoneType] {
if val, ok := fieldMap[field]; ok {
if val.IsNull() || val.IsUnknown() {
@@ -309,5 +328,4 @@ func (r *sdnVnetResource) ValidateConfig(ctx context.Context, req resource.Valid
)
}
}
-
}
diff --git a/fwprovider/cluster/sdn/resource_sdn_zones.go b/fwprovider/cluster/sdn/resource_sdn_zones.go
index 4c37df38..8e93cfe8 100644
--- a/fwprovider/cluster/sdn/resource_sdn_zones.go
+++ b/fwprovider/cluster/sdn/resource_sdn_zones.go
@@ -55,6 +55,7 @@ func (r *sdnZoneResource) Configure(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected config.Resource, got: %T", req.ProviderData),
)
+
return
}
@@ -163,15 +164,19 @@ func (r *sdnZoneResource) Create(
resp *resource.CreateResponse,
) {
var plan sdnZoneModel
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+
if resp.Diagnostics.HasError() {
return
}
reqData := plan.toAPIRequestBody()
+
err := r.client.CreateZone(ctx, reqData)
if err != nil {
resp.Diagnostics.AddError("Unable to Create SDN Zone", err.Error())
+
return
}
@@ -185,7 +190,9 @@ func (r *sdnZoneResource) Read(
resp *resource.ReadResponse,
) {
var state sdnZoneModel
+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -198,6 +205,7 @@ func (r *sdnZoneResource) Read(
}
resp.Diagnostics.AddError("Unable to Read SDN Zone", err.Error())
+
return
}
@@ -212,12 +220,15 @@ func (r *sdnZoneResource) Update(
resp *resource.UpdateResponse,
) {
var plan sdnZoneModel
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+
if resp.Diagnostics.HasError() {
return
}
reqData := plan.toAPIRequestBody()
+
err := r.client.UpdateZone(ctx, reqData)
if err != nil {
resp.Diagnostics.AddError("Unable to Update SDN Zone", err.Error())
@@ -233,7 +244,9 @@ func (r *sdnZoneResource) Delete(
resp *resource.DeleteResponse,
) {
var state sdnZoneModel
+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
if resp.Diagnostics.HasError() {
return
}
@@ -253,10 +266,12 @@ func (r *sdnZoneResource) ImportState(
if err != nil {
if errors.Is(err, api.ErrResourceDoesNotExist) {
resp.Diagnostics.AddError("Zone does not exist", err.Error())
+
return
}
resp.Diagnostics.AddError("Unable to Import SDN Zone", err.Error())
+
return
}
@@ -265,15 +280,20 @@ func (r *sdnZoneResource) ImportState(
resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...)
}
-func (r *sdnZoneResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+func (r *sdnZoneResource) ValidateConfig(
+ ctx context.Context,
+ req resource.ValidateConfigRequest,
+ resp *resource.ValidateConfigResponse,
+) {
var data sdnZoneModel
+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
- // Check the type field
+ // Check the type field.
if data.Type.IsNull() || data.Type.IsUnknown() {
return
}
@@ -287,13 +307,13 @@ func (r *sdnZoneResource) ValidateConfig(ctx context.Context, req resource.Valid
zoneType := data.Type.ValueString()
- // Extracts required fields and at the same time checks zone type validity
+ // Extracts required fields and at the same time checks zone type validity.
fields, ok := required[zoneType]
if !ok {
return
}
- // Map of field names to their values from data
+ // Map of field names to their values from data.
fieldMap := map[string]attr.Value{
"bridge": data.Bridge,
"service_vlan": data.ServiceVLAN,
diff --git a/fwprovider/cluster/sdn/sdn_subnet_model.go b/fwprovider/cluster/sdn/sdn_subnet_model.go
index fbd4e20e..cc478e44 100644
--- a/fwprovider/cluster/sdn/sdn_subnet_model.go
+++ b/fwprovider/cluster/sdn/sdn_subnet_model.go
@@ -1,12 +1,11 @@
package sdn
/*
---------------------------------- Subnet Model Terraform ---------------------------------
+SUBNET MODEL TERRAFORM
Note: Currently in the API there are Delete and Digest options which are not available
in the UI so the choice was made to remove them temporary, waiting for a fix.
Also, it is not really in the way of working with terraform to use such parameters.
-----------------------------------------------------------------------------------------
*/
import (
"context"
@@ -42,7 +41,9 @@ func (m *sdnSubnetModel) importFromAPI(name string, data *subnets.SubnetData) {
m.Type = types.StringPointerValue(data.Type)
m.Vnet = types.StringPointerValue(data.Vnet)
+
m.DhcpDnsServer = types.StringPointerValue(data.DHCPDNSServer)
+
if data.DHCPRange != nil {
var ranges []dhcpRangeModel
for _, r := range data.DHCPRange {
@@ -51,6 +52,7 @@ func (m *sdnSubnetModel) importFromAPI(name string, data *subnets.SubnetData) {
EndAddress: types.StringValue(r.EndAddress),
})
}
+
m.DhcpRange = ranges
}
@@ -59,31 +61,42 @@ func (m *sdnSubnetModel) importFromAPI(name string, data *subnets.SubnetData) {
m.Snat = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.SNAT))
}
-func (m *sdnSubnetModel) toAPIRequestBody() *subnets.SubnetRequestData {
+func (m *sdnSubnetModel) toAPIRequestBody(ctx context.Context) *subnets.SubnetRequestData {
data := &subnets.SubnetRequestData{}
- // When creating the subnet it is ok to pass subnet cidr, but when updating need to pass canonical name
+ // When creating the subnet it is ok to pass subnet cidr, but when updating need to pass canonical name.
if m.CanonicalName.ValueString() == "" {
data.ID = m.Subnet.ValueString()
} else {
data.ID = m.CanonicalName.ValueString()
}
- tflog.Warn(context.Background(), "TO API", map[string]any{
+
+ tflog.Warn(ctx, "TO API", map[string]any{
"canonical name": m.CanonicalName.ValueString(),
"ID": m.ID.ValueString(),
})
+
data.Type = m.Type.ValueStringPointer()
data.Vnet = m.Vnet.ValueStringPointer()
+
data.DHCPDNSServer = m.DhcpDnsServer.ValueStringPointer()
+
if m.DhcpRange != nil {
var dhcpRanges []string
for _, r := range m.DhcpRange {
- dhcpRanges = append(dhcpRanges, fmt.Sprintf("start-address=%s,end-address=%s", r.StartAddress.ValueString(), r.EndAddress.ValueString()))
+ dhcpRanges = append(
+ dhcpRanges,
+ fmt.Sprintf("start-address=%s,end-address=%s",
+ r.StartAddress.ValueString(),
+ r.EndAddress.ValueString()))
}
+
data.DHCPRange = dhcpRanges
}
+
data.DNSZonePrefix = m.DnsZonePrefix.ValueStringPointer()
data.Gateway = m.Gateway.ValueStringPointer()
data.SNAT = ptrConversion.BoolToInt64Ptr(m.Snat.ValueBoolPointer())
+
return data
}
diff --git a/fwprovider/cluster/sdn/sdn_vnet_model.go b/fwprovider/cluster/sdn/sdn_vnet_model.go
index af26c298..86d62d06 100644
--- a/fwprovider/cluster/sdn/sdn_vnet_model.go
+++ b/fwprovider/cluster/sdn/sdn_vnet_model.go
@@ -1,10 +1,7 @@
package sdn
/*
---------------------------------- VNET Model Terraform ---------------------------------
-
-
-----------------------------------------------------------------------------------------
+VNET MODEL TERRAFORM
*/
import (
diff --git a/fwprovider/cluster/sdn/sdn_zone_model.go b/fwprovider/cluster/sdn/sdn_zone_model.go
index c3de2927..36380c0c 100644
--- a/fwprovider/cluster/sdn/sdn_zone_model.go
+++ b/fwprovider/cluster/sdn/sdn_zone_model.go
@@ -16,14 +16,14 @@ type sdnZoneModel struct {
DNSZone types.String `tfsdk:"dns_zone"`
Nodes types.String `tfsdk:"nodes"`
MTU types.Int64 `tfsdk:"mtu"`
- // VLAN
+ // VLAN.
Bridge types.String `tfsdk:"bridge"`
- // QinQ
+ // QinQ.
ServiceVLAN types.Int64 `tfsdk:"tag"`
ServiceVLANProtocol types.String `tfsdk:"vlan_protocol"`
- // VXLAN
+ // VXLAN.
Peers types.String `tfsdk:"peers"`
- // EVPN
+ // EVPN.
Controller types.String `tfsdk:"controller"`
ExitNodes types.String `tfsdk:"exit_nodes"`
PrimaryExitNode types.String `tfsdk:"primary_exit_node"`
@@ -57,7 +57,6 @@ func (m *sdnZoneModel) importFromAPI(name string, data *zones.ZoneData) {
m.ExitNodesLocalRouting = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.ExitNodesLocalRouting))
m.AdvertiseSubnets = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.AdvertiseSubnets))
m.DisableARPNDSuppression = types.BoolPointerValue(ptrConversion.Int64ToBoolPtr(data.DisableARPNDSuppression))
-
}
func (m *sdnZoneModel) toAPIRequestBody() *zones.ZoneRequestData {
diff --git a/proxmox/cluster/client.go b/proxmox/cluster/client.go
index 6a06f1a7..dd94dda0 100644
--- a/proxmox/cluster/client.go
+++ b/proxmox/cluster/client.go
@@ -58,17 +58,17 @@ func (c *Client) Metrics() *metrics.Client {
return &metrics.Client{Client: c}
}
-// SDNZones returns a client for managing the cluster's SDN zones
+// SDNZones returns a client for managing the cluster's SDN zones.
func (c *Client) SDNZones() *zones.Client {
return &zones.Client{Client: c}
}
-// SDNVnets returns a client for managing the cluster's SDN Vnets
+// SDNVnets returns a client for managing the cluster's SDN Vnets.
func (c *Client) SDNVnets() *vnets.Client {
return &vnets.Client{Client: c}
}
-// SDNSubnets returns a client for managing the cluster's SDN Subnets
+// SDNSubnets returns a client for managing the cluster's SDN Subnets.
func (c *Client) SDNSubnets() *subnets.Client {
return &subnets.Client{Client: c}
}
diff --git a/proxmox/cluster/sdn/sdn_test.go b/proxmox/cluster/sdn/sdn_test.go
index 1e6c13b7..d6581cd3 100644
--- a/proxmox/cluster/sdn/sdn_test.go
+++ b/proxmox/cluster/sdn/sdn_test.go
@@ -1,7 +1,6 @@
package sdn
import (
- "context"
"os"
"testing"
@@ -30,16 +29,22 @@ type testClients struct {
}
func getTestClients(t *testing.T) *testClients {
+ t.Helper()
+
apiToken := os.Getenv("PVE_TOKEN")
+
url := os.Getenv("PVE_URL")
if apiToken == "" || url == "" {
t.Skip("PVE_TOKEN and PVE_URL must be set")
}
+
conn, err := api.NewConnection(url, true, "")
if err != nil {
t.Fatalf("connection error: %v", err)
}
+
creds := api.Credentials{TokenCredentials: &api.TokenCredentials{APIToken: apiToken}}
+
client, err := api.NewClient(creds, conn)
if err != nil {
t.Fatalf("client error: %v", err)
@@ -56,7 +61,9 @@ func TestSDNLifecycle(t *testing.T) {
clients := getTestClients(t)
t.Run("Create Zone", func(t *testing.T) {
- err := clients.zone.CreateZone(context.Background(), &zones.ZoneRequestData{
+ t.Parallel()
+
+ err := clients.zone.CreateZone(t.Context(), &zones.ZoneRequestData{
ZoneData: zones.ZoneData{
ID: testZoneID,
Type: ptr.Ptr("vlan"),
@@ -72,19 +79,24 @@ func TestSDNLifecycle(t *testing.T) {
})
t.Run("Get Zone", func(t *testing.T) {
- zone, err := clients.zone.GetZone(context.Background(), testZoneID)
+ t.Parallel()
+
+ zone, err := clients.zone.GetZone(t.Context(), testZoneID)
if err != nil {
t.Fatalf("GetZone failed: %v", err)
}
+
t.Logf("Zone: %+v", zone)
})
t.Run("Update Zone", func(t *testing.T) {
- err := clients.zone.UpdateZone(context.Background(), &zones.ZoneRequestData{
+ t.Parallel()
+
+ err := clients.zone.UpdateZone(t.Context(), &zones.ZoneRequestData{
ZoneData: zones.ZoneData{
ID: testZoneID,
Nodes: ptr.Ptr("updatednode"),
- Bridge: ptr.Ptr("vmbr1"), // simulate a VLAN-related update
+ Bridge: ptr.Ptr("vmbr1"), // simulate a VLAN-related update.
},
})
if err != nil {
@@ -93,7 +105,9 @@ func TestSDNLifecycle(t *testing.T) {
})
t.Run("Create VNet", func(t *testing.T) {
- err := clients.vnet.CreateVnet(context.Background(), &vnets.VnetRequestData{
+ t.Parallel()
+
+ err := clients.vnet.CreateVnet(t.Context(), &vnets.VnetRequestData{
VnetData: vnets.VnetData{
ID: testVnetID,
Zone: ptr.Ptr(testZoneID),
@@ -110,15 +124,20 @@ func TestSDNLifecycle(t *testing.T) {
})
t.Run("Get VNet", func(t *testing.T) {
- vnet, err := clients.vnet.GetVnet(context.Background(), testVnetID)
+ t.Parallel()
+
+ vnet, err := clients.vnet.GetVnet(t.Context(), testVnetID)
if err != nil {
t.Fatalf("GetVnet failed: %v", err)
}
+
t.Logf("VNet: %+v", vnet)
})
t.Run("Update VNet", func(t *testing.T) {
- err := clients.vnet.UpdateVnet(context.Background(), &vnets.VnetRequestData{
+ t.Parallel()
+
+ err := clients.vnet.UpdateVnet(t.Context(), &vnets.VnetRequestData{
VnetData: vnets.VnetData{
ID: testVnetID,
Alias: ptr.Ptr("UpdatedAlias"),
@@ -130,6 +149,8 @@ func TestSDNLifecycle(t *testing.T) {
})
t.Run("Create Subnet", func(t *testing.T) {
+ t.Parallel()
+
ptr := &subnets.SubnetData{
ID: testSubnetCIDR,
Vnet: ptr.Ptr(testVnetID),
@@ -144,21 +165,27 @@ func TestSDNLifecycle(t *testing.T) {
req := &subnets.SubnetRequestData{
EncodedSubnetData: *ptr.ToEncoded(),
}
- err := clients.subnet.CreateSubnet(context.Background(), testVnetID, req)
+
+ err := clients.subnet.CreateSubnet(t.Context(), testVnetID, req)
if err != nil {
t.Fatalf("CreateSubnet failed: %v", err)
}
})
t.Run("Get Subnet", func(t *testing.T) {
- subnet, err := clients.subnet.GetSubnet(context.Background(), testVnetID, testSubnetCanonical)
+ t.Parallel()
+
+ subnet, err := clients.subnet.GetSubnet(t.Context(), testVnetID, testSubnetCanonical)
if err != nil {
t.Fatalf("GetSubnet failed: %v", err)
}
+
t.Logf("Subnet: %+v", subnet)
})
t.Run("Update Subnet", func(t *testing.T) {
+ t.Parallel()
+
ptr := &subnets.SubnetData{
ID: testSubnetCanonical,
Vnet: ptr.Ptr(testVnetID),
@@ -167,28 +194,35 @@ func TestSDNLifecycle(t *testing.T) {
req := &subnets.SubnetRequestData{
EncodedSubnetData: *ptr.ToEncoded(),
}
- err := clients.subnet.UpdateSubnet(context.Background(), testVnetID, req)
+
+ err := clients.subnet.UpdateSubnet(t.Context(), testVnetID, req)
if err != nil {
t.Fatalf("UpdateSubnet failed: %v", err)
}
})
t.Run("Delete Subnet", func(t *testing.T) {
- err := clients.subnet.DeleteSubnet(context.Background(), testVnetID, testSubnetCanonical)
+ t.Parallel()
+
+ err := clients.subnet.DeleteSubnet(t.Context(), testVnetID, testSubnetCanonical)
if err != nil {
t.Fatalf("DeleteSubnet failed: %v", err)
}
})
t.Run("Delete VNet", func(t *testing.T) {
- err := clients.vnet.DeleteVnet(context.Background(), testVnetID)
+ t.Parallel()
+
+ err := clients.vnet.DeleteVnet(t.Context(), testVnetID)
if err != nil {
t.Fatalf("DeleteVnet failed: %v", err)
}
})
t.Run("Delete Zone", func(t *testing.T) {
- err := clients.zone.DeleteZone(context.Background(), testZoneID)
+ t.Parallel()
+
+ err := clients.zone.DeleteZone(t.Context(), testZoneID)
if err != nil {
t.Fatalf("DeleteZone failed: %v", err)
}
diff --git a/proxmox/cluster/sdn/subnets/subnets.go b/proxmox/cluster/sdn/subnets/subnets.go
index ec2c6458..e295729d 100644
--- a/proxmox/cluster/sdn/subnets/subnets.go
+++ b/proxmox/cluster/sdn/subnets/subnets.go
@@ -8,13 +8,13 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmox/api"
)
-// GetSubnet retrieves a single Subnet by ID and containing Vnet's ID
+// GetSubnet retrieves a single Subnet by ID and containing Vnet's ID.
func (c *Client) GetSubnet(ctx context.Context, vnetID string, id string) (*SubnetData, error) {
resBody := &SubnetResponseBody{}
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(vnetID, id), nil, resBody)
if err != nil {
- return nil, fmt.Errorf("Error reading SDN subnet %s for Vnet %s: %w", id, vnetID, err)
+ return nil, fmt.Errorf("error reading SDN subnet %s for Vnet %s: %w", id, vnetID, err)
}
if resBody.Data == nil {
@@ -24,13 +24,13 @@ func (c *Client) GetSubnet(ctx context.Context, vnetID string, id string) (*Subn
return resBody.Data, nil
}
-// GetSubnets lists all Subnets related to a Vnet
+// GetSubnets lists all Subnets related to a Vnet.
func (c *Client) GetSubnets(ctx context.Context, vnetID string) ([]SubnetData, error) {
resBody := &SubnetsResponseBody{}
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(vnetID, ""), nil, resBody)
if err != nil {
- return nil, fmt.Errorf("Error listing Subnets for Vnet %s: %w", vnetID, err)
+ return nil, fmt.Errorf("error listing Subnets for Vnet %s: %w", vnetID, err)
}
if resBody.Data == nil {
@@ -40,31 +40,31 @@ func (c *Client) GetSubnets(ctx context.Context, vnetID string) ([]SubnetData, e
return *resBody.Data, nil
}
-// CreateSubnet creates a new Subnet in the defined Vnet
+// CreateSubnet creates a new Subnet in the defined Vnet.
func (c *Client) CreateSubnet(ctx context.Context, vnetID string, data *SubnetRequestData) error {
err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(vnetID, ""), data, nil)
if err != nil {
- return fmt.Errorf("Error creating subnet %s on VNet %s: %w", data.ID, vnetID, err)
+ return fmt.Errorf("error creating subnet %s on VNet %s: %w", data.ID, vnetID, err)
}
return nil
}
-// UpdateSubnet updates an existing subnet inside a defined vnet
+// UpdateSubnet updates an existing subnet inside a defined vnet.
func (c *Client) UpdateSubnet(ctx context.Context, vnetID string, data *SubnetRequestData) error {
err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(vnetID, data.ID), data, nil)
if err != nil {
- return fmt.Errorf("Error updating subnet %s on VNet %s: %w", data.ID, vnetID, err)
+ return fmt.Errorf("error updating subnet %s on VNet %s: %w", data.ID, vnetID, err)
}
return nil
}
-// DeleteSubnet deletes an existing subnet inside a defined vnet
+// DeleteSubnet deletes an existing subnet inside a defined vnet.
func (c *Client) DeleteSubnet(ctx context.Context, vnetID string, id string) error {
err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(vnetID, id), nil, nil)
if err != nil {
- return fmt.Errorf("Error deleting subnet %s on VNet %s: %s", id, vnetID, err)
+ return fmt.Errorf("error deleting subnet %s on VNet %s: %w", id, vnetID, err)
}
return nil
diff --git a/proxmox/cluster/sdn/subnets/subnets_types.go b/proxmox/cluster/sdn/subnets/subnets_types.go
index aa0af6ba..1335e9b0 100644
--- a/proxmox/cluster/sdn/subnets/subnets_types.go
+++ b/proxmox/cluster/sdn/subnets/subnets_types.go
@@ -5,7 +5,7 @@ import (
)
/*
---------------------------------- SUBNETS -----------------------------------------------
+SUBNETS
This part is related to the SDN component : SubNets
Based on docs :
@@ -18,18 +18,16 @@ Notes:
2. Currently in the API there are Delete and Digest options which are not available
in the UI so the choice was made to remove them temporary, waiting for a fix.
3. It is also not really in the terraform spirit to update elements like this.
-
------------------------------------------------------------------------------------------
*/
type SubnetData struct {
- ID string `json:"subnet,omitempty" url:"subnet,omitempty"`
- Type *string `json:"type,omitempty" url:"type,omitempty"`
- Vnet *string `json:"vnet,omitempty" url:"vnet,omitempty"`
- DHCPDNSServer *string `json:"dhcp-dns-server,omitempty" url:"dhcp-dns-server,omitempty"`
- DHCPRange DHCPRangeList `json:"dhcp-range,omitempty" url:"dhcp-range,omitempty"`
- DNSZonePrefix *string `json:"dnszoneprefix,omitempty" url:"dnszoneprefix,omitempty"`
- Gateway *string `json:"gateway,omitempty" url:"gateway,omitempty"`
- SNAT *int64 `json:"snat,omitempty" url:"snat,omitempty"`
+ ID string `json:"subnet,omitempty" url:"subnet,omitempty"`
+ Type *string `json:"type,omitempty" url:"type,omitempty"`
+ Vnet *string `json:"vnet,omitempty" url:"vnet,omitempty"`
+ DHCPDNSServer *string `json:"dhcp-dns-server,omitempty" url:"dhcp-dns-server,omitempty"`
+ DHCPRange DHCPRangeList `json:"dhcp-range,omitempty" url:"dhcp-range,omitempty"`
+ DNSZonePrefix *string `json:"dnszoneprefix,omitempty" url:"dnszoneprefix,omitempty"`
+ Gateway *string `json:"gateway,omitempty" url:"gateway,omitempty"`
+ SNAT *int64 `json:"snat,omitempty" url:"snat,omitempty"`
}
type SubnetRequestData struct {
@@ -62,14 +60,14 @@ type EncodedSubnetData struct {
Type *string `url:"type,omitempty"`
Vnet *string `url:"vnet,omitempty"`
DHCPDNSServer *string `url:"dhcp-dns-server,omitempty"`
- DHCPRange []string `url:"dhcp-range,omitempty"` // manually formatted
+ DHCPRange []string `url:"dhcp-range,omitempty"`
DNSZonePrefix *string `url:"dnszoneprefix,omitempty"`
Gateway *string `url:"gateway,omitempty"`
SNAT *int64 `url:"snat,omitempty"`
}
func (s *SubnetData) ToEncoded() *EncodedSubnetData {
- var encodedRanges []string
+ encodedRanges := make([]string, 0, len(s.DHCPRange))
for _, r := range s.DHCPRange {
encodedRanges = append(encodedRanges, fmt.Sprintf("start-address=%s,end-address=%s", r.StartAddress, r.EndAddress))
}
diff --git a/proxmox/cluster/sdn/vnets/vnets.go b/proxmox/cluster/sdn/vnets/vnets.go
index b6f194b7..bb56359a 100644
--- a/proxmox/cluster/sdn/vnets/vnets.go
+++ b/proxmox/cluster/sdn/vnets/vnets.go
@@ -9,13 +9,13 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones"
)
-// GetVnet retrieves a single SDN Vnet by ID
+// GetVnet retrieves a single SDN Vnet by ID.
func (c *Client) GetVnet(ctx context.Context, id string) (*VnetData, error) {
resBody := &VnetResponseBody{}
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(id), nil, resBody)
if err != nil {
- return nil, fmt.Errorf("Error reading SDN Vnet %s: %w", id, err)
+ return nil, fmt.Errorf("error reading SDN Vnet %s: %w", id, err)
}
if resBody.Data == nil {
@@ -25,13 +25,13 @@ func (c *Client) GetVnet(ctx context.Context, id string) (*VnetData, error) {
return resBody.Data, nil
}
-// GetVnets lists all SDN Vnets
+// GetVnets lists all SDN Vnets.
func (c *Client) GetVnets(ctx context.Context) ([]VnetData, error) {
resBody := &VnetsResponseBody{}
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(""), nil, resBody)
if err != nil {
- return nil, fmt.Errorf("Error listing SDN Vnets: %w", err)
+ return nil, fmt.Errorf("error listing SDN Vnets: %w", err)
}
if resBody.Data == nil {
@@ -41,31 +41,31 @@ func (c *Client) GetVnets(ctx context.Context) ([]VnetData, error) {
return *resBody.Data, nil
}
-// CreateVnet creates a new SDN VNET
+// CreateVnet creates a new SDN VNET.
func (c *Client) CreateVnet(ctx context.Context, data *VnetRequestData) error {
err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(""), data, nil)
if err != nil {
- return fmt.Errorf("Error creating SDN VNET: %w", err)
+ return fmt.Errorf("error creating SDN VNET: %w", err)
}
return nil
}
-// UpdateVnet Updates an existing VNet
+// UpdateVnet Updates an existing VNet.
func (c *Client) UpdateVnet(ctx context.Context, data *VnetRequestData) error {
err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(data.ID), data, nil)
if err != nil {
- return fmt.Errorf("Error updating SDN VNET: %w", err)
+ return fmt.Errorf("error updating SDN VNET: %w", err)
}
return nil
}
-// DeleteVnet deletes an SDN VNET by ID
+// DeleteVnet deletes an SDN VNET by ID.
func (c *Client) DeleteVnet(ctx context.Context, id string) error {
err := c.DoRequest(ctx, http.MethodDelete, c.ExpandPath(id), nil, nil)
if err != nil {
- return fmt.Errorf("Error deleting SDN VNET: %w", err)
+ return fmt.Errorf("error deleting SDN VNET: %w", err)
}
return nil
@@ -73,9 +73,10 @@ func (c *Client) DeleteVnet(ctx context.Context, id string) error {
func (c *Client) GetParentZone(ctx context.Context, zoneId string) (*zones.ZoneData, error) {
parentZone := zones.ZoneResponseBody{}
+
err := c.DoRequest(ctx, http.MethodGet, c.ParentPath(zoneId), nil, parentZone)
if err != nil {
- return nil, fmt.Errorf("Error fetching vnet's parent zone %s: %w", zoneId, err)
+ return nil, fmt.Errorf("error fetching vnet's parent zone %s: %w", zoneId, err)
}
return parentZone.Data, nil
diff --git a/proxmox/cluster/sdn/vnets/vnets_types.go b/proxmox/cluster/sdn/vnets/vnets_types.go
index 8b622aa3..ba9edacc 100644
--- a/proxmox/cluster/sdn/vnets/vnets_types.go
+++ b/proxmox/cluster/sdn/vnets/vnets_types.go
@@ -1,7 +1,7 @@
package vnets
/*
---------------------------------- VNETS ---------------------------------
+VNETS
This part is related to the SDN component : VNETS
Based on docs :
@@ -20,19 +20,15 @@ Notes:
4. Currently in the API there are Delete and Digest options which are not available
in the UI so the choice was made to remove them temporary, waiting for a fix.
-
--------------------------------------------------------------------------
*/
type VnetData struct {
- ID string `json:"vnet,omitempty" url:"vnet,omitempty"`
- Zone *string `json:"zone,omitempty" url:"zone,omitempty"`
- Alias *string `json:"alias,omitempty" url:"alias,omitempty"`
- IsolatePorts *int64 `json:"isolate-ports,omitempty" url:"isolate-ports,omitempty"`
- Tag *int64 `json:"tag,omitempty" url:"tag,omitempty"`
- Type *string `json:"type,omitempty" url:"type,omitempty"`
- VlanAware *int64 `json:"vlanaware,omitempty" url:"vlanaware,omitempty"`
- // DeleteSettings *string `json:"delete,omitempty" url:"delete,omitempty"`
- // Digest *string `json:"digest,omitempty" url:"digest,omitempty"`
+ ID string `json:"vnet,omitempty" url:"vnet,omitempty"`
+ Zone *string `json:"zone,omitempty" url:"zone,omitempty"`
+ Alias *string `json:"alias,omitempty" url:"alias,omitempty"`
+ IsolatePorts *int64 `json:"isolate-ports,omitempty" url:"isolate-ports,omitempty"`
+ Tag *int64 `json:"tag,omitempty" url:"tag,omitempty"`
+ Type *string `json:"type,omitempty" url:"type,omitempty"`
+ VlanAware *int64 `json:"vlanaware,omitempty" url:"vlanaware,omitempty"`
}
type VnetRequestData struct {
diff --git a/proxmox/cluster/sdn/zones/zones.go b/proxmox/cluster/sdn/zones/zones.go
index b616c3af..450a975e 100644
--- a/proxmox/cluster/sdn/zones/zones.go
+++ b/proxmox/cluster/sdn/zones/zones.go
@@ -52,10 +52,11 @@ func (c *Client) CreateZone(ctx context.Context, data *ZoneRequestData) error {
// UpdateZone updates an existing SDN zone.
func (c *Client) UpdateZone(ctx context.Context, data *ZoneRequestData) error {
- // PVE API does not allow to pass "type" in PUT requests, this doesn't makes any sense
- // since other required params like port, server must still be there
- // while we could spawn another struct, let's just fix it silently
+ /* PVE API does not allow to pass "type" in PUT requests, this doesn't makes any sense
+ since other required params like port, server must still be there
+ while we could spawn another struct, let's just fix it silently */
data.Type = nil
+
err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(data.ID), data, nil)
if err != nil {
return fmt.Errorf("error updating SDN zone: %w", err)
diff --git a/proxmox/cluster/sdn/zones/zones_types.go b/proxmox/cluster/sdn/zones/zones_types.go
index 68e141bd..b7479245 100644
--- a/proxmox/cluster/sdn/zones/zones_types.go
+++ b/proxmox/cluster/sdn/zones/zones_types.go
@@ -1,43 +1,42 @@
package zones
/*
---------------------------------- ZONES ---------------------------------
+ZONES
This part is related to the first SDN component : Zones
Based on docs :
https://pve.proxmox.com/pve-docs/chapter-pvesdn.html#pvesdn_config_zone
https://pve.proxmox.com/pve-docs/api-viewer/index.html#/cluster/sdn/zones
--------------------------------------------------------------------------
*/
type ZoneData struct {
- ID string `json:"zone,omitempty" url:"zone,omitempty"`
- Type *string `json:"type,omitempty" url:"type,omitempty"`
- IPAM *string `json:"ipam,omitempty" url:"ipam,omitempty"`
- DNS *string `json:"dns,omitempty" url:"dns,omitempty"`
- ReverseDNS *string `json:"reversedns,omitempty" url:"reversedns,omitempty"`
- DNSZone *string `json:"dnszone,omitempty" url:"dnszone,omitempty"`
- Nodes *string `json:"nodes,omitempty" url:"nodes,omitempty"`
- MTU *int64 `json:"mtu,omitempty" url:"mtu,omitempty"`
+ ID string `json:"zone,omitempty" url:"zone,omitempty"`
+ Type *string `json:"type,omitempty" url:"type,omitempty"`
+ IPAM *string `json:"ipam,omitempty" url:"ipam,omitempty"`
+ DNS *string `json:"dns,omitempty" url:"dns,omitempty"`
+ ReverseDNS *string `json:"reversedns,omitempty" url:"reversedns,omitempty"`
+ DNSZone *string `json:"dnszone,omitempty" url:"dnszone,omitempty"`
+ Nodes *string `json:"nodes,omitempty" url:"nodes,omitempty"`
+ MTU *int64 `json:"mtu,omitempty" url:"mtu,omitempty"`
- // VLAN
- Bridge *string `json:"bridge,omitempty" url:"bridge,omitempty"`
+ // VLAN.
+ Bridge *string `json:"bridge,omitempty" url:"bridge,omitempty"`
- // QinQ
- ServiceVLAN *int64 `json:"tag,omitempty" url:"tag,omitempty"`
- ServiceVLANProtocol *string `json:"vlan-protocol,omitempty" url:"vlan-protocol,omitempty"`
+ // QinQ.
+ ServiceVLAN *int64 `json:"tag,omitempty" url:"tag,omitempty"`
+ ServiceVLANProtocol *string `json:"vlan-protocol,omitempty" url:"vlan-protocol,omitempty"`
- // VXLAN
- Peers *string `json:"peers,omitempty" url:"peers,omitempty"`
+ // VXLAN.
+ Peers *string `json:"peers,omitempty" url:"peers,omitempty"`
- // EVPN
- Controller *string `json:"controller,omitempty" url:"controller,omitempty"`
- VRFVXLANID *int64 `json:"vrf-vxlan,omitempty" url:"vrf-vxlan,omitempty"`
- ExitNodes *string `json:"exitnodes,omitempty" url:"exitnodes,omitempty"`
- PrimaryExitNode *string `json:"exitnodes-primary,omitempty" url:"exitnodes-primary,omitempty"`
- ExitNodesLocalRouting *int64 `json:"exitnodes-local-routing,omitempty" url:"exitnodes-local-routing,omitempty"`
- AdvertiseSubnets *int64 `json:"advertise-subnets,omitempty" url:"advertise-subnets,omitempty"`
+ // EVPN.
+ Controller *string `json:"controller,omitempty" url:"controller,omitempty"`
+ VRFVXLANID *int64 `json:"vrf-vxlan,omitempty" url:"vrf-vxlan,omitempty"`
+ ExitNodes *string `json:"exitnodes,omitempty" url:"exitnodes,omitempty"`
+ PrimaryExitNode *string `json:"exitnodes-primary,omitempty" url:"exitnodes-primary,omitempty"`
+ ExitNodesLocalRouting *int64 `json:"exitnodes-local-routing,omitempty" url:"exitnodes-local-routing,omitempty"`
+ AdvertiseSubnets *int64 `json:"advertise-subnets,omitempty" url:"advertise-subnets,omitempty"`
DisableARPNDSuppression *int64 `json:"disable-arp-nd-suppression,omitempty" url:"disable-arp-nd-suppression,omitempty"`
- RouteTargetImport *string `json:"rt-import,omitempty" url:"rt-import,omitempty"`
+ RouteTargetImport *string `json:"rt-import,omitempty" url:"rt-import,omitempty"`
}
// ZoneRequestData wraps a ZoneData struct with optional delete instructions.
diff --git a/proxmox/helpers/ptr/ptr.go b/proxmox/helpers/ptr/ptr.go
index 9bf5ffdc..facd01f4 100644
--- a/proxmox/helpers/ptr/ptr.go
+++ b/proxmox/helpers/ptr/ptr.go
@@ -63,5 +63,6 @@ func PtrOrNil[T any](d *schema.ResourceData, key string) *T {
return &val
}
+
return nil
}