Overhaul VirtFusion provider: 20 resources, 30 data sources, multipage pagination

Complete rewrite of the VirtFusion Terraform provider with full API coverage:

- 20 managed resources (server, build, SSH key, user, firewall, IP blocks, etc.)
- 30 data sources (hypervisors, packages, servers, IP blocks, self-service, etc.)
- New HTTP client with proper error handling, query parameter support, and
  automatic multipage pagination via GetAllPages (fetches all pages from
  Laravel-style paginated endpoints and merges into a single response)
- Fixed type mismatches against live API: ServerData.Suspended (int→bool),
  IPBlockData.Type (string→int), PackageData json tags (primaryStorage, etc.),
  ServerData nested CPU/Settings/Resources structure, HypervisorGroupResources
  array response
- Configurable results-per-page (default 300) on all list data sources
- Migrated CI from GitHub Actions to Gitea Actions
- Updated goreleaser config, go.mod dependencies, and examples

Verified against live VirtFusion instance at cp.vps.ezscale.tech:
all data sources return correct data with full pagination support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 02:01:16 -04:00
parent a3a16f46fa
commit 6b7430b67b
92 changed files with 18443 additions and 1488 deletions

View File

@@ -0,0 +1,317 @@
// Copyright (c) EZSCALE.
// SPDX-License-Identifier: MPL-2.0
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"terraform-provider-virtfusion/internal/client"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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"
)
var (
_ resource.Resource = &ServerFirewallResource{}
_ resource.ResourceWithConfigure = &ServerFirewallResource{}
)
// NewServerFirewallResource returns a new resource for managing server firewalls.
func NewServerFirewallResource() resource.Resource {
return &ServerFirewallResource{}
}
// ServerFirewallResource defines the resource implementation.
type ServerFirewallResource struct {
client *client.Client
}
// ServerFirewallResourceModel describes the resource data model.
type ServerFirewallResourceModel struct {
ID types.String `tfsdk:"id"`
ServerID types.Int64 `tfsdk:"server_id"`
InterfaceName types.String `tfsdk:"interface_name"`
Rules types.List `tfsdk:"rules"`
}
// FirewallRuleModel describes a single firewall rule.
type FirewallRuleModel struct {
Action types.String `tfsdk:"action"`
Direction types.String `tfsdk:"direction"`
Protocol types.String `tfsdk:"protocol"`
Port types.String `tfsdk:"port"`
IP types.String `tfsdk:"ip"`
}
func (r *ServerFirewallResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server_firewall"
}
func (r *ServerFirewallResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Manages a VirtFusion server firewall.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: "Composite identifier in the format `server_id/interface_name`.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"server_id": schema.Int64Attribute{
MarkdownDescription: "The ID of the server.",
Required: true,
},
"interface_name": schema.StringAttribute{
MarkdownDescription: "The network interface name. Defaults to `eth0`.",
Optional: true,
Computed: true,
Default: stringdefault.StaticString("eth0"),
},
"rules": schema.ListNestedAttribute{
MarkdownDescription: "The firewall rules.",
Optional: true,
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"action": schema.StringAttribute{
MarkdownDescription: "The action for the rule (e.g. `accept`, `drop`).",
Required: true,
},
"direction": schema.StringAttribute{
MarkdownDescription: "The direction for the rule (e.g. `in`, `out`).",
Required: true,
},
"protocol": schema.StringAttribute{
MarkdownDescription: "The protocol for the rule (e.g. `tcp`, `udp`).",
Required: true,
},
"port": schema.StringAttribute{
MarkdownDescription: "The port or port range for the rule.",
Required: true,
},
"ip": schema.StringAttribute{
MarkdownDescription: "The IP address or CIDR for the rule.",
Required: true,
},
},
},
},
},
}
}
func (r *ServerFirewallResource) 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.", req.ProviderData),
)
return
}
r.client = c
}
func (r *ServerFirewallResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data ServerFirewallResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
serverID := data.ServerID.ValueInt64()
iface := data.InterfaceName.ValueString()
// Enable the firewall
_, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/firewall/%s/enable", serverID, iface), nil)
if err != nil {
resp.Diagnostics.AddError("Error enabling server firewall", err.Error())
return
}
// Set rules if provided
rules, diags := r.extractRules(ctx, data)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
if len(rules) > 0 {
rulesReq := client.FirewallSetRulesRequest{Rules: rules}
_, err = r.client.Post(ctx, fmt.Sprintf("/servers/%d/firewall/%s/rules", serverID, iface), rulesReq)
if err != nil {
resp.Diagnostics.AddError("Error setting firewall rules", err.Error())
return
}
}
data.ID = types.StringValue(fmt.Sprintf("%d/%s", serverID, iface))
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *ServerFirewallResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data ServerFirewallResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
serverID := data.ServerID.ValueInt64()
iface := data.InterfaceName.ValueString()
result, err := r.client.Get(ctx, fmt.Sprintf("/servers/%d/firewall/%s", serverID, iface))
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 firewall", err.Error())
return
}
var fwResp client.FirewallResponse
if err := json.Unmarshal(result, &fwResp); err != nil {
resp.Diagnostics.AddError("Error parsing firewall response", err.Error())
return
}
// If the firewall is not enabled, remove from state
if !fwResp.Data.Enabled {
resp.State.RemoveResource(ctx)
return
}
data.ID = types.StringValue(fmt.Sprintf("%d/%s", serverID, iface))
// Map API rules to the model
ruleObjects := make([]attr.Value, len(fwResp.Data.Rules))
for i, rule := range fwResp.Data.Rules {
ruleObj, diags := types.ObjectValue(
firewallRuleAttrTypes(),
map[string]attr.Value{
"action": types.StringValue(rule.Action),
"direction": types.StringValue(rule.Direction),
"protocol": types.StringValue(rule.Protocol),
"port": types.StringValue(rule.Port),
"ip": types.StringValue(rule.IP),
},
)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ruleObjects[i] = ruleObj
}
rulesList, diags := types.ListValue(types.ObjectType{AttrTypes: firewallRuleAttrTypes()}, ruleObjects)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
data.Rules = rulesList
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *ServerFirewallResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data ServerFirewallResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
serverID := data.ServerID.ValueInt64()
iface := data.InterfaceName.ValueString()
rules, diags := r.extractRules(ctx, data)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
rulesReq := client.FirewallSetRulesRequest{Rules: rules}
_, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/firewall/%s/rules", serverID, iface), rulesReq)
if err != nil {
resp.Diagnostics.AddError("Error updating firewall rules", err.Error())
return
}
data.ID = types.StringValue(fmt.Sprintf("%d/%s", serverID, iface))
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *ServerFirewallResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data ServerFirewallResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
serverID := data.ServerID.ValueInt64()
iface := data.InterfaceName.ValueString()
_, err := r.client.Post(ctx, fmt.Sprintf("/servers/%d/firewall/%s/disable", serverID, iface), nil)
if err != nil {
var apiErr *client.APIError
if errors.As(err, &apiErr) && apiErr.IsNotFound() {
return
}
resp.Diagnostics.AddError("Error disabling server firewall", err.Error())
}
}
// extractRules converts the rules list from the model into client.FirewallRule slice.
func (r *ServerFirewallResource) extractRules(ctx context.Context, data ServerFirewallResourceModel) ([]client.FirewallRule, diag.Diagnostics) {
var diags diag.Diagnostics
if data.Rules.IsNull() || data.Rules.IsUnknown() {
return nil, diags
}
var ruleModels []FirewallRuleModel
diags.Append(data.Rules.ElementsAs(ctx, &ruleModels, false)...)
if diags.HasError() {
return nil, diags
}
rules := make([]client.FirewallRule, len(ruleModels))
for i, rm := range ruleModels {
rules[i] = client.FirewallRule{
Action: rm.Action.ValueString(),
Direction: rm.Direction.ValueString(),
Protocol: rm.Protocol.ValueString(),
Port: rm.Port.ValueString(),
IP: rm.IP.ValueString(),
}
}
return rules, diags
}
// firewallRuleAttrTypes returns the attribute types for a firewall rule object.
func firewallRuleAttrTypes() map[string]attr.Type {
return map[string]attr.Type{
"action": types.StringType,
"direction": types.StringType,
"protocol": types.StringType,
"port": types.StringType,
"ip": types.StringType,
}
}