feat: towerops_coverage resource

Wraps the new /api/v1/coverages endpoints. Schema mirrors the cnHeat
mental model — antenna, frequency, height, azimuth, downtilt, radius
— with cnHeat-style imperial units (height_agl_ft, radius_mi,
frequency_ghz) the API converts to SI before validation.

Compute is asynchronous: 'terraform apply' returns once the prediction
is queued. The status / progress_pct / error_message / png_path /
raster_path / computed_at fields are computed and update on
'terraform refresh' as the worker progresses through queued →
computing → ready (or failed). Downstream resources that need a
ready heatmap can use a terraform_data trigger that polls the
status field.

Resource semantics:
- Create   POST   /api/v1/coverages           (auto-queues compute)
- Read     GET    /api/v1/coverages/:id
- Update   PATCH  /api/v1/coverages/:id       (does NOT auto-recompute)
- Delete   DELETE /api/v1/coverages/:id

Update doesn't auto-recompute because parameter tweaks may not
require a fresh heatmap; operators can call the recompute endpoint
directly via the existing client method when they want fresh
outputs.

ImportState supported via the standard UUID passthrough.
This commit is contained in:
Graham McIntire 2026-05-06 17:21:30 -05:00
parent 936b278652
commit 05530894ae
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 531 additions and 0 deletions

View file

