Files
benvin 3913b3920d Scaffold terraform-provider-tomswallapi
Terraform provider (plugin-framework) for the tomswall fleet control plane.
Resources: zone, address_group (static/dns/asn, with computed resolved
prefixes), portgroup, fabric, device, binding (device:zone), and rule. Each
resource does full CRUD against the tomswallapi HTTP API with bearer-token auth
and ImportState support; rules recreate on update since the rules API is
create/delete only. Includes Makefile with make patch|minor|major release tags,
Woodpecker pre-commit/build/test/release pipelines (release publishes to the
artifactapi terraform registry), README, and a worked example.
2026-07-19 22:26:07 +10:00

184 lines
6.8 KiB
Go

package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &addressGroupResource{}
_ resource.ResourceWithImportState = &addressGroupResource{}
)
type addressGroupResource struct{ client *apiClient }
type addressGroupModel struct {
Name types.String `tfsdk:"name"`
Type types.String `tfsdk:"type"`
Members types.List `tfsdk:"members"`
Refresh types.String `tfsdk:"refresh"`
Description types.String `tfsdk:"description"`
Resolved types.List `tfsdk:"resolved"`
ResolvedAt types.String `tfsdk:"resolved_at"`
}
type addressGroupAPI struct {
Name string `json:"name"`
Type string `json:"type"`
Members []string `json:"members"`
Refresh string `json:"refresh,omitempty"`
Description string `json:"description,omitempty"`
Resolved []string `json:"resolved,omitempty"`
ResolvedAt string `json:"resolved_at,omitempty"`
}
func NewAddressGroupResource() resource.Resource { return &addressGroupResource{} }
func (r *addressGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_address_group"
}
func (r *addressGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "An address group, materialized as an nftables named set. Population source is static (CIDRs), dns (FQDNs resolved on-device), or asn (ASNs expanded centrally).",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Group name (globally unique). ASN groups conventionally use an asn_ prefix.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"type": schema.StringAttribute{
Description: "Population source: static, dns, or asn.",
Required: true,
},
"members": schema.ListAttribute{
Description: "For static: CIDRs/IPs. For dns: FQDNs. For asn: ASN numbers.",
Required: true,
ElementType: types.StringType,
},
"refresh": schema.StringAttribute{
Description: "asn: prefix cache TTL (e.g. 24h). dns: honor_ttl.",
Optional: true,
},
"description": schema.StringAttribute{
Optional: true,
},
"resolved": schema.ListAttribute{
Description: "Server-managed: concrete prefixes the ASN expander last produced.",
Computed: true,
ElementType: types.StringType,
},
"resolved_at": schema.StringAttribute{
Description: "Server-managed: timestamp of the last ASN expansion.",
Computed: true,
},
},
}
}
func (r *addressGroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *addressGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan addressGroupModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *addressGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan addressGroupModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *addressGroupResource) upsert(ctx context.Context, plan addressGroupModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := addressGroupAPI{
Name: plan.Name.ValueString(),
Type: plan.Type.ValueString(),
Members: listToStrings(ctx, plan.Members, diags),
Refresh: plan.Refresh.ValueString(),
Description: plan.Description.ValueString(),
}
if diags.HasError() {
return
}
var out addressGroupAPI
if err := r.client.put(ctx, "/api/v1/address-groups/"+pathEscape(body.Name), body, &out); err != nil {
diags.AddError("upsert address_group failed", err.Error())
return
}
diags.Append(state.Set(ctx, r.toModel(ctx, out, plan, diags))...)
}
func (r *addressGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state addressGroupModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out addressGroupAPI
if err := r.client.get(ctx, "/api/v1/address-groups/"+pathEscape(state.Name.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read address_group failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...)
}
func (r *addressGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state addressGroupModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/address-groups/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete address_group failed", err.Error())
}
}
func (r *addressGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
// toModel maps the API response back to state. Optional string fields fall back
// to the prior plan/state value so that omitempty responses don't churn state.
func (r *addressGroupResource) toModel(ctx context.Context, api addressGroupAPI, prior addressGroupModel, diags *diag.Diagnostics) addressGroupModel {
m := addressGroupModel{
Name: types.StringValue(api.Name),
Type: types.StringValue(api.Type),
Members: stringsToList(ctx, api.Members, diags),
Refresh: optionalString(api.Refresh, prior.Refresh),
Description: optionalString(api.Description, prior.Description),
Resolved: stringsToList(ctx, api.Resolved, diags),
ResolvedAt: types.StringValue(api.ResolvedAt),
}
return m
}
// optionalString returns a value for an optional attribute: the API value when
// present, else the prior planned value (preserving null when both are empty).
func optionalString(apiVal string, prior types.String) types.String {
if apiVal != "" {
return types.StringValue(apiVal)
}
return prior
}