0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-07-04 21:14:05 +00:00

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>
This commit is contained in:
MacherelR 2025-05-30 14:06:08 +02:00
parent 221faafc8c
commit 58ff2ff240
43 changed files with 3105 additions and 6 deletions

View File

@ -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 generated by tfplugindocs -->
## 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)
<a id="nestedatt--dhcp_range"></a>
### Nested Schema for `dhcp_range`
Read-Only:
- `end_address` (String) End of the DHCP range.
- `start_address` (String) Start of the DHCP range.

View File

@ -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 generated by tfplugindocs -->
## 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.

View File

@ -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 generated by tfplugindocs -->
## 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.

View File

@ -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 generated by tfplugindocs -->
## 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')
<a id="nestedatt--dhcp_range"></a>
### Nested Schema for `dhcp_range`
Required:
- `end_address` (String) End of the DHCP range.
- `start_address` (String) Start of the DHCP range.

View File

@ -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 generated by tfplugindocs -->
## 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').

View File

@ -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 generated by tfplugindocs -->
## 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.

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -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

View File

@ -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)...)
}

View File

@ -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)...)
}

View File

@ -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)...)
}

View File

@ -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()),
)
}
}
}
}

View File

@ -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),
)
}
}
}

View File

@ -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),
)
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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,
})
})
}
}

View File

@ -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,
})
})
}
}

View File

@ -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,
})
})
}
}

View File

@ -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,
})
})
}
}

View File

@ -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,

View File

@ -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}
}

View File

@ -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)
}
})
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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",
},
},
}
}