// Copyright (c) EZSCALE. // SPDX-License-Identifier: MPL-2.0 package provider import ( "context" "encoding/json" "errors" "fmt" "strconv" "terraform-provider-virtfusion/internal/client" "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/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" ) // Ensure provider defined types fully satisfy framework interfaces. var ( _ resource.Resource = &ServerResource{} _ resource.ResourceWithConfigure = &ServerResource{} _ resource.ResourceWithImportState = &ServerResource{} ) // NewServerResource returns a new server resource. func NewServerResource() resource.Resource { return &ServerResource{} } // ServerResource defines the resource implementation. type ServerResource struct { client *client.Client } // ServerResourceModel describes the resource data model. type ServerResourceModel struct { // Computed ID types.Int64 `tfsdk:"id"` UUID types.String `tfsdk:"uuid"` Hostname types.String `tfsdk:"hostname"` // Required (create) PackageID types.Int64 `tfsdk:"package_id"` UserID types.Int64 `tfsdk:"user_id"` HypervisorID types.Int64 `tfsdk:"hypervisor_id"` // Optional (create) Ipv4 types.Int64 `tfsdk:"ipv4"` Storage types.Int64 `tfsdk:"storage"` Memory types.Int64 `tfsdk:"memory"` Cores types.Int64 `tfsdk:"cores"` Traffic types.Int64 `tfsdk:"traffic"` InboundNetworkSpeed types.Int64 `tfsdk:"inbound_network_speed"` OutboundNetworkSpeed types.Int64 `tfsdk:"outbound_network_speed"` StorageProfile types.Int64 `tfsdk:"storage_profile"` NetworkProfile types.Int64 `tfsdk:"network_profile"` DryRun types.Bool `tfsdk:"dry_run"` AdditionalStorage1 types.Int64 `tfsdk:"additional_storage_1"` AdditionalStorage1Profile types.Int64 `tfsdk:"additional_storage_1_profile"` AdditionalStorage2 types.Int64 `tfsdk:"additional_storage_2"` AdditionalStorage2Profile types.Int64 `tfsdk:"additional_storage_2_profile"` // Optional (update-only) Name types.String `tfsdk:"name"` CPUThrottle types.Int64 `tfsdk:"cpu_throttle"` VNCEnabled types.Bool `tfsdk:"vnc_enabled"` Suspended types.Bool `tfsdk:"suspended"` BackupPlanID types.Int64 `tfsdk:"backup_plan_id"` CustomXML types.String `tfsdk:"custom_xml"` OwnerUserID types.Int64 `tfsdk:"owner_user_id"` } func (r *ServerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_server" } func (r *ServerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "Manages a VirtFusion server.", Attributes: map[string]schema.Attribute{ // Computed "id": schema.Int64Attribute{ MarkdownDescription: "The server ID.", Computed: true, PlanModifiers: []planmodifier.Int64{ int64planmodifier.UseStateForUnknown(), }, }, "uuid": schema.StringAttribute{ MarkdownDescription: "The server UUID.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "hostname": schema.StringAttribute{ MarkdownDescription: "The server hostname.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, // Required "package_id": schema.Int64Attribute{ MarkdownDescription: "The package ID for the server.", Required: true, }, "user_id": schema.Int64Attribute{ MarkdownDescription: "The user ID who owns the server.", Required: true, }, "hypervisor_id": schema.Int64Attribute{ MarkdownDescription: "The hypervisor ID where the server will be created.", Required: true, }, // Optional (create) "ipv4": schema.Int64Attribute{ MarkdownDescription: "Number of IPv4 addresses to assign. Defaults to 1.", Optional: true, Computed: true, Default: int64default.StaticInt64(1), }, "storage": schema.Int64Attribute{ MarkdownDescription: "Storage size override in GB.", Optional: true, }, "memory": schema.Int64Attribute{ MarkdownDescription: "Memory size override in MB.", Optional: true, }, "cores": schema.Int64Attribute{ MarkdownDescription: "Number of CPU cores override.", Optional: true, }, "traffic": schema.Int64Attribute{ MarkdownDescription: "Traffic limit override in GB.", Optional: true, }, "inbound_network_speed": schema.Int64Attribute{ MarkdownDescription: "Inbound network speed override in Mbps.", Optional: true, }, "outbound_network_speed": schema.Int64Attribute{ MarkdownDescription: "Outbound network speed override in Mbps.", Optional: true, }, "storage_profile": schema.Int64Attribute{ MarkdownDescription: "Storage profile ID.", Optional: true, }, "network_profile": schema.Int64Attribute{ MarkdownDescription: "Network profile ID.", Optional: true, }, "dry_run": schema.BoolAttribute{ MarkdownDescription: "If true, validates the request without creating the server.", Optional: true, Computed: true, Default: booldefault.StaticBool(false), }, "additional_storage_1": schema.Int64Attribute{ MarkdownDescription: "Additional storage 1 size in GB.", Optional: true, }, "additional_storage_1_profile": schema.Int64Attribute{ MarkdownDescription: "Additional storage 1 profile ID.", Optional: true, }, "additional_storage_2": schema.Int64Attribute{ MarkdownDescription: "Additional storage 2 size in GB.", Optional: true, }, "additional_storage_2_profile": schema.Int64Attribute{ MarkdownDescription: "Additional storage 2 profile ID.", Optional: true, }, // Optional (update-only) "name": schema.StringAttribute{ MarkdownDescription: "The server display name.", Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "cpu_throttle": schema.Int64Attribute{ MarkdownDescription: "CPU throttle percentage (0-100).", Optional: true, }, "vnc_enabled": schema.BoolAttribute{ MarkdownDescription: "Whether VNC is enabled on the server.", Optional: true, }, "suspended": schema.BoolAttribute{ MarkdownDescription: "Whether the server is suspended.", Optional: true, }, "backup_plan_id": schema.Int64Attribute{ MarkdownDescription: "Backup plan ID. Set to 0 to remove the backup plan.", Optional: true, }, "custom_xml": schema.StringAttribute{ MarkdownDescription: "Custom XML configuration for the server.", Optional: true, }, "owner_user_id": schema.Int64Attribute{ MarkdownDescription: "The user ID to transfer ownership to.", Optional: true, }, }, } } func (r *ServerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } c, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Resource Configure Type", fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return } r.client = c } func (r *ServerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan ServerResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } // Build the create request from plan values. createReq := client.ServerCreateRequest{ PackageID: plan.PackageID.ValueInt64(), UserID: plan.UserID.ValueInt64(), HypervisorID: plan.HypervisorID.ValueInt64(), } if !plan.Ipv4.IsNull() && !plan.Ipv4.IsUnknown() { v := plan.Ipv4.ValueInt64() createReq.Ipv4 = &v } if !plan.Storage.IsNull() && !plan.Storage.IsUnknown() { v := plan.Storage.ValueInt64() createReq.Storage = &v } if !plan.Memory.IsNull() && !plan.Memory.IsUnknown() { v := plan.Memory.ValueInt64() createReq.Memory = &v } if !plan.Cores.IsNull() && !plan.Cores.IsUnknown() { v := plan.Cores.ValueInt64() createReq.CPUCores = &v } if !plan.Traffic.IsNull() && !plan.Traffic.IsUnknown() { v := plan.Traffic.ValueInt64() createReq.Traffic = &v } if !plan.InboundNetworkSpeed.IsNull() && !plan.InboundNetworkSpeed.IsUnknown() { v := plan.InboundNetworkSpeed.ValueInt64() createReq.NetworkSpeedInbound = &v } if !plan.OutboundNetworkSpeed.IsNull() && !plan.OutboundNetworkSpeed.IsUnknown() { v := plan.OutboundNetworkSpeed.ValueInt64() createReq.NetworkSpeedOutbound = &v } if !plan.StorageProfile.IsNull() && !plan.StorageProfile.IsUnknown() { v := plan.StorageProfile.ValueInt64() createReq.StorageProfile = &v } if !plan.NetworkProfile.IsNull() && !plan.NetworkProfile.IsUnknown() { v := plan.NetworkProfile.ValueInt64() createReq.NetworkProfile = &v } if !plan.DryRun.IsNull() && !plan.DryRun.IsUnknown() { v := plan.DryRun.ValueBool() createReq.DryRun = &v } if !plan.AdditionalStorage1.IsNull() && !plan.AdditionalStorage1.IsUnknown() { v := plan.AdditionalStorage1.ValueInt64() createReq.AdditionalStorage1 = &v } if !plan.AdditionalStorage1Profile.IsNull() && !plan.AdditionalStorage1Profile.IsUnknown() { v := plan.AdditionalStorage1Profile.ValueInt64() createReq.AdditionalStorage1Profile = &v } if !plan.AdditionalStorage2.IsNull() && !plan.AdditionalStorage2.IsUnknown() { v := plan.AdditionalStorage2.ValueInt64() createReq.AdditionalStorage2 = &v } if !plan.AdditionalStorage2Profile.IsNull() && !plan.AdditionalStorage2Profile.IsUnknown() { v := plan.AdditionalStorage2Profile.ValueInt64() createReq.AdditionalStorage2Profile = &v } // Create the server. rawResp, err := r.client.Post(ctx, "/servers", createReq) if err != nil { resp.Diagnostics.AddError("Error Creating Server", err.Error()) return } // Parse the create response. var serverResp client.ServerResponse if err := json.Unmarshal(rawResp, &serverResp); err != nil { resp.Diagnostics.AddError("Error Parsing Server Response", err.Error()) return } // Set computed values from the response. plan.ID = types.Int64Value(serverResp.Data.ID) plan.UUID = types.StringValue(serverResp.Data.UUID) plan.Hostname = types.StringValue(serverResp.Data.Hostname) // If name was not set in the plan, use the name from the API response. if plan.Name.IsNull() || plan.Name.IsUnknown() { plan.Name = types.StringValue(serverResp.Data.Name) } // After creation, apply update-only attributes if they are set. serverID := serverResp.Data.ID // Apply name if explicitly set in the plan. if !plan.Name.IsNull() && !plan.Name.IsUnknown() && plan.Name.ValueString() != serverResp.Data.Name { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/name", serverID), client.ServerModifyNameRequest{ Name: plan.Name.ValueString(), }) if err != nil { resp.Diagnostics.AddError("Error Setting Server Name", err.Error()) return } } // Apply CPU throttle if set. if !plan.CPUThrottle.IsNull() && !plan.CPUThrottle.IsUnknown() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/cpuThrottle", serverID), client.ServerModifyCPUThrottleRequest{ Percentage: plan.CPUThrottle.ValueInt64(), }) if err != nil { resp.Diagnostics.AddError("Error Setting CPU Throttle", err.Error()) return } } // Apply VNC if set. if !plan.VNCEnabled.IsNull() && !plan.VNCEnabled.IsUnknown() && plan.VNCEnabled.ValueBool() { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/vnc", serverID), nil) if err != nil { resp.Diagnostics.AddError("Error Enabling VNC", err.Error()) return } } // Apply suspended if set to true. if !plan.Suspended.IsNull() && !plan.Suspended.IsUnknown() && plan.Suspended.ValueBool() { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/suspend", serverID), nil) if err != nil { resp.Diagnostics.AddError("Error Suspending Server", err.Error()) return } } // Apply backup plan if set. if !plan.BackupPlanID.IsNull() && !plan.BackupPlanID.IsUnknown() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/backups/plan/%d", serverID, plan.BackupPlanID.ValueInt64()), nil) if err != nil { resp.Diagnostics.AddError("Error Setting Backup Plan", err.Error()) return } } // Apply custom XML if set. if !plan.CustomXML.IsNull() && !plan.CustomXML.IsUnknown() { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/customXML", serverID), client.ServerCustomXMLRequest{ XML: plan.CustomXML.ValueString(), }) if err != nil { resp.Diagnostics.AddError("Error Setting Custom XML", err.Error()) return } } // Apply owner change if set and different from the creating user. if !plan.OwnerUserID.IsNull() && !plan.OwnerUserID.IsUnknown() && plan.OwnerUserID.ValueInt64() != plan.UserID.ValueInt64() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/owner/%d", serverID, plan.OwnerUserID.ValueInt64()), nil) if err != nil { resp.Diagnostics.AddError("Error Changing Server Owner", err.Error()) return } } resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *ServerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state ServerResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } rawResp, err := r.client.Get(ctx, fmt.Sprintf("/servers/%d", state.ID.ValueInt64())) if err != nil { var apiErr *client.APIError if errors.As(err, &apiErr) && apiErr.IsNotFound() { resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Error Reading Server", err.Error()) return } var serverResp client.ServerResponse if err := json.Unmarshal(rawResp, &serverResp); err != nil { resp.Diagnostics.AddError("Error Parsing Server Response", err.Error()) return } // Map API response to state model. s := serverResp.Data state.ID = types.Int64Value(s.ID) state.UUID = types.StringValue(s.UUID) state.Hostname = types.StringValue(s.Hostname) state.Name = types.StringValue(s.Name) state.HypervisorID = types.Int64Value(s.HypervisorID) // Map optional create attributes from the nested API response if they were set in state. if !state.Storage.IsNull() && s.Settings != nil && s.Settings.Resources != nil { state.Storage = types.Int64Value(s.Settings.Resources.Storage) } if !state.Memory.IsNull() && s.Settings != nil && s.Settings.Resources != nil { state.Memory = types.Int64Value(s.Settings.Resources.Memory) } if !state.Cores.IsNull() && s.CPU != nil { state.Cores = types.Int64Value(s.CPU.Cores) } if !state.Traffic.IsNull() && s.Settings != nil && s.Settings.Resources != nil { state.Traffic = types.Int64Value(s.Settings.Resources.Traffic) } // NetworkSpeed and profiles are not returned by the detail API; preserve from state. if !state.Suspended.IsNull() { state.Suspended = types.BoolValue(s.Suspended) } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *ServerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan, state ServerResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } serverID := state.ID.ValueInt64() // Preserve computed values from state. plan.ID = state.ID plan.UUID = state.UUID plan.Hostname = state.Hostname // Name change. if !plan.Name.Equal(state.Name) { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/name", serverID), client.ServerModifyNameRequest{ Name: plan.Name.ValueString(), }) if err != nil { resp.Diagnostics.AddError("Error Modifying Server Name", err.Error()) return } } // CPU cores change. if !plan.Cores.Equal(state.Cores) && !plan.Cores.IsNull() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/cpuCores", serverID), client.ServerModifyCPURequest{ CPUCores: plan.Cores.ValueInt64(), }) if err != nil { resp.Diagnostics.AddError("Error Modifying Server CPU Cores", err.Error()) return } } // Memory change. if !plan.Memory.Equal(state.Memory) && !plan.Memory.IsNull() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/memory", serverID), client.ServerModifyMemoryRequest{ Memory: plan.Memory.ValueInt64(), }) if err != nil { resp.Diagnostics.AddError("Error Modifying Server Memory", err.Error()) return } } // Traffic change. if !plan.Traffic.Equal(state.Traffic) && !plan.Traffic.IsNull() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/traffic", serverID), client.ServerModifyTrafficRequest{ Traffic: plan.Traffic.ValueInt64(), }) if err != nil { resp.Diagnostics.AddError("Error Modifying Server Traffic", err.Error()) return } } // CPU throttle change. if !plan.CPUThrottle.Equal(state.CPUThrottle) && !plan.CPUThrottle.IsNull() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/modify/cpuThrottle", serverID), client.ServerModifyCPUThrottleRequest{ Percentage: plan.CPUThrottle.ValueInt64(), }) if err != nil { resp.Diagnostics.AddError("Error Modifying CPU Throttle", err.Error()) return } } // VNC toggle. if !plan.VNCEnabled.Equal(state.VNCEnabled) && !plan.VNCEnabled.IsNull() { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/vnc", serverID), nil) if err != nil { resp.Diagnostics.AddError("Error Toggling VNC", err.Error()) return } } // Suspended change. if !plan.Suspended.Equal(state.Suspended) && !plan.Suspended.IsNull() { if plan.Suspended.ValueBool() { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/suspend", serverID), nil) if err != nil { resp.Diagnostics.AddError("Error Suspending Server", err.Error()) return } } else { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/unsuspend", serverID), nil) if err != nil { resp.Diagnostics.AddError("Error Unsuspending Server", err.Error()) return } } } // Backup plan change. if !plan.BackupPlanID.Equal(state.BackupPlanID) { planID := int64(0) if !plan.BackupPlanID.IsNull() { planID = plan.BackupPlanID.ValueInt64() } _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/backups/plan/%d", serverID, planID), nil) if err != nil { resp.Diagnostics.AddError("Error Modifying Backup Plan", err.Error()) return } } // Custom XML change. if !plan.CustomXML.Equal(state.CustomXML) && !plan.CustomXML.IsNull() { _, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/customXML", serverID), client.ServerCustomXMLRequest{ XML: plan.CustomXML.ValueString(), }) if err != nil { resp.Diagnostics.AddError("Error Setting Custom XML", err.Error()) return } } // Owner change. if !plan.OwnerUserID.Equal(state.OwnerUserID) && !plan.OwnerUserID.IsNull() { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/owner/%d", serverID, plan.OwnerUserID.ValueInt64()), nil) if err != nil { resp.Diagnostics.AddError("Error Changing Server Owner", err.Error()) return } } // Package change. if !plan.PackageID.Equal(state.PackageID) { _, err := r.client.Put(ctx, fmt.Sprintf("/servers/%d/package/%d", serverID, plan.PackageID.ValueInt64()), nil) if err != nil { resp.Diagnostics.AddError("Error Changing Server Package", err.Error()) return } } resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *ServerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state ServerResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } _, err := r.client.Delete(ctx, fmt.Sprintf("/servers/%d?delay=0", state.ID.ValueInt64())) if err != nil { var apiErr *client.APIError if errors.As(err, &apiErr) && apiErr.IsNotFound() { // Already deleted, nothing to do. return } resp.Diagnostics.AddError("Error Deleting Server", err.Error()) return } } func (r *ServerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { id, err := strconv.ParseInt(req.ID, 10, 64) if err != nil { resp.Diagnostics.AddError( "Invalid Import ID", fmt.Sprintf("Could not parse server ID %q as an integer: %s", req.ID, err), ) return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.Int64Value(id))...) }