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
This commit is contained in:
Graham McIntire 2026-02-04 15:07:09 -06:00
parent eb6923c4b3
commit 4cdb96e189
No known key found for this signature in database
4 changed files with 379 additions and 22 deletions

View file

@ -14,6 +14,8 @@ The provider requires an API token for authentication. Generate a token from the
## Example Usage ## Example Usage
### Basic Usage with Site Hierarchy
```terraform ```terraform
terraform { terraform {
required_providers { 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 ## Schema
### Required ### Required

View file

@ -6,10 +6,12 @@ description: |-
# towerops_device (Resource) # 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 ## Example Usage
### Site-based Device with SNMP v2c
```terraform ```terraform
resource "towerops_device" "router" { resource "towerops_device" "router" {
site_id = towerops_site.example.id 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 ### Minimal Configuration
```terraform ```terraform
resource "towerops_device" "switch" { resource "towerops_device" "switch" {
site_id = towerops_site.example.id
name = "Access Switch" name = "Access Switch"
ip_address = "192.168.1.2" ip_address = "192.168.1.2"
} }
@ -37,18 +68,31 @@ resource "towerops_device" "switch" {
### Required ### 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. - `ip_address` (String) - The IP address of the device.
### Optional ### 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. - `description` (String) - A description of the device.
- `monitoring_enabled` (Boolean) - Whether monitoring is enabled for this device. Default: `true`. - `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_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_version` (String) - The SNMP version to use (`1`, `2c`, or `3`). Default: `"2c"`.
- `snmp_port` (Number) - The SNMP port to use. Default: `161`. - `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 ### Read-Only
- `id` (String) - The unique identifier of the device. - `id` (String) - The unique identifier of the device.

View file

@ -48,7 +48,8 @@ type Site struct {
// Device represents a TowerOps device. // Device represents a TowerOps device.
type Device struct { type Device struct {
ID string `json:"id,omitempty"` 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"` Name *string `json:"name,omitempty"`
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
@ -57,7 +58,14 @@ type Device struct {
SNMPVersion *string `json:"snmp_version,omitempty"` SNMPVersion *string `json:"snmp_version,omitempty"`
SNMPPort *int `json:"snmp_port,omitempty"` SNMPPort *int `json:"snmp_port,omitempty"`
CheckIntervalSeconds *int `json:"check_interval_seconds,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. // APIError represents an error response from the API.

View file

@ -26,16 +26,23 @@ type DeviceResource struct {
// DeviceResourceModel describes the resource data model. // DeviceResourceModel describes the resource data model.
type DeviceResourceModel struct { type DeviceResourceModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
SiteID types.String `tfsdk:"site_id"` SiteID types.String `tfsdk:"site_id"`
Name types.String `tfsdk:"name"` OrganizationID types.String `tfsdk:"organization_id"`
IPAddress types.String `tfsdk:"ip_address"` Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"` IPAddress types.String `tfsdk:"ip_address"`
MonitoringEnabled types.Bool `tfsdk:"monitoring_enabled"` Description types.String `tfsdk:"description"`
SNMPEnabled types.Bool `tfsdk:"snmp_enabled"` MonitoringEnabled types.Bool `tfsdk:"monitoring_enabled"`
SNMPVersion types.String `tfsdk:"snmp_version"` SNMPEnabled types.Bool `tfsdk:"snmp_enabled"`
SNMPPort types.Int64 `tfsdk:"snmp_port"` SNMPVersion types.String `tfsdk:"snmp_version"`
InsertedAt types.String `tfsdk:"inserted_at"` 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. // 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) { func (r *DeviceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{ 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{ Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{ "id": schema.StringAttribute{
Description: "The unique identifier of the device.", 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{ "site_id": schema.StringAttribute{
Description: "The ID of the site this device belongs to.", Description: "The ID of the site this device belongs to. Optional if organization_id is provided.",
Required: true, 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{ PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(), stringplanmodifier.RequiresReplace(),
}, },
@ -102,6 +118,32 @@ func (r *DeviceResource) Schema(ctx context.Context, req resource.SchemaRequest,
Computed: true, Computed: true,
Default: int64default.StaticInt64(161), 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{ "inserted_at": schema.StringAttribute{
Description: "The timestamp when the device was created.", Description: "The timestamp when the device was created.",
Computed: true, Computed: true,
@ -139,10 +181,19 @@ func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest,
} }
device := Device{ device := Device{
SiteID: data.SiteID.ValueString(),
IPAddress: data.IPAddress.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() { if !data.Name.IsNull() {
name := data.Name.ValueString() name := data.Name.ValueString()
device.Name = &name device.Name = &name
@ -173,6 +224,37 @@ func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest,
device.SNMPPort = &port 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) created, err := r.client.CreateDevice(device)
if err != nil { if err != nil {
resp.Diagnostics.AddError("Failed to create device", err.Error()) 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.ID = types.StringValue(created.ID)
data.InsertedAt = types.StringValue(created.InsertedAt) 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 { if created.Name != nil {
data.Name = types.StringValue(*created.Name) data.Name = types.StringValue(*created.Name)
} }
@ -215,7 +309,18 @@ func (r *DeviceResource) Read(ctx context.Context, req resource.ReadRequest, res
return 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) data.IPAddress = types.StringValue(device.IPAddress)
if device.Name != nil { 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)) 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)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }
@ -259,10 +401,19 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest,
} }
device := Device{ device := Device{
SiteID: data.SiteID.ValueString(),
IPAddress: data.IPAddress.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() { if !data.Name.IsNull() {
name := data.Name.ValueString() name := data.Name.ValueString()
device.Name = &name device.Name = &name
@ -293,6 +444,37 @@ func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest,
device.SNMPPort = &port 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) updated, err := r.client.UpdateDevice(data.ID.ValueString(), device)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { 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.ID = types.StringValue(created.ID)
data.InsertedAt = types.StringValue(created.InsertedAt) data.InsertedAt = types.StringValue(created.InsertedAt)
data.IPAddress = types.StringValue(created.IPAddress) 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 { if created.Name != nil {
data.Name = types.StringValue(*created.Name) 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) 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 { if updated.Name != nil {
data.Name = types.StringValue(*updated.Name) 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)) 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)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }