terraform-provider-towerops/internal/provider/coverage_resource.go
Graham McIntire 05530894ae
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.
2026-05-06 17:21:30 -05:00

416 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}