From 4cdb96e189817024afcaff346e887038cf8e7d54 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 4 Feb 2026 15:07:09 -0600 Subject: [PATCH] feat: add site-less devices and SNMPv3 support - Make site_id optional, add organization_id field - Devices can be created without sites (directly in organization) - Add SNMPv3 authentication and privacy fields - Update documentation with new examples --- docs/index.md | 61 ++++++ docs/resources/device.md | 52 ++++- internal/provider/client.go | 12 +- internal/provider/device_resource.go | 276 +++++++++++++++++++++++++-- 4 files changed, 379 insertions(+), 22 deletions(-) diff --git a/docs/index.md b/docs/index.md index ef2d69d..ff3a0ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,8 @@ The provider requires an API token for authentication. Generate a token from the ## Example Usage +### Basic Usage with Site Hierarchy + ```terraform terraform { required_providers { @@ -40,6 +42,65 @@ resource "towerops_device" "router" { } ``` +### Site-less Device (Direct Organization Assignment) + +Devices can be created without a site, assigned directly to the organization: + +```terraform +resource "towerops_device" "cloud_router" { + name = "Cloud Router" + ip_address = "10.0.1.1" + # No site_id - device belongs directly to organization +} +``` + +### SNMPv3 Device Configuration + +```terraform +resource "towerops_device" "secure_switch" { + name = "Secure Switch" + ip_address = "192.168.1.10" + snmp_version = "3" + + # SNMPv3 Authentication and Privacy + snmpv3_security_level = "authPriv" + snmpv3_username = "snmpuser" + snmpv3_auth_protocol = "SHA-256" + snmpv3_auth_password = var.snmp_auth_password + snmpv3_priv_protocol = "AES" + snmpv3_priv_password = var.snmp_priv_password +} +``` + +### Complete Example with Multiple Configurations + +```terraform +# Traditional site-based device with SNMP v2c +resource "towerops_device" "legacy_router" { + site_id = towerops_site.example.id + name = "Legacy Router" + ip_address = "192.168.1.1" + snmp_version = "2c" +} + +# Organization-level device with SNMPv3 +resource "towerops_device" "modern_switch" { + name = "Modern Switch" + ip_address = "10.0.2.1" + snmp_version = "3" + + snmpv3_security_level = "authPriv" + snmpv3_username = "admin" + snmpv3_auth_protocol = "SHA-256" + snmpv3_auth_password = var.snmp_auth_pass + snmpv3_priv_protocol = "AES-256" + snmpv3_priv_password = var.snmp_priv_pass + + monitoring_enabled = true + snmp_enabled = true +} +``` + ## Schema ### Required diff --git a/docs/resources/device.md b/docs/resources/device.md index f872389..285a874 100644 --- a/docs/resources/device.md +++ b/docs/resources/device.md @@ -6,10 +6,12 @@ description: |- # towerops_device (Resource) -Manages a TowerOps device. Devices represent network equipment at a site. +Manages a TowerOps device. Devices represent network equipment that can be assigned to a site or directly to the organization. ## Example Usage +### Site-based Device with SNMP v2c + ```terraform resource "towerops_device" "router" { site_id = towerops_site.example.id @@ -23,11 +25,40 @@ resource "towerops_device" "router" { } ``` +### Organization-level Device (Site-less) + +Devices can be created without a site, assigned directly to the organization: + +```terraform +resource "towerops_device" "cloud_router" { + name = "Cloud Router" + ip_address = "10.0.1.1" + # No site_id - device belongs directly to organization +} +``` + +### Device with SNMPv3 + +```terraform +resource "towerops_device" "secure_switch" { + name = "Secure Switch" + ip_address = "192.168.1.10" + snmp_version = "3" + + # SNMPv3 security settings + snmpv3_security_level = "authPriv" + snmpv3_username = "snmpuser" + snmpv3_auth_protocol = "SHA-256" + snmpv3_auth_password = var.snmp_auth_password + snmpv3_priv_protocol = "AES" + snmpv3_priv_password = var.snmp_priv_password +} +``` + ### Minimal Configuration ```terraform resource "towerops_device" "switch" { - site_id = towerops_site.example.id name = "Access Switch" ip_address = "192.168.1.2" } @@ -37,18 +68,31 @@ resource "towerops_device" "switch" { ### Required -- `site_id` (String) - The ID of the site this device belongs to. Changing this forces a new resource. -- `name` (String) - The name of the device. - `ip_address` (String) - The IP address of the device. ### Optional +- `site_id` (String) - The ID of the site this device belongs to. Optional if `organization_id` is provided. Changing this forces a new resource. +- `organization_id` (String) - The ID of the organization this device belongs to. Defaults to the authenticated organization if not provided. Changing this forces a new resource. +- `name` (String) - The name of the device. If not provided, will be auto-discovered from SNMP. - `description` (String) - A description of the device. - `monitoring_enabled` (Boolean) - Whether monitoring is enabled for this device. Default: `true`. - `snmp_enabled` (Boolean) - Whether SNMP polling is enabled for this device. Default: `true`. - `snmp_version` (String) - The SNMP version to use (`1`, `2c`, or `3`). Default: `"2c"`. - `snmp_port` (Number) - The SNMP port to use. Default: `161`. +#### SNMPv3 Fields (only used when `snmp_version = "3"`) + +- `snmpv3_security_level` (String) - SNMPv3 security level. Must be one of: + - `noAuthNoPriv` - No authentication or privacy + - `authNoPriv` - Authentication without privacy + - `authPriv` - Authentication with privacy +- `snmpv3_username` (String) - SNMPv3 username. +- `snmpv3_auth_protocol` (String) - SNMPv3 authentication protocol. Must be one of: `MD5`, `SHA`, `SHA-224`, `SHA-256`, `SHA-384`, `SHA-512`. +- `snmpv3_auth_password` (String, Sensitive) - SNMPv3 authentication password. +- `snmpv3_priv_protocol` (String) - SNMPv3 privacy protocol. Must be one of: `DES`, `AES`, `AES-192`, `AES-256`. +- `snmpv3_priv_password` (String, Sensitive) - SNMPv3 privacy password. + ### Read-Only - `id` (String) - The unique identifier of the device. diff --git a/internal/provider/client.go b/internal/provider/client.go index d1a0b19..83e640d 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -48,7 +48,8 @@ type Site struct { // Device represents a TowerOps device. type Device struct { ID string `json:"id,omitempty"` - SiteID string `json:"site_id"` + SiteID *string `json:"site_id,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` Name *string `json:"name,omitempty"` IPAddress string `json:"ip_address"` Description *string `json:"description,omitempty"` @@ -57,7 +58,14 @@ type Device struct { SNMPVersion *string `json:"snmp_version,omitempty"` SNMPPort *int `json:"snmp_port,omitempty"` CheckIntervalSeconds *int `json:"check_interval_seconds,omitempty"` - InsertedAt string `json:"inserted_at,omitempty"` + // SNMPv3 fields + SNMPv3SecurityLevel *string `json:"snmpv3_security_level,omitempty"` + SNMPv3Username *string `json:"snmpv3_username,omitempty"` + SNMPv3AuthProtocol *string `json:"snmpv3_auth_protocol,omitempty"` + SNMPv3AuthPassword *string `json:"snmpv3_auth_password,omitempty"` + SNMPv3PrivProtocol *string `json:"snmpv3_priv_protocol,omitempty"` + SNMPv3PrivPassword *string `json:"snmpv3_priv_password,omitempty"` + InsertedAt string `json:"inserted_at,omitempty"` } // APIError represents an error response from the API. diff --git a/internal/provider/device_resource.go b/internal/provider/device_resource.go index cd5c44a..94b210e 100644 --- a/internal/provider/device_resource.go +++ b/internal/provider/device_resource.go @@ -26,16 +26,23 @@ type DeviceResource struct { // DeviceResourceModel describes the resource data model. type DeviceResourceModel struct { - ID types.String `tfsdk:"id"` - SiteID types.String `tfsdk:"site_id"` - Name types.String `tfsdk:"name"` - IPAddress types.String `tfsdk:"ip_address"` - Description types.String `tfsdk:"description"` - MonitoringEnabled types.Bool `tfsdk:"monitoring_enabled"` - SNMPEnabled types.Bool `tfsdk:"snmp_enabled"` - SNMPVersion types.String `tfsdk:"snmp_version"` - SNMPPort types.Int64 `tfsdk:"snmp_port"` - InsertedAt types.String `tfsdk:"inserted_at"` + ID types.String `tfsdk:"id"` + SiteID types.String `tfsdk:"site_id"` + OrganizationID types.String `tfsdk:"organization_id"` + Name types.String `tfsdk:"name"` + IPAddress types.String `tfsdk:"ip_address"` + Description types.String `tfsdk:"description"` + MonitoringEnabled types.Bool `tfsdk:"monitoring_enabled"` + SNMPEnabled types.Bool `tfsdk:"snmp_enabled"` + SNMPVersion types.String `tfsdk:"snmp_version"` + SNMPPort types.Int64 `tfsdk:"snmp_port"` + SNMPv3SecurityLevel types.String `tfsdk:"snmpv3_security_level"` + SNMPv3Username types.String `tfsdk:"snmpv3_username"` + SNMPv3AuthProtocol types.String `tfsdk:"snmpv3_auth_protocol"` + SNMPv3AuthPassword types.String `tfsdk:"snmpv3_auth_password"` + SNMPv3PrivProtocol types.String `tfsdk:"snmpv3_priv_protocol"` + SNMPv3PrivPassword types.String `tfsdk:"snmpv3_priv_password"` + InsertedAt types.String `tfsdk:"inserted_at"` } // NewDeviceResource creates a new device resource. @@ -49,7 +56,7 @@ func (r *DeviceResource) Metadata(ctx context.Context, req resource.MetadataRequ func (r *DeviceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "Manages a TowerOps device. Devices represent network equipment at a site.", + Description: "Manages a TowerOps device. Devices represent network equipment at a site or directly in an organization.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Description: "The unique identifier of the device.", @@ -59,8 +66,17 @@ func (r *DeviceResource) Schema(ctx context.Context, req resource.SchemaRequest, }, }, "site_id": schema.StringAttribute{ - Description: "The ID of the site this device belongs to.", - Required: true, + Description: "The ID of the site this device belongs to. Optional if organization_id is provided.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "organization_id": schema.StringAttribute{ + Description: "The ID of the organization this device belongs to. Required if site_id is not provided.", + Optional: true, + Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, @@ -102,6 +118,32 @@ func (r *DeviceResource) Schema(ctx context.Context, req resource.SchemaRequest, Computed: true, Default: int64default.StaticInt64(161), }, + "snmpv3_security_level": schema.StringAttribute{ + Description: "SNMPv3 security level (noAuthNoPriv, authNoPriv, or authPriv). Only used when snmp_version is '3'.", + Optional: true, + }, + "snmpv3_username": schema.StringAttribute{ + Description: "SNMPv3 username. Only used when snmp_version is '3'.", + Optional: true, + }, + "snmpv3_auth_protocol": schema.StringAttribute{ + Description: "SNMPv3 authentication protocol (MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512). Only used when snmp_version is '3'.", + Optional: true, + }, + "snmpv3_auth_password": schema.StringAttribute{ + Description: "SNMPv3 authentication password. Only used when snmp_version is '3'.", + Optional: true, + Sensitive: true, + }, + "snmpv3_priv_protocol": schema.StringAttribute{ + Description: "SNMPv3 privacy protocol (DES, AES, AES-192, AES-256). Only used when snmp_version is '3'.", + Optional: true, + }, + "snmpv3_priv_password": schema.StringAttribute{ + Description: "SNMPv3 privacy password. Only used when snmp_version is '3'.", + Optional: true, + Sensitive: true, + }, "inserted_at": schema.StringAttribute{ Description: "The timestamp when the device was created.", Computed: true, @@ -139,10 +181,19 @@ func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest, } device := Device{ - SiteID: data.SiteID.ValueString(), IPAddress: data.IPAddress.ValueString(), } + if !data.SiteID.IsNull() { + siteID := data.SiteID.ValueString() + device.SiteID = &siteID + } + + if !data.OrganizationID.IsNull() { + orgID := data.OrganizationID.ValueString() + device.OrganizationID = &orgID + } + if !data.Name.IsNull() { name := data.Name.ValueString() device.Name = &name @@ -173,6 +224,37 @@ func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest, device.SNMPPort = &port } + // SNMPv3 fields + if !data.SNMPv3SecurityLevel.IsNull() { + level := data.SNMPv3SecurityLevel.ValueString() + device.SNMPv3SecurityLevel = &level + } + + if !data.SNMPv3Username.IsNull() { + username := data.SNMPv3Username.ValueString() + device.SNMPv3Username = &username + } + + if !data.SNMPv3AuthProtocol.IsNull() { + protocol := data.SNMPv3AuthProtocol.ValueString() + device.SNMPv3AuthProtocol = &protocol + } + + if !data.SNMPv3AuthPassword.IsNull() { + password := data.SNMPv3AuthPassword.ValueString() + device.SNMPv3AuthPassword = &password + } + + if !data.SNMPv3PrivProtocol.IsNull() { + protocol := data.SNMPv3PrivProtocol.ValueString() + device.SNMPv3PrivProtocol = &protocol + } + + if !data.SNMPv3PrivPassword.IsNull() { + password := data.SNMPv3PrivPassword.ValueString() + device.SNMPv3PrivPassword = &password + } + created, err := r.client.CreateDevice(device) if err != nil { resp.Diagnostics.AddError("Failed to create device", err.Error()) @@ -182,6 +264,18 @@ func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest, data.ID = types.StringValue(created.ID) data.InsertedAt = types.StringValue(created.InsertedAt) + if created.SiteID != nil { + data.SiteID = types.StringValue(*created.SiteID) + } else { + data.SiteID = types.StringNull() + } + + if created.OrganizationID != nil { + data.OrganizationID = types.StringValue(*created.OrganizationID) + } else { + data.OrganizationID = types.StringNull() + } + if created.Name != nil { data.Name = types.StringValue(*created.Name) } @@ -215,7 +309,18 @@ func (r *DeviceResource) Read(ctx context.Context, req resource.ReadRequest, res return } - data.SiteID = types.StringValue(device.SiteID) + if device.SiteID != nil { + data.SiteID = types.StringValue(*device.SiteID) + } else { + data.SiteID = types.StringNull() + } + + if device.OrganizationID != nil { + data.OrganizationID = types.StringValue(*device.OrganizationID) + } else { + data.OrganizationID = types.StringNull() + } + data.IPAddress = types.StringValue(device.IPAddress) if device.Name != nil { @@ -247,6 +352,43 @@ func (r *DeviceResource) Read(ctx context.Context, req resource.ReadRequest, res data.SNMPPort = types.Int64Value(int64(*device.SNMPPort)) } + // SNMPv3 fields + if device.SNMPv3SecurityLevel != nil { + data.SNMPv3SecurityLevel = types.StringValue(*device.SNMPv3SecurityLevel) + } else { + data.SNMPv3SecurityLevel = types.StringNull() + } + + if device.SNMPv3Username != nil { + data.SNMPv3Username = types.StringValue(*device.SNMPv3Username) + } else { + data.SNMPv3Username = types.StringNull() + } + + if device.SNMPv3AuthProtocol != nil { + data.SNMPv3AuthProtocol = types.StringValue(*device.SNMPv3AuthProtocol) + } else { + data.SNMPv3AuthProtocol = types.StringNull() + } + + if device.SNMPv3AuthPassword != nil { + data.SNMPv3AuthPassword = types.StringValue(*device.SNMPv3AuthPassword) + } else { + data.SNMPv3AuthPassword = types.StringNull() + } + + if device.SNMPv3PrivProtocol != nil { + data.SNMPv3PrivProtocol = types.StringValue(*device.SNMPv3PrivProtocol) + } else { + data.SNMPv3PrivProtocol = types.StringNull() + } + + if device.SNMPv3PrivPassword != nil { + data.SNMPv3PrivPassword = types.StringValue(*device.SNMPv3PrivPassword) + } else { + data.SNMPv3PrivPassword = types.StringNull() + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -259,10 +401,19 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, } device := Device{ - SiteID: data.SiteID.ValueString(), IPAddress: data.IPAddress.ValueString(), } + if !data.SiteID.IsNull() { + siteID := data.SiteID.ValueString() + device.SiteID = &siteID + } + + if !data.OrganizationID.IsNull() { + orgID := data.OrganizationID.ValueString() + device.OrganizationID = &orgID + } + if !data.Name.IsNull() { name := data.Name.ValueString() device.Name = &name @@ -293,6 +444,37 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, device.SNMPPort = &port } + // SNMPv3 fields + if !data.SNMPv3SecurityLevel.IsNull() { + level := data.SNMPv3SecurityLevel.ValueString() + device.SNMPv3SecurityLevel = &level + } + + if !data.SNMPv3Username.IsNull() { + username := data.SNMPv3Username.ValueString() + device.SNMPv3Username = &username + } + + if !data.SNMPv3AuthProtocol.IsNull() { + protocol := data.SNMPv3AuthProtocol.ValueString() + device.SNMPv3AuthProtocol = &protocol + } + + if !data.SNMPv3AuthPassword.IsNull() { + password := data.SNMPv3AuthPassword.ValueString() + device.SNMPv3AuthPassword = &password + } + + if !data.SNMPv3PrivProtocol.IsNull() { + protocol := data.SNMPv3PrivProtocol.ValueString() + device.SNMPv3PrivProtocol = &protocol + } + + if !data.SNMPv3PrivPassword.IsNull() { + password := data.SNMPv3PrivPassword.ValueString() + device.SNMPv3PrivPassword = &password + } + updated, err := r.client.UpdateDevice(data.ID.ValueString(), device) if err != nil { if errors.Is(err, ErrNotFound) { @@ -305,6 +487,19 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, data.ID = types.StringValue(created.ID) data.InsertedAt = types.StringValue(created.InsertedAt) data.IPAddress = types.StringValue(created.IPAddress) + + if created.SiteID != nil { + data.SiteID = types.StringValue(*created.SiteID) + } else { + data.SiteID = types.StringNull() + } + + if created.OrganizationID != nil { + data.OrganizationID = types.StringValue(*created.OrganizationID) + } else { + data.OrganizationID = types.StringNull() + } + if created.Name != nil { data.Name = types.StringValue(*created.Name) } @@ -332,6 +527,18 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, data.IPAddress = types.StringValue(updated.IPAddress) + if updated.SiteID != nil { + data.SiteID = types.StringValue(*updated.SiteID) + } else { + data.SiteID = types.StringNull() + } + + if updated.OrganizationID != nil { + data.OrganizationID = types.StringValue(*updated.OrganizationID) + } else { + data.OrganizationID = types.StringNull() + } + if updated.Name != nil { data.Name = types.StringValue(*updated.Name) } @@ -351,6 +558,43 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, data.SNMPPort = types.Int64Value(int64(*updated.SNMPPort)) } + // SNMPv3 fields + if updated.SNMPv3SecurityLevel != nil { + data.SNMPv3SecurityLevel = types.StringValue(*updated.SNMPv3SecurityLevel) + } else { + data.SNMPv3SecurityLevel = types.StringNull() + } + + if updated.SNMPv3Username != nil { + data.SNMPv3Username = types.StringValue(*updated.SNMPv3Username) + } else { + data.SNMPv3Username = types.StringNull() + } + + if updated.SNMPv3AuthProtocol != nil { + data.SNMPv3AuthProtocol = types.StringValue(*updated.SNMPv3AuthProtocol) + } else { + data.SNMPv3AuthProtocol = types.StringNull() + } + + if updated.SNMPv3AuthPassword != nil { + data.SNMPv3AuthPassword = types.StringValue(*updated.SNMPv3AuthPassword) + } else { + data.SNMPv3AuthPassword = types.StringNull() + } + + if updated.SNMPv3PrivProtocol != nil { + data.SNMPv3PrivProtocol = types.StringValue(*updated.SNMPv3PrivProtocol) + } else { + data.SNMPv3PrivProtocol = types.StringNull() + } + + if updated.SNMPv3PrivPassword != nil { + data.SNMPv3PrivPassword = types.StringValue(*updated.SNMPv3PrivPassword) + } else { + data.SNMPv3PrivPassword = types.StringNull() + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) }