3913b3920d
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.
184 lines
6.8 KiB
Go
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
|
|
}
|