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