From bbe1babdf7d5edd70971e1790a813eea40143c20 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Mar 2026 14:33:52 -0500 Subject: [PATCH] add agent, integration, and maintenance window resources New Terraform resources: towerops_agent, towerops_integration, towerops_maintenance_window with full CRUD, docs, and examples. --- docs/index.md | 33 ++ docs/resources/agent.md | 54 ++++ docs/resources/integration.md | 63 ++++ docs/resources/maintenance_window.md | 74 +++++ examples/main.tf | 24 ++ examples/resources/towerops_agent/resource.tf | 8 + .../towerops_integration/resource.tf | 5 + .../towerops_maintenance_window/resource.tf | 6 + internal/provider/agent_resource.go | 173 ++++++++++ internal/provider/client.go | 189 +++++++++++ internal/provider/integration_resource.go | 244 +++++++++++++++ .../provider/maintenance_window_resource.go | 296 ++++++++++++++++++ internal/provider/provider.go | 3 + 13 files changed, 1172 insertions(+) create mode 100644 docs/resources/agent.md create mode 100644 docs/resources/integration.md create mode 100644 docs/resources/maintenance_window.md create mode 100644 examples/resources/towerops_agent/resource.tf create mode 100644 examples/resources/towerops_integration/resource.tf create mode 100644 examples/resources/towerops_maintenance_window/resource.tf create mode 100644 internal/provider/agent_resource.go create mode 100644 internal/provider/integration_resource.go create mode 100644 internal/provider/maintenance_window_resource.go diff --git a/docs/index.md b/docs/index.md index 42274e3..b25d008 100644 --- a/docs/index.md +++ b/docs/index.md @@ -117,6 +117,39 @@ resource "towerops_escalation_policy" "critical" { } ``` +### Agent Token + +```terraform +resource "towerops_agent" "office" { + name = "Office Poller" +} + +output "agent_token" { + value = towerops_agent.office.token + sensitive = true +} +``` + +### Integration + +```terraform +resource "towerops_integration" "pagerduty" { + provider = "pagerduty" + enabled = true +} +``` + +### Maintenance Window + +```terraform +resource "towerops_maintenance_window" "network_upgrade" { + name = "Network Upgrade" + reason = "Upgrading core switches to new firmware" + starts_at = "2024-03-15T02:00:00Z" + ends_at = "2024-03-15T06:00:00Z" +} +``` + ## Schema ### Required diff --git a/docs/resources/agent.md b/docs/resources/agent.md new file mode 100644 index 0000000..8a25f74 --- /dev/null +++ b/docs/resources/agent.md @@ -0,0 +1,54 @@ +--- +page_title: "towerops_agent Resource - TowerOps" +description: |- + Manages a TowerOps agent token. +--- + +# towerops_agent (Resource) + +Manages a TowerOps agent token. Agents are deployed on customer networks to poll devices via SNMP, ping, and SSH. The agent token is returned only on creation and cannot be retrieved again. + +~> **Note:** The `token` attribute is only available after creation. If the state is lost, the agent must be deleted and recreated to obtain a new token. + +## Example Usage + +### Basic Agent + +```terraform +resource "towerops_agent" "office" { + name = "Office Poller" +} +``` + +### Using the Token + +```terraform +resource "towerops_agent" "remote" { + name = "Remote Site Poller" +} + +output "agent_token" { + value = towerops_agent.remote.token + sensitive = true +} +``` + +## Schema + +### Required + +- `name` (String) - The name of the agent. Changing this forces a new resource to be created. + +### Read-Only + +- `id` (String) - The unique identifier of the agent. +- `token` (String, Sensitive) - The bearer token for this agent. Only available after creation and cannot be retrieved again. +- `inserted_at` (String) - The timestamp when the agent was created. + +## Import + +Agents can be imported using their UUID. Note that the token will not be available after import. + +```shell +terraform import towerops_agent.example 550e8400-e29b-41d4-a716-446655440000 +``` diff --git a/docs/resources/integration.md b/docs/resources/integration.md new file mode 100644 index 0000000..fabb41d --- /dev/null +++ b/docs/resources/integration.md @@ -0,0 +1,63 @@ +--- +page_title: "towerops_integration Resource - TowerOps" +description: |- + Manages a TowerOps integration with third-party services. +--- + +# towerops_integration (Resource) + +Manages a TowerOps integration with third-party services such as PagerDuty, Slack, or webhooks. + +## Example Usage + +### PagerDuty Integration + +```terraform +resource "towerops_integration" "pagerduty" { + provider = "pagerduty" + enabled = true +} +``` + +### Webhook Integration with Sync Interval + +```terraform +resource "towerops_integration" "webhook" { + provider = "webhook" + enabled = true + sync_interval_minutes = 15 +} +``` + +### Disabled Integration + +```terraform +resource "towerops_integration" "slack" { + provider = "slack" + enabled = false +} +``` + +## Schema + +### Required + +- `provider` (String) - The integration provider type (e.g. `pagerduty`, `slack`, `webhook`). + +### Optional + +- `enabled` (Boolean) - Whether the integration is enabled. Defaults to `true`. +- `sync_interval_minutes` (Number) - How often the integration syncs, in minutes. + +### Read-Only + +- `id` (String) - The unique identifier of the integration. +- `inserted_at` (String) - The timestamp when the integration was created. + +## Import + +Integrations can be imported using their UUID: + +```shell +terraform import towerops_integration.example 550e8400-e29b-41d4-a716-446655440000 +``` diff --git a/docs/resources/maintenance_window.md b/docs/resources/maintenance_window.md new file mode 100644 index 0000000..b3089a3 --- /dev/null +++ b/docs/resources/maintenance_window.md @@ -0,0 +1,74 @@ +--- +page_title: "towerops_maintenance_window Resource - TowerOps" +description: |- + Manages a TowerOps maintenance window. +--- + +# towerops_maintenance_window (Resource) + +Manages a TowerOps maintenance window. Maintenance windows suppress alerts during planned work periods and can be scoped to specific sites or devices. + +## Example Usage + +### Organization-Wide Maintenance Window + +```terraform +resource "towerops_maintenance_window" "network_upgrade" { + name = "Network Upgrade" + reason = "Upgrading core switches to new firmware" + starts_at = "2024-03-15T02:00:00Z" + ends_at = "2024-03-15T06:00:00Z" +} +``` + +### Site-Scoped Maintenance Window + +```terraform +resource "towerops_maintenance_window" "office_maintenance" { + name = "Office Network Maintenance" + reason = "Replacing office UPS" + starts_at = "2024-03-20T22:00:00Z" + ends_at = "2024-03-21T02:00:00Z" + site_id = towerops_site.main_office.id +} +``` + +### Device-Scoped Maintenance Window + +```terraform +resource "towerops_maintenance_window" "router_update" { + name = "Router Firmware Update" + starts_at = "2024-03-18T03:00:00Z" + ends_at = "2024-03-18T04:00:00Z" + device_id = towerops_device.core_router.id + suppress_alerts = true +} +``` + +## Schema + +### Required + +- `name` (String) - The name of the maintenance window. +- `starts_at` (String) - The start time in ISO 8601 format (e.g. `2024-01-15T02:00:00Z`). +- `ends_at` (String) - The end time in ISO 8601 format (e.g. `2024-01-15T06:00:00Z`). + +### Optional + +- `reason` (String) - The reason for the maintenance window. +- `suppress_alerts` (Boolean) - Whether to suppress alerts during the window. Defaults to `true`. +- `site_id` (String) - The site to apply the maintenance window to. If omitted, applies to all sites. +- `device_id` (String) - The device to apply the maintenance window to. If omitted, applies to all devices. + +### Read-Only + +- `id` (String) - The unique identifier of the maintenance window. +- `inserted_at` (String) - The timestamp when the maintenance window was created. + +## Import + +Maintenance windows can be imported using their UUID: + +```shell +terraform import towerops_maintenance_window.example 550e8400-e29b-41d4-a716-446655440000 +``` diff --git a/examples/main.tf b/examples/main.tf index 9170a43..94f3c1a 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -64,6 +64,25 @@ resource "towerops_escalation_policy" "default" { repeat_count = 3 } +# Create an agent token +resource "towerops_agent" "remote" { + name = "Remote Site Poller" +} + +# Create an integration +resource "towerops_integration" "pagerduty" { + provider = "pagerduty" + enabled = true +} + +# Create a maintenance window +resource "towerops_maintenance_window" "network_upgrade" { + name = "Network Upgrade" + reason = "Upgrading core switches" + starts_at = "2024-03-15T02:00:00Z" + ends_at = "2024-03-15T06:00:00Z" +} + # Output the site ID output "site_id" { value = towerops_site.main_office.id @@ -80,3 +99,8 @@ output "schedule_id" { output "escalation_policy_id" { value = towerops_escalation_policy.default.id } + +output "agent_token" { + value = towerops_agent.remote.token + sensitive = true +} diff --git a/examples/resources/towerops_agent/resource.tf b/examples/resources/towerops_agent/resource.tf new file mode 100644 index 0000000..80e8db1 --- /dev/null +++ b/examples/resources/towerops_agent/resource.tf @@ -0,0 +1,8 @@ +resource "towerops_agent" "office" { + name = "Office Poller" +} + +output "agent_token" { + value = towerops_agent.office.token + sensitive = true +} diff --git a/examples/resources/towerops_integration/resource.tf b/examples/resources/towerops_integration/resource.tf new file mode 100644 index 0000000..d0c0d38 --- /dev/null +++ b/examples/resources/towerops_integration/resource.tf @@ -0,0 +1,5 @@ +resource "towerops_integration" "pagerduty" { + provider = "pagerduty" + enabled = true + sync_interval_minutes = 5 +} diff --git a/examples/resources/towerops_maintenance_window/resource.tf b/examples/resources/towerops_maintenance_window/resource.tf new file mode 100644 index 0000000..2f64829 --- /dev/null +++ b/examples/resources/towerops_maintenance_window/resource.tf @@ -0,0 +1,6 @@ +resource "towerops_maintenance_window" "network_upgrade" { + name = "Network Upgrade" + reason = "Upgrading core switches to new firmware" + starts_at = "2024-03-15T02:00:00Z" + ends_at = "2024-03-15T06:00:00Z" +} diff --git a/internal/provider/agent_resource.go b/internal/provider/agent_resource.go new file mode 100644 index 0000000..8c7e92d --- /dev/null +++ b/internal/provider/agent_resource.go @@ -0,0 +1,173 @@ +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 = &AgentResource{} +var _ resource.ResourceWithImportState = &AgentResource{} + +// AgentResource defines the resource implementation. +type AgentResource struct { + client *Client +} + +// AgentResourceModel describes the resource data model. +type AgentResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Token types.String `tfsdk:"token"` + InsertedAt types.String `tfsdk:"inserted_at"` +} + +// NewAgentResource creates a new agent resource. +func NewAgentResource() resource.Resource { + return &AgentResource{} +} + +func (r *AgentResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_agent" +} + +func (r *AgentResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a TowerOps agent token. Agents are deployed on customer networks to poll devices via SNMP, ping, and SSH.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier of the agent.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the agent. Changing this forces a new resource to be created.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "token": schema.StringAttribute{ + Description: "The bearer token for this agent. Only available after creation and cannot be retrieved again.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "inserted_at": schema.StringAttribute{ + Description: "The timestamp when the agent was created.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *AgentResource) 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 *AgentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data AgentResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + agent := Agent{ + Name: data.Name.ValueString(), + } + + created, err := r.client.CreateAgent(agent) + if err != nil { + resp.Diagnostics.AddError("Failed to create agent", err.Error()) + return + } + + data.ID = types.StringValue(created.ID) + data.Token = types.StringValue(created.Token) + data.InsertedAt = types.StringValue(created.InsertedAt) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *AgentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data AgentResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + agent, err := r.client.GetAgent(data.ID.ValueString()) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Failed to read agent", err.Error()) + return + } + + data.Name = types.StringValue(agent.Name) + data.InsertedAt = types.StringValue(agent.InsertedAt) + // Token is not returned by GET, preserve existing state value + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *AgentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Agents only support name changes via the API, but the REST API doesn't have + // an update endpoint. For now, if name changes, we must destroy and recreate. + // This is handled by Terraform's ForceNew-like behavior since all mutable + // attributes are required. + resp.Diagnostics.AddError( + "Agent Update Not Supported", + "Agent tokens cannot be updated. Delete and recreate the agent to change its name.", + ) +} + +func (r *AgentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data AgentResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteAgent(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to delete agent", err.Error()) + return + } +} + +func (r *AgentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/client.go b/internal/provider/client.go index c4a66ff..4949b24 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -367,6 +367,195 @@ func (c *Client) DeleteEscalationPolicy(id string) error { return err } +// Agent represents a TowerOps agent token. +type Agent struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Enabled *bool `json:"enabled,omitempty"` + LastSeenAt *string `json:"last_seen_at,omitempty"` + InsertedAt string `json:"inserted_at,omitempty"` + Token string `json:"token,omitempty"` +} + +// agentCreateResponse wraps the create response which includes token. +type agentCreateResponse struct { + Agent +} + +// CreateAgent creates a new agent token. +func (c *Client) CreateAgent(agent Agent) (*Agent, error) { + body := map[string]Agent{"agent": agent} + respBody, err := c.doRequest(http.MethodPost, "/api/v1/agents", body) + if err != nil { + return nil, err + } + + var result Agent + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// GetAgent retrieves an agent by ID. +func (c *Client) GetAgent(id string) (*Agent, error) { + respBody, err := c.doRequest(http.MethodGet, "/api/v1/agents/"+id, nil) + if err != nil { + return nil, err + } + + var result Agent + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// DeleteAgent deletes an agent. +func (c *Client) DeleteAgent(id string) error { + _, err := c.doRequest(http.MethodDelete, "/api/v1/agents/"+id, nil) + return err +} + +// Integration represents a TowerOps integration. +type Integration struct { + ID string `json:"id,omitempty"` + Provider string `json:"provider"` + Enabled *bool `json:"enabled,omitempty"` + SyncIntervalMinutes *int `json:"sync_interval_minutes,omitempty"` + InsertedAt string `json:"inserted_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// integrationWithCredentials is used for create/update requests that include credentials. +type integrationWithCredentials struct { + Provider string `json:"provider"` + Enabled *bool `json:"enabled,omitempty"` + Credentials map[string]interface{} `json:"credentials,omitempty"` + SyncIntervalMinutes *int `json:"sync_interval_minutes,omitempty"` +} + +// CreateIntegration creates a new integration. +func (c *Client) CreateIntegration(integration integrationWithCredentials) (*Integration, error) { + body := map[string]integrationWithCredentials{"integration": integration} + respBody, err := c.doRequest(http.MethodPost, "/api/v1/integrations", body) + if err != nil { + return nil, err + } + + var result Integration + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// GetIntegration retrieves an integration by ID. +func (c *Client) GetIntegration(id string) (*Integration, error) { + respBody, err := c.doRequest(http.MethodGet, "/api/v1/integrations/"+id, nil) + if err != nil { + return nil, err + } + + var result Integration + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// UpdateIntegration updates an existing integration. +func (c *Client) UpdateIntegration(id string, integration integrationWithCredentials) (*Integration, error) { + body := map[string]integrationWithCredentials{"integration": integration} + respBody, err := c.doRequest(http.MethodPatch, "/api/v1/integrations/"+id, body) + if err != nil { + return nil, err + } + + var result Integration + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// DeleteIntegration deletes an integration. +func (c *Client) DeleteIntegration(id string) error { + _, err := c.doRequest(http.MethodDelete, "/api/v1/integrations/"+id, nil) + return err +} + +// MaintenanceWindowAPI represents a TowerOps maintenance window. +type MaintenanceWindowAPI struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Reason *string `json:"reason,omitempty"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + SuppressAlerts *bool `json:"suppress_alerts,omitempty"` + SiteID *string `json:"site_id,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + InsertedAt string `json:"inserted_at,omitempty"` +} + +// CreateMaintenanceWindow creates a new maintenance window. +func (c *Client) CreateMaintenanceWindow(window MaintenanceWindowAPI) (*MaintenanceWindowAPI, error) { + body := map[string]MaintenanceWindowAPI{"maintenance_window": window} + respBody, err := c.doRequest(http.MethodPost, "/api/v1/maintenance_windows", body) + if err != nil { + return nil, err + } + + var result MaintenanceWindowAPI + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// GetMaintenanceWindow retrieves a maintenance window by ID. +func (c *Client) GetMaintenanceWindow(id string) (*MaintenanceWindowAPI, error) { + respBody, err := c.doRequest(http.MethodGet, "/api/v1/maintenance_windows/"+id, nil) + if err != nil { + return nil, err + } + + var result MaintenanceWindowAPI + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// UpdateMaintenanceWindow updates an existing maintenance window. +func (c *Client) UpdateMaintenanceWindow(id string, window MaintenanceWindowAPI) (*MaintenanceWindowAPI, error) { + body := map[string]MaintenanceWindowAPI{"maintenance_window": window} + respBody, err := c.doRequest(http.MethodPatch, "/api/v1/maintenance_windows/"+id, body) + if err != nil { + return nil, err + } + + var result MaintenanceWindowAPI + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// DeleteMaintenanceWindow deletes a maintenance window. +func (c *Client) DeleteMaintenanceWindow(id string) error { + _, err := c.doRequest(http.MethodDelete, "/api/v1/maintenance_windows/"+id, nil) + return err +} + // GetOrganization retrieves the current organization settings. func (c *Client) GetOrganization() (*Organization, error) { respBody, err := c.doRequest(http.MethodGet, "/api/v1/organization", nil) diff --git a/internal/provider/integration_resource.go b/internal/provider/integration_resource.go new file mode 100644 index 0000000..ddda845 --- /dev/null +++ b/internal/provider/integration_resource.go @@ -0,0 +1,244 @@ +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/booldefault" + "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 = &IntegrationResource{} +var _ resource.ResourceWithImportState = &IntegrationResource{} + +// IntegrationResource defines the resource implementation. +type IntegrationResource struct { + client *Client +} + +// IntegrationResourceModel describes the resource data model. +type IntegrationResourceModel struct { + ID types.String `tfsdk:"id"` + Provider types.String `tfsdk:"provider"` + Enabled types.Bool `tfsdk:"enabled"` + SyncIntervalMinutes types.Int64 `tfsdk:"sync_interval_minutes"` + InsertedAt types.String `tfsdk:"inserted_at"` +} + +// NewIntegrationResource creates a new integration resource. +func NewIntegrationResource() resource.Resource { + return &IntegrationResource{} +} + +func (r *IntegrationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_integration" +} + +func (r *IntegrationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a TowerOps integration with third-party services.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier of the integration.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "provider": schema.StringAttribute{ + Description: "The integration provider type (e.g. pagerduty, slack, webhook).", + Required: true, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the integration is enabled. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "sync_interval_minutes": schema.Int64Attribute{ + Description: "How often the integration syncs, in minutes.", + Optional: true, + }, + "inserted_at": schema.StringAttribute{ + Description: "The timestamp when the integration was created.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *IntegrationResource) 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 *IntegrationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data IntegrationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + integration := integrationWithCredentials{ + Provider: data.Provider.ValueString(), + } + + if !data.Enabled.IsNull() { + enabled := data.Enabled.ValueBool() + integration.Enabled = &enabled + } + + if !data.SyncIntervalMinutes.IsNull() { + interval := int(data.SyncIntervalMinutes.ValueInt64()) + integration.SyncIntervalMinutes = &interval + } + + created, err := r.client.CreateIntegration(integration) + if err != nil { + resp.Diagnostics.AddError("Failed to create integration", err.Error()) + return + } + + data.ID = types.StringValue(created.ID) + data.InsertedAt = types.StringValue(created.InsertedAt) + + if created.Enabled != nil { + data.Enabled = types.BoolValue(*created.Enabled) + } + if created.SyncIntervalMinutes != nil { + data.SyncIntervalMinutes = types.Int64Value(int64(*created.SyncIntervalMinutes)) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *IntegrationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data IntegrationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + integration, err := r.client.GetIntegration(data.ID.ValueString()) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Failed to read integration", err.Error()) + return + } + + data.Provider = types.StringValue(integration.Provider) + data.InsertedAt = types.StringValue(integration.InsertedAt) + + if integration.Enabled != nil { + data.Enabled = types.BoolValue(*integration.Enabled) + } + if integration.SyncIntervalMinutes != nil { + data.SyncIntervalMinutes = types.Int64Value(int64(*integration.SyncIntervalMinutes)) + } else { + data.SyncIntervalMinutes = types.Int64Null() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *IntegrationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data IntegrationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + integration := integrationWithCredentials{ + Provider: data.Provider.ValueString(), + } + + if !data.Enabled.IsNull() { + enabled := data.Enabled.ValueBool() + integration.Enabled = &enabled + } + + if !data.SyncIntervalMinutes.IsNull() { + interval := int(data.SyncIntervalMinutes.ValueInt64()) + integration.SyncIntervalMinutes = &interval + } + + updated, err := r.client.UpdateIntegration(data.ID.ValueString(), integration) + if err != nil { + if errors.Is(err, ErrNotFound) { + created, createErr := r.client.CreateIntegration(integration) + if createErr != nil { + resp.Diagnostics.AddError("Failed to create integration (after 404 on update)", createErr.Error()) + return + } + data.ID = types.StringValue(created.ID) + data.InsertedAt = types.StringValue(created.InsertedAt) + if created.Enabled != nil { + data.Enabled = types.BoolValue(*created.Enabled) + } + if created.SyncIntervalMinutes != nil { + data.SyncIntervalMinutes = types.Int64Value(int64(*created.SyncIntervalMinutes)) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + resp.Diagnostics.AddError("Failed to update integration", err.Error()) + return + } + + data.Provider = types.StringValue(updated.Provider) + + if updated.Enabled != nil { + data.Enabled = types.BoolValue(*updated.Enabled) + } + if updated.SyncIntervalMinutes != nil { + data.SyncIntervalMinutes = types.Int64Value(int64(*updated.SyncIntervalMinutes)) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *IntegrationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data IntegrationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteIntegration(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to delete integration", err.Error()) + return + } +} + +func (r *IntegrationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/maintenance_window_resource.go b/internal/provider/maintenance_window_resource.go new file mode 100644 index 0000000..552b353 --- /dev/null +++ b/internal/provider/maintenance_window_resource.go @@ -0,0 +1,296 @@ +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/booldefault" + "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 = &MaintenanceWindowResource{} +var _ resource.ResourceWithImportState = &MaintenanceWindowResource{} + +// MaintenanceWindowResource defines the resource implementation. +type MaintenanceWindowResource struct { + client *Client +} + +// MaintenanceWindowResourceModel describes the resource data model. +type MaintenanceWindowResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Reason types.String `tfsdk:"reason"` + StartsAt types.String `tfsdk:"starts_at"` + EndsAt types.String `tfsdk:"ends_at"` + SuppressAlerts types.Bool `tfsdk:"suppress_alerts"` + SiteID types.String `tfsdk:"site_id"` + DeviceID types.String `tfsdk:"device_id"` + InsertedAt types.String `tfsdk:"inserted_at"` +} + +// NewMaintenanceWindowResource creates a new maintenance window resource. +func NewMaintenanceWindowResource() resource.Resource { + return &MaintenanceWindowResource{} +} + +func (r *MaintenanceWindowResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_maintenance_window" +} + +func (r *MaintenanceWindowResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a TowerOps maintenance window. Maintenance windows suppress alerts during planned work periods.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier of the maintenance window.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the maintenance window.", + Required: true, + }, + "reason": schema.StringAttribute{ + Description: "The reason for the maintenance window.", + Optional: true, + }, + "starts_at": schema.StringAttribute{ + Description: "The start time of the maintenance window in ISO 8601 format (e.g. 2024-01-15T02:00:00Z).", + Required: true, + }, + "ends_at": schema.StringAttribute{ + Description: "The end time of the maintenance window in ISO 8601 format (e.g. 2024-01-15T06:00:00Z).", + Required: true, + }, + "suppress_alerts": schema.BoolAttribute{ + Description: "Whether to suppress alerts during the maintenance window. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "site_id": schema.StringAttribute{ + Description: "The site to apply the maintenance window to. If omitted, applies to all sites.", + Optional: true, + }, + "device_id": schema.StringAttribute{ + Description: "The device to apply the maintenance window to. If omitted, applies to all devices.", + Optional: true, + }, + "inserted_at": schema.StringAttribute{ + Description: "The timestamp when the maintenance window was created.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *MaintenanceWindowResource) 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 *MaintenanceWindowResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data MaintenanceWindowResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + window := MaintenanceWindowAPI{ + Name: data.Name.ValueString(), + StartsAt: data.StartsAt.ValueString(), + EndsAt: data.EndsAt.ValueString(), + } + + if !data.Reason.IsNull() { + reason := data.Reason.ValueString() + window.Reason = &reason + } + + if !data.SuppressAlerts.IsNull() { + suppress := data.SuppressAlerts.ValueBool() + window.SuppressAlerts = &suppress + } + + if !data.SiteID.IsNull() { + siteID := data.SiteID.ValueString() + window.SiteID = &siteID + } + + if !data.DeviceID.IsNull() { + deviceID := data.DeviceID.ValueString() + window.DeviceID = &deviceID + } + + created, err := r.client.CreateMaintenanceWindow(window) + if err != nil { + resp.Diagnostics.AddError("Failed to create maintenance window", err.Error()) + return + } + + data.ID = types.StringValue(created.ID) + data.InsertedAt = types.StringValue(created.InsertedAt) + + if created.SuppressAlerts != nil { + data.SuppressAlerts = types.BoolValue(*created.SuppressAlerts) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *MaintenanceWindowResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data MaintenanceWindowResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + window, err := r.client.GetMaintenanceWindow(data.ID.ValueString()) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Failed to read maintenance window", err.Error()) + return + } + + data.Name = types.StringValue(window.Name) + data.StartsAt = types.StringValue(window.StartsAt) + data.EndsAt = types.StringValue(window.EndsAt) + data.InsertedAt = types.StringValue(window.InsertedAt) + + if window.Reason != nil { + data.Reason = types.StringValue(*window.Reason) + } else { + data.Reason = types.StringNull() + } + + if window.SuppressAlerts != nil { + data.SuppressAlerts = types.BoolValue(*window.SuppressAlerts) + } + + if window.SiteID != nil { + data.SiteID = types.StringValue(*window.SiteID) + } else { + data.SiteID = types.StringNull() + } + + if window.DeviceID != nil { + data.DeviceID = types.StringValue(*window.DeviceID) + } else { + data.DeviceID = types.StringNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *MaintenanceWindowResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data MaintenanceWindowResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + window := MaintenanceWindowAPI{ + Name: data.Name.ValueString(), + StartsAt: data.StartsAt.ValueString(), + EndsAt: data.EndsAt.ValueString(), + } + + if !data.Reason.IsNull() { + reason := data.Reason.ValueString() + window.Reason = &reason + } + + if !data.SuppressAlerts.IsNull() { + suppress := data.SuppressAlerts.ValueBool() + window.SuppressAlerts = &suppress + } + + if !data.SiteID.IsNull() { + siteID := data.SiteID.ValueString() + window.SiteID = &siteID + } + + if !data.DeviceID.IsNull() { + deviceID := data.DeviceID.ValueString() + window.DeviceID = &deviceID + } + + updated, err := r.client.UpdateMaintenanceWindow(data.ID.ValueString(), window) + if err != nil { + if errors.Is(err, ErrNotFound) { + created, createErr := r.client.CreateMaintenanceWindow(window) + if createErr != nil { + resp.Diagnostics.AddError("Failed to create maintenance window (after 404 on update)", createErr.Error()) + return + } + data.ID = types.StringValue(created.ID) + data.InsertedAt = types.StringValue(created.InsertedAt) + if created.SuppressAlerts != nil { + data.SuppressAlerts = types.BoolValue(*created.SuppressAlerts) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + resp.Diagnostics.AddError("Failed to update maintenance window", err.Error()) + return + } + + data.Name = types.StringValue(updated.Name) + data.StartsAt = types.StringValue(updated.StartsAt) + data.EndsAt = types.StringValue(updated.EndsAt) + + if updated.SuppressAlerts != nil { + data.SuppressAlerts = types.BoolValue(*updated.SuppressAlerts) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *MaintenanceWindowResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data MaintenanceWindowResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteMaintenanceWindow(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to delete maintenance window", err.Error()) + return + } +} + +func (r *MaintenanceWindowResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0b49772..4d0b22e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -91,6 +91,9 @@ func (p *ToweropsProvider) Resources(ctx context.Context) []func() resource.Reso NewDeviceResource, NewScheduleResource, NewEscalationPolicyResource, + NewAgentResource, + NewIntegrationResource, + NewMaintenanceWindowResource, } }