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), + } +}