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:
@@ -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 ¬FoundError{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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user