diff --git a/fwprovider/cluster/sdn/zone/resource_evpn.go b/fwprovider/cluster/sdn/zone/resource_evpn.go new file mode 100644 index 00000000..a7d3836b --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_evpn.go @@ -0,0 +1,191 @@ +/* + * 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 zone + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + + "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/bpg/terraform-provider-proxmox/proxmox/helpers/ptr" +) + +var ( + _ resource.ResourceWithConfigure = &EVPNResource{} + _ resource.ResourceWithImportState = &EVPNResource{} +) + +type EVPNResource struct { + client *zones.Client +} + +func NewEVPNResource() resource.Resource { + return &EVPNResource{} +} + +func (r *EVPNResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_sdn_zone_evpn" +} + +func (r *EVPNResource) 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 *EVPNResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan evpnModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + reqData.Type = ptr.Ptr(zones.TypeEVPN) + + if err := r.client.CreateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Create SDN EVPN Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *EVPNResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state evpnModel + + 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 EVPN Zone", + err.Error(), + ) + return + } + + readModel := &evpnModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} + +func (r *EVPNResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan evpnModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + + if err := r.client.UpdateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Update SDN EVPN Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *EVPNResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state evpnModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteZone(ctx, state.ID.ValueString()); err != nil && + !errors.Is(err, api.ErrResourceDoesNotExist) { + resp.Diagnostics.AddError( + "Unable to Delete SDN EVPN Zone", + err.Error(), + ) + } +} + +func (r *EVPNResource) 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(fmt.Sprintf("Zone %s does not exist", req.ID), err.Error()) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Unable to Import SDN EVPN Zone %s", req.ID), err.Error()) + return + } + readModel := &evpnModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} diff --git a/fwprovider/cluster/sdn/zone/resource_model.go b/fwprovider/cluster/sdn/zone/resource_model.go index 4368b7fc..35969379 100644 --- a/fwprovider/cluster/sdn/zone/resource_model.go +++ b/fwprovider/cluster/sdn/zone/resource_model.go @@ -14,6 +14,8 @@ import ( "github.com/bpg/terraform-provider-proxmox/fwprovider/types/stringset" "github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/zones" + + proxmoxtypes "github.com/bpg/terraform-provider-proxmox/proxmox/types" ) type baseModel struct { @@ -51,18 +53,6 @@ func (m *baseModel) importFromAPI(name string, data *zones.ZoneData, diags *diag m.MTU = types.Int64PointerValue(data.MTU) m.Nodes = stringset.NewValueString(data.Nodes, diags, stringset.WithSeparator(",")) m.ReverseDNS = types.StringPointerValue(data.ReverseDNS) - // m.Bridge = types.StringPointerValue(data.Bridge) - // m.ServiceVLAN = types.Int64PointerValue(data.ServiceVLAN) - // m.ServiceVLANProtocol = types.StringPointerValue(data.ServiceVLANProtocol) - // m.Peers = stringset.NewValueString(data.Peers, diags, comaSeparated) - // m.Controller = types.StringPointerValue(data.Controller) - // m.ExitNodes = stringset.NewValueString(data.ExitNodes, diags, comaSeparated) - // m.PrimaryExitNode = types.StringPointerValue(data.ExitNodesPrimary) - // 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 *baseModel) toAPIRequestBody(ctx context.Context, diags *diag.Diagnostics) *zones.ZoneRequestData { @@ -76,18 +66,113 @@ func (m *baseModel) toAPIRequestBody(ctx context.Context, diags *diag.Diagnostic data.DNSZone = m.DNSZone.ValueStringPointer() data.Nodes = m.Nodes.ValueStringPointer(ctx, diags, stringset.WithSeparator(",")) 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(ctx, diags, comaSeparated) - // data.Controller = m.Controller.ValueStringPointer() - // data.ExitNodes = m.ExitNodes.ValueStringPointer(ctx, diags, comaSeparated) - // data.ExitNodesPrimary = 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 +} + +type simpleModel struct { + baseModel +} + +type vlanModel struct { + baseModel + + Bridge types.String `tfsdk:"bridge"` +} + +func (m *vlanModel) importFromAPI(name string, data *zones.ZoneData, diags *diag.Diagnostics) { + m.baseModel.importFromAPI(name, data, diags) + + m.Bridge = types.StringPointerValue(data.Bridge) +} + +func (m *vlanModel) toAPIRequestBody(ctx context.Context, diags *diag.Diagnostics) *zones.ZoneRequestData { + data := m.baseModel.toAPIRequestBody(ctx, diags) + + data.Bridge = m.Bridge.ValueStringPointer() + + return data +} + +type qinqModel struct { + vlanModel + + ServiceVLAN types.Int64 `tfsdk:"service_vlan"` + ServiceVLANProtocol types.String `tfsdk:"service_vlan_protocol"` +} + +func (m *qinqModel) importFromAPI(name string, data *zones.ZoneData, diags *diag.Diagnostics) { + m.vlanModel.importFromAPI(name, data, diags) + + m.ServiceVLAN = types.Int64PointerValue(data.ServiceVLAN) + m.ServiceVLANProtocol = types.StringPointerValue(data.ServiceVLANProtocol) +} + +func (m *qinqModel) toAPIRequestBody(ctx context.Context, diags *diag.Diagnostics) *zones.ZoneRequestData { + data := m.vlanModel.toAPIRequestBody(ctx, diags) + + data.ServiceVLAN = m.ServiceVLAN.ValueInt64Pointer() + data.ServiceVLANProtocol = m.ServiceVLANProtocol.ValueStringPointer() + + return data +} + +type vxlanModel struct { + baseModel + + Peers stringset.Value `tfsdk:"peers"` +} + +func (m *vxlanModel) importFromAPI(name string, data *zones.ZoneData, diags *diag.Diagnostics) { + m.baseModel.importFromAPI(name, data, diags) + m.Peers = stringset.NewValueString(data.Peers, diags, stringset.WithSeparator(",")) +} + +func (m *vxlanModel) toAPIRequestBody(ctx context.Context, diags *diag.Diagnostics) *zones.ZoneRequestData { + data := m.baseModel.toAPIRequestBody(ctx, diags) + + data.Peers = m.Peers.ValueStringPointer(ctx, diags, stringset.WithSeparator(",")) + + return data +} + +type evpnModel struct { + baseModel + + AdvertiseSubnets types.Bool `tfsdk:"advertise_subnets"` + Controller types.String `tfsdk:"controller"` + DisableARPNDSuppression types.Bool `tfsdk:"disable_arp_nd_suppression"` + ExitNodes stringset.Value `tfsdk:"exit_nodes"` + ExitNodesLocalRouting types.Bool `tfsdk:"exit_nodes_local_routing"` + PrimaryExitNode types.String `tfsdk:"primary_exit_node"` + RouteTargetImport types.String `tfsdk:"rt_import"` + VRFVXLANID types.Int64 `tfsdk:"vrf_vxlan"` +} + +func (m *evpnModel) importFromAPI(name string, data *zones.ZoneData, diags *diag.Diagnostics) { + m.baseModel.importFromAPI(name, data, diags) + + m.AdvertiseSubnets = types.BoolPointerValue(data.AdvertiseSubnets.PointerBool()) + m.Controller = types.StringPointerValue(data.Controller) + m.DisableARPNDSuppression = types.BoolPointerValue(data.DisableARPNDSuppression.PointerBool()) + m.ExitNodes = stringset.NewValueString(data.ExitNodes, diags, stringset.WithSeparator(",")) + m.ExitNodesLocalRouting = types.BoolPointerValue(data.ExitNodesLocalRouting.PointerBool()) + m.PrimaryExitNode = types.StringPointerValue(data.ExitNodesPrimary) + m.RouteTargetImport = types.StringPointerValue(data.RouteTargetImport) + m.VRFVXLANID = types.Int64PointerValue(data.VRFVXLANID) +} + +func (m *evpnModel) toAPIRequestBody(ctx context.Context, diags *diag.Diagnostics) *zones.ZoneRequestData { + data := m.baseModel.toAPIRequestBody(ctx, diags) + + data.AdvertiseSubnets = proxmoxtypes.CustomBoolPtr(m.AdvertiseSubnets.ValueBoolPointer()) + data.Controller = m.Controller.ValueStringPointer() + data.DisableARPNDSuppression = proxmoxtypes.CustomBoolPtr(m.DisableARPNDSuppression.ValueBoolPointer()) + data.ExitNodes = m.ExitNodes.ValueStringPointer(ctx, diags, stringset.WithSeparator(",")) + data.ExitNodesLocalRouting = proxmoxtypes.CustomBoolPtr(m.ExitNodesLocalRouting.ValueBoolPointer()) + data.ExitNodesPrimary = m.PrimaryExitNode.ValueStringPointer() + data.RouteTargetImport = m.RouteTargetImport.ValueStringPointer() + data.VRFVXLANID = m.VRFVXLANID.ValueInt64Pointer() return data } diff --git a/fwprovider/cluster/sdn/zone/resource_qinq.go b/fwprovider/cluster/sdn/zone/resource_qinq.go new file mode 100644 index 00000000..1e91ec3a --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_qinq.go @@ -0,0 +1,191 @@ +/* + * 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 zone + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + + "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/bpg/terraform-provider-proxmox/proxmox/helpers/ptr" +) + +var ( + _ resource.ResourceWithConfigure = &QinQResource{} + _ resource.ResourceWithImportState = &QinQResource{} +) + +type QinQResource struct { + client *zones.Client +} + +func NewQinQResource() resource.Resource { + return &QinQResource{} +} + +func (r *QinQResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_sdn_zone_qinq" +} + +func (r *QinQResource) 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 *QinQResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan qinqModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + reqData.Type = ptr.Ptr(zones.TypeQinQ) + + if err := r.client.CreateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Create SDN QinQ Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *QinQResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state qinqModel + + 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 QinQ Zone", + err.Error(), + ) + return + } + + readModel := &qinqModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} + +func (r *QinQResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan qinqModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + + if err := r.client.UpdateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Update SDN QinQ Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *QinQResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state qinqModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteZone(ctx, state.ID.ValueString()); err != nil && + !errors.Is(err, api.ErrResourceDoesNotExist) { + resp.Diagnostics.AddError( + "Unable to Delete SDN QinQ Zone", + err.Error(), + ) + } +} + +func (r *QinQResource) 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(fmt.Sprintf("Zone %s does not exist", req.ID), err.Error()) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Unable to Import SDN QinQ Zone %s", req.ID), err.Error()) + return + } + readModel := &qinqModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} diff --git a/fwprovider/cluster/sdn/zone/resource_qinq_test.go b/fwprovider/cluster/sdn/zone/resource_qinq_test.go new file mode 100644 index 00000000..bb047a5c --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_qinq_test.go @@ -0,0 +1,65 @@ +//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 zone_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/test" +) + +func TestAccResourceSDNZoneQinQ(t *testing.T) { + t.Parallel() + + te := test.InitEnvironment(t) + + tests := []struct { + name string + steps []resource.TestStep + }{ + {"create and update QinQ zone", []resource.TestStep{{ + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_sdn_zone_qinq" "zone_qinq" { + id = "zoneQ" + nodes = ["pve"] + mtu = 1496 + bridge = "vmbr0" + service_vlan = 100 + service_vlan_protocol = "802.1ad" + } + `), + }, { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_sdn_zone_qinq" "zone_qinq" { + id = "zoneQ" + nodes = ["pve"] + mtu = 1495 + bridge = "vmbr0" + service_vlan = 200 + service_vlan_protocol = "802.1q" + } + `), + ResourceName: "proxmox_virtual_environment_sdn_zone_qinq.zone_qinq", + ImportStateId: "zoneQ", + ImportState: true, + ImportStateVerify: 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/cluster/sdn/zone/resource_schema.go b/fwprovider/cluster/sdn/zone/resource_schema.go index e26864dd..8aa78f35 100644 --- a/fwprovider/cluster/sdn/zone/resource_schema.go +++ b/fwprovider/cluster/sdn/zone/resource_schema.go @@ -22,16 +22,16 @@ import ( "github.com/bpg/terraform-provider-proxmox/fwprovider/types/stringset" ) -func commonAttributes(base ...map[string]schema.Attribute) map[string]schema.Attribute { - if len(base) > 1 { - panic("commonAttributes expects at most one base map") +func baseAttributesWith(extraAttributes ...map[string]schema.Attribute) map[string]schema.Attribute { + if len(extraAttributes) > 1 { + panic("baseAttributesWith expects at most one extraAttributes map") } - if len(base) == 0 { - base = append(base, make(map[string]schema.Attribute)) + if len(extraAttributes) == 0 { + extraAttributes = append(extraAttributes, make(map[string]schema.Attribute)) } - maps.Copy(base[0], map[string]schema.Attribute{ + maps.Copy(extraAttributes[0], map[string]schema.Attribute{ "dns": schema.StringAttribute{ Optional: true, Description: "DNS API server address.", @@ -72,7 +72,7 @@ func commonAttributes(base ...map[string]schema.Attribute) map[string]schema.Att }, }) - return base[0] + return extraAttributes[0] } func (r *SimpleResource) Schema( @@ -85,11 +85,11 @@ func (r *SimpleResource) Schema( MarkdownDescription: "Simple Zone in Proxmox SDN. It will create an isolated VNet bridge. " + "This bridge is not linked to a physical interface, and VM traffic is only local on each the node. " + "It can be used in NAT or routed setups.", - Attributes: commonAttributes(), + Attributes: baseAttributesWith(), } } -func (r *VLAN) Schema( +func (r *VLANResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, @@ -99,7 +99,7 @@ func (r *VLAN) Schema( MarkdownDescription: "VLAN Zone in Proxmox SDN. It uses an existing local Linux or OVS bridge to connect to the " + "node's physical interface. It uses VLAN tagging defined in the VNet to isolate the network segments. " + "This allows connectivity of VMs between different nodes.", - Attributes: commonAttributes(map[string]schema.Attribute{ + Attributes: baseAttributesWith(map[string]schema.Attribute{ "bridge": schema.StringAttribute{ Description: "Bridge interface for VLAN.", MarkdownDescription: "The local bridge or OVS switch, already configured on _each_ node that allows " + @@ -110,7 +110,7 @@ func (r *VLAN) Schema( } } -func (r *QinQ) Schema( +func (r *QinQResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, @@ -122,7 +122,7 @@ func (r *QinQ) Schema( "VLAN tag is defined by the VNet. Your physical network switches must support stacked VLANs for this " + "configuration. Due to the double stacking of tags, you need 4 more bytes for QinQ VLANs. " + "For example, you must reduce the MTU to 1496 if you physical interface MTU is 1500.", - Attributes: commonAttributes(map[string]schema.Attribute{ + Attributes: baseAttributesWith(map[string]schema.Attribute{ "bridge": schema.StringAttribute{ Description: "A local, VLAN-aware bridge that is already configured on each local node", Optional: true, @@ -145,7 +145,7 @@ func (r *QinQ) Schema( } } -func (r *VXLAN) Schema( +func (r *VXLANResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, @@ -157,7 +157,7 @@ func (r *VXLAN) Schema( "destination port 4789. You have to configure the underlay network yourself to enable UDP connectivity " + "between all peers. Because VXLAN encapsulation uses 50 bytes, the MTU needs to be 50 bytes lower than the " + "outgoing physical interface.", - Attributes: commonAttributes(map[string]schema.Attribute{ + Attributes: baseAttributesWith(map[string]schema.Attribute{ "peers": stringset.ResourceAttribute( "A list of IP addresses of each node in the VXLAN zone.", "A list of IP addresses of each node in the VXLAN zone. "+ @@ -168,7 +168,7 @@ func (r *VXLAN) Schema( } } -func (r *EVPN) Schema( +func (r *EVPNResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, @@ -177,7 +177,7 @@ func (r *EVPN) Schema( Description: "EVPN Zone in Proxmox SDN.", MarkdownDescription: "EVPN Zone in Proxmox SDN. The EVPN zone creates a routable Layer 3 network, capable of " + "spanning across multiple clusters.", - Attributes: commonAttributes(map[string]schema.Attribute{ + Attributes: baseAttributesWith(map[string]schema.Attribute{ "advertise_subnets": schema.BoolAttribute{ Optional: true, Description: "Enable subnet advertisement for EVPN.", @@ -202,6 +202,12 @@ func (r *EVPN) Schema( "rt_import": schema.StringAttribute{ Optional: true, Description: "Route target import for EVPN.", + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^(\d+):(\d+)$`), + "must be in the format ':' (e.g., '65000:65000')", + ), + }, }, "vrf_vxlan": schema.Int64Attribute{ Optional: true, diff --git a/fwprovider/cluster/sdn/zone/resource_simple.go b/fwprovider/cluster/sdn/zone/resource_simple.go index a40883d0..da15af95 100644 --- a/fwprovider/cluster/sdn/zone/resource_simple.go +++ b/fwprovider/cluster/sdn/zone/resource_simple.go @@ -28,12 +28,10 @@ type SimpleResource struct { client *zones.Client } -// NewSimpleResource creates a new instance of the Simple resource. func NewSimpleResource() resource.Resource { return &SimpleResource{} } -// Metadata defines the name of the resource. func (r *SimpleResource) Metadata( _ context.Context, req resource.MetadataRequest, @@ -72,7 +70,7 @@ func (r *SimpleResource) Create( req resource.CreateRequest, resp *resource.CreateResponse, ) { - var plan baseModel + var plan simpleModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -85,7 +83,7 @@ func (r *SimpleResource) Create( if err := r.client.CreateZone(ctx, reqData); err != nil { resp.Diagnostics.AddError( - "Unable to Create SDN Zone", + "Unable to Create SDN SimpleZone", err.Error(), ) @@ -100,7 +98,7 @@ func (r *SimpleResource) Read( req resource.ReadRequest, resp *resource.ReadResponse, ) { - var state baseModel + var state simpleModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) @@ -116,7 +114,7 @@ func (r *SimpleResource) Read( } resp.Diagnostics.AddError( - "Unable to Read SDN Zone", + "Unable to Read SDN SimpleZone", err.Error(), ) @@ -133,7 +131,7 @@ func (r *SimpleResource) Update( req resource.UpdateRequest, resp *resource.UpdateResponse, ) { - var plan baseModel + var plan simpleModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -145,7 +143,7 @@ func (r *SimpleResource) Update( if err := r.client.UpdateZone(ctx, reqData); err != nil { resp.Diagnostics.AddError( - "Unable to Update SDN Zone", + "Unable to Update SDN Simple Zone", err.Error(), ) @@ -160,7 +158,7 @@ func (r *SimpleResource) Delete( req resource.DeleteRequest, resp *resource.DeleteResponse, ) { - var state baseModel + var state simpleModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) @@ -171,7 +169,7 @@ func (r *SimpleResource) Delete( if err := r.client.DeleteZone(ctx, state.ID.ValueString()); err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) { resp.Diagnostics.AddError( - "Unable to Delete SDN Zone", + "Unable to Delete SDN Simple Zone", err.Error(), ) } @@ -182,4 +180,20 @@ func (r *SimpleResource) ImportState( 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(fmt.Sprintf("Zone %s does not exist", req.ID), err.Error()) + + return + } + + resp.Diagnostics.AddError(fmt.Sprintf("Unable to Import SDN Simple Zone %s", req.ID), err.Error()) + + return + } + + readModel := &simpleModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) } diff --git a/fwprovider/cluster/sdn/zone/resource_simple_test.go b/fwprovider/cluster/sdn/zone/resource_simple_test.go index 8ce5c307..87bc9198 100644 --- a/fwprovider/cluster/sdn/zone/resource_simple_test.go +++ b/fwprovider/cluster/sdn/zone/resource_simple_test.go @@ -17,6 +17,8 @@ import ( ) func TestAccResourceSDNZoneSimple(t *testing.T) { + t.Parallel() + te := test.InitEnvironment(t) tests := []struct { @@ -39,6 +41,10 @@ func TestAccResourceSDNZoneSimple(t *testing.T) { mtu = 1495 } `), + ResourceName: "proxmox_virtual_environment_sdn_zone_simple.zone_simple", + ImportStateId: "zoneS", + ImportState: true, + ImportStateVerify: true, }}}, } diff --git a/fwprovider/cluster/sdn/zone/resource_vlan.go b/fwprovider/cluster/sdn/zone/resource_vlan.go new file mode 100644 index 00000000..e303f107 --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_vlan.go @@ -0,0 +1,191 @@ +/* + * 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 zone + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + + "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/bpg/terraform-provider-proxmox/proxmox/helpers/ptr" +) + +var ( + _ resource.ResourceWithConfigure = &VLANResource{} + _ resource.ResourceWithImportState = &VLANResource{} +) + +type VLANResource struct { + client *zones.Client +} + +func NewVLANResource() resource.Resource { + return &VLANResource{} +} + +func (r *VLANResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_sdn_zone_vlan" +} + +func (r *VLANResource) 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 *VLANResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan vlanModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + reqData.Type = ptr.Ptr(zones.TypeVLAN) + + if err := r.client.CreateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Create SDN VLAN Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *VLANResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state vlanModel + + 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 VLAN Zone", + err.Error(), + ) + return + } + + readModel := &vlanModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} + +func (r *VLANResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan vlanModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + + if err := r.client.UpdateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Update SDN VLAN Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *VLANResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state vlanModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteZone(ctx, state.ID.ValueString()); err != nil && + !errors.Is(err, api.ErrResourceDoesNotExist) { + resp.Diagnostics.AddError( + "Unable to Delete SDN VLAN Zone", + err.Error(), + ) + } +} + +func (r *VLANResource) 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(fmt.Sprintf("Zone %s does not exist", req.ID), err.Error()) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Unable to Import SDN VLAN Zone %s", req.ID), err.Error()) + return + } + readModel := &vlanModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} diff --git a/fwprovider/cluster/sdn/zone/resource_vlan_test.go b/fwprovider/cluster/sdn/zone/resource_vlan_test.go new file mode 100644 index 00000000..dc8b64eb --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_vlan_test.go @@ -0,0 +1,61 @@ +//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 zone_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/test" +) + +func TestAccResourceSDNZoneVLAN(t *testing.T) { + t.Parallel() + + te := test.InitEnvironment(t) + + tests := []struct { + name string + steps []resource.TestStep + }{ + {"create and update VLAN zone", []resource.TestStep{{ + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_sdn_zone_vlan" "zone_vlan" { + id = "zoneV" + nodes = ["pve"] + mtu = 1496 + bridge = "vmbr0" + } + `), + }, { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_sdn_zone_vlan" "zone_vlan" { + id = "zoneV" + nodes = ["pve"] + mtu = 1495 + bridge = "vmbr0" + } + `), + ResourceName: "proxmox_virtual_environment_sdn_zone_vlan.zone_vlan", + ImportStateId: "zoneV", + ImportState: true, + ImportStateVerify: 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/cluster/sdn/zone/resource_vxlan.go b/fwprovider/cluster/sdn/zone/resource_vxlan.go new file mode 100644 index 00000000..703b9320 --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_vxlan.go @@ -0,0 +1,191 @@ +/* + * 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 zone + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + + "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/bpg/terraform-provider-proxmox/proxmox/helpers/ptr" +) + +var ( + _ resource.ResourceWithConfigure = &VXLANResource{} + _ resource.ResourceWithImportState = &VXLANResource{} +) + +type VXLANResource struct { + client *zones.Client +} + +func NewVXLANResource() resource.Resource { + return &VXLANResource{} +} + +func (r *VXLANResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_sdn_zone_vxlan" +} + +func (r *VXLANResource) 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 *VXLANResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan vxlanModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + reqData.Type = ptr.Ptr(zones.TypeVXLAN) + + if err := r.client.CreateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Create SDN VXLAN Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *VXLANResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state vxlanModel + + 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 VXLAN Zone", + err.Error(), + ) + return + } + + readModel := &vxlanModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} + +func (r *VXLANResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan vxlanModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + reqData := plan.toAPIRequestBody(ctx, &resp.Diagnostics) + + if err := r.client.UpdateZone(ctx, reqData); err != nil { + resp.Diagnostics.AddError( + "Unable to Update SDN VXLAN Zone", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *VXLANResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state vxlanModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteZone(ctx, state.ID.ValueString()); err != nil && + !errors.Is(err, api.ErrResourceDoesNotExist) { + resp.Diagnostics.AddError( + "Unable to Delete SDN VXLAN Zone", + err.Error(), + ) + } +} + +func (r *VXLANResource) 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(fmt.Sprintf("Zone %s does not exist", req.ID), err.Error()) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Unable to Import SDN VXLAN Zone %s", req.ID), err.Error()) + return + } + readModel := &vxlanModel{} + readModel.importFromAPI(zone.ID, zone, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, readModel)...) +} diff --git a/fwprovider/cluster/sdn/zone/resource_vxlan_test.go b/fwprovider/cluster/sdn/zone/resource_vxlan_test.go new file mode 100644 index 00000000..7bb2f30d --- /dev/null +++ b/fwprovider/cluster/sdn/zone/resource_vxlan_test.go @@ -0,0 +1,61 @@ +//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 zone_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/test" +) + +func TestAccResourceSDNZoneVXLAN(t *testing.T) { + t.Parallel() + + te := test.InitEnvironment(t) + + tests := []struct { + name string + steps []resource.TestStep + }{ + {"create and update VXLAN zone", []resource.TestStep{{ + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_sdn_zone_vxlan" "zone_vxlan" { + id = "zoneX" + nodes = ["pve"] + mtu = 1450 + peers = ["10.0.0.1", "10.0.0.2"] + } + `), + }, { + Config: te.RenderConfig(` + resource "proxmox_virtual_environment_sdn_zone_vxlan" "zone_vxlan" { + id = "zoneX" + nodes = ["pve"] + mtu = 1440 + peers = ["10.0.0.3", "10.0.0.4"] + } + `), + ResourceName: "proxmox_virtual_environment_sdn_zone_vxlan.zone_vxlan", + ImportStateId: "zoneX", + ImportState: true, + ImportStateVerify: 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/proxmox/api/client.go b/proxmox/api/client.go index 663dc9c9..8cc89d5f 100644 --- a/proxmox/api/client.go +++ b/proxmox/api/client.go @@ -352,7 +352,9 @@ func validateResponseCode(res *http.Response) error { errList = append(errList, split...) } - msg = fmt.Sprintf("%s (%s)", msg, strings.Join(errList, " - ")) + if len(errList) > 0 { + msg = fmt.Sprintf("%s (%s)", msg, strings.Join(errList, " - ")) + } } httpError := &HTTPError{ diff --git a/proxmox/cluster/sdn/zones/zones_types.go b/proxmox/cluster/sdn/zones/zones_types.go index d753bc15..6b40695d 100644 --- a/proxmox/cluster/sdn/zones/zones_types.go +++ b/proxmox/cluster/sdn/zones/zones_types.go @@ -6,6 +6,8 @@ package zones +import "github.com/bpg/terraform-provider-proxmox/proxmox/types" + const ( TypeSimple = "simple" TypeVLAN = "vlan" @@ -35,14 +37,14 @@ type ZoneData struct { 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"` - ExitNodesPrimary *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"` + 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"` + ExitNodesPrimary *string `json:"exitnodes-primary,omitempty" url:"exitnodes-primary,omitempty"` + ExitNodesLocalRouting *types.CustomBool `json:"exitnodes-local-routing,omitempty" url:"exitnodes-local-routing,omitempty,int"` + AdvertiseSubnets *types.CustomBool `json:"advertise-subnets,omitempty" url:"advertise-subnets,omitempty,int"` + DisableARPNDSuppression *types.CustomBool `json:"disable-arp-nd-suppression,omitempty" url:"disable-arp-nd-suppression,omitempty,int"` + RouteTargetImport *string `json:"rt-import,omitempty" url:"rt-import,omitempty"` } // ZoneRequestData wraps a ZoneData struct with optional delete instructions.