This commit is contained in:
Graham McIntire 2026-01-26 11:02:26 -06:00
commit 8fed566e22
No known key found for this signature in database
11 changed files with 1211 additions and 0 deletions

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
golang 1.25.6

View file

@ -0,0 +1,180 @@
# TowerOps Terraform Provider Design
## Overview
A Terraform provider for managing TowerOps resources (Sites and Devices) via the existing REST API.
## Scope
- **Resources**: Sites, Devices
- **Authentication**: API token in provider config
- **Import**: Supported for existing resources
- **Endpoint**: Fixed production URL
## Provider Configuration
```hcl
terraform {
required_providers {
towerops = {
source = "towerops/towerops"
}
}
}
provider "towerops" {
token = var.towerops_api_token # Required, scopes all operations to one org
}
```
The token determines which organization's resources are accessible via the existing API token authentication.
## Project Structure
```
towerops-tf-provider/
├── main.go # Entry point
├── go.mod / go.sum
├── internal/
│ └── provider/
│ ├── provider.go # Provider schema + config
│ ├── client.go # HTTP client for TowerOps API
│ ├── site_resource.go
│ ├── device_resource.go
├── examples/
│ └── main.tf # Usage example
└── docs/ # Generated documentation
```
Uses the Terraform Plugin Framework (modern approach).
## Resource Schemas
### towerops_site
```hcl
resource "towerops_site" "main" {
name = "Main Office"
location = "New York, NY" # Optional
snmp_community = "public" # Optional
}
output "site_id" {
value = towerops_site.main.id
}
```
**Attributes:**
- `name` (Required, string) - Site name, 2-200 chars
- `location` (Optional, string) - Physical location description
- `snmp_community` (Optional, string) - Default SNMP community for devices
**Computed:**
- `id` (string) - UUID assigned by TowerOps
- `inserted_at` (string) - Creation timestamp
### towerops_device
```hcl
resource "towerops_device" "router" {
site_id = towerops_site.main.id
name = "Core Router"
ip_address = "192.168.1.1"
monitoring_enabled = true # Optional, default true
snmp_enabled = true # Optional, default true
snmp_version = "2c" # Optional, default "2c"
snmp_port = 161 # Optional, default 161
}
```
**Attributes:**
- `site_id` (Required, string) - UUID of parent site
- `name` (Required, string) - Device name
- `ip_address` (Required, string) - Device IP address
- `description` (Optional, string) - Device description
- `monitoring_enabled` (Optional, bool) - Enable monitoring, default true
- `snmp_enabled` (Optional, bool) - Enable SNMP polling, default true
- `snmp_version` (Optional, string) - SNMP version (1, 2c, 3), default "2c"
- `snmp_port` (Optional, int) - SNMP port, default 161
**Computed:**
- `id` (string) - UUID assigned by TowerOps
- `inserted_at` (string) - Creation timestamp
## HTTP Client
```go
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// Site operations
func (c *Client) CreateSite(site Site) (*Site, error)
func (c *Client) GetSite(id string) (*Site, error)
func (c *Client) UpdateSite(id string, site Site) (*Site, error)
func (c *Client) DeleteSite(id string) error
// Device operations
func (c *Client) CreateDevice(device Device) (*Device, error)
func (c *Client) GetDevice(id string) (*Device, error)
func (c *Client) UpdateDevice(id string, device Device) (*Device, error)
func (c *Client) DeleteDevice(id string) error
```
### API Mapping
| Terraform Operation | HTTP Method | Endpoint |
|---------------------|-------------|----------|
| Create | POST | `/api/v1/sites` or `/api/v1/devices` |
| Read | GET | `/api/v1/sites/:id` or `/api/v1/devices/:id` |
| Update | PATCH | `/api/v1/sites/:id` or `/api/v1/devices/:id` |
| Delete | DELETE | `/api/v1/sites/:id` or `/api/v1/devices/:id` |
| Import | GET | Same as Read |
## Import Support
```bash
# Import existing site by UUID
terraform import towerops_site.main 550e8400-e29b-41d4-a716-446655440000
# Import existing device by UUID
terraform import towerops_device.router 7c9e6679-7425-40de-944b-e07fc1f90ae7
```
Import uses the same `GetSite`/`GetDevice` client methods as Read.
## Testing Strategy
1. **Unit tests** - Mock HTTP responses, test schema validation
2. **Acceptance tests** - Run against real TowerOps instance (skipped in CI without credentials)
```go
func TestAccSiteResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `resource "towerops_site" "test" { name = "Test Site" }`,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("towerops_site.test", "name", "Test Site"),
resource.TestCheckResourceAttrSet("towerops_site.test", "id"),
),
},
},
})
}
```
## Implementation Order
1. Project scaffolding (go.mod, main.go)
2. HTTP client with Site CRUD
3. Site resource implementation
4. HTTP client Device CRUD
5. Device resource implementation
6. Import support for both resources
7. Acceptance tests
8. Documentation and examples

