Scaffold terraform-provider-tomswallapi

Terraform provider (plugin-framework) for the tomswall fleet control plane.
Resources: zone, address_group (static/dns/asn, with computed resolved
prefixes), portgroup, fabric, device, binding (device:zone), and rule. Each
resource does full CRUD against the tomswallapi HTTP API with bearer-token auth
and ImportState support; rules recreate on update since the rules API is
create/delete only. Includes Makefile with make patch|minor|major release tags,
Woodpecker pre-commit/build/test/release pipelines (release publishes to the
artifactapi terraform registry), README, and a worked example.
This commit is contained in:
benvin
2026-07-19 22:26:07 +10:00
commit 3913b3920d
22 changed files with 1910 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
type apiClient struct {
baseURL string
token string
httpClient *http.Client
}
func newAPIClient(baseURL, token string) *apiClient {
return &apiClient{baseURL: baseURL, token: token, httpClient: &http.Client{}}
}
func (c *apiClient) get(ctx context.Context, path string, out any) error {
return c.do(ctx, http.MethodGet, path, nil, out)
}
func (c *apiClient) put(ctx context.Context, path string, body, out any) error {
return c.do(ctx, http.MethodPut, path, body, out)
}
func (c *apiClient) post(ctx context.Context, path string, body, out any) error {
return c.do(ctx, http.MethodPost, path, body, out)
}
func (c *apiClient) del(ctx context.Context, path string) error {
return c.do(ctx, http.MethodDelete, path, nil, nil)
}
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return &notFoundError{path: path}
}
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api error %d: %s", resp.StatusCode, string(b))
}
if out != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
func pathEscape(s string) string { return url.PathEscape(s) }
type notFoundError struct{ path string }
func (e *notFoundError) Error() string { return fmt.Sprintf("not found: %s", e.path) }
func isNotFound(err error) bool {
_, ok := err.(*notFoundError)
return ok
}
+66
View File
@@ -0,0 +1,66 @@
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// configureClient extracts the shared *apiClient from provider data, used by
// every resource's Configure.
func configureClient(req resource.ConfigureRequest, resp *resource.ConfigureResponse) *apiClient {
if req.ProviderData == nil {
return nil
}
client, ok := req.ProviderData.(*apiClient)
if !ok {
resp.Diagnostics.AddError("unexpected provider data type", fmt.Sprintf("got %T", req.ProviderData))
return nil
}
return client
}
// listToStrings converts a Terraform list into a []string. A null/unknown list
// yields nil.
func listToStrings(ctx context.Context, l types.List, diags *diag.Diagnostics) []string {
if l.IsNull() || l.IsUnknown() {
return nil
}
var out []string
diags.Append(l.ElementsAs(ctx, &out, false)...)
return out
}
// stringsToList converts a []string into a Terraform list of strings, rendering
// nil as an empty list so state stays consistent.
func stringsToList(ctx context.Context, s []string, diags *diag.Diagnostics) types.List {
if s == nil {
s = []string{}
}
l, d := types.ListValueFrom(ctx, types.StringType, s)
diags.Append(d...)
return l
}
// 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() {
return nil
}
out := map[string]string{}
diags.Append(m.ElementsAs(ctx, &out, false)...)
return out
}
// stringsToMap converts a map[string]string into a Terraform map.
func stringsToMap(ctx context.Context, m map[string]string, diags *diag.Diagnostics) types.Map {
if m == nil {
m = map[string]string{}
}
v, d := types.MapValueFrom(ctx, types.StringType, m)
diags.Append(d...)
return v
}
+83
View File
@@ -0,0 +1,83 @@
package provider
import (
"context"
"os"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ provider.Provider = &tomswallProvider{}
type tomswallProvider struct {
version string
}
type tomswallProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
Token types.String `tfsdk:"token"`
}
// New returns the provider constructor.
func New(version string) func() provider.Provider {
return func() provider.Provider { return &tomswallProvider{version: version} }
}
func (p *tomswallProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "tomswallapi"
resp.Version = p.version
}
func (p *tomswallProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manage the tomswall fleet control plane: fleet-global zones, address groups, portgroups, rules, policies, and fabrics, plus per-device zone->interface bindings.",
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
Description: "The tomswallapi base URL (e.g. https://tomswallapi.k8s.syd1.au.unkin.net).",
Required: true,
},
"token": schema.StringAttribute{
Description: "Write token (bearer) for mutating operations. Defaults to the TOMSWALLAPI_WRITE_TOKEN environment variable.",
Optional: true,
Sensitive: true,
},
},
}
}
func (p *tomswallProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config tomswallProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
token := config.Token.ValueString()
if token == "" {
token = os.Getenv("TOMSWALLAPI_WRITE_TOKEN")
}
client := newAPIClient(config.Endpoint.ValueString(), token)
resp.DataSourceData = client
resp.ResourceData = client
}
func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewZoneResource,
NewFabricResource,
NewPortGroupResource,
NewAddressGroupResource,
NewDeviceResource,
NewBindingResource,
NewRuleResource,
}
}
func (p *tomswallProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &addressGroupResource{}
_ resource.ResourceWithImportState = &addressGroupResource{}
)
type addressGroupResource struct{ client *apiClient }
type addressGroupModel struct {
Name types.String `tfsdk:"name"`
Type types.String `tfsdk:"type"`
Members types.List `tfsdk:"members"`
Refresh types.String `tfsdk:"refresh"`
Description types.String `tfsdk:"description"`
Resolved types.List `tfsdk:"resolved"`
ResolvedAt types.String `tfsdk:"resolved_at"`
}
type addressGroupAPI struct {
Name string `json:"name"`
Type string `json:"type"`
Members []string `json:"members"`
Refresh string `json:"refresh,omitempty"`
Description string `json:"description,omitempty"`
Resolved []string `json:"resolved,omitempty"`
ResolvedAt string `json:"resolved_at,omitempty"`
}
func NewAddressGroupResource() resource.Resource { return &addressGroupResource{} }
func (r *addressGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_address_group"
}
func (r *addressGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "An address group, materialized as an nftables named set. Population source is static (CIDRs), dns (FQDNs resolved on-device), or asn (ASNs expanded centrally).",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Group name (globally unique). ASN groups conventionally use an asn_ prefix.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"type": schema.StringAttribute{
Description: "Population source: static, dns, or asn.",
Required: true,
},
"members": schema.ListAttribute{
Description: "For static: CIDRs/IPs. For dns: FQDNs. For asn: ASN numbers.",
Required: true,
ElementType: types.StringType,
},
"refresh": schema.StringAttribute{
Description: "asn: prefix cache TTL (e.g. 24h). dns: honor_ttl.",
Optional: true,
},
"description": schema.StringAttribute{
Optional: true,
},
"resolved": schema.ListAttribute{
Description: "Server-managed: concrete prefixes the ASN expander last produced.",
Computed: true,
ElementType: types.StringType,
},
"resolved_at": schema.StringAttribute{
Description: "Server-managed: timestamp of the last ASN expansion.",
Computed: true,
},
},
}
}
func (r *addressGroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *addressGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan addressGroupModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *addressGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan addressGroupModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *addressGroupResource) upsert(ctx context.Context, plan addressGroupModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := addressGroupAPI{
Name: plan.Name.ValueString(),
Type: plan.Type.ValueString(),
Members: listToStrings(ctx, plan.Members, diags),
Refresh: plan.Refresh.ValueString(),
Description: plan.Description.ValueString(),
}
if diags.HasError() {
return
}
var out addressGroupAPI
if err := r.client.put(ctx, "/api/v1/address-groups/"+pathEscape(body.Name), body, &out); err != nil {
diags.AddError("upsert address_group failed", err.Error())
return
}
diags.Append(state.Set(ctx, r.toModel(ctx, out, plan, diags))...)
}
func (r *addressGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state addressGroupModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out addressGroupAPI
if err := r.client.get(ctx, "/api/v1/address-groups/"+pathEscape(state.Name.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read address_group failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...)
}
func (r *addressGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state addressGroupModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/address-groups/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete address_group failed", err.Error())
}
}
func (r *addressGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
// toModel maps the API response back to state. Optional string fields fall back
// to the prior plan/state value so that omitempty responses don't churn state.
func (r *addressGroupResource) toModel(ctx context.Context, api addressGroupAPI, prior addressGroupModel, diags *diag.Diagnostics) addressGroupModel {
m := addressGroupModel{
Name: types.StringValue(api.Name),
Type: types.StringValue(api.Type),
Members: stringsToList(ctx, api.Members, diags),
Refresh: optionalString(api.Refresh, prior.Refresh),
Description: optionalString(api.Description, prior.Description),
Resolved: stringsToList(ctx, api.Resolved, diags),
ResolvedAt: types.StringValue(api.ResolvedAt),
}
return m
}
// optionalString returns a value for an optional attribute: the API value when
// present, else the prior planned value (preserving null when both are empty).
func optionalString(apiVal string, prior types.String) types.String {
if apiVal != "" {
return types.StringValue(apiVal)
}
return prior
}
+155
View File
@@ -0,0 +1,155 @@
package provider
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &bindingResource{}
_ resource.ResourceWithImportState = &bindingResource{}
)
type bindingResource struct{ client *apiClient }
type bindingModel struct {
Device types.String `tfsdk:"device"`
Zone types.String `tfsdk:"zone"`
Interfaces types.List `tfsdk:"interfaces"`
}
type bindingAPI struct {
Device string `json:"device"`
Zone string `json:"zone"`
Interfaces []string `json:"interfaces"`
}
func NewBindingResource() resource.Resource { return &bindingResource{} }
func (r *bindingResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_binding"
}
func (r *bindingResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Binds a global zone to a device's local interface(s) — the only legitimately host-specific object in the model.",
Attributes: map[string]schema.Attribute{
"device": schema.StringAttribute{
Description: "Device name.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"zone": schema.StringAttribute{
Description: "Global zone name.",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"interfaces": schema.ListAttribute{
Description: "Local interface names this zone attaches to on the device, e.g. [\"eth1\"].",
Required: true,
ElementType: types.StringType,
},
},
}
}
func (r *bindingResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *bindingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan bindingModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *bindingResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan bindingModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *bindingResource) bindingPath(device, zone string) string {
return "/api/v1/devices/" + pathEscape(device) + "/bindings/" + pathEscape(zone)
}
func (r *bindingResource) upsert(ctx context.Context, plan bindingModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := bindingAPI{
Device: plan.Device.ValueString(),
Zone: plan.Zone.ValueString(),
Interfaces: listToStrings(ctx, plan.Interfaces, diags),
}
if diags.HasError() {
return
}
var out bindingAPI
if err := r.client.put(ctx, r.bindingPath(body.Device, body.Zone), body, &out); err != nil {
diags.AddError("upsert binding failed", err.Error())
return
}
diags.Append(state.Set(ctx, r.toModel(ctx, out, diags))...)
}
func (r *bindingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state bindingModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out bindingAPI
if err := r.client.get(ctx, r.bindingPath(state.Device.ValueString(), state.Zone.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read binding failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, &resp.Diagnostics))...)
}
func (r *bindingResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state bindingModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, r.bindingPath(state.Device.ValueString(), state.Zone.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete binding failed", err.Error())
}
}
// ImportState accepts "device:zone".
func (r *bindingResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
device, zone, ok := strings.Cut(req.ID, ":")
if !ok || device == "" || zone == "" {
resp.Diagnostics.AddError("invalid import ID", fmt.Sprintf("expected device:zone, got %q", req.ID))
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("device"), device)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("zone"), zone)...)
}
func (r *bindingResource) toModel(ctx context.Context, api bindingAPI, diags *diag.Diagnostics) bindingModel {
return bindingModel{
Device: types.StringValue(api.Device),
Zone: types.StringValue(api.Zone),
Interfaces: stringsToList(ctx, api.Interfaces, diags),
}
}
+168
View File
@@ -0,0 +1,168 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &deviceResource{}
_ resource.ResourceWithImportState = &deviceResource{}
)
type deviceResource struct{ client *apiClient }
type deviceModel struct {
Name types.String `tfsdk:"name"`
Class types.String `tfsdk:"class"`
Fabric types.String `tfsdk:"fabric"`
Resolver types.List `tfsdk:"resolver"`
Settings types.Map `tfsdk:"settings"`
}
type deviceAPI struct {
Name string `json:"name"`
Class string `json:"class"`
Fabric string `json:"fabric,omitempty"`
Resolver []string `json:"resolver,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
}
func NewDeviceResource() resource.Resource { return &deviceResource{} }
func (r *deviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_device"
}
func (r *deviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A fleet member. Its class (router/firewall) and fabric determine how it enforces intents; bindings map global zones to its local interfaces.",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Device name (globally unique).",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"class": schema.StringAttribute{
Description: "Device class: router or firewall.",
Required: true,
},
"fabric": schema.StringAttribute{
Description: "Routing domain (fabric) this device belongs to.",
Optional: true,
},
"resolver": schema.ListAttribute{
Description: "Per-device DNS resolver override for dns address groups. Falls back to the fleet default, then system resolvers.",
Optional: true,
ElementType: types.StringType,
},
"settings": schema.MapAttribute{
Description: "Per-device settings overrides (e.g. table_name, log_level, address_family).",
Optional: true,
ElementType: types.StringType,
},
},
}
}
func (r *deviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *deviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan deviceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *deviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan deviceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *deviceResource) upsert(ctx context.Context, plan deviceModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := deviceAPI{
Name: plan.Name.ValueString(),
Class: plan.Class.ValueString(),
Fabric: plan.Fabric.ValueString(),
Resolver: listToStrings(ctx, plan.Resolver, diags),
Settings: mapToStrings(ctx, plan.Settings, diags),
}
if diags.HasError() {
return
}
var out deviceAPI
if err := r.client.put(ctx, "/api/v1/devices/"+pathEscape(body.Name), body, &out); err != nil {
diags.AddError("upsert device failed", err.Error())
return
}
diags.Append(state.Set(ctx, r.toModel(ctx, out, plan, diags))...)
}
func (r *deviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state deviceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out deviceAPI
if err := r.client.get(ctx, "/api/v1/devices/"+pathEscape(state.Name.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read device failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...)
}
func (r *deviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state deviceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/devices/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete device failed", err.Error())
}
}
func (r *deviceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
func (r *deviceResource) toModel(ctx context.Context, api deviceAPI, prior deviceModel, diags *diag.Diagnostics) deviceModel {
m := deviceModel{
Name: types.StringValue(api.Name),
Class: types.StringValue(api.Class),
Fabric: optionalString(api.Fabric, prior.Fabric),
}
// Preserve null for optional collections the user left unset.
if prior.Resolver.IsNull() && len(api.Resolver) == 0 {
m.Resolver = types.ListNull(types.StringType)
} else {
m.Resolver = stringsToList(ctx, api.Resolver, diags)
}
if prior.Settings.IsNull() && len(api.Settings) == 0 {
m.Settings = types.MapNull(types.StringType)
} else {
m.Settings = stringsToMap(ctx, api.Settings, diags)
}
return m
}
+142
View File
@@ -0,0 +1,142 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &fabricResource{}
_ resource.ResourceWithImportState = &fabricResource{}
)
type fabricResource struct{ client *apiClient }
type fabricModel struct {
Name types.String `tfsdk:"name"`
EnforceOnRouters types.Bool `tfsdk:"enforce_on_routers"`
Description types.String `tfsdk:"description"`
}
type fabricAPI struct {
Name string `json:"name"`
EnforceOnRouters bool `json:"enforce_on_routers"`
Description string `json:"description,omitempty"`
}
func NewFabricResource() resource.Resource { return &fabricResource{} }
func (r *fabricResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_fabric"
}
func (r *fabricResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A routing domain. enforce_on_routers toggles defense-in-depth (every router carries the intent) versus transparent transit (only boundary firewalls enforce).",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Fabric name (globally unique).",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"enforce_on_routers": schema.BoolAttribute{
Description: "When true, routers in this fabric enforce intents too (defense-in-depth).",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"description": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString(""),
},
},
}
}
func (r *fabricResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *fabricResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan fabricModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *fabricResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan fabricModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *fabricResource) upsert(ctx context.Context, plan fabricModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := fabricAPI{
Name: plan.Name.ValueString(),
EnforceOnRouters: plan.EnforceOnRouters.ValueBool(),
Description: plan.Description.ValueString(),
}
var out fabricAPI
if err := r.client.put(ctx, "/api/v1/fabrics/"+pathEscape(body.Name), body, &out); err != nil {
diags.AddError("upsert fabric failed", err.Error())
return
}
diags.Append(state.Set(ctx, fabricToModel(out))...)
}
func (r *fabricResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state fabricModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out fabricAPI
if err := r.client.get(ctx, "/api/v1/fabrics/"+pathEscape(state.Name.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read fabric failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, fabricToModel(out))...)
}
func (r *fabricResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state fabricModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/fabrics/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete fabric failed", err.Error())
}
}
func (r *fabricResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
func fabricToModel(api fabricAPI) fabricModel {
return fabricModel{
Name: types.StringValue(api.Name),
EnforceOnRouters: types.BoolValue(api.EnforceOnRouters),
Description: types.StringValue(api.Description),
}
}
+141
View File
@@ -0,0 +1,141 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &portGroupResource{}
_ resource.ResourceWithImportState = &portGroupResource{}
)
type portGroupResource struct{ client *apiClient }
type portGroupModel struct {
Name types.String `tfsdk:"name"`
Proto types.String `tfsdk:"proto"`
Ports types.List `tfsdk:"ports"`
}
type portGroupAPI struct {
Name string `json:"name"`
Proto string `json:"proto"`
Ports []string `json:"ports"`
}
func NewPortGroupResource() resource.Resource { return &portGroupResource{} }
func (r *portGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_portgroup"
}
func (r *portGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A reusable proto+ports combination referenced by rules.",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Portgroup name (globally unique).",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"proto": schema.StringAttribute{
Description: "Protocol, e.g. tcp or udp.",
Required: true,
},
"ports": schema.ListAttribute{
Description: "Ports or port ranges, e.g. [\"80\", \"443\", \"1024-65535\"].",
Required: true,
ElementType: types.StringType,
},
},
}
}
func (r *portGroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *portGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan portGroupModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *portGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan portGroupModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *portGroupResource) upsert(ctx context.Context, plan portGroupModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := portGroupAPI{
Name: plan.Name.ValueString(),
Proto: plan.Proto.ValueString(),
Ports: listToStrings(ctx, plan.Ports, diags),
}
if diags.HasError() {
return
}
var out portGroupAPI
if err := r.client.put(ctx, "/api/v1/portgroups/"+pathEscape(body.Name), body, &out); err != nil {
diags.AddError("upsert portgroup failed", err.Error())
return
}
diags.Append(state.Set(ctx, r.toModel(ctx, out, diags))...)
}
func (r *portGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state portGroupModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out portGroupAPI
if err := r.client.get(ctx, "/api/v1/portgroups/"+pathEscape(state.Name.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read portgroup failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, &resp.Diagnostics))...)
}
func (r *portGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state portGroupModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/portgroups/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete portgroup failed", err.Error())
}
}
func (r *portGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
func (r *portGroupResource) toModel(ctx context.Context, api portGroupAPI, diags *diag.Diagnostics) portGroupModel {
return portGroupModel{
Name: types.StringValue(api.Name),
Proto: types.StringValue(api.Proto),
Ports: stringsToList(ctx, api.Ports, diags),
}
}
+224
View File
@@ -0,0 +1,224 @@
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 = &ruleResource{}
_ resource.ResourceWithImportState = &ruleResource{}
)
type ruleResource struct{ client *apiClient }
type ruleModel struct {
ID types.Int64 `tfsdk:"id"`
Priority types.Int64 `tfsdk:"priority"`
Action types.String `tfsdk:"action"`
Source types.List `tfsdk:"source"`
Dest types.List `tfsdk:"dest"`
Proto types.String `tfsdk:"proto"`
PortGroup types.String `tfsdk:"portgroup"`
Ports types.List `tfsdk:"ports"`
Log types.String `tfsdk:"log"`
Comment types.String `tfsdk:"comment"`
}
type ruleAPI struct {
ID int64 `json:"id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
Source []string `json:"source"`
Dest []string `json:"dest"`
Proto string `json:"proto,omitempty"`
PortGroup string `json:"portgroup,omitempty"`
Ports []string `json:"ports,omitempty"`
Log string `json:"log,omitempty"`
Comment string `json:"comment,omitempty"`
}
func NewRuleResource() resource.Resource { return &ruleResource{} }
func (r *ruleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_rule"
}
func (r *ruleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A fleet-global firewall intent. Source and dest are shorewall-style element lists (bare zone, or zone:+ipset / zone:&fqdn); a selector must always be paired with a zone.",
Attributes: map[string]schema.Attribute{
"id": schema.Int64Attribute{
Description: "Server-assigned rule id.",
Computed: true,
PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()},
},
"priority": schema.Int64Attribute{
Description: "Evaluation priority; lower is evaluated first.",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"action": schema.StringAttribute{
Description: "accept, drop, reject, etc.",
Required: true,
},
"source": schema.ListAttribute{
Description: "Source element list, e.g. [\"loc\", \"net:+asn_cloudflare\"].",
Required: true,
ElementType: types.StringType,
},
"dest": schema.ListAttribute{
Description: "Dest element list.",
Required: true,
ElementType: types.StringType,
},
"proto": schema.StringAttribute{
Description: "Protocol (used when portgroup is not set).",
Optional: true,
},
"portgroup": schema.StringAttribute{
Description: "Named portgroup supplying proto+ports.",
Optional: true,
},
"ports": schema.ListAttribute{
Description: "Explicit ports (used when portgroup is not set).",
Optional: true,
ElementType: types.StringType,
},
"log": schema.StringAttribute{Optional: true},
"comment": schema.StringAttribute{Optional: true},
},
}
}
func (r *ruleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *ruleResource) body(ctx context.Context, plan ruleModel, diags *diag.Diagnostics) ruleAPI {
return ruleAPI{
Priority: int(plan.Priority.ValueInt64()),
Action: plan.Action.ValueString(),
Source: listToStrings(ctx, plan.Source, diags),
Dest: listToStrings(ctx, plan.Dest, diags),
Proto: plan.Proto.ValueString(),
PortGroup: plan.PortGroup.ValueString(),
Ports: listToStrings(ctx, plan.Ports, diags),
Log: plan.Log.ValueString(),
Comment: plan.Comment.ValueString(),
}
}
func (r *ruleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan ruleModel
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 ruleAPI
if err := r.client.post(ctx, "/api/v1/rules", body, &out); err != nil {
resp.Diagnostics.AddError("create rule failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...)
}
// Update: the rules API is create/delete only (no in-place PUT), so replace by
// deleting the old id and creating the new definition, keeping it in one apply.
func (r *ruleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state ruleModel
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 ruleAPI
if err := r.client.post(ctx, "/api/v1/rules", body, &out); err != nil {
resp.Diagnostics.AddError("recreate rule failed", err.Error())
return
}
if id := state.ID.ValueInt64(); id != 0 {
if err := r.client.del(ctx, "/api/v1/rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete old rule failed", err.Error())
return
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...)
}
func (r *ruleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state ruleModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out ruleAPI
if err := r.client.get(ctx, "/api/v1/rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read rule failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...)
}
func (r *ruleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state ruleModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete rule failed", err.Error())
}
}
func (r *ruleResource) 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", "rule id must be an integer")
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...)
}
func (r *ruleResource) toModel(ctx context.Context, api ruleAPI, prior ruleModel, diags *diag.Diagnostics) ruleModel {
m := ruleModel{
ID: types.Int64Value(api.ID),
Priority: types.Int64Value(int64(api.Priority)),
Action: types.StringValue(api.Action),
Source: stringsToList(ctx, api.Source, diags),
Dest: stringsToList(ctx, api.Dest, diags),
Proto: optionalString(api.Proto, prior.Proto),
PortGroup: optionalString(api.PortGroup, prior.PortGroup),
Log: optionalString(api.Log, prior.Log),
Comment: optionalString(api.Comment, prior.Comment),
}
// ports is optional; preserve null when unset and the API returns none.
if prior.Ports.IsNull() && len(api.Ports) == 0 {
m.Ports = types.ListNull(types.StringType)
} else {
m.Ports = stringsToList(ctx, api.Ports, diags)
}
return m
}
+157
View File
@@ -0,0 +1,157 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &zoneResource{}
_ resource.ResourceWithImportState = &zoneResource{}
)
type zoneResource struct{ client *apiClient }
type zoneModel struct {
Name types.String `tfsdk:"name"`
Type types.String `tfsdk:"type"`
Subnets types.List `tfsdk:"subnets"`
Parent types.String `tfsdk:"parent"`
}
type zoneAPI struct {
Name string `json:"name"`
Type string `json:"type"`
Subnets []string `json:"subnets"`
Parent string `json:"parent,omitempty"`
}
func NewZoneResource() resource.Resource { return &zoneResource{} }
func (r *zoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_zone"
}
func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A fleet-global network segment (a named set of subnets). Zones are the topological anchors of the firewall model.",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Zone name (globally unique).",
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"type": schema.StringAttribute{
Description: "Zone type: ip, ip6, or firewall.",
Optional: true,
Computed: true,
Default: stringdefault.StaticString("ip"),
},
"subnets": schema.ListAttribute{
Description: "CIDRs belonging to this zone. May be empty for a no-subnet zone such as an internet-facing 'net'.",
Optional: true,
ElementType: types.StringType,
},
"parent": schema.StringAttribute{
Description: "Parent zone name for subzone nesting; the child's subnets must be within the parent's.",
Optional: true,
},
},
}
}
func (r *zoneResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
r.client = configureClient(req, resp)
}
func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan zoneModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan zoneModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
r.upsert(ctx, plan, &resp.Diagnostics, &resp.State)
}
func (r *zoneResource) upsert(ctx context.Context, plan zoneModel, diags *diag.Diagnostics, state *tfsdk.State) {
body := zoneAPI{
Name: plan.Name.ValueString(),
Type: plan.Type.ValueString(),
Subnets: listToStrings(ctx, plan.Subnets, diags),
Parent: plan.Parent.ValueString(),
}
if diags.HasError() {
return
}
var out zoneAPI
if err := r.client.put(ctx, "/api/v1/zones/"+pathEscape(body.Name), body, &out); err != nil {
diags.AddError("upsert zone failed", err.Error())
return
}
diags.Append(state.Set(ctx, r.toModel(ctx, out, diags))...)
}
func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state zoneModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var out zoneAPI
if err := r.client.get(ctx, "/api/v1/zones/"+pathEscape(state.Name.ValueString()), &out); err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("read zone failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, &resp.Diagnostics))...)
}
func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state zoneModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.del(ctx, "/api/v1/zones/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("delete zone failed", err.Error())
}
}
func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
func (r *zoneResource) toModel(ctx context.Context, api zoneAPI, diags *diag.Diagnostics) zoneModel {
m := zoneModel{
Name: types.StringValue(api.Name),
Type: types.StringValue(api.Type),
Subnets: stringsToList(ctx, api.Subnets, diags),
}
if api.Parent == "" {
m.Parent = types.StringNull()
} else {
m.Parent = types.StringValue(api.Parent)
}
return m
}