@ -663,3 +663,117 @@ func (c *Client) UpdateOrganization(org Organization) (*Organization, error) {
return &result.Data, nil
}
// Coverage represents a TowerOps RF coverage prediction.
//
// Inputs use the imperial virtual fields (height_agl_ft, radius_mi,
// frequency_ghz) that the Phoenix changeset converts to SI before
// validation. Read-back fields (status, png_path, etc.) are populated
// by the worker once compute completes.
type Coverage struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
OrganizationID string `json:"organization_id,omitempty"`
SiteID string `json:"site_id"`
DeviceID *string `json:"device_id,omitempty"`
AntennaSlug string `json:"antenna_slug"`
FrequencyGHz *float64 `json:"frequency_ghz,omitempty"`
FrequencyMHz *int `json:"frequency_mhz,omitempty"`
TXPowerDbm *float64 `json:"tx_power_dbm,omitempty"`
CableLossDb *float64 `json:"cable_loss_db,omitempty"`
SmGainDbi *float64 `json:"sm_gain_dbi,omitempty"`
HeightAGLFt *float64 `json:"height_agl_ft,omitempty"`
HeightAGLM *float64 `json:"height_agl_m,omitempty"`
HeightAboveRooftopM *float64 `json:"height_above_rooftop_m,omitempty"`
AzimuthDeg *float64 `json:"azimuth_deg,omitempty"`
DowntiltDeg *float64 `json:"downtilt_deg,omitempty"`
RadiusMi *float64 `json:"radius_mi,omitempty"`
RadiusM *int `json:"radius_m,omitempty"`
CellSizeM *int `json:"cell_size_m,omitempty"`
ReceiverHeightM *float64 `json:"receiver_height_m,omitempty"`
RxThresholdDbm *float64 `json:"rx_threshold_dbm,omitempty"`
FoliageTuning *int `json:"foliage_tuning,omitempty"`
LatitudeOverride *float64 `json:"latitude_override,omitempty"`
LongitudeOverride *float64 `json:"longitude_override,omitempty"`
Status string `json:"status,omitempty"`
ProgressPct int `json:"progress_pct,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ComputedAt string `json:"computed_at,omitempty"`
PNGPath string `json:"png_path,omitempty"`
RasterPath string `json:"raster_path,omitempty"`
InsertedAt string `json:"inserted_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// CreateCoverage creates a new coverage and immediately queues
// compute. Returned struct will have status="queued"; poll
// GetCoverage to wait for status="ready".
func (c *Client) CreateCoverage(coverage Coverage) (*Coverage, error) {
body := map[string]Coverage{"coverage": coverage}
respBody, err := c.doRequest(http.MethodPost, "/api/v1/coverages", body)
if err != nil {
return nil, err
}
var result Coverage
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// GetCoverage retrieves a coverage by ID.
func (c *Client) GetCoverage(id string) (*Coverage, error) {
respBody, err := c.doRequest(http.MethodGet, "/api/v1/coverages/"+id, nil)
if err != nil {
return nil, err
}
var result Coverage
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// UpdateCoverage updates editable fields of a coverage. Does not
// auto-recompute — call RecomputeCoverage if you want fresh
// outputs after a parameter change.
func (c *Client) UpdateCoverage(id string, coverage Coverage) (*Coverage, error) {
body := map[string]Coverage{"coverage": coverage}
respBody, err := c.doRequest(http.MethodPatch, "/api/v1/coverages/"+id, body)
if err != nil {
return nil, err
}
var result Coverage
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// DeleteCoverage deletes a coverage and its on-disk rasters.
func (c *Client) DeleteCoverage(id string) error {
_, err := c.doRequest(http.MethodDelete, "/api/v1/coverages/"+id, nil)
return err
}
// RecomputeCoverage requeues the compute pipeline for an existing
// coverage. Returns the coverage with status="queued".
func (c *Client) RecomputeCoverage(id string) (*Coverage, error) {
respBody, err := c.doRequest(http.MethodPost, "/api/v1/coverages/"+id+"/recompute", nil)
if err != nil {
return nil, err
}
var result Coverage
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}

View file

@ -0,0 +1,416 @@
package provider
import (
"context"
"errors"
"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 = &CoverageResource{}
var _ resource.ResourceWithImportState = &CoverageResource{}
// CoverageResource defines the resource implementation for cnHeat-style
// RF coverage predictions.
type CoverageResource struct {
client *Client
}
// CoverageResourceModel is the Terraform-side shape of the resource.
//
// The compute pipeline is asynchronous — `terraform apply` returns as
// soon as the prediction is queued. `status` will move from "queued"
// → "computing" → "ready" (or "failed") and updates on subsequent
// `terraform refresh` / `plan` runs. Use `terraform_data` triggers
// or external polling if downstream resources need to wait for ready.
type CoverageResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
SiteID types.String `tfsdk:"site_id"`
DeviceID types.String `tfsdk:"device_id"`
AntennaSlug types.String `tfsdk:"antenna_slug"`
FrequencyGHz types.Float64 `tfsdk:"frequency_ghz"`
TXPowerDbm types.Float64 `tfsdk:"tx_power_dbm"`
HeightAGLFt types.Float64 `tfsdk:"height_agl_ft"`
AzimuthDeg types.Float64 `tfsdk:"azimuth_deg"`
DowntiltDeg types.Float64 `tfsdk:"downtilt_deg"`
RadiusMi types.Float64 `tfsdk:"radius_mi"`
CableLossDb types.Float64 `tfsdk:"cable_loss_db"`
FoliageTuning types.Int64 `tfsdk:"foliage_tuning"`
LatitudeOverride types.Float64 `tfsdk:"latitude_override"`
LongitudeOverride types.Float64 `tfsdk:"longitude_override"`
Status types.String `tfsdk:"status"`
ProgressPct types.Int64 `tfsdk:"progress_pct"`
ErrorMessage types.String `tfsdk:"error_message"`
ComputedAt types.String `tfsdk:"computed_at"`
PNGPath types.String `tfsdk:"png_path"`
RasterPath types.String `tfsdk:"raster_path"`
InsertedAt types.String `tfsdk:"inserted_at"`
}
// NewCoverageResource creates a new coverage resource.
func NewCoverageResource() resource.Resource {
return &CoverageResource{}
}
func (r *CoverageResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_coverage"
}
func (r *CoverageResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manages a TowerOps RF coverage prediction. Compute runs asynchronously; the `status` field reaches \"ready\" once the heatmap is on disk.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Unique identifier of the coverage.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Description: "Human-readable name for the coverage (unique per site).",
Required: true,
},
"site_id": schema.StringAttribute{
Description: "ID of the site the coverage belongs to.",
Required: true,
},
"device_id": schema.StringAttribute{
Description: "Optional ID of the device this coverage is attached to.",
Optional: true,
},
"antenna_slug": schema.StringAttribute{
Description: "Slug of the antenna in Towerops's catalog (e.g. \"rf-elements-twistport-symmetrical-horn-30\").",
Required: true,
},
"frequency_ghz": schema.Float64Attribute{
Description: "Operating frequency in GHz (0.790).",
Required: true,
},
"tx_power_dbm": schema.Float64Attribute{
Description: "Transmit power at the radio in dBm (-10 to 50).",
Required: true,
},
"height_agl_ft": schema.Float64Attribute{
Description: "Antenna height above ground level in feet (3650).",
Required: true,
},
"azimuth_deg": schema.Float64Attribute{
Description: "Antenna boresight azimuth in degrees, true north (0360).",
Required: true,
},
"downtilt_deg": schema.Float64Attribute{
Description: "Antenna mechanical down-tilt in degrees (positive = down).",
Optional: true,
Computed: true,
},
"radius_mi": schema.Float64Attribute{
Description: "Coverage radius in miles (0.325).",
Required: true,
},
"cable_loss_db": schema.Float64Attribute{
Description: "Coax / waveguide loss in dB.",
Optional: true,
Computed: true,
},
"foliage_tuning": schema.Int64Attribute{
Description: "0100 slider that increases predicted path loss in wooded areas.",
Optional: true,
Computed: true,
},
"latitude_override": schema.Float64Attribute{
Description: "Optional latitude override for the antenna location. Defaults to the parent site's latitude.",
Optional: true,
},
"longitude_override": schema.Float64Attribute{
Description: "Optional longitude override for the antenna location. Defaults to the parent site's longitude.",
Optional: true,
},
"status": schema.StringAttribute{
Description: "Compute status: \"draft\", \"queued\", \"computing\", \"ready\", or \"failed\".",
Computed: true,
},
"progress_pct": schema.Int64Attribute{
Description: "Compute progress in percent (0100).",
Computed: true,
},
"error_message": schema.StringAttribute{
Description: "Failure reason when status is \"failed\".",
Computed: true,
},
"computed_at": schema.StringAttribute{
Description: "Timestamp when the most recent successful compute finished.",
Computed: true,
},
"png_path": schema.StringAttribute{
Description: "Server-relative URL of the colored heatmap PNG (default SM-height tier).",
Computed: true,
},
"raster_path": schema.StringAttribute{
Description: "Server-relative URL of the Float32 GeoTIFF raster.",
Computed: true,
},
"inserted_at": schema.StringAttribute{
Description: "Timestamp the coverage record was created.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *CoverageResource) 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 *CoverageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data CoverageResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
created, err := r.client.CreateCoverage(buildCoverageFromModel(&data))
if err != nil {
resp.Diagnostics.AddError("Failed to create coverage", err.Error())
return
}
applyCoverageToModel(&data, created)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *CoverageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data CoverageResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
cov, err := r.client.GetCoverage(data.ID.ValueString())
if err != nil {
if errors.Is(err, ErrNotFound) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Failed to read coverage", err.Error())
return
}
applyCoverageToModel(&data, cov)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *CoverageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data CoverageResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
updated, err := r.client.UpdateCoverage(data.ID.ValueString(), buildCoverageFromModel(&data))
if err != nil {
if errors.Is(err, ErrNotFound) {
created, createErr := r.client.CreateCoverage(buildCoverageFromModel(&data))
if createErr != nil {
resp.Diagnostics.AddError("Failed to recreate coverage after 404", createErr.Error())
return
}
applyCoverageToModel(&data, created)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
return
}
resp.Diagnostics.AddError("Failed to update coverage", err.Error())
return
}
applyCoverageToModel(&data, updated)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *CoverageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data CoverageResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteCoverage(data.ID.ValueString()); err != nil && !errors.Is(err, ErrNotFound) {
resp.Diagnostics.AddError("Failed to delete coverage", err.Error())
return
}
}
func (r *CoverageResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
// buildCoverageFromModel converts the Terraform plan into the JSON
// shape the API expects.
func buildCoverageFromModel(data *CoverageResourceModel) Coverage {
cov := Coverage{
Name: data.Name.ValueString(),
SiteID: data.SiteID.ValueString(),
AntennaSlug: data.AntennaSlug.ValueString(),
}
if !data.DeviceID.IsNull() && !data.DeviceID.IsUnknown() {
v := data.DeviceID.ValueString()
cov.DeviceID = &v
}
if !data.FrequencyGHz.IsNull() && !data.FrequencyGHz.IsUnknown() {
v := data.FrequencyGHz.ValueFloat64()
cov.FrequencyGHz = &v
}
if !data.TXPowerDbm.IsNull() && !data.TXPowerDbm.IsUnknown() {
v := data.TXPowerDbm.ValueFloat64()
cov.TXPowerDbm = &v
}
if !data.HeightAGLFt.IsNull() && !data.HeightAGLFt.IsUnknown() {
v := data.HeightAGLFt.ValueFloat64()
cov.HeightAGLFt = &v
}
if !data.AzimuthDeg.IsNull() && !data.AzimuthDeg.IsUnknown() {
v := data.AzimuthDeg.ValueFloat64()
cov.AzimuthDeg = &v
}
if !data.DowntiltDeg.IsNull() && !data.DowntiltDeg.IsUnknown() {
v := data.DowntiltDeg.ValueFloat64()
cov.DowntiltDeg = &v
}
if !data.RadiusMi.IsNull() && !data.RadiusMi.IsUnknown() {
v := data.RadiusMi.ValueFloat64()
cov.RadiusMi = &v
}
if !data.CableLossDb.IsNull() && !data.CableLossDb.IsUnknown() {
v := data.CableLossDb.ValueFloat64()
cov.CableLossDb = &v
}
if !data.FoliageTuning.IsNull() && !data.FoliageTuning.IsUnknown() {
v := int(data.FoliageTuning.ValueInt64())
cov.FoliageTuning = &v
}
if !data.LatitudeOverride.IsNull() && !data.LatitudeOverride.IsUnknown() {
v := data.LatitudeOverride.ValueFloat64()
cov.LatitudeOverride = &v
}
if !data.LongitudeOverride.IsNull() && !data.LongitudeOverride.IsUnknown() {
v := data.LongitudeOverride.ValueFloat64()
cov.LongitudeOverride = &v
}
return cov
}
// applyCoverageToModel maps the API response back onto the Terraform
// model.
func applyCoverageToModel(data *CoverageResourceModel, cov *Coverage) {
data.ID = types.StringValue(cov.ID)
data.Name = types.StringValue(cov.Name)
data.SiteID = types.StringValue(cov.SiteID)
data.AntennaSlug = types.StringValue(cov.AntennaSlug)
if cov.DeviceID != nil {
data.DeviceID = types.StringValue(*cov.DeviceID)
} else {
data.DeviceID = types.StringNull()
}
if cov.FrequencyGHz != nil {
data.FrequencyGHz = types.Float64Value(*cov.FrequencyGHz)
} else if cov.FrequencyMHz != nil {
data.FrequencyGHz = types.Float64Value(float64(*cov.FrequencyMHz) / 1000.0)
}
if cov.TXPowerDbm != nil {
data.TXPowerDbm = types.Float64Value(*cov.TXPowerDbm)
}
if cov.HeightAGLFt != nil {
data.HeightAGLFt = types.Float64Value(*cov.HeightAGLFt)
} else if cov.HeightAGLM != nil {
data.HeightAGLFt = types.Float64Value(*cov.HeightAGLM / 0.3048)
}
if cov.AzimuthDeg != nil {
data.AzimuthDeg = types.Float64Value(*cov.AzimuthDeg)
}
if cov.DowntiltDeg != nil {
data.DowntiltDeg = types.Float64Value(*cov.DowntiltDeg)
} else {
data.DowntiltDeg = types.Float64Value(0)
}
if cov.RadiusMi != nil {
data.RadiusMi = types.Float64Value(*cov.RadiusMi)
} else if cov.RadiusM != nil {
data.RadiusMi = types.Float64Value(float64(*cov.RadiusM) / 1609.344)
}
if cov.CableLossDb != nil {
data.CableLossDb = types.Float64Value(*cov.CableLossDb)
} else {
data.CableLossDb = types.Float64Value(0)
}
if cov.FoliageTuning != nil {
data.FoliageTuning = types.Int64Value(int64(*cov.FoliageTuning))
} else {
data.FoliageTuning = types.Int64Value(0)
}
if cov.LatitudeOverride != nil {
data.LatitudeOverride = types.Float64Value(*cov.LatitudeOverride)
} else {
data.LatitudeOverride = types.Float64Null()
}
if cov.LongitudeOverride != nil {
data.LongitudeOverride = types.Float64Value(*cov.LongitudeOverride)
} else {
data.LongitudeOverride = types.Float64Null()
}
data.Status = types.StringValue(cov.Status)
data.ProgressPct = types.Int64Value(int64(cov.ProgressPct))
if cov.ErrorMessage != "" {
data.ErrorMessage = types.StringValue(cov.ErrorMessage)
} else {
data.ErrorMessage = types.StringNull()
}
if cov.ComputedAt != "" {
data.ComputedAt = types.StringValue(cov.ComputedAt)
} else {
data.ComputedAt = types.StringNull()
}
if cov.PNGPath != "" {
data.PNGPath = types.StringValue(cov.PNGPath)
} else {
data.PNGPath = types.StringNull()
}
if cov.RasterPath != "" {
data.RasterPath = types.StringValue(cov.RasterPath)
} else {
data.RasterPath = types.StringNull()
}
if cov.InsertedAt != "" {
data.InsertedAt = types.StringValue(cov.InsertedAt)
}
}

View file

@ -95,6 +95,7 @@ func (p *ToweropsProvider) Resources(ctx context.Context) []func() resource.Reso
NewAgentResource,
NewIntegrationResource,
NewMaintenanceWindowResource,
NewCoverageResource,
}
}