54
examples/main.tf Normal file
View file

@ -0,0 +1,54 @@
terraform {
required_providers {
towerops = {
source = "towerops/towerops"
}
}
}
variable "towerops_api_token" {
description = "TowerOps API token"
type = string
sensitive = true
}
provider "towerops" {
token = var.towerops_api_token
}
# Create a site
resource "towerops_site" "main_office" {
name = "Main Office"
location = "New York, NY"
snmp_community = "public"
}
# Create devices at the site
resource "towerops_device" "core_router" {
site_id = towerops_site.main_office.id
name = "Core Router"
ip_address = "192.168.1.1"
monitoring_enabled = true
snmp_enabled = true
snmp_version = "2c"
snmp_port = 161
}
resource "towerops_device" "access_switch" {
site_id = towerops_site.main_office.id
name = "Access Switch"
ip_address = "192.168.1.2"
monitoring_enabled = true
snmp_enabled = true
}
# Output the site ID
output "site_id" {
value = towerops_site.main_office.id
}
output "router_id" {
value = towerops_device.core_router.id
}

30
go.mod Normal file
View file

@ -0,0 +1,30 @@
module github.com/towerops/terraform-provider-towerops
go 1.21
require github.com/hashicorp/terraform-plugin-framework v1.4.2
require (
github.com/fatih/color v1.16.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/go-hclog v1.6.2 // indirect
github.com/hashicorp/go-plugin v1.6.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/terraform-plugin-go v0.19.1 // indirect
github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect
github.com/hashicorp/terraform-registry-address v0.2.3 // indirect
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac // indirect
google.golang.org/grpc v1.60.1 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)

81
go.sum Normal file
View file

@ -0,0 +1,81 @@
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I=
github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A=
github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/terraform-plugin-framework v1.4.2 h1:P7a7VP1GZbjc4rv921Xy5OckzhoiO3ig6SGxwelD2sI=
github.com/hashicorp/terraform-plugin-framework v1.4.2/go.mod h1:GWl3InPFZi2wVQmdVnINPKys09s9mLmTZr95/ngLnbY=
github.com/hashicorp/terraform-plugin-go v0.19.1 h1:lf/jTGTeELcz5IIbn/94mJdmnTjRYm6S6ct/JqCSr50=
github.com/hashicorp/terraform-plugin-go v0.19.1/go.mod h1:5NMIS+DXkfacX6o5HCpswda5yjkSYfKzn1Nfl9l+qRs=
github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow=
github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI=
github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM=
github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ=
github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac h1:nUQEQmH/csSvFECKYRv6HWEyypysidKl2I6Qpsglq/0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA=
google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

212
internal/provider/client.go Normal file
View file

