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.
416 lines
14 KiB
Go
416 lines
14 KiB
Go
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.7–90).",
|
||
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 (3–650).",
|
||
Required: true,
|
||
},
|
||
"azimuth_deg": schema.Float64Attribute{
|
||
Description: "Antenna boresight azimuth in degrees, true north (0–360).",
|
||
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.3–25).",
|
||
Required: true,
|
||
},
|
||
"cable_loss_db": schema.Float64Attribute{
|
||
Description: "Coax / waveguide loss in dB.",
|
||
Optional: true,
|
||
Computed: true,
|
||
},
|
||
"foliage_tuning": schema.Int64Attribute{
|
||
Description: "0–100 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 (0–100).",
|
||
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)
|
||
}
|
||
}
|