diff --git a/internal/provider/client.go b/internal/provider/client.go index 3f864cf..1547291 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -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 +} diff --git a/internal/provider/coverage_resource.go b/internal/provider/coverage_resource.go new file mode 100644 index 0000000..9d11202 --- /dev/null +++ b/internal/provider/coverage_resource.go @@ -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.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) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4d2dd3b..d1dd04a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -95,6 +95,7 @@ func (p *ToweropsProvider) Resources(ctx context.Context) []func() resource.Reso NewAgentResource, NewIntegrationResource, NewMaintenanceWindowResource, + NewCoverageResource, } }