@ -0,0 +1,212 @@
package provider
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultBaseURL = "https://app.towerops.io"
// Client is the TowerOps API client.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient creates a new TowerOps API client.
func NewClient(token string) *Client {
return &Client{
BaseURL: defaultBaseURL,
Token: token,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Site represents a TowerOps site.
type Site struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Location *string `json:"location,omitempty"`
SNMPCommunity *string `json:"snmp_community,omitempty"`
InsertedAt string `json:"inserted_at,omitempty"`
}
// Device represents a TowerOps device.
type Device struct {
ID string `json:"id,omitempty"`
SiteID string `json:"site_id"`
Name string `json:"name"`
IPAddress string `json:"ip_address"`
Description *string `json:"description,omitempty"`
MonitoringEnabled *bool `json:"monitoring_enabled,omitempty"`
SNMPEnabled *bool `json:"snmp_enabled,omitempty"`
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"`
}
// APIError represents an error response from the API.
type APIError struct {
Error string `json:"error,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
func (c *Client) doRequest(method, path string, body interface{}) ([]byte, error) {
var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonBody)
}
req, err := http.NewRequest(method, c.BaseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode >= 400 {
var apiErr APIError
if err := json.Unmarshal(respBody, &apiErr); err == nil {
if apiErr.Error != "" {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, apiErr.Error)
}
if len(apiErr.Errors) > 0 {
return nil, fmt.Errorf("API validation error (%d): %v", resp.StatusCode, apiErr.Errors)
}
}
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
// CreateSite creates a new site.
func (c *Client) CreateSite(site Site) (*Site, error) {
body := map[string]Site{"site": site}
respBody, err := c.doRequest(http.MethodPost, "/api/v1/sites", body)
if err != nil {
return nil, err
}
var result Site
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// GetSite retrieves a site by ID.
func (c *Client) GetSite(id string) (*Site, error) {
respBody, err := c.doRequest(http.MethodGet, "/api/v1/sites/"+id, nil)
if err != nil {
return nil, err
}
var result Site
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// UpdateSite updates an existing site.
func (c *Client) UpdateSite(id string, site Site) (*Site, error) {
body := map[string]Site{"site": site}
respBody, err := c.doRequest(http.MethodPatch, "/api/v1/sites/"+id, body)
if err != nil {
return nil, err
}
var result Site
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// DeleteSite deletes a site.
func (c *Client) DeleteSite(id string) error {
_, err := c.doRequest(http.MethodDelete, "/api/v1/sites/"+id, nil)
return err
}
// CreateDevice creates a new device.
func (c *Client) CreateDevice(device Device) (*Device, error) {
body := map[string]Device{"device": device}
respBody, err := c.doRequest(http.MethodPost, "/api/v1/devices", body)
if err != nil {
return nil, err
}
var result Device
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// GetDevice retrieves a device by ID.
func (c *Client) GetDevice(id string) (*Device, error) {
respBody, err := c.doRequest(http.MethodGet, "/api/v1/devices/"+id, nil)
if err != nil {
return nil, err
}
var result Device
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// UpdateDevice updates an existing device.
func (c *Client) UpdateDevice(id string, device Device) (*Device, error) {
body := map[string]Device{"device": device}
respBody, err := c.doRequest(http.MethodPatch, "/api/v1/devices/"+id, body)
if err != nil {
return nil, err
}
var result Device
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// DeleteDevice deletes a device.
func (c *Client) DeleteDevice(id string) error {
_, err := c.doRequest(http.MethodDelete, "/api/v1/devices/"+id, nil)
return err
}

View file

@ -0,0 +1,317 @@
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/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/types"
)
var _ resource.Resource = &DeviceResource{}
var _ resource.ResourceWithImportState = &DeviceResource{}
// DeviceResource defines the resource implementation.
type DeviceResource struct {
client *Client
}
// 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"`
}
// NewDeviceResource creates a new device resource.
func NewDeviceResource() resource.Resource {
return &DeviceResource{}
}
func (r *DeviceResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_device"
}
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.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "The unique identifier of the device.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"site_id": schema.StringAttribute{
Description: "The ID of the site this device belongs to.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"name": schema.StringAttribute{
Description: "The name of the device.",
Required: true,
},
"ip_address": schema.StringAttribute{
Description: "The IP address of the device.",
Required: true,
},
"description": schema.StringAttribute{
Description: "A description of the device.",
Optional: true,
},
"monitoring_enabled": schema.BoolAttribute{
Description: "Whether monitoring is enabled for this device.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"snmp_enabled": schema.BoolAttribute{
Description: "Whether SNMP polling is enabled for this device.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"snmp_version": schema.StringAttribute{
Description: "The SNMP version to use (1, 2c, or 3).",
Optional: true,
Computed: true,
Default: stringdefault.StaticString("2c"),
},
"snmp_port": schema.Int64Attribute{
Description: "The SNMP port to use.",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(161),
},
"inserted_at": schema.StringAttribute{
Description: "The timestamp when the device was created.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *DeviceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *Client, got: %T", req.ProviderData),
)
return
}
r.client = client
}
func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data DeviceResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
device := Device{
SiteID: data.SiteID.ValueString(),
Name: data.Name.ValueString(),
IPAddress: data.IPAddress.ValueString(),
}
if !data.Description.IsNull() {
desc := data.Description.ValueString()
device.Description = &desc
}
if !data.MonitoringEnabled.IsNull() {
enabled := data.MonitoringEnabled.ValueBool()
device.MonitoringEnabled = &enabled
}
if !data.SNMPEnabled.IsNull() {
enabled := data.SNMPEnabled.ValueBool()
device.SNMPEnabled = &enabled
}
if !data.SNMPVersion.IsNull() {
version := data.SNMPVersion.ValueString()
device.SNMPVersion = &version
}
if !data.SNMPPort.IsNull() {
port := int(data.SNMPPort.ValueInt64())
device.SNMPPort = &port
}
created, err := r.client.CreateDevice(device)
if err != nil {
resp.Diagnostics.AddError("Failed to create device", err.Error())
return
}
data.ID = types.StringValue(created.ID)
data.InsertedAt = types.StringValue(created.InsertedAt)
if created.MonitoringEnabled != nil {
data.MonitoringEnabled = types.BoolValue(*created.MonitoringEnabled)
}
if created.SNMPEnabled != nil {
data.SNMPEnabled = types.BoolValue(*created.SNMPEnabled)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *DeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data DeviceResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
device, err := r.client.GetDevice(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Failed to read device", err.Error())
return
}
data.SiteID = types.StringValue(device.SiteID)
data.Name = types.StringValue(device.Name)
data.IPAddress = types.StringValue(device.IPAddress)
data.InsertedAt = types.StringValue(device.InsertedAt)
if device.Description != nil {
data.Description = types.StringValue(*device.Description)
} else {
data.Description = types.StringNull()
}
if device.MonitoringEnabled != nil {
data.MonitoringEnabled = types.BoolValue(*device.MonitoringEnabled)
}
if device.SNMPEnabled != nil {
data.SNMPEnabled = types.BoolValue(*device.SNMPEnabled)
}
if device.SNMPVersion != nil {
data.SNMPVersion = types.StringValue(*device.SNMPVersion)
}
if device.SNMPPort != nil {
data.SNMPPort = types.Int64Value(int64(*device.SNMPPort))
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data DeviceResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
device := Device{
SiteID: data.SiteID.ValueString(),
Name: data.Name.ValueString(),
IPAddress: data.IPAddress.ValueString(),
}
if !data.Description.IsNull() {
desc := data.Description.ValueString()
device.Description = &desc
}
if !data.MonitoringEnabled.IsNull() {
enabled := data.MonitoringEnabled.ValueBool()
device.MonitoringEnabled = &enabled
}
if !data.SNMPEnabled.IsNull() {
enabled := data.SNMPEnabled.ValueBool()
device.SNMPEnabled = &enabled
}
if !data.SNMPVersion.IsNull() {
version := data.SNMPVersion.ValueString()
device.SNMPVersion = &version
}
if !data.SNMPPort.IsNull() {
port := int(data.SNMPPort.ValueInt64())
device.SNMPPort = &port
}
updated, err := r.client.UpdateDevice(data.ID.ValueString(), device)
if err != nil {
resp.Diagnostics.AddError("Failed to update device", err.Error())
return
}
data.Name = types.StringValue(updated.Name)
data.IPAddress = types.StringValue(updated.IPAddress)
if updated.Description != nil {
data.Description = types.StringValue(*updated.Description)
}
if updated.MonitoringEnabled != nil {
data.MonitoringEnabled = types.BoolValue(*updated.MonitoringEnabled)
}
if updated.SNMPEnabled != nil {
data.SNMPEnabled = types.BoolValue(*updated.SNMPEnabled)
}
if updated.SNMPVersion != nil {
data.SNMPVersion = types.StringValue(*updated.SNMPVersion)
}
if updated.SNMPPort != nil {
data.SNMPPort = types.Int64Value(int64(*updated.SNMPPort))
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *DeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data DeviceResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
err := r.client.DeleteDevice(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Failed to delete device", err.Error())
return
}
}
func (r *DeviceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

View file

@ -0,0 +1,91 @@
package provider
import (
"context"
"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 = &ToweropsProvider{}
// ToweropsProvider defines the provider implementation.
type ToweropsProvider struct {
version string
}
// ToweropsProviderModel describes the provider data model.
type ToweropsProviderModel struct {
Token types.String `tfsdk:"token"`
}
// New creates a new provider instance.
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &ToweropsProvider{
version: version,
}
}
}
func (p *ToweropsProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "towerops"
resp.Version = p.version
}
func (p *ToweropsProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "The TowerOps provider allows you to manage TowerOps resources such as sites and devices.",
Attributes: map[string]schema.Attribute{
"token": schema.StringAttribute{
Description: "The API token for authenticating with TowerOps. This token determines which organization's resources are accessible.",
Required: true,
Sensitive: true,
},
},
}
}
func (p *ToweropsProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config ToweropsProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
if config.Token.IsUnknown() {
resp.Diagnostics.AddError(
"Unknown TowerOps API Token",
"The provider cannot create the TowerOps API client as there is an unknown configuration value for the API token.",
)
return
}
if config.Token.IsNull() || config.Token.ValueString() == "" {
resp.Diagnostics.AddError(
"Missing TowerOps API Token",
"The provider requires a token to authenticate with the TowerOps API.",
)
return
}
client := NewClient(config.Token.ValueString())
resp.DataSourceData = client
resp.ResourceData = client
}
func (p *ToweropsProvider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewSiteResource,
NewDeviceResource,
}
}
func (p *ToweropsProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{}
}

