mirror of
https://github.com/bpg/terraform-provider-proxmox.git
synced 2025-07-08 14:55:02 +00:00
380 lines
10 KiB
Go
380 lines
10 KiB
Go
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(ctx))
|
|
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
|
|
|
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
|
|
reqData := plan.toAPIRequestBody(ctx)
|
|
|
|
if plan.Vnet.IsNull() || plan.Vnet.IsUnknown() {
|
|
resp.Diagnostics.AddAttributeError(
|
|
path.Root("vnet"),
|
|
"missing required field",
|
|
"Missing the parent vnet's ID attribute, which is required to define a subnet")
|
|
|
|
return
|
|
}
|
|
|
|
err := r.client.UpdateSubnet(ctx, plan.Vnet.ValueString(), reqData)
|
|
if err != nil {
|
|
resp.Diagnostics.AddError("Error updating subnet", err.Error())
|
|
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
|
|
}
|
|
|
|
// 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()),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|