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 }