View file

@ -0,0 +1,223 @@
package provider
import (
"context"
"fmt"
"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/types"
)
var _ resource.Resource = &SiteResource{}
var _ resource.ResourceWithImportState = &SiteResource{}
// SiteResource defines the resource implementation.
type SiteResource struct {
client *Client
}
// SiteResourceModel describes the resource data model.
type SiteResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Location types.String `tfsdk:"location"`
SNMPCommunity types.String `tfsdk:"snmp_community"`
InsertedAt types.String `tfsdk:"inserted_at"`
}
// NewSiteResource creates a new site resource.
func NewSiteResource() resource.Resource {
return &SiteResource{}
}
func (r *SiteResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_site"
}
func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manages a TowerOps site. Sites represent physical locations that contain devices.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "The unique identifier of the site.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Description: "The name of the site. Must be between 2 and 200 characters.",
Required: true,
},
"location": schema.StringAttribute{
Description: "The physical location or address of the site.",
Optional: true,
},
"snmp_community": schema.StringAttribute{
Description: "The default SNMP community string for devices at this site.",
Optional: true,
Sensitive: true,
},
"inserted_at": schema.StringAttribute{
Description: "The timestamp when the site was created.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *SiteResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *Client, got: %T", req.ProviderData),
)
return
}
r.client = client
}
func (r *SiteResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data SiteResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
site := Site{
Name: data.Name.ValueString(),
}
if !data.Location.IsNull() {
location := data.Location.ValueString()
site.Location = &location
}
if !data.SNMPCommunity.IsNull() {
community := data.SNMPCommunity.ValueString()
site.SNMPCommunity = &community
}
created, err := r.client.CreateSite(site)
if err != nil {
resp.Diagnostics.AddError("Failed to create site", err.Error())
return
}
data.ID = types.StringValue(created.ID)
data.InsertedAt = types.StringValue(created.InsertedAt)
if created.Location != nil {
data.Location = types.StringValue(*created.Location)
}
if created.SNMPCommunity != nil {
data.SNMPCommunity = types.StringValue(*created.SNMPCommunity)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *SiteResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data SiteResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
site, err := r.client.GetSite(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Failed to read site", err.Error())
return
}
data.Name = types.StringValue(site.Name)
data.InsertedAt = types.StringValue(site.InsertedAt)
if site.Location != nil {
data.Location = types.StringValue(*site.Location)
} else {
data.Location = types.StringNull()
}
if site.SNMPCommunity != nil {
data.SNMPCommunity = types.StringValue(*site.SNMPCommunity)
} else {
data.SNMPCommunity = types.StringNull()
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *SiteResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data SiteResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
site := Site{
Name: data.Name.ValueString(),
}
if !data.Location.IsNull() {
location := data.Location.ValueString()
site.Location = &location
}
if !data.SNMPCommunity.IsNull() {
community := data.SNMPCommunity.ValueString()
site.SNMPCommunity = &community
}
updated, err := r.client.UpdateSite(data.ID.ValueString(), site)
if err != nil {
resp.Diagnostics.AddError("Failed to update site", err.Error())
return
}
data.Name = types.StringValue(updated.Name)
if updated.Location != nil {
data.Location = types.StringValue(*updated.Location)
}
if updated.SNMPCommunity != nil {
data.SNMPCommunity = types.StringValue(*updated.SNMPCommunity)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *SiteResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data SiteResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
err := r.client.DeleteSite(data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Failed to delete site", err.Error())
return
}
}
func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

22
main.go Normal file
View file

@ -0,0 +1,22 @@
package main
import (
"context"
"log"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/towerops/terraform-provider-towerops/internal/provider"
)
var version = "dev"
func main() {
opts := providerserver.ServeOpts{
Address: "registry.terraform.io/towerops/towerops",
}
err := providerserver.Serve(context.Background(), provider.New(version), opts)
if err != nil {
log.Fatal(err.Error())
}
}

BIN
terraform-provider-towerops Executable file

Binary file not shown.