From 71216601c67bdb4625f665ab2a19553b2b8f1b7b Mon Sep 17 00:00:00 2001 From: benvin Date: Sat, 25 Jul 2026 23:22:16 +1000 Subject: [PATCH 1/4] Add policy/blrule/conntrack provider resources Add tomswallapi_policy, tomswallapi_blrule, and tomswallapi_conntrack for the global-compiled long-tail tier, following the id-keyed resource pattern. Add an optionalList helper for the dport/sport list attributes. Register and document. --- README.md | 3 + internal/provider/helpers.go | 9 ++ internal/provider/provider.go | 3 + internal/provider/resource_blrule.go | 189 ++++++++++++++++++++++ internal/provider/resource_conntrack.go | 199 ++++++++++++++++++++++++ internal/provider/resource_policy.go | 165 ++++++++++++++++++++ 6 files changed, 568 insertions(+) create mode 100644 internal/provider/resource_blrule.go create mode 100644 internal/provider/resource_conntrack.go create mode 100644 internal/provider/resource_policy.go diff --git a/README.md b/README.md index aa485b5..b1fa990 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ provider "tomswallapi" { | `tomswallapi_snat` | id | masquerade/SNAT; `source` zone-or-CIDR, `egress` zone | | `tomswallapi_netmap` | id | net-to-net map, anchored `device:zone`\|`device:interface` | | `tomswallapi_nat` | id | 1:1 static NAT bound to a `device` | +| `tomswallapi_policy` | id | default zone-to-zone posture (`priority` ordered) | +| `tomswallapi_blrule` | id | blacklist/whitelist rule (pre-rules) | +| `tomswallapi_conntrack` | id | connection-tracking control (notrack/helper) | ## Example diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index e17ba69..5bbeb51 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -45,6 +45,15 @@ func stringsToList(ctx context.Context, s []string, diags *diag.Diagnostics) typ return l } +// optionalList maps an API string slice back to state for an optional list +// attribute, preserving a null value the user left unset when the API returns none. +func optionalList(ctx context.Context, apiVals []string, prior types.List, diags *diag.Diagnostics) types.List { + if prior.IsNull() && len(apiVals) == 0 { + return types.ListNull(types.StringType) + } + return stringsToList(ctx, apiVals, diags) +} + // mapToStrings converts a Terraform map into a map[string]string. func mapToStrings(ctx context.Context, m types.Map, diags *diag.Diagnostics) map[string]string { if m.IsNull() || m.IsUnknown() { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 58c8bb2..147bb69 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -78,6 +78,9 @@ func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resour NewSNATResource, NewNetmapResource, NewNATResource, + NewPolicyResource, + NewBlruleResource, + NewConntrackResource, } } diff --git a/internal/provider/resource_blrule.go b/internal/provider/resource_blrule.go new file mode 100644 index 0000000..42cb069 --- /dev/null +++ b/internal/provider/resource_blrule.go @@ -0,0 +1,189 @@ +package provider + +import ( + "context" + "strconv" + + "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/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/types" +) + +var ( + _ resource.Resource = &blruleResource{} + _ resource.ResourceWithImportState = &blruleResource{} +) + +type blruleResource struct{ client *apiClient } + +type blruleModel struct { + ID types.Int64 `tfsdk:"id"` + Priority types.Int64 `tfsdk:"priority"` + Action types.String `tfsdk:"action"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + Log types.String `tfsdk:"log"` + Comment types.String `tfsdk:"comment"` +} + +type blruleAPI struct { + ID int64 `json:"id,omitempty"` + Priority int `json:"priority"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Log string `json:"log,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewBlruleResource() resource.Resource { return &blruleResource{} } + +func (r *blruleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_blrule" +} + +func (r *blruleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A blacklist/whitelist rule, processed before normal rules.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "priority": schema.Int64Attribute{ + Description: "Evaluation priority; lower first.", + Optional: true, Computed: true, Default: int64default.StaticInt64(0), + }, + "action": schema.StringAttribute{Description: "drop, reject, whitelist, ...", Required: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "log": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *blruleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *blruleResource) body(ctx context.Context, plan blruleModel, diags *diag.Diagnostics) blruleAPI { + return blruleAPI{ + Priority: int(plan.Priority.ValueInt64()), + Action: plan.Action.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Proto: plan.Proto.ValueString(), + DPort: listToStrings(ctx, plan.DPort, diags), + SPort: listToStrings(ctx, plan.SPort, diags), + Log: plan.Log.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *blruleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan blruleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out blruleAPI + if err := r.client.post(ctx, "/api/v1/blrules", body, &out); err != nil { + resp.Diagnostics.AddError("create blrule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *blruleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state blruleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out blruleAPI + if err := r.client.post(ctx, "/api/v1/blrules", body, &out); err != nil { + resp.Diagnostics.AddError("recreate blrule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/blrules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old blrule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *blruleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state blruleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out blruleAPI + if err := r.client.get(ctx, "/api/v1/blrules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read blrule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *blruleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state blruleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/blrules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete blrule failed", err.Error()) + } +} + +func (r *blruleResource) 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", "blrule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *blruleResource) toModel(ctx context.Context, api blruleAPI, prior blruleModel, diags *diag.Diagnostics) blruleModel { + return blruleModel{ + ID: types.Int64Value(api.ID), + Priority: types.Int64Value(int64(api.Priority)), + Action: types.StringValue(api.Action), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), + DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), + Log: optionalString(api.Log, prior.Log), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_conntrack.go b/internal/provider/resource_conntrack.go new file mode 100644 index 0000000..c8199a1 --- /dev/null +++ b/internal/provider/resource_conntrack.go @@ -0,0 +1,199 @@ +package provider + +import ( + "context" + "strconv" + + "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/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/types" +) + +var ( + _ resource.Resource = &conntrackResource{} + _ resource.ResourceWithImportState = &conntrackResource{} +) + +type conntrackResource struct{ client *apiClient } + +type conntrackModel struct { + ID types.Int64 `tfsdk:"id"` + Priority types.Int64 `tfsdk:"priority"` + Action types.String `tfsdk:"action"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + Chain types.String `tfsdk:"chain"` + Helper types.String `tfsdk:"helper"` + User types.String `tfsdk:"user"` + Comment types.String `tfsdk:"comment"` +} + +type conntrackAPI struct { + ID int64 `json:"id,omitempty"` + Priority int `json:"priority"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Chain string `json:"chain,omitempty"` + Helper string `json:"helper,omitempty"` + User string `json:"user,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewConntrackResource() resource.Resource { return &conntrackResource{} } + +func (r *conntrackResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_conntrack" +} + +func (r *conntrackResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A connection-tracking control rule (e.g. notrack, or assign a conntrack helper).", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "priority": schema.Int64Attribute{ + Description: "Evaluation priority; lower first.", + Optional: true, Computed: true, Default: int64default.StaticInt64(0), + }, + "action": schema.StringAttribute{Description: "notrack, helper, ...", Required: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "chain": schema.StringAttribute{Optional: true}, + "helper": schema.StringAttribute{Description: "Conntrack helper (ftp, sip, tftp, ...).", Optional: true}, + "user": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *conntrackResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *conntrackResource) body(ctx context.Context, plan conntrackModel, diags *diag.Diagnostics) conntrackAPI { + return conntrackAPI{ + Priority: int(plan.Priority.ValueInt64()), + Action: plan.Action.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Proto: plan.Proto.ValueString(), + DPort: listToStrings(ctx, plan.DPort, diags), + SPort: listToStrings(ctx, plan.SPort, diags), + Chain: plan.Chain.ValueString(), + Helper: plan.Helper.ValueString(), + User: plan.User.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *conntrackResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan conntrackModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out conntrackAPI + if err := r.client.post(ctx, "/api/v1/conntrack", body, &out); err != nil { + resp.Diagnostics.AddError("create conntrack failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *conntrackResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state conntrackModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out conntrackAPI + if err := r.client.post(ctx, "/api/v1/conntrack", body, &out); err != nil { + resp.Diagnostics.AddError("recreate conntrack failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/conntrack/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old conntrack failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *conntrackResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state conntrackModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out conntrackAPI + if err := r.client.get(ctx, "/api/v1/conntrack/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read conntrack failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *conntrackResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state conntrackModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/conntrack/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete conntrack failed", err.Error()) + } +} + +func (r *conntrackResource) 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", "conntrack id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *conntrackResource) toModel(ctx context.Context, api conntrackAPI, prior conntrackModel, diags *diag.Diagnostics) conntrackModel { + return conntrackModel{ + ID: types.Int64Value(api.ID), + Priority: types.Int64Value(int64(api.Priority)), + Action: types.StringValue(api.Action), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), + DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), + Chain: optionalString(api.Chain, prior.Chain), + Helper: optionalString(api.Helper, prior.Helper), + User: optionalString(api.User, prior.User), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_policy.go b/internal/provider/resource_policy.go new file mode 100644 index 0000000..0e4a62b --- /dev/null +++ b/internal/provider/resource_policy.go @@ -0,0 +1,165 @@ +package provider + +import ( + "context" + "strconv" + + "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/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/types" +) + +var ( + _ resource.Resource = &policyResource{} + _ resource.ResourceWithImportState = &policyResource{} +) + +type policyResource struct{ client *apiClient } + +type policyModel struct { + ID types.Int64 `tfsdk:"id"` + Priority types.Int64 `tfsdk:"priority"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Action types.String `tfsdk:"action"` + Log types.String `tfsdk:"log"` +} + +type policyAPI struct { + ID int64 `json:"id,omitempty"` + Priority int `json:"priority"` + Source string `json:"source"` + Dest string `json:"dest"` + Action string `json:"action"` + Log string `json:"log,omitempty"` +} + +func NewPolicyResource() resource.Resource { return &policyResource{} } + +func (r *policyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_policy" +} + +func (r *policyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A default zone-to-zone policy (fleet posture). Lower priority is evaluated first.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}, + }, + "priority": schema.Int64Attribute{ + Description: "Evaluation priority; lower first.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(0), + }, + "source": schema.StringAttribute{Description: "Source zone (or all).", Required: true}, + "dest": schema.StringAttribute{Description: "Dest zone (or all).", Required: true}, + "action": schema.StringAttribute{Description: "accept, drop, reject, continue, none, ...", Required: true}, + "log": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *policyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *policyResource) body(plan policyModel) policyAPI { + return policyAPI{ + Priority: int(plan.Priority.ValueInt64()), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Action: plan.Action.ValueString(), + Log: plan.Log.ValueString(), + } +} + +func (r *policyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan policyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out policyAPI + if err := r.client.post(ctx, "/api/v1/policies", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create policy failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *policyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state policyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out policyAPI + if err := r.client.post(ctx, "/api/v1/policies", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate policy failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/policies/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old policy failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *policyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state policyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out policyAPI + if err := r.client.get(ctx, "/api/v1/policies/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read policy failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *policyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state policyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/policies/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete policy failed", err.Error()) + } +} + +func (r *policyResource) 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", "policy id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *policyResource) toModel(api policyAPI, prior policyModel) policyModel { + return policyModel{ + ID: types.Int64Value(api.ID), + Priority: types.Int64Value(int64(api.Priority)), + Source: types.StringValue(api.Source), + Dest: types.StringValue(api.Dest), + Action: types.StringValue(api.Action), + Log: optionalString(api.Log, prior.Log), + } +} -- 2.47.3 From 006201d944b28bc6ca9d54c70e0fe22c7e7025e0 Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 26 Jul 2026 13:06:17 +1000 Subject: [PATCH 2/4] Add host/provider/route/routing_rule provider resources Batch 2 provider resources (per-device routing tier), id-keyed. Add optionalInt64 helper. Register and document. --- README.md | 4 + internal/provider/helpers.go | 9 ++ internal/provider/provider.go | 4 + internal/provider/resource_host.go | 171 ++++++++++++++++++++ internal/provider/resource_provider.go | 180 +++++++++++++++++++++ internal/provider/resource_route.go | 167 +++++++++++++++++++ internal/provider/resource_routing_rule.go | 173 ++++++++++++++++++++ 7 files changed, 708 insertions(+) create mode 100644 internal/provider/resource_host.go create mode 100644 internal/provider/resource_provider.go create mode 100644 internal/provider/resource_route.go create mode 100644 internal/provider/resource_routing_rule.go diff --git a/README.md b/README.md index b1fa990..bb780f1 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ provider "tomswallapi" { | `tomswallapi_policy` | id | default zone-to-zone posture (`priority` ordered) | | `tomswallapi_blrule` | id | blacklist/whitelist rule (pre-rules) | | `tomswallapi_conntrack` | id | connection-tracking control (notrack/helper) | +| `tomswallapi_host` | id | zone→address constraint on a `device` interface | +| `tomswallapi_provider` | id | multi-ISP routing provider on a `device` | +| `tomswallapi_route` | id | static route on a `device` (`oif` = egress iface) | +| `tomswallapi_routing_rule` | id | policy routing to a provider table (rtrules) | ## Example diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 5bbeb51..fd8e98d 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -45,6 +45,15 @@ func stringsToList(ctx context.Context, s []string, diags *diag.Diagnostics) typ return l } +// optionalInt64 maps an omitempty API int back to state, preserving a prior null +// when the API returns the zero value. +func optionalInt64(apiVal int, prior types.Int64) types.Int64 { + if apiVal != 0 { + return types.Int64Value(int64(apiVal)) + } + return prior +} + // optionalList maps an API string slice back to state for an optional list // attribute, preserving a null value the user left unset when the API returns none. func optionalList(ctx context.Context, apiVals []string, prior types.List, diags *diag.Diagnostics) types.List { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 147bb69..244361f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -81,6 +81,10 @@ func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resour NewPolicyResource, NewBlruleResource, NewConntrackResource, + NewHostResource, + NewProviderResource, + NewRouteResource, + NewRoutingRuleResource, } } diff --git a/internal/provider/resource_host.go b/internal/provider/resource_host.go new file mode 100644 index 0000000..74fc2be --- /dev/null +++ b/internal/provider/resource_host.go @@ -0,0 +1,171 @@ +package provider + +import ( + "context" + "strconv" + + "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/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &hostResource{} + _ resource.ResourceWithImportState = &hostResource{} +) + +type hostResource struct{ client *apiClient } + +type hostModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Zone types.String `tfsdk:"zone"` + Interface types.String `tfsdk:"interface"` + Addresses types.List `tfsdk:"addresses"` + Exclusions types.List `tfsdk:"exclusions"` + Dynamic types.Bool `tfsdk:"dynamic"` +} + +type hostAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Zone string `json:"zone"` + Interface string `json:"interface"` + Addresses []string `json:"addresses,omitempty"` + Exclusions []string `json:"exclusions,omitempty"` + Dynamic bool `json:"dynamic,omitempty"` +} + +func NewHostResource() resource.Resource { return &hostResource{} } + +func (r *hostResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_host" +} + +func (r *hostResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Constrains a zone to specific addresses on a device's interface.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "zone": schema.StringAttribute{Required: true}, + "interface": schema.StringAttribute{Required: true}, + "addresses": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "exclusions": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "dynamic": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + }, + } +} + +func (r *hostResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *hostResource) body(ctx context.Context, plan hostModel, diags *diag.Diagnostics) hostAPI { + return hostAPI{ + Device: plan.Device.ValueString(), + Zone: plan.Zone.ValueString(), + Interface: plan.Interface.ValueString(), + Addresses: listToStrings(ctx, plan.Addresses, diags), + Exclusions: listToStrings(ctx, plan.Exclusions, diags), + Dynamic: plan.Dynamic.ValueBool(), + } +} + +func (r *hostResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan hostModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out hostAPI + if err := r.client.post(ctx, "/api/v1/hosts", body, &out); err != nil { + resp.Diagnostics.AddError("create host failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *hostResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state hostModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out hostAPI + if err := r.client.post(ctx, "/api/v1/hosts", body, &out); err != nil { + resp.Diagnostics.AddError("recreate host failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/hosts/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old host failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *hostResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state hostModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out hostAPI + if err := r.client.get(ctx, "/api/v1/hosts/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read host failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *hostResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state hostModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/hosts/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete host failed", err.Error()) + } +} + +func (r *hostResource) 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", "host id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *hostResource) toModel(ctx context.Context, api hostAPI, prior hostModel, diags *diag.Diagnostics) hostModel { + return hostModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Zone: types.StringValue(api.Zone), + Interface: types.StringValue(api.Interface), + Addresses: optionalList(ctx, api.Addresses, prior.Addresses, diags), + Exclusions: optionalList(ctx, api.Exclusions, prior.Exclusions, diags), + Dynamic: types.BoolValue(api.Dynamic), + } +} diff --git a/internal/provider/resource_provider.go b/internal/provider/resource_provider.go new file mode 100644 index 0000000..e09a843 --- /dev/null +++ b/internal/provider/resource_provider.go @@ -0,0 +1,180 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &providerResource{} + _ resource.ResourceWithImportState = &providerResource{} +) + +type providerResource struct{ client *apiClient } + +type providerModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Name types.String `tfsdk:"name"` + Number types.Int64 `tfsdk:"number"` + Mark types.Int64 `tfsdk:"mark"` + Duplicate types.String `tfsdk:"duplicate"` + Interface types.String `tfsdk:"interface"` + Gateway types.String `tfsdk:"gateway"` + Copy types.List `tfsdk:"copy"` +} + +type providerAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Name string `json:"name"` + Number int `json:"number"` + Mark int `json:"mark,omitempty"` + Duplicate string `json:"duplicate,omitempty"` + Interface string `json:"interface"` + Gateway string `json:"gateway,omitempty"` + Copy []string `json:"copy,omitempty"` +} + +func NewProviderResource() resource.Resource { return &providerResource{} } + +func (r *providerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_provider" +} + +func (r *providerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A multi-ISP routing provider on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "name": schema.StringAttribute{Required: true}, + "number": schema.Int64Attribute{Description: "Provider routing table number.", Required: true}, + "mark": schema.Int64Attribute{Optional: true}, + "duplicate": schema.StringAttribute{Optional: true}, + "interface": schema.StringAttribute{Required: true}, + "gateway": schema.StringAttribute{Optional: true}, + "copy": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + }, + } +} + +func (r *providerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *providerResource) body(ctx context.Context, plan providerModel, diags *diag.Diagnostics) providerAPI { + return providerAPI{ + Device: plan.Device.ValueString(), + Name: plan.Name.ValueString(), + Number: int(plan.Number.ValueInt64()), + Mark: int(plan.Mark.ValueInt64()), + Duplicate: plan.Duplicate.ValueString(), + Interface: plan.Interface.ValueString(), + Gateway: plan.Gateway.ValueString(), + Copy: listToStrings(ctx, plan.Copy, diags), + } +} + +func (r *providerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan providerModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out providerAPI + if err := r.client.post(ctx, "/api/v1/providers", body, &out); err != nil { + resp.Diagnostics.AddError("create provider failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *providerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state providerModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out providerAPI + if err := r.client.post(ctx, "/api/v1/providers", body, &out); err != nil { + resp.Diagnostics.AddError("recreate provider failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/providers/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old provider failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *providerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state providerModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out providerAPI + if err := r.client.get(ctx, "/api/v1/providers/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read provider failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *providerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state providerModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/providers/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete provider failed", err.Error()) + } +} + +func (r *providerResource) 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", "provider id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *providerResource) toModel(ctx context.Context, api providerAPI, prior providerModel, diags *diag.Diagnostics) providerModel { + return providerModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Name: types.StringValue(api.Name), + Number: types.Int64Value(int64(api.Number)), + Mark: optionalInt64(api.Mark, prior.Mark), + Duplicate: optionalString(api.Duplicate, prior.Duplicate), + Interface: types.StringValue(api.Interface), + Gateway: optionalString(api.Gateway, prior.Gateway), + Copy: optionalList(ctx, api.Copy, prior.Copy, diags), + } +} diff --git a/internal/provider/resource_route.go b/internal/provider/resource_route.go new file mode 100644 index 0000000..52c0b49 --- /dev/null +++ b/internal/provider/resource_route.go @@ -0,0 +1,167 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &routeResource{} + _ resource.ResourceWithImportState = &routeResource{} +) + +type routeResource struct{ client *apiClient } + +type routeModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Provider types.String `tfsdk:"provider"` + Dest types.String `tfsdk:"dest"` + Gateway types.String `tfsdk:"gateway"` + Oif types.String `tfsdk:"oif"` + Persistent types.Bool `tfsdk:"persistent"` + Comment types.String `tfsdk:"comment"` +} + +type routeAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Provider string `json:"provider,omitempty"` + Dest string `json:"dest"` + Gateway string `json:"gateway,omitempty"` + Oif string `json:"oif,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewRouteResource() resource.Resource { return &routeResource{} } + +func (r *routeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_route" +} + +func (r *routeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A static route on a device. `oif` is the egress interface.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "provider": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Required: true}, + "gateway": schema.StringAttribute{Optional: true}, + "oif": schema.StringAttribute{Description: "Egress interface.", Optional: true}, + "persistent": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *routeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *routeResource) body(plan routeModel) routeAPI { + return routeAPI{ + Device: plan.Device.ValueString(), + Provider: plan.Provider.ValueString(), + Dest: plan.Dest.ValueString(), + Gateway: plan.Gateway.ValueString(), + Oif: plan.Oif.ValueString(), + Persistent: plan.Persistent.ValueBool(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *routeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan routeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out routeAPI + if err := r.client.post(ctx, "/api/v1/routes", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create route failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *routeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state routeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out routeAPI + if err := r.client.post(ctx, "/api/v1/routes", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate route failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/routes/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old route failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *routeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state routeModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out routeAPI + if err := r.client.get(ctx, "/api/v1/routes/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read route failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *routeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state routeModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/routes/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete route failed", err.Error()) + } +} + +func (r *routeResource) 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", "route id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *routeResource) toModel(api routeAPI, prior routeModel) routeModel { + return routeModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Provider: optionalString(api.Provider, prior.Provider), + Dest: types.StringValue(api.Dest), + Gateway: optionalString(api.Gateway, prior.Gateway), + Oif: optionalString(api.Oif, prior.Oif), + Persistent: types.BoolValue(api.Persistent), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_routing_rule.go b/internal/provider/resource_routing_rule.go new file mode 100644 index 0000000..8ba365c --- /dev/null +++ b/internal/provider/resource_routing_rule.go @@ -0,0 +1,173 @@ +package provider + +import ( + "context" + "strconv" + + "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/types" +) + +var ( + _ resource.Resource = &routingRuleResource{} + _ resource.ResourceWithImportState = &routingRuleResource{} +) + +type routingRuleResource struct{ client *apiClient } + +type routingRuleModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Provider types.String `tfsdk:"provider"` + Priority types.Int64 `tfsdk:"priority"` + Persistent types.Bool `tfsdk:"persistent"` + Mark types.String `tfsdk:"mark"` + Comment types.String `tfsdk:"comment"` +} + +type routingRuleAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Provider string `json:"provider"` + Priority int `json:"priority,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Mark string `json:"mark,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewRoutingRuleResource() resource.Resource { return &routingRuleResource{} } + +func (r *routingRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_routing_rule" +} + +func (r *routingRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Directs traffic to a provider's routing table on a device (rtrules).", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "provider": schema.StringAttribute{Required: true}, + "priority": schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0)}, + "persistent": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "mark": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *routingRuleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *routingRuleResource) body(plan routingRuleModel) routingRuleAPI { + return routingRuleAPI{ + Device: plan.Device.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Provider: plan.Provider.ValueString(), + Priority: int(plan.Priority.ValueInt64()), + Persistent: plan.Persistent.ValueBool(), + Mark: plan.Mark.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *routingRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan routingRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out routingRuleAPI + if err := r.client.post(ctx, "/api/v1/routing-rules", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create routing_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *routingRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state routingRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out routingRuleAPI + if err := r.client.post(ctx, "/api/v1/routing-rules", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate routing_rule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/routing-rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old routing_rule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *routingRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state routingRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out routingRuleAPI + if err := r.client.get(ctx, "/api/v1/routing-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read routing_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *routingRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state routingRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/routing-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete routing_rule failed", err.Error()) + } +} + +func (r *routingRuleResource) 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", "routing_rule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *routingRuleResource) toModel(api routingRuleAPI, prior routingRuleModel) routingRuleModel { + return routingRuleModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Provider: types.StringValue(api.Provider), + Priority: types.Int64Value(int64(api.Priority)), + Persistent: types.BoolValue(api.Persistent), + Mark: optionalString(api.Mark, prior.Mark), + Comment: optionalString(api.Comment, prior.Comment), + } +} -- 2.47.3 From ed6e5e45ae39bca2c4dc153204176167c468a4a4 Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 26 Jul 2026 15:23:15 +1000 Subject: [PATCH 3/4] Add L2/misc provider resources (tunnel/stopped_rule/proxy_arp/proxy_ndp/arp_rule/maclist) Batch 3 provider resources, id-keyed. proxy_arp/proxy_ndp share one implementation (typeSuffix + endpoint). Register and document. --- README.md | 5 + internal/provider/provider.go | 6 + internal/provider/resource_arp_rule.go | 171 ++++++++++++++++++++ internal/provider/resource_maclist.go | 175 ++++++++++++++++++++ internal/provider/resource_proxy.go | 178 ++++++++++++++++++++ internal/provider/resource_stopped_rule.go | 180 +++++++++++++++++++++ internal/provider/resource_tunnel.go | 175 ++++++++++++++++++++ 7 files changed, 890 insertions(+) create mode 100644 internal/provider/resource_arp_rule.go create mode 100644 internal/provider/resource_maclist.go create mode 100644 internal/provider/resource_proxy.go create mode 100644 internal/provider/resource_stopped_rule.go create mode 100644 internal/provider/resource_tunnel.go diff --git a/README.md b/README.md index bb780f1..8e161c5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ provider "tomswallapi" { | `tomswallapi_provider` | id | multi-ISP routing provider on a `device` | | `tomswallapi_route` | id | static route on a `device` (`oif` = egress iface) | | `tomswallapi_routing_rule` | id | policy routing to a provider table (rtrules) | +| `tomswallapi_tunnel` | id | VPN tunnel definition on a `device` | +| `tomswallapi_stopped_rule` | id | traffic allowed while the firewall is stopped | +| `tomswallapi_proxy_arp` / `_proxy_ndp` | id | proxy ARP/NDP on a `device` | +| `tomswallapi_arp_rule` | id | ARP-level rule on a `device` | +| `tomswallapi_maclist` | id | MAC/IP verification on a `device` interface | ## Example diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 244361f..b5e8d8e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -85,6 +85,12 @@ func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resour NewProviderResource, NewRouteResource, NewRoutingRuleResource, + NewTunnelResource, + NewStoppedRuleResource, + NewProxyARPResource, + NewProxyNDPResource, + NewArpRuleResource, + NewMaclistResource, } } diff --git a/internal/provider/resource_arp_rule.go b/internal/provider/resource_arp_rule.go new file mode 100644 index 0000000..55bceee --- /dev/null +++ b/internal/provider/resource_arp_rule.go @@ -0,0 +1,171 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &arpRuleResource{} + _ resource.ResourceWithImportState = &arpRuleResource{} +) + +type arpRuleResource struct{ client *apiClient } + +type arpRuleModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + ActionAddress types.String `tfsdk:"action_address"` + ActionMAC types.String `tfsdk:"action_mac"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Opcode types.Int64 `tfsdk:"opcode"` + Comment types.String `tfsdk:"comment"` +} + +type arpRuleAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + ActionAddress string `json:"action_address,omitempty"` + ActionMAC string `json:"action_mac,omitempty"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Opcode int `json:"opcode,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewArpRuleResource() resource.Resource { return &arpRuleResource{} } + +func (r *arpRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_arp_rule" +} + +func (r *arpRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "An ARP-level rule on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Required: true}, + "action_address": schema.StringAttribute{Optional: true}, + "action_mac": schema.StringAttribute{Optional: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "opcode": schema.Int64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *arpRuleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *arpRuleResource) body(plan arpRuleModel) arpRuleAPI { + return arpRuleAPI{ + Device: plan.Device.ValueString(), + Action: plan.Action.ValueString(), + ActionAddress: plan.ActionAddress.ValueString(), + ActionMAC: plan.ActionMAC.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Opcode: int(plan.Opcode.ValueInt64()), + Comment: plan.Comment.ValueString(), + } +} + +func (r *arpRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan arpRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out arpRuleAPI + if err := r.client.post(ctx, "/api/v1/arp-rules", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create arp_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *arpRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state arpRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out arpRuleAPI + if err := r.client.post(ctx, "/api/v1/arp-rules", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate arp_rule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/arp-rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old arp_rule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *arpRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state arpRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out arpRuleAPI + if err := r.client.get(ctx, "/api/v1/arp-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read arp_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *arpRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state arpRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/arp-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete arp_rule failed", err.Error()) + } +} + +func (r *arpRuleResource) 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", "arp_rule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *arpRuleResource) toModel(api arpRuleAPI, prior arpRuleModel) arpRuleModel { + return arpRuleModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Action: types.StringValue(api.Action), + ActionAddress: optionalString(api.ActionAddress, prior.ActionAddress), + ActionMAC: optionalString(api.ActionMAC, prior.ActionMAC), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Opcode: optionalInt64(api.Opcode, prior.Opcode), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_maclist.go b/internal/provider/resource_maclist.go new file mode 100644 index 0000000..e3d3909 --- /dev/null +++ b/internal/provider/resource_maclist.go @@ -0,0 +1,175 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &maclistResource{} + _ resource.ResourceWithImportState = &maclistResource{} +) + +type maclistResource struct{ client *apiClient } + +type maclistModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + Interface types.String `tfsdk:"interface"` + MAC types.String `tfsdk:"mac"` + Addresses types.List `tfsdk:"addresses"` + Log types.String `tfsdk:"log"` + Comment types.String `tfsdk:"comment"` +} + +type maclistAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + Interface string `json:"interface"` + MAC string `json:"mac,omitempty"` + Addresses []string `json:"addresses,omitempty"` + Log string `json:"log,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewMaclistResource() resource.Resource { return &maclistResource{} } + +func (r *maclistResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_maclist" +} + +func (r *maclistResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A MAC/IP verification entry on a device's interface.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Description: "accept, drop, ...", Required: true}, + "interface": schema.StringAttribute{Required: true}, + "mac": schema.StringAttribute{Optional: true}, + "addresses": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "log": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *maclistResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *maclistResource) body(ctx context.Context, plan maclistModel, diags *diag.Diagnostics) maclistAPI { + return maclistAPI{ + Device: plan.Device.ValueString(), + Action: plan.Action.ValueString(), + Interface: plan.Interface.ValueString(), + MAC: plan.MAC.ValueString(), + Addresses: listToStrings(ctx, plan.Addresses, diags), + Log: plan.Log.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *maclistResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan maclistModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out maclistAPI + if err := r.client.post(ctx, "/api/v1/maclist", body, &out); err != nil { + resp.Diagnostics.AddError("create maclist failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *maclistResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state maclistModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out maclistAPI + if err := r.client.post(ctx, "/api/v1/maclist", body, &out); err != nil { + resp.Diagnostics.AddError("recreate maclist failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/maclist/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old maclist failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *maclistResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state maclistModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out maclistAPI + if err := r.client.get(ctx, "/api/v1/maclist/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read maclist failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *maclistResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state maclistModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/maclist/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete maclist failed", err.Error()) + } +} + +func (r *maclistResource) 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", "maclist id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *maclistResource) toModel(ctx context.Context, api maclistAPI, prior maclistModel, diags *diag.Diagnostics) maclistModel { + return maclistModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Action: types.StringValue(api.Action), + Interface: types.StringValue(api.Interface), + MAC: optionalString(api.MAC, prior.MAC), + Addresses: optionalList(ctx, api.Addresses, prior.Addresses, diags), + Log: optionalString(api.Log, prior.Log), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_proxy.go b/internal/provider/resource_proxy.go new file mode 100644 index 0000000..67f1186 --- /dev/null +++ b/internal/provider/resource_proxy.go @@ -0,0 +1,178 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// proxyResource backs both tomswallapi_proxy_arp and tomswallapi_proxy_ndp +// (identical shape); typeSuffix and endpoint distinguish them. +type proxyResource struct { + client *apiClient + typeSuffix string + endpoint string +} + +func NewProxyARPResource() resource.Resource { + return &proxyResource{typeSuffix: "_proxy_arp", endpoint: "/api/v1/proxy-arp"} +} +func NewProxyNDPResource() resource.Resource { + return &proxyResource{typeSuffix: "_proxy_ndp", endpoint: "/api/v1/proxy-ndp"} +} + +var ( + _ resource.Resource = &proxyResource{} + _ resource.ResourceWithImportState = &proxyResource{} +) + +type proxyModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Address types.String `tfsdk:"address"` + Interface types.String `tfsdk:"interface"` + External types.String `tfsdk:"external"` + HaveRoute types.Bool `tfsdk:"haveroute"` + Persistent types.Bool `tfsdk:"persistent"` + Comment types.String `tfsdk:"comment"` +} + +type proxyAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Address string `json:"address"` + Interface string `json:"interface,omitempty"` + External string `json:"external"` + HaveRoute bool `json:"haveroute,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func (r *proxyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + r.typeSuffix +} + +func (r *proxyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Answers ARP/NDP on behalf of another host (proxy arp/ndp), on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "address": schema.StringAttribute{Required: true}, + "interface": schema.StringAttribute{Optional: true}, + "external": schema.StringAttribute{Description: "External interface.", Required: true}, + "haveroute": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "persistent": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *proxyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *proxyResource) body(plan proxyModel) proxyAPI { + return proxyAPI{ + Device: plan.Device.ValueString(), + Address: plan.Address.ValueString(), + Interface: plan.Interface.ValueString(), + External: plan.External.ValueString(), + HaveRoute: plan.HaveRoute.ValueBool(), + Persistent: plan.Persistent.ValueBool(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *proxyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan proxyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out proxyAPI + if err := r.client.post(ctx, r.endpoint, r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create proxy failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *proxyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state proxyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out proxyAPI + if err := r.client.post(ctx, r.endpoint, r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate proxy failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, r.endpoint+"/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old proxy failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *proxyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state proxyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out proxyAPI + if err := r.client.get(ctx, r.endpoint+"/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read proxy failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *proxyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state proxyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, r.endpoint+"/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete proxy failed", err.Error()) + } +} + +func (r *proxyResource) 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", "proxy id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *proxyResource) toModel(api proxyAPI, prior proxyModel) proxyModel { + return proxyModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Address: types.StringValue(api.Address), + Interface: optionalString(api.Interface, prior.Interface), + External: types.StringValue(api.External), + HaveRoute: types.BoolValue(api.HaveRoute), + Persistent: types.BoolValue(api.Persistent), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_stopped_rule.go b/internal/provider/resource_stopped_rule.go new file mode 100644 index 0000000..ae23836 --- /dev/null +++ b/internal/provider/resource_stopped_rule.go @@ -0,0 +1,180 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &stoppedRuleResource{} + _ resource.ResourceWithImportState = &stoppedRuleResource{} +) + +type stoppedRuleResource struct{ client *apiClient } + +type stoppedRuleModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + Comment types.String `tfsdk:"comment"` +} + +type stoppedRuleAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewStoppedRuleResource() resource.Resource { return &stoppedRuleResource{} } + +func (r *stoppedRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_stopped_rule" +} + +func (r *stoppedRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Traffic permitted when the firewall is stopped, on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Description: "accept, drop, ...", Required: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *stoppedRuleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *stoppedRuleResource) body(ctx context.Context, plan stoppedRuleModel, diags *diag.Diagnostics) stoppedRuleAPI { + return stoppedRuleAPI{ + Device: plan.Device.ValueString(), + Action: plan.Action.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Proto: plan.Proto.ValueString(), + DPort: listToStrings(ctx, plan.DPort, diags), + SPort: listToStrings(ctx, plan.SPort, diags), + Comment: plan.Comment.ValueString(), + } +} + +func (r *stoppedRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan stoppedRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out stoppedRuleAPI + if err := r.client.post(ctx, "/api/v1/stopped-rules", body, &out); err != nil { + resp.Diagnostics.AddError("create stopped_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *stoppedRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state stoppedRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out stoppedRuleAPI + if err := r.client.post(ctx, "/api/v1/stopped-rules", body, &out); err != nil { + resp.Diagnostics.AddError("recreate stopped_rule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/stopped-rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old stopped_rule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *stoppedRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state stoppedRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out stoppedRuleAPI + if err := r.client.get(ctx, "/api/v1/stopped-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read stopped_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *stoppedRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state stoppedRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/stopped-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete stopped_rule failed", err.Error()) + } +} + +func (r *stoppedRuleResource) 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", "stopped_rule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *stoppedRuleResource) toModel(ctx context.Context, api stoppedRuleAPI, prior stoppedRuleModel, diags *diag.Diagnostics) stoppedRuleModel { + return stoppedRuleModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Action: types.StringValue(api.Action), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), + DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_tunnel.go b/internal/provider/resource_tunnel.go new file mode 100644 index 0000000..a9b20d9 --- /dev/null +++ b/internal/provider/resource_tunnel.go @@ -0,0 +1,175 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tunnelResource{} + _ resource.ResourceWithImportState = &tunnelResource{} +) + +type tunnelResource struct{ client *apiClient } + +type tunnelModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Type types.String `tfsdk:"type"` + Zone types.String `tfsdk:"zone"` + Gateways types.List `tfsdk:"gateways"` + GatewayZones types.List `tfsdk:"gateway_zones"` + Port types.Int64 `tfsdk:"port"` + Comment types.String `tfsdk:"comment"` +} + +type tunnelAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Type string `json:"type"` + Zone string `json:"zone"` + Gateways []string `json:"gateways,omitempty"` + GatewayZones []string `json:"gateway_zones,omitempty"` + Port int `json:"port,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTunnelResource() resource.Resource { return &tunnelResource{} } + +func (r *tunnelResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tunnel" +} + +func (r *tunnelResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A VPN tunnel definition on a device (allows encapsulated traffic).", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "type": schema.StringAttribute{Description: "Tunnel type (ipsec, openvpn:udp, ...).", Required: true}, + "zone": schema.StringAttribute{Required: true}, + "gateways": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "gateway_zones": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "port": schema.Int64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tunnelResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tunnelResource) body(ctx context.Context, plan tunnelModel, diags *diag.Diagnostics) tunnelAPI { + return tunnelAPI{ + Device: plan.Device.ValueString(), + Type: plan.Type.ValueString(), + Zone: plan.Zone.ValueString(), + Gateways: listToStrings(ctx, plan.Gateways, diags), + GatewayZones: listToStrings(ctx, plan.GatewayZones, diags), + Port: int(plan.Port.ValueInt64()), + Comment: plan.Comment.ValueString(), + } +} + +func (r *tunnelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tunnelModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tunnelAPI + if err := r.client.post(ctx, "/api/v1/tunnels", body, &out); err != nil { + resp.Diagnostics.AddError("create tunnel failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tunnelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tunnelModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tunnelAPI + if err := r.client.post(ctx, "/api/v1/tunnels", body, &out); err != nil { + resp.Diagnostics.AddError("recreate tunnel failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tunnels/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tunnel failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tunnelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tunnelModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tunnelAPI + if err := r.client.get(ctx, "/api/v1/tunnels/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tunnel failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *tunnelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tunnelModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tunnels/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tunnel failed", err.Error()) + } +} + +func (r *tunnelResource) 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", "tunnel id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tunnelResource) toModel(ctx context.Context, api tunnelAPI, prior tunnelModel, diags *diag.Diagnostics) tunnelModel { + return tunnelModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Type: types.StringValue(api.Type), + Zone: types.StringValue(api.Zone), + Gateways: optionalList(ctx, api.Gateways, prior.Gateways, diags), + GatewayZones: optionalList(ctx, api.GatewayZones, prior.GatewayZones, diags), + Port: optionalInt64(api.Port, prior.Port), + Comment: optionalString(api.Comment, prior.Comment), + } +} -- 2.47.3 From 2e6bd1eff86110f0051afeae51e5e491ad998a8a Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 26 Jul 2026 16:02:26 +1000 Subject: [PATCH 4/4] Add traffic-control provider resources (mangle/accounting/tc_*) Batch 4 provider resources, id-keyed. Register and document. --- README.md | 3 + internal/provider/provider.go | 7 + internal/provider/resource_accounting.go | 182 ++++++++++++++++++ internal/provider/resource_mangle.go | 211 +++++++++++++++++++++ internal/provider/resource_tc_class.go | 158 +++++++++++++++ internal/provider/resource_tc_device.go | 151 +++++++++++++++ internal/provider/resource_tc_filter.go | 183 ++++++++++++++++++ internal/provider/resource_tc_interface.go | 153 +++++++++++++++ internal/provider/resource_tc_priority.go | 175 +++++++++++++++++ 9 files changed, 1223 insertions(+) create mode 100644 internal/provider/resource_accounting.go create mode 100644 internal/provider/resource_mangle.go create mode 100644 internal/provider/resource_tc_class.go create mode 100644 internal/provider/resource_tc_device.go create mode 100644 internal/provider/resource_tc_filter.go create mode 100644 internal/provider/resource_tc_interface.go create mode 100644 internal/provider/resource_tc_priority.go diff --git a/README.md b/README.md index 8e161c5..870f5a0 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ provider "tomswallapi" { | `tomswallapi_proxy_arp` / `_proxy_ndp` | id | proxy ARP/NDP on a `device` | | `tomswallapi_arp_rule` | id | ARP-level rule on a `device` | | `tomswallapi_maclist` | id | MAC/IP verification on a `device` interface | +| `tomswallapi_mangle` | id | packet-mangling rule on a `device` | +| `tomswallapi_accounting` | id | traffic-accounting rule on a `device` | +| `tomswallapi_tc_device` / `_tc_class` / `_tc_filter` / `_tc_interface` / `_tc_priority` | id | traffic-shaping on a `device` | ## Example diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b5e8d8e..b4bb7fd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -91,6 +91,13 @@ func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resour NewProxyNDPResource, NewArpRuleResource, NewMaclistResource, + NewMangleResource, + NewAccountingResource, + NewTCDeviceResource, + NewTCClassResource, + NewTCFilterResource, + NewTCInterfaceResource, + NewTCPriorityResource, } } diff --git a/internal/provider/resource_accounting.go b/internal/provider/resource_accounting.go new file mode 100644 index 0000000..08c3df6 --- /dev/null +++ b/internal/provider/resource_accounting.go @@ -0,0 +1,182 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &accountingResource{} + _ resource.ResourceWithImportState = &accountingResource{} +) + +type accountingResource struct{ client *apiClient } + +type accountingModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + Section types.String `tfsdk:"section"` + Chain types.String `tfsdk:"chain"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + Mark types.String `tfsdk:"mark"` + Comment types.String `tfsdk:"comment"` +} + +type accountingAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + Section string `json:"section,omitempty"` + Chain string `json:"chain,omitempty"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Mark string `json:"mark,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewAccountingResource() resource.Resource { return &accountingResource{} } + +func (r *accountingResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_accounting" +} + +func (r *accountingResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A traffic-accounting rule on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Required: true}, + "section": schema.StringAttribute{Optional: true}, + "chain": schema.StringAttribute{Optional: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "mark": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *accountingResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *accountingResource) body(ctx context.Context, plan accountingModel, diags *diag.Diagnostics) accountingAPI { + return accountingAPI{ + Device: plan.Device.ValueString(), Action: plan.Action.ValueString(), Section: plan.Section.ValueString(), + Chain: plan.Chain.ValueString(), Source: plan.Source.ValueString(), Dest: plan.Dest.ValueString(), + Proto: plan.Proto.ValueString(), DPort: listToStrings(ctx, plan.DPort, diags), SPort: listToStrings(ctx, plan.SPort, diags), + Mark: plan.Mark.ValueString(), Comment: plan.Comment.ValueString(), + } +} + +func (r *accountingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan accountingModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out accountingAPI + if err := r.client.post(ctx, "/api/v1/accounting", body, &out); err != nil { + resp.Diagnostics.AddError("create accounting failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *accountingResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state accountingModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out accountingAPI + if err := r.client.post(ctx, "/api/v1/accounting", body, &out); err != nil { + resp.Diagnostics.AddError("recreate accounting failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/accounting/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old accounting failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *accountingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state accountingModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out accountingAPI + if err := r.client.get(ctx, "/api/v1/accounting/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read accounting failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *accountingResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state accountingModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/accounting/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete accounting failed", err.Error()) + } +} + +func (r *accountingResource) 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", "accounting id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *accountingResource) toModel(ctx context.Context, api accountingAPI, prior accountingModel, diags *diag.Diagnostics) accountingModel { + return accountingModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Action: types.StringValue(api.Action), + Section: optionalString(api.Section, prior.Section), Chain: optionalString(api.Chain, prior.Chain), + Source: optionalString(api.Source, prior.Source), Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), Mark: optionalString(api.Mark, prior.Mark), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_mangle.go b/internal/provider/resource_mangle.go new file mode 100644 index 0000000..fad7f4d --- /dev/null +++ b/internal/provider/resource_mangle.go @@ -0,0 +1,211 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &mangleResource{} + _ resource.ResourceWithImportState = &mangleResource{} +) + +type mangleResource struct{ client *apiClient } + +type mangleModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + Chain types.String `tfsdk:"chain"` + MarkValue types.String `tfsdk:"mark_value"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + User types.String `tfsdk:"user"` + Mark types.String `tfsdk:"mark"` + Length types.String `tfsdk:"length"` + TOS types.String `tfsdk:"tos"` + Helper types.String `tfsdk:"helper"` + Probability types.Float64 `tfsdk:"probability"` + Comment types.String `tfsdk:"comment"` +} + +type mangleAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + Chain string `json:"chain,omitempty"` + MarkValue string `json:"mark_value,omitempty"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + User string `json:"user,omitempty"` + Mark string `json:"mark,omitempty"` + Length string `json:"length,omitempty"` + TOS string `json:"tos,omitempty"` + Helper string `json:"helper,omitempty"` + Probability *float64 `json:"probability,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewMangleResource() resource.Resource { return &mangleResource{} } + +func (r *mangleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_mangle" +} + +func (r *mangleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A packet-mangling rule (marking, TOS, etc.) on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Required: true}, + "chain": schema.StringAttribute{Optional: true}, + "mark_value": schema.StringAttribute{Optional: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "user": schema.StringAttribute{Optional: true}, + "mark": schema.StringAttribute{Optional: true}, + "length": schema.StringAttribute{Optional: true}, + "tos": schema.StringAttribute{Optional: true}, + "helper": schema.StringAttribute{Optional: true}, + "probability": schema.Float64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *mangleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *mangleResource) body(ctx context.Context, plan mangleModel, diags *diag.Diagnostics) mangleAPI { + b := mangleAPI{ + Device: plan.Device.ValueString(), Action: plan.Action.ValueString(), Chain: plan.Chain.ValueString(), + MarkValue: plan.MarkValue.ValueString(), Source: plan.Source.ValueString(), Dest: plan.Dest.ValueString(), + Proto: plan.Proto.ValueString(), DPort: listToStrings(ctx, plan.DPort, diags), SPort: listToStrings(ctx, plan.SPort, diags), + User: plan.User.ValueString(), Mark: plan.Mark.ValueString(), Length: plan.Length.ValueString(), + TOS: plan.TOS.ValueString(), Helper: plan.Helper.ValueString(), Comment: plan.Comment.ValueString(), + } + if !plan.Probability.IsNull() && !plan.Probability.IsUnknown() { + p := plan.Probability.ValueFloat64() + b.Probability = &p + } + return b +} + +func (r *mangleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan mangleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out mangleAPI + if err := r.client.post(ctx, "/api/v1/mangle", body, &out); err != nil { + resp.Diagnostics.AddError("create mangle failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *mangleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state mangleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out mangleAPI + if err := r.client.post(ctx, "/api/v1/mangle", body, &out); err != nil { + resp.Diagnostics.AddError("recreate mangle failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/mangle/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old mangle failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *mangleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state mangleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out mangleAPI + if err := r.client.get(ctx, "/api/v1/mangle/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read mangle failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *mangleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state mangleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/mangle/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete mangle failed", err.Error()) + } +} + +func (r *mangleResource) 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", "mangle id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *mangleResource) toModel(ctx context.Context, api mangleAPI, prior mangleModel, diags *diag.Diagnostics) mangleModel { + m := mangleModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Action: types.StringValue(api.Action), + Chain: optionalString(api.Chain, prior.Chain), MarkValue: optionalString(api.MarkValue, prior.MarkValue), + Source: optionalString(api.Source, prior.Source), Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), User: optionalString(api.User, prior.User), + Mark: optionalString(api.Mark, prior.Mark), Length: optionalString(api.Length, prior.Length), + TOS: optionalString(api.TOS, prior.TOS), Helper: optionalString(api.Helper, prior.Helper), + Comment: optionalString(api.Comment, prior.Comment), + } + if api.Probability != nil { + m.Probability = types.Float64Value(*api.Probability) + } else { + m.Probability = prior.Probability + } + return m +} diff --git a/internal/provider/resource_tc_class.go b/internal/provider/resource_tc_class.go new file mode 100644 index 0000000..d82e619 --- /dev/null +++ b/internal/provider/resource_tc_class.go @@ -0,0 +1,158 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tcClassResource{} + _ resource.ResourceWithImportState = &tcClassResource{} +) + +type tcClassResource struct{ client *apiClient } + +type tcClassModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Interface types.String `tfsdk:"interface"` + Mark types.Int64 `tfsdk:"mark"` + Rate types.String `tfsdk:"rate"` + Ceil types.String `tfsdk:"ceil"` + Priority types.Int64 `tfsdk:"priority"` + Comment types.String `tfsdk:"comment"` +} + +type tcClassAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Interface string `json:"interface"` + Mark int `json:"mark,omitempty"` + Rate string `json:"rate,omitempty"` + Ceil string `json:"ceil,omitempty"` + Priority int `json:"priority,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTCClassResource() resource.Resource { return &tcClassResource{} } + +func (r *tcClassResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tc_class" +} + +func (r *tcClassResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A traffic-shaping class on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "interface": schema.StringAttribute{Description: "iface:class or iface:parent:class.", Required: true}, + "mark": schema.Int64Attribute{Optional: true}, + "rate": schema.StringAttribute{Optional: true}, + "ceil": schema.StringAttribute{Optional: true}, + "priority": schema.Int64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tcClassResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tcClassResource) body(plan tcClassModel) tcClassAPI { + return tcClassAPI{ + Device: plan.Device.ValueString(), Interface: plan.Interface.ValueString(), Mark: int(plan.Mark.ValueInt64()), + Rate: plan.Rate.ValueString(), Ceil: plan.Ceil.ValueString(), Priority: int(plan.Priority.ValueInt64()), + Comment: plan.Comment.ValueString(), + } +} + +func (r *tcClassResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tcClassModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out tcClassAPI + if err := r.client.post(ctx, "/api/v1/tc-classes", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create tc_class failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *tcClassResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tcClassModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcClassAPI + if err := r.client.post(ctx, "/api/v1/tc-classes", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate tc_class failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tc-classes/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tc_class failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *tcClassResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tcClassModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcClassAPI + if err := r.client.get(ctx, "/api/v1/tc-classes/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tc_class failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *tcClassResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tcClassModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tc-classes/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tc_class failed", err.Error()) + } +} + +func (r *tcClassResource) 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", "tc_class id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tcClassResource) toModel(api tcClassAPI, prior tcClassModel) tcClassModel { + return tcClassModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Interface: types.StringValue(api.Interface), + Mark: optionalInt64(api.Mark, prior.Mark), Rate: optionalString(api.Rate, prior.Rate), + Ceil: optionalString(api.Ceil, prior.Ceil), Priority: optionalInt64(api.Priority, prior.Priority), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_tc_device.go b/internal/provider/resource_tc_device.go new file mode 100644 index 0000000..ea1905c --- /dev/null +++ b/internal/provider/resource_tc_device.go @@ -0,0 +1,151 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tcDeviceResource{} + _ resource.ResourceWithImportState = &tcDeviceResource{} +) + +type tcDeviceResource struct{ client *apiClient } + +type tcDeviceModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Interface types.String `tfsdk:"interface"` + InBandwidth types.String `tfsdk:"in_bandwidth"` + OutBandwidth types.String `tfsdk:"out_bandwidth"` + Comment types.String `tfsdk:"comment"` +} + +type tcDeviceAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Interface string `json:"interface"` + InBandwidth string `json:"in_bandwidth,omitempty"` + OutBandwidth string `json:"out_bandwidth,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTCDeviceResource() resource.Resource { return &tcDeviceResource{} } + +func (r *tcDeviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tc_device" +} + +func (r *tcDeviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A traffic-shaping device (root qdisc) on a device's interface.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "interface": schema.StringAttribute{Required: true}, + "in_bandwidth": schema.StringAttribute{Optional: true}, + "out_bandwidth": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tcDeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tcDeviceResource) body(plan tcDeviceModel) tcDeviceAPI { + return tcDeviceAPI{ + Device: plan.Device.ValueString(), Interface: plan.Interface.ValueString(), + InBandwidth: plan.InBandwidth.ValueString(), OutBandwidth: plan.OutBandwidth.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *tcDeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tcDeviceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out tcDeviceAPI + if err := r.client.post(ctx, "/api/v1/tc-devices", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create tc_device failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *tcDeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tcDeviceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcDeviceAPI + if err := r.client.post(ctx, "/api/v1/tc-devices", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate tc_device failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tc-devices/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tc_device failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *tcDeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tcDeviceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcDeviceAPI + if err := r.client.get(ctx, "/api/v1/tc-devices/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tc_device failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *tcDeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tcDeviceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tc-devices/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tc_device failed", err.Error()) + } +} + +func (r *tcDeviceResource) 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", "tc_device id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tcDeviceResource) toModel(api tcDeviceAPI, prior tcDeviceModel) tcDeviceModel { + return tcDeviceModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Interface: types.StringValue(api.Interface), + InBandwidth: optionalString(api.InBandwidth, prior.InBandwidth), OutBandwidth: optionalString(api.OutBandwidth, prior.OutBandwidth), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_tc_filter.go b/internal/provider/resource_tc_filter.go new file mode 100644 index 0000000..c0033c6 --- /dev/null +++ b/internal/provider/resource_tc_filter.go @@ -0,0 +1,183 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tcFilterResource{} + _ resource.ResourceWithImportState = &tcFilterResource{} +) + +type tcFilterResource struct{ client *apiClient } + +type tcFilterModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Class types.String `tfsdk:"class"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + TOS types.String `tfsdk:"tos"` + Length types.Int64 `tfsdk:"length"` + Priority types.Int64 `tfsdk:"priority"` + Comment types.String `tfsdk:"comment"` +} + +type tcFilterAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Class string `json:"class"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + TOS string `json:"tos,omitempty"` + Length int `json:"length,omitempty"` + Priority int `json:"priority,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTCFilterResource() resource.Resource { return &tcFilterResource{} } + +func (r *tcFilterResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tc_filter" +} + +func (r *tcFilterResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A traffic-classification filter (packet → class) on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "class": schema.StringAttribute{Description: "interface:class target.", Required: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "tos": schema.StringAttribute{Optional: true}, + "length": schema.Int64Attribute{Optional: true}, + "priority": schema.Int64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tcFilterResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tcFilterResource) body(ctx context.Context, plan tcFilterModel, diags *diag.Diagnostics) tcFilterAPI { + return tcFilterAPI{ + Device: plan.Device.ValueString(), Class: plan.Class.ValueString(), Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), Proto: plan.Proto.ValueString(), + DPort: listToStrings(ctx, plan.DPort, diags), SPort: listToStrings(ctx, plan.SPort, diags), + TOS: plan.TOS.ValueString(), Length: int(plan.Length.ValueInt64()), Priority: int(plan.Priority.ValueInt64()), + Comment: plan.Comment.ValueString(), + } +} + +func (r *tcFilterResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tcFilterModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tcFilterAPI + if err := r.client.post(ctx, "/api/v1/tc-filters", body, &out); err != nil { + resp.Diagnostics.AddError("create tc_filter failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tcFilterResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tcFilterModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tcFilterAPI + if err := r.client.post(ctx, "/api/v1/tc-filters", body, &out); err != nil { + resp.Diagnostics.AddError("recreate tc_filter failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tc-filters/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tc_filter failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tcFilterResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tcFilterModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcFilterAPI + if err := r.client.get(ctx, "/api/v1/tc-filters/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tc_filter failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *tcFilterResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tcFilterModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tc-filters/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tc_filter failed", err.Error()) + } +} + +func (r *tcFilterResource) 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", "tc_filter id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tcFilterResource) toModel(ctx context.Context, api tcFilterAPI, prior tcFilterModel, diags *diag.Diagnostics) tcFilterModel { + return tcFilterModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Class: types.StringValue(api.Class), + Source: optionalString(api.Source, prior.Source), Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), TOS: optionalString(api.TOS, prior.TOS), + Length: optionalInt64(api.Length, prior.Length), Priority: optionalInt64(api.Priority, prior.Priority), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_tc_interface.go b/internal/provider/resource_tc_interface.go new file mode 100644 index 0000000..1cae54c --- /dev/null +++ b/internal/provider/resource_tc_interface.go @@ -0,0 +1,153 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tcInterfaceResource{} + _ resource.ResourceWithImportState = &tcInterfaceResource{} +) + +type tcInterfaceResource struct{ client *apiClient } + +type tcInterfaceModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Interface types.String `tfsdk:"interface"` + Type types.String `tfsdk:"type"` + InBandwidth types.String `tfsdk:"in_bandwidth"` + OutBandwidth types.String `tfsdk:"out_bandwidth"` + Comment types.String `tfsdk:"comment"` +} + +type tcInterfaceAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Interface string `json:"interface"` + Type string `json:"type,omitempty"` + InBandwidth string `json:"in_bandwidth,omitempty"` + OutBandwidth string `json:"out_bandwidth,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTCInterfaceResource() resource.Resource { return &tcInterfaceResource{} } + +func (r *tcInterfaceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tc_interface" +} + +func (r *tcInterfaceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Simple per-interface traffic shaping on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "interface": schema.StringAttribute{Required: true}, + "type": schema.StringAttribute{Description: "external or internal.", Optional: true}, + "in_bandwidth": schema.StringAttribute{Optional: true}, + "out_bandwidth": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tcInterfaceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tcInterfaceResource) body(plan tcInterfaceModel) tcInterfaceAPI { + return tcInterfaceAPI{ + Device: plan.Device.ValueString(), Interface: plan.Interface.ValueString(), Type: plan.Type.ValueString(), + InBandwidth: plan.InBandwidth.ValueString(), OutBandwidth: plan.OutBandwidth.ValueString(), Comment: plan.Comment.ValueString(), + } +} + +func (r *tcInterfaceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tcInterfaceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out tcInterfaceAPI + if err := r.client.post(ctx, "/api/v1/tc-interfaces", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create tc_interface failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *tcInterfaceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tcInterfaceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcInterfaceAPI + if err := r.client.post(ctx, "/api/v1/tc-interfaces", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate tc_interface failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tc-interfaces/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tc_interface failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *tcInterfaceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tcInterfaceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcInterfaceAPI + if err := r.client.get(ctx, "/api/v1/tc-interfaces/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tc_interface failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *tcInterfaceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tcInterfaceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tc-interfaces/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tc_interface failed", err.Error()) + } +} + +func (r *tcInterfaceResource) 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", "tc_interface id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tcInterfaceResource) toModel(api tcInterfaceAPI, prior tcInterfaceModel) tcInterfaceModel { + return tcInterfaceModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Interface: types.StringValue(api.Interface), + Type: optionalString(api.Type, prior.Type), InBandwidth: optionalString(api.InBandwidth, prior.InBandwidth), + OutBandwidth: optionalString(api.OutBandwidth, prior.OutBandwidth), Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_tc_priority.go b/internal/provider/resource_tc_priority.go new file mode 100644 index 0000000..0b0d0f7 --- /dev/null +++ b/internal/provider/resource_tc_priority.go @@ -0,0 +1,175 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tcPriorityResource{} + _ resource.ResourceWithImportState = &tcPriorityResource{} +) + +type tcPriorityResource struct{ client *apiClient } + +type tcPriorityModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Band types.Int64 `tfsdk:"band"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + Address types.String `tfsdk:"address"` + Interface types.String `tfsdk:"interface"` + Helper types.String `tfsdk:"helper"` + Comment types.String `tfsdk:"comment"` +} + +type tcPriorityAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Band int `json:"band"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Address string `json:"address,omitempty"` + Interface string `json:"interface,omitempty"` + Helper string `json:"helper,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTCPriorityResource() resource.Resource { return &tcPriorityResource{} } + +func (r *tcPriorityResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tc_priority" +} + +func (r *tcPriorityResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A simple priority-band classification on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "band": schema.Int64Attribute{Description: "Priority band (1, 2, or 3).", Required: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "address": schema.StringAttribute{Optional: true}, + "interface": schema.StringAttribute{Optional: true}, + "helper": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tcPriorityResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tcPriorityResource) body(ctx context.Context, plan tcPriorityModel, diags *diag.Diagnostics) tcPriorityAPI { + return tcPriorityAPI{ + Device: plan.Device.ValueString(), Band: int(plan.Band.ValueInt64()), Proto: plan.Proto.ValueString(), + DPort: listToStrings(ctx, plan.DPort, diags), SPort: listToStrings(ctx, plan.SPort, diags), + Address: plan.Address.ValueString(), Interface: plan.Interface.ValueString(), + Helper: plan.Helper.ValueString(), Comment: plan.Comment.ValueString(), + } +} + +func (r *tcPriorityResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tcPriorityModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tcPriorityAPI + if err := r.client.post(ctx, "/api/v1/tc-priorities", body, &out); err != nil { + resp.Diagnostics.AddError("create tc_priority failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tcPriorityResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tcPriorityModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tcPriorityAPI + if err := r.client.post(ctx, "/api/v1/tc-priorities", body, &out); err != nil { + resp.Diagnostics.AddError("recreate tc_priority failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tc-priorities/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tc_priority failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tcPriorityResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tcPriorityModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tcPriorityAPI + if err := r.client.get(ctx, "/api/v1/tc-priorities/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tc_priority failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *tcPriorityResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tcPriorityModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tc-priorities/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tc_priority failed", err.Error()) + } +} + +func (r *tcPriorityResource) 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", "tc_priority id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tcPriorityResource) toModel(ctx context.Context, api tcPriorityAPI, prior tcPriorityModel, diags *diag.Diagnostics) tcPriorityModel { + return tcPriorityModel{ + ID: types.Int64Value(api.ID), Device: types.StringValue(api.Device), Band: types.Int64Value(int64(api.Band)), + Proto: optionalString(api.Proto, prior.Proto), DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), Address: optionalString(api.Address, prior.Address), + Interface: optionalString(api.Interface, prior.Interface), Helper: optionalString(api.Helper, prior.Helper), + Comment: optionalString(api.Comment, prior.Comment), + } +} -- 2.47.3