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 01/11] 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 000000000..f66e241a9 --- /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 000000000..af09546d1 --- /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 000000000..0c7824851 --- /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 000000000..0d14c016f --- /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 000000000..6698a48d1 --- /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 000000000..5501ad075 --- /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 705d79e8e..bc53f49e7 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 53bc1ef9d..ecb817c91 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 000000000..e381bf4eb --- /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 eb47415c1..71a6911fc 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 e881eb209..4f3f14af3 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 000000000..8602f6d3a --- /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 000000000..68d491166 --- /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 000000000..0dde0d886 --- /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 000000000..38e42eda4 --- /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 000000000..6f30322e1 --- /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 000000000..4c37df382 --- /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 000000000..fbd4e20e3 --- /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 000000000..af26c2983 --- /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 000000000..c3de29277 --- /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 000000000..a4cc1c3ec --- /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 4304e53ea..768daa788 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 000000000..20c4abb61 --- /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 000000000..2a35c0622 --- /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 000000000..9309897e6 --- /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 000000000..e763d116d --- /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 6e0b7d8e7..80e53f837 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 e4f2314a7..6a06f1a74 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 000000000..1e6c13b7a --- /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 000000000..87f49c1a9 --- /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 000000000..72633007f --- /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 000000000..ec2c6458f --- /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 000000000..aa0af6bac --- /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 000000000..16d259169 --- /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 000000000..b5fc42c57 --- /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 000000000..b6f194b7d --- /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 000000000..8b622aa34 --- /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 000000000..6a0804d6e --- /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 000000000..11e8942e0 --- /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 000000000..b616c3afc --- /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 000000000..68e141bde --- /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 62213c930..9bf5ffdc8 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 000000000..ae15bbd9d --- /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 02/11] 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 | 57 ++++++++------- proxmox/helpers/ptr/ptr.go | 1 + 20 files changed, 308 insertions(+), 154 deletions(-) diff --git a/example/resource_virtual_environment_vm.tf b/example/resource_virtual_environment_vm.tf index 56554b7e6..61a42017d 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 4f3f14af3..e881eb209 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 8602f6d3a..f784dd93b 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 68d491166..d4381ef59 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 0dde0d886..30558a0fc 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 38e42eda4..37108306e 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 6f30322e1..b4d70b6db 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 4c37df382..8e93cfe88 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 fbd4e20e3..cc478e447 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 af26c2983..86d62d068 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 c3de29277..36380c0c9 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 6a06f1a74..dd94dda0a 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 1e6c13b7a..d6581cd31 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 ec2c6458f..e295729d7 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 aa0af6bac..1335e9b0f 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 b6f194b7d..bb56359a7 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 8b622aa34..ba9edacc8 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 b616c3afc..450a975e1 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 68e141bde..b74792455 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"` - - // 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"` + 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"` + 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 9bf5ffdc8..facd01f44 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 } From a76cc6256da0ad86a2efe514ad03c5718681d477 Mon Sep 17 00:00:00 2001 From: Pavel Boldyrev <627562+bpg@users.noreply.github.com> Date: Tue, 24 Jun 2025 20:40:27 -0400 Subject: [PATCH 03/11] fix(tests): fix `make example` tests (#2007) Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- example/resource_virtual_environment_container.tf | 2 +- example/resource_virtual_environment_download_file.tf | 6 +++--- example/resource_virtual_environment_trunks.tf | 4 +++- example/variables.tf | 8 ++++---- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/example/resource_virtual_environment_container.tf b/example/resource_virtual_environment_container.tf index 705d79e8e..d7135beb0 100644 --- a/example/resource_virtual_environment_container.tf +++ b/example/resource_virtual_environment_container.tf @@ -42,7 +42,7 @@ resource "proxmox_virtual_environment_container" "example_template" { node_name = data.proxmox_virtual_environment_nodes.example.names[0] operating_system { - template_file_id = proxmox_virtual_environment_download_file.release_20240725_ubuntu_24_noble_lxc_img.id + template_file_id = proxmox_virtual_environment_download_file.release_20250610_ubuntu_24_noble_lxc_img.id type = "ubuntu" } diff --git a/example/resource_virtual_environment_download_file.tf b/example/resource_virtual_environment_download_file.tf index 53bc1ef9d..8532d95ab 100644 --- a/example/resource_virtual_environment_download_file.tf +++ b/example/resource_virtual_environment_download_file.tf @@ -1,11 +1,11 @@ ## Debian and ubuntu image download -resource "proxmox_virtual_environment_download_file" "release_20240725_ubuntu_24_noble_lxc_img" { +resource "proxmox_virtual_environment_download_file" "release_20250610_ubuntu_24_noble_lxc_img" { content_type = "vztmpl" datastore_id = "local" node_name = "pve" - url = var.release_20240725_ubuntu_24_noble_lxc_img_url - checksum = var.release_20240725_ubuntu_24_noble_lxc_img_checksum + url = var.release_20250610_ubuntu_24_noble_lxc_img_url + checksum = var.release_20250610_ubuntu_24_noble_lxc_img_checksum checksum_algorithm = "sha256" upload_timeout = 4444 overwrite_unmanaged = true diff --git a/example/resource_virtual_environment_trunks.tf b/example/resource_virtual_environment_trunks.tf index 67cb06e0e..103b0524e 100644 --- a/example/resource_virtual_environment_trunks.tf +++ b/example/resource_virtual_environment_trunks.tf @@ -42,6 +42,8 @@ resource "proxmox_virtual_environment_vm" "trunks-example" { enabled = true } + serial_device {} + boot_order = ["scsi0"] scsi_hardware = "virtio-scsi-pci" @@ -50,4 +52,4 @@ resource "proxmox_virtual_environment_vm" "trunks-example" { bridge = "vmbr0" trunks = "10;20;30" } -} \ No newline at end of file +} diff --git a/example/variables.tf b/example/variables.tf index eb47415c1..968bd1915 100644 --- a/example/variables.tf +++ b/example/variables.tf @@ -19,14 +19,14 @@ variable "latest_debian_12_bookworm_qcow2_img_url" { default = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" } -variable "release_20240725_ubuntu_24_noble_lxc_img_url" { +variable "release_20250610_ubuntu_24_noble_lxc_img_url" { type = string description = "The URL for the Ubuntu 24.04 LXC image" - default = "https://mirrors.servercentral.com/ubuntu-cloud-images/releases/24.04/release-20240725/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz" + default = "https://mirrors.servercentral.com/ubuntu-cloud-images/releases/24.04/release-20250610/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz" } -variable "release_20240725_ubuntu_24_noble_lxc_img_checksum" { +variable "release_20250610_ubuntu_24_noble_lxc_img_checksum" { type = string description = "The checksum for the Ubuntu 24.04 LXC image" - default = "d767d38cb25b2c25d84edc31a80dd1c29a8c922b74188b0e14768b2b2fb6df8e" + default = "ae1fc4b5f020e6f1f2048beb5a7635f7bce4d72723239b7dea86af062cc1ab79" } From 2d9e0b585e307196b97a680716db75c9cc010bac Mon Sep 17 00:00:00 2001 From: Marco Attia <54147992+Vaneixus@users.noreply.github.com> Date: Sat, 28 Jun 2025 01:23:22 +0000 Subject: [PATCH 04/11] feat: add support for 'import' content type in Proxmox file resources (#1983) Signed-off-by: Marco Attia <54147992+Vaneixus@users.noreply.github.com> Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- README.md | 2 +- docs/guides/setup-proxmox-for-tests.md | 2 +- .../virtual_environment_download_file.md | 26 +++++++++++-- docs/resources/virtual_environment_file.md | 17 ++++++++- ...ource_virtual_environment_download_file.tf | 10 +++++ .../resource.tf | 18 +++++++++ .../resource.tf | 19 ++++++++++ fwprovider/datasource_version.go | 2 +- fwprovider/nodes/resource_download_file.go | 8 ++-- .../nodes/resource_download_file_test.go | 17 ++++++++- proxmox/version/capabilities.go | 20 ++++++++++ proxmox/version/version_types.go | 30 +++++++++++++-- proxmoxtf/resource/file.go | 37 +++++++++++++------ proxmoxtf/resource/file_test.go | 7 +++- proxmoxtf/resource/validators/file.go | 1 + .../guides/setup-proxmox-for-tests.md.tmpl | 2 +- 16 files changed, 189 insertions(+), 29 deletions(-) create mode 100644 examples/resources/proxmox_virtual_environment_file/resource.tf create mode 100644 proxmox/version/capabilities.go diff --git a/README.md b/README.md index a780e8e78..da27fdfd0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The following assumptions are made about the test environment: - It has one node named `pve` - The node has local storages named `local` and `local-lvm` -- The "Snippets" content type is enabled in the `local` storage +- The "Snippets" and "Import" content types are enabled in the `local` storage - Default Linux Bridge "vmbr0" is VLAN aware (datacenter -> pve -> network -> edit & apply) Create `example/terraform.tfvars` with the following variables: diff --git a/docs/guides/setup-proxmox-for-tests.md b/docs/guides/setup-proxmox-for-tests.md index 2d2f9f722..71ea146f4 100644 --- a/docs/guides/setup-proxmox-for-tests.md +++ b/docs/guides/setup-proxmox-for-tests.md @@ -92,4 +92,4 @@ Goal is to have a proxmox node in VM using for a job 10. Now you can run `make example`. -11. If you see error with proxmox_virtual_environment_file: the datastore "local" does not support content type "snippets"; supported content types are: `[backup, iso, vztmpl]`, you need to enable them, see . +11. If you see error with proxmox_virtual_environment_file: the datastore "local" does not support content type "snippets"; supported content types are: `[backup, iso, vztmpl, import]`, you need to enable them, see . diff --git a/docs/resources/virtual_environment_download_file.md b/docs/resources/virtual_environment_download_file.md index 74609db38..42ed5a499 100644 --- a/docs/resources/virtual_environment_download_file.md +++ b/docs/resources/virtual_environment_download_file.md @@ -4,12 +4,12 @@ title: proxmox_virtual_environment_download_file parent: Resources subcategory: Virtual Environment description: |- - Manages files upload using PVE download-url API. It can be fully compatible and faster replacement for image files created using proxmox_virtual_environment_file. Supports images for VMs (ISO images) and LXC (CT Templates). + Manages files upload using PVE download-url API. It can be fully compatible and faster replacement for image files created using proxmox_virtual_environment_file. Supports images for VMs (ISO and disk images) and LXC (CT Templates). --- # Resource: proxmox_virtual_environment_download_file -Manages files upload using PVE download-url API. It can be fully compatible and faster replacement for image files created using `proxmox_virtual_environment_file`. Supports images for VMs (ISO images) and LXC (CT Templates). +Manages files upload using PVE download-url API. It can be fully compatible and faster replacement for image files created using `proxmox_virtual_environment_file`. Supports images for VMs (ISO and disk images) and LXC (CT Templates). ~> Besides the `Datastore.AllocateTemplate` privilege, this resource requires both the `Sys.Audit` and `Sys.Modify` privileges.

For more details, see the [`download-url`](https://pve.proxmox.com/pve-docs/api-viewer/index.html#/nodes/{node}/storage/{storage}/download-url) API documentation under the "Required permissions" section. @@ -27,6 +27,16 @@ resource "proxmox_virtual_environment_download_file" "release_20231228_debian_12 checksum_algorithm = "sha512" } +resource "proxmox_virtual_environment_download_file" "release_20231228_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + file_name = "debian-12-generic-amd64-20231228-1609.qcow2" + node_name = "pve" + url = "https://cloud.debian.org/images/cloud/bookworm/20231228-1609/debian-12-generic-amd64-20231228-1609.qcow2" + checksum = "d2fbcf11fb28795842e91364d8c7b69f1870db09ff299eb94e4fbbfa510eb78d141e74c1f4bf6dfa0b7e33d0c3b66e6751886feadb4e9916f778bab1776bdf1b" + checksum_algorithm = "sha512" +} + resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_qcow2_img" { content_type = "iso" datastore_id = "local" @@ -35,6 +45,14 @@ resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_ url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" } +resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + file_name = "debian-12-generic-amd64.qcow2" + node_name = "pve" + url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" +} + resource "proxmox_virtual_environment_download_file" "latest_ubuntu_22_jammy_qcow2_img" { content_type = "iso" datastore_id = "local" @@ -73,7 +91,7 @@ resource "proxmox_virtual_environment_download_file" "latest_ubuntu_22_jammy_lxc ### Required -- `content_type` (String) The file content type. Must be `iso` for VM images or `vztmpl` for LXC images. +- `content_type` (String) The file content type. Must be `iso` or `import` for VM images or `vztmpl` for LXC images. - `datastore_id` (String) The identifier for the target datastore. - `node_name` (String) The node name. - `url` (String) The URL to download the file from. Must match regex: `https?://.*`. @@ -83,7 +101,7 @@ resource "proxmox_virtual_environment_download_file" "latest_ubuntu_22_jammy_lxc - `checksum` (String) The expected checksum of the file. - `checksum_algorithm` (String) The algorithm to calculate the checksum of the file. Must be `md5` | `sha1` | `sha224` | `sha256` | `sha384` | `sha512`. - `decompression_algorithm` (String) Decompress the downloaded file using the specified compression algorithm. Must be one of `gz` | `lzo` | `zst` | `bz2`. -- `file_name` (String) The file name. If not provided, it is calculated using `url`. PVE will raise 'wrong file extension' error for some popular extensions file `.raw` or `.qcow2`. Workaround is to use e.g. `.img` instead. +- `file_name` (String) The file name. If not provided, it is calculated using `url`. PVE will raise 'wrong file extension' error for some popular extensions file `.raw` or `.qcow2` on PVE versions prior to 8.4. Workaround is to use e.g. `.img` instead. - `overwrite` (Boolean) By default `true`. If `true` and file size has changed in the datastore, it will be replaced. If `false`, there will be no check. - `overwrite_unmanaged` (Boolean) If `true` and a file with the same name already exists in the datastore, it will be deleted and the new file will be downloaded. If `false` and the file already exists, an error will be returned. - `upload_timeout` (Number) The file download timeout seconds. Default is 600 (10min). diff --git a/docs/resources/virtual_environment_file.md b/docs/resources/virtual_environment_file.md index 90c362750..4c3dfaf68 100644 --- a/docs/resources/virtual_environment_file.md +++ b/docs/resources/virtual_environment_file.md @@ -7,7 +7,7 @@ subcategory: Virtual Environment # Resource: proxmox_virtual_environment_file -Use this resource to upload files to a Proxmox VE node. The file can be a backup, an ISO image, a snippet, or a container template depending on the `content_type` attribute. +Use this resource to upload files to a Proxmox VE node. The file can be a backup, an ISO image, a Disk Image, a snippet, or a container template depending on the `content_type` attribute. ## Example Usage @@ -33,6 +33,8 @@ resource "proxmox_virtual_environment_file" "backup" { -> Consider using `proxmox_virtual_environment_download_file` resource instead. Using this resource for images is less efficient (requires to transfer uploaded image to node) though still supported. +-> Importing Disks is not enabled by default in new Proxmox installations. You need to enable them in the 'Datacenter>Storage' section of the proxmox interface before first using this resource with `content_type = "import"`. + ```hcl resource "proxmox_virtual_environment_file" "ubuntu_container_template" { content_type = "iso" @@ -45,6 +47,18 @@ resource "proxmox_virtual_environment_file" "ubuntu_container_template" { } ``` +```hcl +resource "proxmox_virtual_environment_file" "ubuntu_container_template" { + content_type = "import" + datastore_id = "local" + node_name = "pve" + + source_file { + path = "https://cloud-images.ubuntu.com/jammy/20230929/jammy-server-cloudimg-amd64-disk-kvm.img" + } +} +``` + ### Snippets -> Snippets are not enabled by default in new Proxmox installations. You need to enable them in the 'Datacenter>Storage' section of the proxmox interface before first using this resource. @@ -126,6 +140,7 @@ resource "proxmox_virtual_environment_file" "ubuntu_container_template" { - `backup` (allowed extensions: `.vzdump`, `.tar.gz`, `.tar.xz`, `tar.zst`) - `iso` (allowed extensions: `.iso`, `.img`) - `snippets` (allowed extensions: any) + - `import` (allowed extensions: `.raw`, `.qcow2`, `.vmdk`) - `vztmpl` (allowed extensions: `.tar.gz`, `.tar.xz`, `tar.zst`) - `datastore_id` - (Required) The datastore id. - `file_mode` - The file mode in octal format, e.g. `0700` or `600`. Note that the prefixes `0o` and `0x` is not supported! Setting this attribute is also only allowed for `root@pam` authenticated user. diff --git a/example/resource_virtual_environment_download_file.tf b/example/resource_virtual_environment_download_file.tf index 8532d95ab..895fcb529 100644 --- a/example/resource_virtual_environment_download_file.tf +++ b/example/resource_virtual_environment_download_file.tf @@ -20,3 +20,13 @@ resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_ overwrite = true overwrite_unmanaged = true } + +resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + file_name = "debian-12-generic-amd64.qcow2" + node_name = "pve" + url = var.latest_debian_12_bookworm_qcow2_img_url + overwrite = true + overwrite_unmanaged = true +} diff --git a/examples/resources/proxmox_virtual_environment_download_file/resource.tf b/examples/resources/proxmox_virtual_environment_download_file/resource.tf index 655f64e21..adc833158 100644 --- a/examples/resources/proxmox_virtual_environment_download_file/resource.tf +++ b/examples/resources/proxmox_virtual_environment_download_file/resource.tf @@ -8,6 +8,16 @@ resource "proxmox_virtual_environment_download_file" "release_20231228_debian_12 checksum_algorithm = "sha512" } +resource "proxmox_virtual_environment_download_file" "release_20231228_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + file_name = "debian-12-generic-amd64-20231228-1609.qcow2" + node_name = "pve" + url = "https://cloud.debian.org/images/cloud/bookworm/20231228-1609/debian-12-generic-amd64-20231228-1609.qcow2" + checksum = "d2fbcf11fb28795842e91364d8c7b69f1870db09ff299eb94e4fbbfa510eb78d141e74c1f4bf6dfa0b7e33d0c3b66e6751886feadb4e9916f778bab1776bdf1b" + checksum_algorithm = "sha512" +} + resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_qcow2_img" { content_type = "iso" datastore_id = "local" @@ -16,6 +26,14 @@ resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_ url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" } +resource "proxmox_virtual_environment_download_file" "latest_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + file_name = "debian-12-generic-amd64.qcow2" + node_name = "pve" + url = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" +} + resource "proxmox_virtual_environment_download_file" "latest_ubuntu_22_jammy_qcow2_img" { content_type = "iso" datastore_id = "local" diff --git a/examples/resources/proxmox_virtual_environment_file/resource.tf b/examples/resources/proxmox_virtual_environment_file/resource.tf new file mode 100644 index 000000000..67977b54e --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_file/resource.tf @@ -0,0 +1,19 @@ +resource "proxmox_virtual_environment_file" "latest_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + node_name = "pve" + + source_file { + path = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" + } +} + +resource "proxmox_virtual_environment_file" "release_20231228_debian_12_bookworm_qcow2" { + content_type = "import" + datastore_id = "local" + node_name = "pve" + + source_file { + path = "https://cloud.debian.org/images/cloud/bookworm/20231228-1609/debian-12-generic-amd64-20231228-1609.qcow2" + } +} diff --git a/fwprovider/datasource_version.go b/fwprovider/datasource_version.go index eb7d32835..76afc5c8f 100644 --- a/fwprovider/datasource_version.go +++ b/fwprovider/datasource_version.go @@ -115,7 +115,7 @@ func (d *versionDatasource) Read(ctx context.Context, _ datasource.ReadRequest, state.Release = types.StringValue(version.Release) state.RepositoryID = types.StringValue(version.RepositoryID) - state.Version = types.StringValue(version.Version) + state.Version = types.StringValue(version.Version.String()) state.ID = types.StringValue("version") diff --git a/fwprovider/nodes/resource_download_file.go b/fwprovider/nodes/resource_download_file.go index abee921d2..c121d1dc5 100644 --- a/fwprovider/nodes/resource_download_file.go +++ b/fwprovider/nodes/resource_download_file.go @@ -153,15 +153,16 @@ func (r *downloadFileResource) Schema( Description: "Manages files upload using PVE download-url API. ", MarkdownDescription: "Manages files upload using PVE download-url API. " + "It can be fully compatible and faster replacement for image files created using " + - "`proxmox_virtual_environment_file`. Supports images for VMs (ISO images) and LXC (CT Templates).", + "`proxmox_virtual_environment_file`. Supports images for VMs (ISO and disk images) and LXC (CT Templates).", Attributes: map[string]schema.Attribute{ "id": attribute.ResourceID(), "content_type": schema.StringAttribute{ - Description: "The file content type. Must be `iso` for VM images or `vztmpl` for LXC images.", + Description: "The file content type. Must be `iso` or `import` for VM images or `vztmpl` for LXC images.", Required: true, Validators: []validator.String{stringvalidator.OneOf([]string{ "iso", "vztmpl", + "import", }...)}, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), @@ -170,7 +171,8 @@ func (r *downloadFileResource) Schema( "file_name": schema.StringAttribute{ Description: "The file name. If not provided, it is calculated " + "using `url`. PVE will raise 'wrong file extension' error for some popular " + - "extensions file `.raw` or `.qcow2`. Workaround is to use e.g. `.img` instead.", + "extensions file `.raw` or `.qcow2` on PVE versions prior to 8.4. " + + "Workaround is to use e.g. `.img` instead.", Computed: true, Required: false, Optional: true, diff --git a/fwprovider/nodes/resource_download_file_test.go b/fwprovider/nodes/resource_download_file_test.go index aad1a6c6b..add33cf96 100644 --- a/fwprovider/nodes/resource_download_file_test.go +++ b/fwprovider/nodes/resource_download_file_test.go @@ -60,7 +60,7 @@ func TestAccResourceDownloadFile(t *testing.T) { }`), ExpectError: regexp.MustCompile(`Attribute url must match HTTP URL regex`), }}}, - {"download qcow2 file", []resource.TestStep{{ + {"download qcow2 file to iso storage", []resource.TestStep{{ Config: te.RenderConfig(` resource "proxmox_virtual_environment_download_file" "qcow2_image" { content_type = "iso" @@ -91,6 +91,21 @@ func TestAccResourceDownloadFile(t *testing.T) { }), ), }}}, + {"download qcow2 file to import storage", []resource.TestStep{{ + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_download_file" "qcow2_image" { + content_type = "import" + node_name = "{{.NodeName}}" + datastore_id = "{{.DatastoreID}}" + file_name = "fake_qcow2_file.qcow2" + url = "{{.FakeFileQCOW2}}" + checksum = "688787d8ff144c502c7f5cffaafe2cc588d86079f9de88304c26b0cb99ce91c6" + checksum_algorithm = "sha256" + overwrite_unmanaged = true + }`), + // the details sais "Image is not in qcow2 format", but we can't assert that + ExpectError: regexp.MustCompile(`Error downloading file from url`), + }}}, {"download & update iso file", []resource.TestStep{ { Config: te.RenderConfig(` diff --git a/proxmox/version/capabilities.go b/proxmox/version/capabilities.go new file mode 100644 index 000000000..5608f9cea --- /dev/null +++ b/proxmox/version/capabilities.go @@ -0,0 +1,20 @@ +/* + * 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 version + +import "github.com/hashicorp/go-version" + +// MinimumProxmoxVersion is the minimum supported Proxmox version by the provider. +// +//nolint:gochecknoglobals +var MinimumProxmoxVersion = ProxmoxVersion{*version.Must(version.NewVersion("8.0.0"))} + +// SupportImportContentType checks if the Proxmox version supports the `import` content type when uploading disk images. +// See https://bugzilla.proxmox.com/show_bug.cgi?id=2424 +func (v *ProxmoxVersion) SupportImportContentType() bool { + return v.GreaterThanOrEqual(version.Must(version.NewVersion("8.4.0"))) +} diff --git a/proxmox/version/version_types.go b/proxmox/version/version_types.go index f4221e6d8..022d5b931 100644 --- a/proxmox/version/version_types.go +++ b/proxmox/version/version_types.go @@ -6,6 +6,12 @@ package version +import ( + "fmt" + + "github.com/hashicorp/go-version" +) + // ResponseBody contains the body from a version response. type ResponseBody struct { Data *ResponseData `json:"data,omitempty"` @@ -13,8 +19,24 @@ type ResponseBody struct { // ResponseData contains the data from a version response. type ResponseData struct { - Console string `json:"console"` - Release string `json:"release"` - RepositoryID string `json:"repoid"` - Version string `json:"version"` + Console string `json:"console"` + Release string `json:"release"` + RepositoryID string `json:"repoid"` + Version ProxmoxVersion `json:"version"` +} + +type ProxmoxVersion struct { + version.Version +} + +func (v *ProxmoxVersion) UnmarshalJSON(data []byte) error { + // Unmarshal the version string into a go-version Version object + ver, err := version.NewVersion(string(data)) + if err != nil { + return fmt.Errorf("failed to parse version %q: %w", string(data), err) + } + + v.Version = *ver + + return nil } diff --git a/proxmoxtf/resource/file.go b/proxmoxtf/resource/file.go index ed6d9f600..617482b8a 100644 --- a/proxmoxtf/resource/file.go +++ b/proxmoxtf/resource/file.go @@ -28,7 +28,9 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/bpg/terraform-provider-proxmox/proxmox" "github.com/bpg/terraform-provider-proxmox/proxmox/api" + "github.com/bpg/terraform-provider-proxmox/proxmox/version" "github.com/bpg/terraform-provider-proxmox/proxmoxtf" "github.com/bpg/terraform-provider-proxmox/proxmoxtf/resource/validators" "github.com/bpg/terraform-provider-proxmox/utils" @@ -325,9 +327,6 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag var diags diag.Diagnostics - contentType, dg := fileGetContentType(d) - diags = append(diags, dg...) - fileName, err := fileGetSourceFileName(d) diags = append(diags, diag.FromErr(err)...) @@ -345,6 +344,9 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag return diag.FromErr(err) } + contentType, dg := fileGetContentType(ctx, d, capi) + diags = append(diags, dg...) + list, err := capi.Node(nodeName).Storage(datastoreID).ListDatastoreFiles(ctx) if err != nil { return diag.FromErr(err) @@ -406,7 +408,7 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag "url": sourceFilePath, }) - version, e := api.GetMinTLSVersion(sourceFileMinTLS) + minTLSVersion, e := api.GetMinTLSVersion(sourceFileMinTLS) if e != nil { return diag.FromErr(e) } @@ -414,7 +416,7 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag httpClient := http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - MinVersion: version, + MinVersion: minTLSVersion, InsecureSkipVerify: sourceFileInsecure, }, }, @@ -553,7 +555,7 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag } switch *contentType { - case "iso", "vztmpl": + case "iso", "vztmpl", "import": _, err = capi.Node(nodeName).Storage(datastoreID).APIUpload( ctx, request, config.TempDir(), ) @@ -600,7 +602,7 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag } - volID, di := fileGetVolumeID(d) + volID, di := fileGetVolumeID(ctx, d, capi) diags = append(diags, di...) if diags.HasError() { return diags @@ -617,11 +619,20 @@ func fileCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag return diags } -func fileGetContentType(d *schema.ResourceData) (*string, diag.Diagnostics) { +func fileGetContentType(ctx context.Context, d *schema.ResourceData, c proxmox.Client) (*string, diag.Diagnostics) { contentType := d.Get(mkResourceVirtualEnvironmentFileContentType).(string) sourceFile := d.Get(mkResourceVirtualEnvironmentFileSourceFile).([]interface{}) sourceRaw := d.Get(mkResourceVirtualEnvironmentFileSourceRaw).([]interface{}) + ver := version.MinimumProxmoxVersion + if versionResp, err := c.Version().Version(ctx); err == nil { + ver = versionResp.Version + } else { + tflog.Warn(ctx, fmt.Sprintf("failed to determine Proxmox VE version, assume %v", ver), map[string]interface{}{ + "error": err, + }) + } + sourceFilePath := "" if len(sourceFile) > 0 { @@ -638,11 +649,15 @@ func fileGetContentType(d *schema.ResourceData) (*string, diag.Diagnostics) { mkResourceVirtualEnvironmentFileSourceRaw, ) } - if contentType == "" { if strings.HasSuffix(sourceFilePath, ".tar.gz") || strings.HasSuffix(sourceFilePath, ".tar.xz") { contentType = "vztmpl" + } else if ver.SupportImportContentType() && + (strings.HasSuffix(sourceFilePath, ".qcow2") || + strings.HasSuffix(sourceFilePath, ".raw") || + strings.HasSuffix(sourceFilePath, ".vmdk")) { + contentType = "import" } else { ext := strings.TrimLeft(strings.ToLower(filepath.Ext(sourceFilePath)), ".") @@ -715,14 +730,14 @@ func fileGetSourceFileName(d *schema.ResourceData) (*string, error) { return &sourceFileFileName, nil } -func fileGetVolumeID(d *schema.ResourceData) (fileVolumeID, diag.Diagnostics) { +func fileGetVolumeID(ctx context.Context, d *schema.ResourceData, c proxmox.Client) (fileVolumeID, diag.Diagnostics) { fileName, err := fileGetSourceFileName(d) if err != nil { return fileVolumeID{}, diag.FromErr(err) } datastoreID := d.Get(mkResourceVirtualEnvironmentFileDatastoreID).(string) - contentType, diags := fileGetContentType(d) + contentType, diags := fileGetContentType(ctx, d, c) return fileVolumeID{ datastoreID: datastoreID, diff --git a/proxmoxtf/resource/file_test.go b/proxmoxtf/resource/file_test.go index 8299de07b..b73a38f4b 100644 --- a/proxmoxtf/resource/file_test.go +++ b/proxmoxtf/resource/file_test.go @@ -118,11 +118,16 @@ func Test_fileParseVolumeID(t *testing.T) { {"missing type", "local:/file.ido", fileVolumeID{}, true}, {"missing file", "local:iso", fileVolumeID{}, true}, {"missing file", "local:iso/", fileVolumeID{}, true}, - {"valid", "local:iso/file.iso", fileVolumeID{ + {"valid iso", "local:iso/file.iso", fileVolumeID{ datastoreID: "local", contentType: "iso", fileName: "file.iso", }, false}, + {"valid import", "local:import/file.qcow2", fileVolumeID{ + datastoreID: "local", + contentType: "import", + fileName: "file.qcow2", + }, false}, } for _, tt := range tests { diff --git a/proxmoxtf/resource/validators/file.go b/proxmoxtf/resource/validators/file.go index 8cd2e57ee..0c8e59083 100644 --- a/proxmoxtf/resource/validators/file.go +++ b/proxmoxtf/resource/validators/file.go @@ -24,6 +24,7 @@ func ContentType() schema.SchemaValidateDiagFunc { "iso", "snippets", "vztmpl", + "import", }, false)) } diff --git a/templates/guides/setup-proxmox-for-tests.md.tmpl b/templates/guides/setup-proxmox-for-tests.md.tmpl index 2d2f9f722..71ea146f4 100644 --- a/templates/guides/setup-proxmox-for-tests.md.tmpl +++ b/templates/guides/setup-proxmox-for-tests.md.tmpl @@ -92,4 +92,4 @@ Goal is to have a proxmox node in VM using for a job 10. Now you can run `make example`. -11. If you see error with proxmox_virtual_environment_file: the datastore "local" does not support content type "snippets"; supported content types are: `[backup, iso, vztmpl]`, you need to enable them, see . +11. If you see error with proxmox_virtual_environment_file: the datastore "local" does not support content type "snippets"; supported content types are: `[backup, iso, vztmpl, import]`, you need to enable them, see . From 7b26553bb678e48e0d3ba7b7ab62b35eb6ac6019 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 01:25:35 +0000 Subject: [PATCH 05/11] docs: add Vaneixus as a contributor for code (#2009) * docs: update CONTRIBUTORS.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index e0ec491a9..97a23c610 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1396,6 +1396,15 @@ "contributions": [ "code" ] + }, + { + "login": "Vaneixus", + "name": "Marco Attia", + "avatar_url": "https://avatars.githubusercontent.com/u/54147992?v=4", + "profile": "https://github.com/Vaneixus", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e4afaeeb9..665b5080a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -197,6 +197,7 @@ Dan McCormack
Dan McCormack

📖 VALKIRIA ACUATICA
VALKIRIA ACUATICA

💻 + Marco Attia
Marco Attia

💻 From b601ca3ed25b7b82d16a291cdaac308b36dbb3f9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:53:31 -0400 Subject: [PATCH 06/11] =?UTF-8?q?chore(deps):=20update=20module=20github.c?= =?UTF-8?q?om/brianvoe/gofakeit/v7=20(v7.2.1=20=E2=86=92=20v7.3.0)=20(#201?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | datasource | package | from | to | | ---------- | ------------------------------- | ------ | ------ | | go | github.com/brianvoe/gofakeit/v7 | v7.2.1 | v7.3.0 | Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 126b57bde..1b58ede80 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,12 @@ tool github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs require ( github.com/Microsoft/go-winio v0.6.2 github.com/avast/retry-go/v4 v4.6.1 - github.com/brianvoe/gofakeit/v7 v7.2.1 + github.com/brianvoe/gofakeit/v7 v7.3.0 github.com/google/go-cmp v0.7.0 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.5.0 + github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/terraform-plugin-framework v1.15.0 github.com/hashicorp/terraform-plugin-framework-timeouts v0.5.0 github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 @@ -53,7 +54,6 @@ require ( github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hc-install v0.9.2 // indirect github.com/hashicorp/hcl/v2 v2.23.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect diff --git a/go.sum b/go.sum index 056959202..be3e92cdc 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI= -github.com/brianvoe/gofakeit/v7 v7.2.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= +github.com/brianvoe/gofakeit/v7 v7.3.0 h1:TWStf7/lLpAjKw+bqwzeORo9jvrxToWEwp9b1J2vApQ= +github.com/brianvoe/gofakeit/v7 v7.3.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= From 8c2a071b40f9be2894da6f88d49457f5202a9712 Mon Sep 17 00:00:00 2001 From: Pavel Boldyrev <627562+bpg@users.noreply.github.com> Date: Mon, 30 Jun 2025 22:07:30 -0400 Subject: [PATCH 07/11] chore(docs): minor fixes / updates in guides (#2014) Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- docs/guides/cloud-image.md | 11 ++++++----- docs/guides/cloud-init.md | 3 +++ .../guides/cloud-image/debian-from-storage/main.tf | 12 ++++++------ examples/guides/cloud-init/native/main.tf | 3 +++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/guides/cloud-image.md b/docs/guides/cloud-image.md index 2ba46c061..ce593f1a2 100644 --- a/docs/guides/cloud-image.md +++ b/docs/guides/cloud-image.md @@ -109,11 +109,12 @@ resource "proxmox_virtual_environment_vm" "debian_vm" { disk { datastore_id = "local-lvm" - file_id = "local:iso/debian-12-genericcloud-amd64.img" - interface = "virtio0" - iothread = true - discard = "on" - size = 20 + # qcow2 image downloaded from https://cloud.debian.org/images/cloud/bookworm/latest/ and renamed to *.img + file_id = "local:iso/debian-12-genericcloud-amd64.img" + interface = "virtio0" + iothread = true + discard = "on" + size = 20 } } ``` diff --git a/docs/guides/cloud-init.md b/docs/guides/cloud-init.md index e44220fcf..4c922a419 100644 --- a/docs/guides/cloud-init.md +++ b/docs/guides/cloud-init.md @@ -21,6 +21,9 @@ resource "proxmox_virtual_environment_vm" "ubuntu_vm" { name = "test-ubuntu" node_name = "pve" + # should be true if qemu agent is not installed / enabled on the VM + stop_on_destroy = true + initialization { ip_config { ipv4 { diff --git a/examples/guides/cloud-image/debian-from-storage/main.tf b/examples/guides/cloud-image/debian-from-storage/main.tf index 10ef21ed4..e0767b37d 100644 --- a/examples/guides/cloud-image/debian-from-storage/main.tf +++ b/examples/guides/cloud-image/debian-from-storage/main.tf @@ -15,11 +15,11 @@ resource "proxmox_virtual_environment_vm" "debian_vm" { disk { datastore_id = "local-lvm" - file_id = "local:iso/debian-12-genericcloud-amd64.img" - interface = "virtio0" - iothread = true - discard = "on" - size = 20 + # qcow2 image downloaded from https://cloud.debian.org/images/cloud/bookworm/latest/ and renamed to *.img + file_id = "local:iso/debian-12-genericcloud-amd64.img" + interface = "virtio0" + iothread = true + discard = "on" + size = 20 } } - diff --git a/examples/guides/cloud-init/native/main.tf b/examples/guides/cloud-init/native/main.tf index 2e3a13b9a..674c7a83e 100644 --- a/examples/guides/cloud-init/native/main.tf +++ b/examples/guides/cloud-init/native/main.tf @@ -6,6 +6,9 @@ resource "proxmox_virtual_environment_vm" "ubuntu_vm" { name = "test-ubuntu" node_name = "pve" + # should be true if qemu agent is not installed / enabled on the VM + stop_on_destroy = true + initialization { ip_config { ipv4 { From 264a4e6c629e97dd713c295085ce3d3064139598 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 08/11] 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 | 2 +- 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, 3104 insertions(+), 5 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 000000000..f66e241a9 --- /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 000000000..af09546d1 --- /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 000000000..0c7824851 --- /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 000000000..0d14c016f --- /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 000000000..6698a48d1 --- /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 000000000..5501ad075 --- /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 d7135beb0..c244f3068 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 895fcb529..0a1dcbbe4 100644 --- a/example/resource_virtual_environment_download_file.tf +++ b/example/resource_virtual_environment_download_file.tf @@ -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 000000000..e381bf4eb --- /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 968bd1915..04f3aa618 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 e881eb209..4f3f14af3 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 000000000..8602f6d3a --- /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 000000000..68d491166 --- /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 000000000..0dde0d886 --- /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 000000000..38e42eda4 --- /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 000000000..6f30322e1 --- /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 000000000..4c37df382 --- /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 000000000..fbd4e20e3 --- /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 000000000..af26c2983 --- /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 000000000..c3de29277 --- /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 000000000..a4cc1c3ec --- /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 4304e53ea..768daa788 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 000000000..20c4abb61 --- /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 000000000..2a35c0622 --- /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 000000000..9309897e6 --- /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 000000000..e763d116d --- /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 6e0b7d8e7..80e53f837 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 e4f2314a7..6a06f1a74 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 000000000..1e6c13b7a --- /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 000000000..87f49c1a9 --- /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 000000000..72633007f --- /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 000000000..ec2c6458f --- /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 000000000..aa0af6bac --- /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 000000000..16d259169 --- /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 000000000..b5fc42c57 --- /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 000000000..b6f194b7d --- /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 000000000..8b622aa34 --- /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 000000000..6a0804d6e --- /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 000000000..11e8942e0 --- /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 000000000..b616c3afc --- /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 000000000..68e141bde --- /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 62213c930..9bf5ffdc8 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 000000000..ae15bbd9d --- /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 0c2aa76c60f0284734d1cbd8aa3a66f5ad1d6d4f 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 09/11] 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 | 57 ++++++++------- proxmox/helpers/ptr/ptr.go | 1 + 20 files changed, 308 insertions(+), 154 deletions(-) diff --git a/example/resource_virtual_environment_vm.tf b/example/resource_virtual_environment_vm.tf index 56554b7e6..61a42017d 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 4f3f14af3..e881eb209 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 8602f6d3a..f784dd93b 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 68d491166..d4381ef59 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 0dde0d886..30558a0fc 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 38e42eda4..37108306e 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 6f30322e1..b4d70b6db 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 4c37df382..8e93cfe88 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 fbd4e20e3..cc478e447 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 af26c2983..86d62d068 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 c3de29277..36380c0c9 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 6a06f1a74..dd94dda0a 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 1e6c13b7a..d6581cd31 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 ec2c6458f..e295729d7 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 aa0af6bac..1335e9b0f 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 b6f194b7d..bb56359a7 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 8b622aa34..ba9edacc8 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 b616c3afc..450a975e1 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 68e141bde..b74792455 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"` - - // 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"` + 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"` + 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 9bf5ffdc8..facd01f44 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 } From 196e972edea90c2d49c151775764c01529521e49 Mon Sep 17 00:00:00 2001 From: MacherelR <64424331+MacherelR@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:00:51 +0200 Subject: [PATCH 10/11] fix(sdn): corrected tests --- .../resource_virtual_environment_download_file.tf | 4 ++-- example/resource_virtual_environment_sdn.tf | 14 +++++++++++--- fwprovider/test/resource_sdn_test.go | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/example/resource_virtual_environment_download_file.tf b/example/resource_virtual_environment_download_file.tf index 4f4559cea..3e7317604 100644 --- a/example/resource_virtual_environment_download_file.tf +++ b/example/resource_virtual_environment_download_file.tf @@ -4,8 +4,8 @@ resource "proxmox_virtual_environment_download_file" "release_20250610_ubuntu_24 content_type = "vztmpl" datastore_id = "local" 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 + url = var.release_20250610_ubuntu_24_noble_lxc_img_url + checksum = var.release_20250610_ubuntu_24_noble_lxc_img_checksum checksum_algorithm = "sha256" upload_timeout = 4444 overwrite_unmanaged = true diff --git a/example/resource_virtual_environment_sdn.tf b/example/resource_virtual_environment_sdn.tf index e381bf4eb..4acffe8c5 100644 --- a/example/resource_virtual_environment_sdn.tf +++ b/example/resource_virtual_environment_sdn.tf @@ -24,6 +24,7 @@ resource "proxmox_virtual_environment_sdn_vnet" "vnet_simple" { 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" { @@ -32,6 +33,7 @@ resource "proxmox_virtual_environment_sdn_vnet" "vnet_vlan" { alias = "vnet in zoneVLAN" tag = 1000 zonetype = proxmox_virtual_environment_sdn_zone.zone_vlan.type + depends_on = [ proxmox_virtual_environment_sdn_zone.zone_vlan ] } # --- SDN Subnets --- @@ -48,6 +50,7 @@ resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple" { ] gateway = "10.10.0.1" snat = true + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_simple ] } resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple2" { @@ -62,6 +65,7 @@ resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple2" { ] gateway = "10.40.0.1" snat = true + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_simple ] } resource "proxmox_virtual_environment_sdn_subnet" "subnet_vlan" { @@ -76,21 +80,25 @@ resource "proxmox_virtual_environment_sdn_subnet" "subnet_vlan" { ] gateway = "10.20.0.100" snat = false + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_vlan ] } # --- Data Sources --- data "proxmox_virtual_environment_sdn_zone" "zone_ex" { - name = "ZoneEx" + name = "zoneS" + depends_on = [ proxmox_virtual_environment_sdn_zone.zone_simple ] } data "proxmox_virtual_environment_sdn_vnet" "vnet_ex" { - name = "VnetEx" + name = "vnetM" + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_simple ] } data "proxmox_virtual_environment_sdn_subnet" "subnet_ex" { - subnet = "ZoneEx-100.100.0.0-24" + subnet = "zoneS-10.10.0.0-24" vnet = data.proxmox_virtual_environment_sdn_vnet.vnet_ex.id + depends_on = [ proxmox_virtual_environment_sdn_subnet.subnet_simple ] } # --- Outputs --- diff --git a/fwprovider/test/resource_sdn_test.go b/fwprovider/test/resource_sdn_test.go index e763d116d..c52c65ce6 100644 --- a/fwprovider/test/resource_sdn_test.go +++ b/fwprovider/test/resource_sdn_test.go @@ -26,14 +26,14 @@ func TestAccResourceSDN(t *testing.T) { resource "proxmox_virtual_environment_sdn_zone" "zone_simple" { name = "zoneS" type = "simple" - nodes = "weisshorn-proxmox" + nodes = "pve" mtu = 1496 } resource "proxmox_virtual_environment_sdn_zone" "zone_vlan" { name = "zoneVLAN" type = "vlan" - nodes = "weisshorn-proxmox" + nodes = "pve" mtu = 1500 bridge = "vmbr0" } @@ -108,7 +108,7 @@ func TestAccResourceSDN(t *testing.T) { "name": "zoneS", "type": "simple", "mtu": "1496", - "nodes": "weisshorn-proxmox", + "nodes": "pve", }), ResourceAttributes("proxmox_virtual_environment_sdn_zone.zone_vlan", map[string]string{ "name": "zoneVLAN", From 676105ef539b161616b43de39b71571f8e09c5e1 Mon Sep 17 00:00:00 2001 From: MacherelR <64424331+MacherelR@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:00:51 +0200 Subject: [PATCH 11/11] fix(sdn): corrected tests Signed-off-by: MacherelR <64424331+MacherelR@users.noreply.github.com> --- .../resource_virtual_environment_download_file.tf | 4 ++-- example/resource_virtual_environment_sdn.tf | 14 +++++++++++--- fwprovider/test/resource_sdn_test.go | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/example/resource_virtual_environment_download_file.tf b/example/resource_virtual_environment_download_file.tf index 4f4559cea..3e7317604 100644 --- a/example/resource_virtual_environment_download_file.tf +++ b/example/resource_virtual_environment_download_file.tf @@ -4,8 +4,8 @@ resource "proxmox_virtual_environment_download_file" "release_20250610_ubuntu_24 content_type = "vztmpl" datastore_id = "local" 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 + url = var.release_20250610_ubuntu_24_noble_lxc_img_url + checksum = var.release_20250610_ubuntu_24_noble_lxc_img_checksum checksum_algorithm = "sha256" upload_timeout = 4444 overwrite_unmanaged = true diff --git a/example/resource_virtual_environment_sdn.tf b/example/resource_virtual_environment_sdn.tf index e381bf4eb..4acffe8c5 100644 --- a/example/resource_virtual_environment_sdn.tf +++ b/example/resource_virtual_environment_sdn.tf @@ -24,6 +24,7 @@ resource "proxmox_virtual_environment_sdn_vnet" "vnet_simple" { 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" { @@ -32,6 +33,7 @@ resource "proxmox_virtual_environment_sdn_vnet" "vnet_vlan" { alias = "vnet in zoneVLAN" tag = 1000 zonetype = proxmox_virtual_environment_sdn_zone.zone_vlan.type + depends_on = [ proxmox_virtual_environment_sdn_zone.zone_vlan ] } # --- SDN Subnets --- @@ -48,6 +50,7 @@ resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple" { ] gateway = "10.10.0.1" snat = true + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_simple ] } resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple2" { @@ -62,6 +65,7 @@ resource "proxmox_virtual_environment_sdn_subnet" "subnet_simple2" { ] gateway = "10.40.0.1" snat = true + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_simple ] } resource "proxmox_virtual_environment_sdn_subnet" "subnet_vlan" { @@ -76,21 +80,25 @@ resource "proxmox_virtual_environment_sdn_subnet" "subnet_vlan" { ] gateway = "10.20.0.100" snat = false + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_vlan ] } # --- Data Sources --- data "proxmox_virtual_environment_sdn_zone" "zone_ex" { - name = "ZoneEx" + name = "zoneS" + depends_on = [ proxmox_virtual_environment_sdn_zone.zone_simple ] } data "proxmox_virtual_environment_sdn_vnet" "vnet_ex" { - name = "VnetEx" + name = "vnetM" + depends_on = [ proxmox_virtual_environment_sdn_vnet.vnet_simple ] } data "proxmox_virtual_environment_sdn_subnet" "subnet_ex" { - subnet = "ZoneEx-100.100.0.0-24" + subnet = "zoneS-10.10.0.0-24" vnet = data.proxmox_virtual_environment_sdn_vnet.vnet_ex.id + depends_on = [ proxmox_virtual_environment_sdn_subnet.subnet_simple ] } # --- Outputs --- diff --git a/fwprovider/test/resource_sdn_test.go b/fwprovider/test/resource_sdn_test.go index e763d116d..c52c65ce6 100644 --- a/fwprovider/test/resource_sdn_test.go +++ b/fwprovider/test/resource_sdn_test.go @@ -26,14 +26,14 @@ func TestAccResourceSDN(t *testing.T) { resource "proxmox_virtual_environment_sdn_zone" "zone_simple" { name = "zoneS" type = "simple" - nodes = "weisshorn-proxmox" + nodes = "pve" mtu = 1496 } resource "proxmox_virtual_environment_sdn_zone" "zone_vlan" { name = "zoneVLAN" type = "vlan" - nodes = "weisshorn-proxmox" + nodes = "pve" mtu = 1500 bridge = "vmbr0" } @@ -108,7 +108,7 @@ func TestAccResourceSDN(t *testing.T) { "name": "zoneS", "type": "simple", "mtu": "1496", - "nodes": "weisshorn-proxmox", + "nodes": "pve", }), ResourceAttributes("proxmox_virtual_environment_sdn_zone.zone_vlan", map[string]string{ "name": "zoneVLAN",