diff --git a/docs/index.md b/docs/index.md index 444a1bf..b2acaad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -133,6 +133,24 @@ output "agent_token" { } ``` +### Service Checks + +```terraform +resource "towerops_check" "web_health" { + name = "Web Health Check" + check_type = "http" + url = "https://example.com/health" + expected_status = 200 + content_match = "\"status\":\"ok\"" +} + +resource "towerops_check" "gateway_ping" { + name = "Gateway Reachability" + check_type = "ping" + host = "10.0.0.1" +} +``` + ### Integration ```terraform diff --git a/docs/resources/check.md b/docs/resources/check.md new file mode 100644 index 0000000..ec625dd --- /dev/null +++ b/docs/resources/check.md @@ -0,0 +1,166 @@ +--- +page_title: "towerops_check Resource - TowerOps" +description: |- + Manages a TowerOps service check. +--- + +# towerops_check (Resource) + +Manages a TowerOps service check. Service checks monitor the availability and responsiveness of network services. Supported check types: HTTP, TCP, DNS, and ping. + +Configuration fields are flattened into top-level attributes rather than nested inside a config block. Only the fields relevant to the chosen `check_type` are used. + +## Example Usage + +### HTTP Health Check + +```terraform +resource "towerops_check" "web_health" { + name = "Web Health Check" + check_type = "http" + url = "https://example.com/health" + expected_status = 200 + content_match = "\"status\":\"ok\"" +} +``` + +### HTTP Check with Custom Settings + +```terraform +resource "towerops_check" "api_check" { + name = "API Endpoint" + check_type = "http" + device_id = towerops_device.web_server.id + url = "https://api.example.com/v1/status" + method = "POST" + expected_status = 200 + verify_ssl = true + follow_redirects = false + interval_seconds = 30 + timeout_ms = 10000 +} +``` + +### TCP Port Check + +```terraform +resource "towerops_check" "radius_port" { + name = "FreeRADIUS Auth Port" + check_type = "tcp" + device_id = towerops_device.radius.id + host = "10.0.0.5" + port = 1812 +} +``` + +### TCP Check with Send/Expect + +```terraform +resource "towerops_check" "smtp_banner" { + name = "SMTP Banner Check" + check_type = "tcp" + host = "mail.example.com" + port = 25 + expect_string = "220" +} +``` + +### DNS Resolution Check + +```terraform +resource "towerops_check" "dns_resolution" { + name = "DNS Resolution" + check_type = "dns" + device_id = towerops_device.dns_server.id + hostname = "google.com" + dns_server = "10.0.0.1" + record_type = "A" + expected_result = "142.250.80.46" +} +``` + +### Ping Check + +```terraform +resource "towerops_check" "gateway_ping" { + name = "Gateway Reachability" + check_type = "ping" + device_id = towerops_device.gateway.id + host = "10.0.0.1" +} +``` + +### Ping Check with Custom Count + +```terraform +resource "towerops_check" "wan_ping" { + name = "WAN Link Ping" + check_type = "ping" + host = "8.8.8.8" + ping_count = 5 + interval_seconds = 120 + timeout_ms = 10000 +} +``` + +## Schema + +### Required + +- `name` (String) - The name of the check. +- `check_type` (String) - The type of check: `http`, `tcp`, `dns`, or `ping`. Changing this forces a new resource. + +### Optional + +- `description` (String) - A description of the check. +- `enabled` (Boolean) - Whether the check is enabled. Default: `true`. +- `device_id` (String) - The ID of the device this check is associated with. +- `agent_token_id` (String) - The ID of the agent token that executes this check. +- `interval_seconds` (Number) - How often the check runs, in seconds. Default: `60`. +- `timeout_ms` (Number) - Timeout for check execution, in milliseconds. Default: `5000`. +- `retry_interval_seconds` (Number) - Interval between retries after a failure, in seconds. Default: `30`. +- `max_check_attempts` (Number) - Number of consecutive failures before transitioning to hard state. Default: `3`. + +#### HTTP Check Fields (used when `check_type = "http"`) + +- `url` (String) - The URL to check. Required for HTTP checks. +- `method` (String) - HTTP method to use. Default: `"GET"`. +- `expected_status` (Number) - Expected HTTP status code. Default: `200`. +- `verify_ssl` (Boolean) - Whether to verify SSL certificates. Default: `true`. +- `follow_redirects` (Boolean) - Whether to follow HTTP redirects. Default: `true`. +- `content_match` (String) - Regex pattern to match against the response body. + +#### TCP Check Fields (used when `check_type = "tcp"`) + +- `host` (String) - The host to connect to. Required for TCP checks. +- `port` (Number) - The TCP port to check. Required for TCP checks. +- `send_string` (String) - Data to send after establishing the TCP connection. +- `expect_string` (String) - Expected response string. + +#### DNS Check Fields (used when `check_type = "dns"`) + +- `hostname` (String) - The hostname to resolve. Required for DNS checks. +- `record_type` (String) - DNS record type (`A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `PTR`). Default: `"A"`. +- `dns_server` (String) - DNS server to query. Uses system default if not set. +- `expected_result` (String) - Expected DNS resolution result. + +#### Ping Check Fields (used when `check_type = "ping"`) + +- `host` (String) - The host to ping. Required for ping checks. +- `ping_count` (Number) - Number of ping packets to send. Default: `3`. + +### Read-Only + +- `id` (String) - The unique identifier of the check. +- `current_state` (Number) - Current check state (0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN). +- `current_state_type` (String) - Current state type (`soft` or `hard`). +- `last_check_at` (String) - Timestamp of the last check execution. +- `inserted_at` (String) - The timestamp when the check was created. + +## Import + +Checks can be imported using their UUID: + +```shell +terraform import towerops_check.web_health 7c9e6679-7425-40de-944b-e07fc1f90ae7 +``` diff --git a/examples/main.tf b/examples/main.tf index dac1a96..92b7d5b 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -83,6 +83,29 @@ resource "towerops_maintenance_window" "network_upgrade" { ends_at = "2024-03-15T06:00:00Z" } +# Service checks +resource "towerops_check" "web_health" { + name = "Web Health Check" + check_type = "http" + url = "https://example.com/health" + expected_status = 200 + content_match = "\"status\":\"ok\"" +} + +resource "towerops_check" "dns_resolution" { + name = "DNS Resolution" + check_type = "dns" + hostname = "google.com" + dns_server = "10.0.0.1" + record_type = "A" +} + +resource "towerops_check" "gateway_ping" { + name = "Gateway Reachability" + check_type = "ping" + host = "10.0.0.1" +} + # Output the site ID output "site_id" { value = towerops_site.main_office.id diff --git a/examples/resources/towerops_check/resource.tf b/examples/resources/towerops_check/resource.tf new file mode 100644 index 0000000..b9123b1 --- /dev/null +++ b/examples/resources/towerops_check/resource.tf @@ -0,0 +1,32 @@ +# HTTP health check +resource "towerops_check" "web_health" { + name = "Web Health Check" + check_type = "http" + url = "https://example.com/health" + expected_status = 200 + content_match = "\"status\":\"ok\"" +} + +# TCP port check +resource "towerops_check" "radius_port" { + name = "FreeRADIUS Auth Port" + check_type = "tcp" + host = "10.0.0.5" + port = 1812 +} + +# DNS resolution check +resource "towerops_check" "dns_resolution" { + name = "DNS Resolution" + check_type = "dns" + hostname = "google.com" + dns_server = "10.0.0.1" + record_type = "A" +} + +# Ping check +resource "towerops_check" "gateway_ping" { + name = "Gateway Reachability" + check_type = "ping" + host = "10.0.0.1" +} diff --git a/internal/provider/check_resource.go b/internal/provider/check_resource.go new file mode 100644 index 0000000..0de77d9 --- /dev/null +++ b/internal/provider/check_resource.go @@ -0,0 +1,660 @@ +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/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.Resource = &CheckResource{} +var _ resource.ResourceWithImportState = &CheckResource{} + +// CheckResource defines the resource implementation. +type CheckResource struct { + client *Client +} + +// CheckResourceModel describes the resource data model. +type CheckResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + CheckType types.String `tfsdk:"check_type"` + Description types.String `tfsdk:"description"` + Enabled types.Bool `tfsdk:"enabled"` + DeviceID types.String `tfsdk:"device_id"` + AgentTokenID types.String `tfsdk:"agent_token_id"` + IntervalSeconds types.Int64 `tfsdk:"interval_seconds"` + TimeoutMs types.Int64 `tfsdk:"timeout_ms"` + RetryIntervalSeconds types.Int64 `tfsdk:"retry_interval_seconds"` + MaxCheckAttempts types.Int64 `tfsdk:"max_check_attempts"` + + // HTTP config + URL types.String `tfsdk:"url"` + Method types.String `tfsdk:"method"` + ExpectedStatus types.Int64 `tfsdk:"expected_status"` + VerifySSL types.Bool `tfsdk:"verify_ssl"` + FollowRedirects types.Bool `tfsdk:"follow_redirects"` + ContentMatch types.String `tfsdk:"content_match"` + + // TCP config + Host types.String `tfsdk:"host"` + Port types.Int64 `tfsdk:"port"` + SendString types.String `tfsdk:"send_string"` + ExpectString types.String `tfsdk:"expect_string"` + + // DNS config + Hostname types.String `tfsdk:"hostname"` + RecordType types.String `tfsdk:"record_type"` + DNSServer types.String `tfsdk:"dns_server"` + ExpectedResult types.String `tfsdk:"expected_result"` + + // Ping config + PingCount types.Int64 `tfsdk:"ping_count"` + + // Computed + CurrentState types.Int64 `tfsdk:"current_state"` + CurrentStateType types.String `tfsdk:"current_state_type"` + LastCheckAt types.String `tfsdk:"last_check_at"` + InsertedAt types.String `tfsdk:"inserted_at"` +} + +// NewCheckResource creates a new check resource. +func NewCheckResource() resource.Resource { + return &CheckResource{} +} + +func (r *CheckResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_check" +} + +func (r *CheckResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a TowerOps service check. Supports HTTP, TCP, DNS, and ping check types.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier of the check.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the check.", + Required: true, + }, + "check_type": schema.StringAttribute{ + Description: "The type of check: http, tcp, dns, or ping.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + Description: "A description of the check.", + Optional: true, + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the check is enabled.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "device_id": schema.StringAttribute{ + Description: "The ID of the device this check is associated with.", + Optional: true, + }, + "agent_token_id": schema.StringAttribute{ + Description: "The ID of the agent token that executes this check.", + Optional: true, + }, + "interval_seconds": schema.Int64Attribute{ + Description: "How often the check runs, in seconds.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(60), + }, + "timeout_ms": schema.Int64Attribute{ + Description: "Timeout for the check execution, in milliseconds.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(5000), + }, + "retry_interval_seconds": schema.Int64Attribute{ + Description: "Interval between retries after a failure, in seconds.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(30), + }, + "max_check_attempts": schema.Int64Attribute{ + Description: "Number of consecutive failures before transitioning to hard state.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3), + }, + + // HTTP config + "url": schema.StringAttribute{ + Description: "The URL to check. Required for HTTP checks.", + Optional: true, + }, + "method": schema.StringAttribute{ + Description: "HTTP method to use. Defaults to GET.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("GET"), + }, + "expected_status": schema.Int64Attribute{ + Description: "Expected HTTP status code. Defaults to 200.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(200), + }, + "verify_ssl": schema.BoolAttribute{ + Description: "Whether to verify SSL certificates. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "follow_redirects": schema.BoolAttribute{ + Description: "Whether to follow HTTP redirects. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "content_match": schema.StringAttribute{ + Description: "Regex pattern to match against HTTP response body.", + Optional: true, + }, + + // TCP config + "host": schema.StringAttribute{ + Description: "The host to check. Used for TCP and ping checks.", + Optional: true, + }, + "port": schema.Int64Attribute{ + Description: "The TCP port to check. Required for TCP checks.", + Optional: true, + }, + "send_string": schema.StringAttribute{ + Description: "Data to send after TCP connection.", + Optional: true, + }, + "expect_string": schema.StringAttribute{ + Description: "Expected response string for TCP checks.", + Optional: true, + }, + + // DNS config + "hostname": schema.StringAttribute{ + Description: "The hostname to resolve. Required for DNS checks.", + Optional: true, + }, + "record_type": schema.StringAttribute{ + Description: "DNS record type (A, AAAA, CNAME, MX, TXT, NS, PTR). Defaults to A.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("A"), + }, + "dns_server": schema.StringAttribute{ + Description: "DNS server to query. Uses system default if not set.", + Optional: true, + }, + "expected_result": schema.StringAttribute{ + Description: "Expected DNS resolution result.", + Optional: true, + }, + + // Ping config + "ping_count": schema.Int64Attribute{ + Description: "Number of ping packets to send. Defaults to 3.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3), + }, + + // Computed + "current_state": schema.Int64Attribute{ + Description: "Current check state (0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN).", + Computed: true, + }, + "current_state_type": schema.StringAttribute{ + Description: "Current state type (soft or hard).", + Computed: true, + }, + "last_check_at": schema.StringAttribute{ + Description: "Timestamp of the last check execution.", + Computed: true, + }, + "inserted_at": schema.StringAttribute{ + Description: "The timestamp when the check was created.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *CheckResource) 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 *CheckResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data CheckResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + check := Check{ + Name: data.Name.ValueString(), + CheckType: data.CheckType.ValueString(), + Config: buildConfig(data), + } + + if !data.Description.IsNull() { + desc := data.Description.ValueString() + check.Description = &desc + } + + if !data.Enabled.IsNull() { + enabled := data.Enabled.ValueBool() + check.Enabled = &enabled + } + + if !data.DeviceID.IsNull() { + id := data.DeviceID.ValueString() + check.DeviceID = &id + } + + if !data.AgentTokenID.IsNull() { + id := data.AgentTokenID.ValueString() + check.AgentTokenID = &id + } + + if !data.IntervalSeconds.IsNull() { + v := int(data.IntervalSeconds.ValueInt64()) + check.IntervalSeconds = &v + } + + if !data.TimeoutMs.IsNull() { + v := int(data.TimeoutMs.ValueInt64()) + check.TimeoutMs = &v + } + + if !data.RetryIntervalSeconds.IsNull() { + v := int(data.RetryIntervalSeconds.ValueInt64()) + check.RetryIntervalSeconds = &v + } + + if !data.MaxCheckAttempts.IsNull() { + v := int(data.MaxCheckAttempts.ValueInt64()) + check.MaxCheckAttempts = &v + } + + created, err := r.client.CreateCheck(check) + if err != nil { + resp.Diagnostics.AddError("Failed to create check", err.Error()) + return + } + + mapCheckToState(created, &data) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CheckResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data CheckResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + check, err := r.client.GetCheck(data.ID.ValueString()) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Failed to read check", err.Error()) + return + } + + mapCheckToState(check, &data) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CheckResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data CheckResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + check := Check{ + Name: data.Name.ValueString(), + CheckType: data.CheckType.ValueString(), + Config: buildConfig(data), + } + + if !data.Description.IsNull() { + desc := data.Description.ValueString() + check.Description = &desc + } + + if !data.Enabled.IsNull() { + enabled := data.Enabled.ValueBool() + check.Enabled = &enabled + } + + if !data.DeviceID.IsNull() { + id := data.DeviceID.ValueString() + check.DeviceID = &id + } + + if !data.AgentTokenID.IsNull() { + id := data.AgentTokenID.ValueString() + check.AgentTokenID = &id + } + + if !data.IntervalSeconds.IsNull() { + v := int(data.IntervalSeconds.ValueInt64()) + check.IntervalSeconds = &v + } + + if !data.TimeoutMs.IsNull() { + v := int(data.TimeoutMs.ValueInt64()) + check.TimeoutMs = &v + } + + if !data.RetryIntervalSeconds.IsNull() { + v := int(data.RetryIntervalSeconds.ValueInt64()) + check.RetryIntervalSeconds = &v + } + + if !data.MaxCheckAttempts.IsNull() { + v := int(data.MaxCheckAttempts.ValueInt64()) + check.MaxCheckAttempts = &v + } + + updated, err := r.client.UpdateCheck(data.ID.ValueString(), check) + if err != nil { + if errors.Is(err, ErrNotFound) { + // Check deleted outside Terraform, recreate + created, createErr := r.client.CreateCheck(check) + if createErr != nil { + resp.Diagnostics.AddError("Failed to create check (after 404 on update)", createErr.Error()) + return + } + mapCheckToState(created, &data) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + resp.Diagnostics.AddError("Failed to update check", err.Error()) + return + } + + mapCheckToState(updated, &data) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CheckResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data CheckResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteCheck(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to delete check", err.Error()) + return + } +} + +func (r *CheckResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// buildConfig constructs the config map from flat Terraform attributes based on check_type. +func buildConfig(data CheckResourceModel) map[string]any { + config := map[string]any{} + checkType := data.CheckType.ValueString() + + switch checkType { + case "http": + if !data.URL.IsNull() { + config["url"] = data.URL.ValueString() + } + if !data.Method.IsNull() { + config["method"] = data.Method.ValueString() + } + if !data.ExpectedStatus.IsNull() { + config["expected_status"] = int(data.ExpectedStatus.ValueInt64()) + } + if !data.VerifySSL.IsNull() { + config["verify_ssl"] = data.VerifySSL.ValueBool() + } + if !data.FollowRedirects.IsNull() { + config["follow_redirects"] = data.FollowRedirects.ValueBool() + } + if !data.ContentMatch.IsNull() { + config["regex"] = data.ContentMatch.ValueString() + } + + case "tcp": + if !data.Host.IsNull() { + config["host"] = data.Host.ValueString() + } + if !data.Port.IsNull() { + config["port"] = int(data.Port.ValueInt64()) + } + if !data.SendString.IsNull() { + config["send"] = data.SendString.ValueString() + } + if !data.ExpectString.IsNull() { + config["expect"] = data.ExpectString.ValueString() + } + + case "dns": + if !data.Hostname.IsNull() { + config["hostname"] = data.Hostname.ValueString() + } + if !data.RecordType.IsNull() { + config["record_type"] = data.RecordType.ValueString() + } + if !data.DNSServer.IsNull() { + config["server"] = data.DNSServer.ValueString() + } + if !data.ExpectedResult.IsNull() { + config["expected"] = data.ExpectedResult.ValueString() + } + + case "ping": + if !data.Host.IsNull() { + config["host"] = data.Host.ValueString() + } + if !data.PingCount.IsNull() { + config["count"] = int(data.PingCount.ValueInt64()) + } + } + + return config +} + +// mapCheckToState maps an API Check response back into the Terraform state model. +func mapCheckToState(check *Check, data *CheckResourceModel) { + data.ID = types.StringValue(check.ID) + data.Name = types.StringValue(check.Name) + data.CheckType = types.StringValue(check.CheckType) + data.InsertedAt = types.StringValue(check.InsertedAt) + + if check.Description != nil { + data.Description = types.StringValue(*check.Description) + } else { + data.Description = types.StringNull() + } + + if check.Enabled != nil { + data.Enabled = types.BoolValue(*check.Enabled) + } + + if check.DeviceID != nil { + data.DeviceID = types.StringValue(*check.DeviceID) + } else { + data.DeviceID = types.StringNull() + } + + if check.AgentTokenID != nil { + data.AgentTokenID = types.StringValue(*check.AgentTokenID) + } else { + data.AgentTokenID = types.StringNull() + } + + if check.IntervalSeconds != nil { + data.IntervalSeconds = types.Int64Value(int64(*check.IntervalSeconds)) + } + + if check.TimeoutMs != nil { + data.TimeoutMs = types.Int64Value(int64(*check.TimeoutMs)) + } + + if check.RetryIntervalSeconds != nil { + data.RetryIntervalSeconds = types.Int64Value(int64(*check.RetryIntervalSeconds)) + } + + if check.MaxCheckAttempts != nil { + data.MaxCheckAttempts = types.Int64Value(int64(*check.MaxCheckAttempts)) + } + + if check.CurrentState != nil { + data.CurrentState = types.Int64Value(int64(*check.CurrentState)) + } else { + data.CurrentState = types.Int64Null() + } + + if check.CurrentStateType != nil { + data.CurrentStateType = types.StringValue(*check.CurrentStateType) + } else { + data.CurrentStateType = types.StringNull() + } + + if check.LastCheckAt != nil { + data.LastCheckAt = types.StringValue(*check.LastCheckAt) + } else { + data.LastCheckAt = types.StringNull() + } + + // Unpack config into flat fields based on check_type + unpackConfig(check.Config, check.CheckType, data) +} + +// unpackConfig reads the config map and sets flat Terraform attributes. +func unpackConfig(config map[string]any, checkType string, data *CheckResourceModel) { + if config == nil { + return + } + + switch checkType { + case "http": + if v, ok := config["url"].(string); ok { + data.URL = types.StringValue(v) + } + if v, ok := config["method"].(string); ok { + data.Method = types.StringValue(v) + } + if v, ok := configInt(config, "expected_status"); ok { + data.ExpectedStatus = types.Int64Value(int64(v)) + } + if v, ok := config["verify_ssl"].(bool); ok { + data.VerifySSL = types.BoolValue(v) + } + if v, ok := config["follow_redirects"].(bool); ok { + data.FollowRedirects = types.BoolValue(v) + } + if v, ok := config["regex"].(string); ok { + data.ContentMatch = types.StringValue(v) + } + + case "tcp": + if v, ok := config["host"].(string); ok { + data.Host = types.StringValue(v) + } + if v, ok := configInt(config, "port"); ok { + data.Port = types.Int64Value(int64(v)) + } + if v, ok := config["send"].(string); ok { + data.SendString = types.StringValue(v) + } + if v, ok := config["expect"].(string); ok { + data.ExpectString = types.StringValue(v) + } + + case "dns": + if v, ok := config["hostname"].(string); ok { + data.Hostname = types.StringValue(v) + } + if v, ok := config["record_type"].(string); ok { + data.RecordType = types.StringValue(v) + } + if v, ok := config["server"].(string); ok { + data.DNSServer = types.StringValue(v) + } + if v, ok := config["expected"].(string); ok { + data.ExpectedResult = types.StringValue(v) + } + + case "ping": + if v, ok := config["host"].(string); ok { + data.Host = types.StringValue(v) + } + if v, ok := configInt(config, "count"); ok { + data.PingCount = types.Int64Value(int64(v)) + } + } +} + +// configInt extracts an integer from a config map, handling JSON's float64 encoding. +func configInt(config map[string]any, key string) (int, bool) { + v, ok := config[key] + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case int64: + return int(n), true + default: + return 0, false + } +} diff --git a/internal/provider/check_resource_test.go b/internal/provider/check_resource_test.go new file mode 100644 index 0000000..8db6a29 --- /dev/null +++ b/internal/provider/check_resource_test.go @@ -0,0 +1,756 @@ +package provider + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "sync" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccCheckResource_http(t *testing.T) { + var checkID string + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "test-http-check-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "Web Health", + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com/health", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "Web Health", + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com/health", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceHTTP(server.URL), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "Web Health"), + resource.TestCheckResourceAttr("towerops_check.test", "check_type", "http"), + resource.TestCheckResourceAttr("towerops_check.test", "url", "https://example.com/health"), + resource.TestCheckResourceAttr("towerops_check.test", "expected_status", "200"), + resource.TestCheckResourceAttr("towerops_check.test", "enabled", "true"), + resource.TestCheckResourceAttrSet("towerops_check.test", "id"), + resource.TestCheckResourceAttrSet("towerops_check.test", "inserted_at"), + ), + }, + }, + }) +} + +func TestAccCheckResource_tcp(t *testing.T) { + var checkID string + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + deviceID := "device-123" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "test-tcp-check-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "MySQL Port", + CheckType: "tcp", + DeviceID: &deviceID, + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "host": "10.0.0.5", + "port": 3306, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "MySQL Port", + CheckType: "tcp", + DeviceID: &deviceID, + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "host": "10.0.0.5", + "port": 3306, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceTCP(server.URL), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "MySQL Port"), + resource.TestCheckResourceAttr("towerops_check.test", "check_type", "tcp"), + resource.TestCheckResourceAttr("towerops_check.test", "host", "10.0.0.5"), + resource.TestCheckResourceAttr("towerops_check.test", "port", "3306"), + resource.TestCheckResourceAttr("towerops_check.test", "device_id", "device-123"), + ), + }, + }, + }) +} + +func TestAccCheckResource_dns(t *testing.T) { + var checkID string + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "test-dns-check-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "DNS Resolution", + CheckType: "dns", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "hostname": "google.com", + "server": "8.8.8.8", + "record_type": "A", + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "DNS Resolution", + CheckType: "dns", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "hostname": "google.com", + "server": "8.8.8.8", + "record_type": "A", + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceDNS(server.URL), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "DNS Resolution"), + resource.TestCheckResourceAttr("towerops_check.test", "check_type", "dns"), + resource.TestCheckResourceAttr("towerops_check.test", "hostname", "google.com"), + resource.TestCheckResourceAttr("towerops_check.test", "dns_server", "8.8.8.8"), + resource.TestCheckResourceAttr("towerops_check.test", "record_type", "A"), + ), + }, + }, + }) +} + +func TestAccCheckResource_ping(t *testing.T) { + var checkID string + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + deviceID := "device-gw" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "test-ping-check-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "Gateway Ping", + CheckType: "ping", + DeviceID: &deviceID, + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "host": "10.0.0.1", + "count": 3, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: "Gateway Ping", + CheckType: "ping", + DeviceID: &deviceID, + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "host": "10.0.0.1", + "count": 3, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourcePing(server.URL), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "Gateway Ping"), + resource.TestCheckResourceAttr("towerops_check.test", "check_type", "ping"), + resource.TestCheckResourceAttr("towerops_check.test", "host", "10.0.0.1"), + resource.TestCheckResourceAttr("towerops_check.test", "ping_count", "3"), + resource.TestCheckResourceAttr("towerops_check.test", "device_id", "device-gw"), + ), + }, + }, + }) +} + +func TestAccCheckResource_update(t *testing.T) { + var checkID string + var currentName string + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "test-update-check-id" + currentName = "Original" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: currentName, + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: currentName, + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/checks/"+checkID: + var body map[string]Check + json.NewDecoder(r.Body).Decode(&body) + currentName = body["check"].Name + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Check{ + ID: checkID, + Name: currentName, + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + }) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/"+checkID: + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceHTTPNamed(server.URL, "Original"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "Original"), + ), + }, + { + Config: testAccCheckResourceHTTPNamed(server.URL, "Updated"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "Updated"), + ), + }, + }, + }) +} + +func TestAccCheckResource_recreateOn404(t *testing.T) { + var checkID string + var checkDeleted bool + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + checkResp := Check{ + ID: "new-check-id", + Name: "Web Health", + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com/health", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + } + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "new-check-id" + checkDeleted = false + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(checkResp) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/"+checkID: + if checkDeleted { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "check not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(checkResp) + + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/checks/"+checkID: + if checkDeleted { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "check not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(checkResp) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/"+checkID: + checkDeleted = true + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceHTTP(server.URL), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("towerops_check.test", "name", "Web Health"), + ), + }, + { + PreConfig: func() { + mu.Lock() + checkDeleted = true + mu.Unlock() + }, + Config: testAccCheckResourceHTTP(server.URL), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("towerops_check.test", "id"), + ), + }, + }, + }) +} + +func TestAccCheckResource_importState(t *testing.T) { + var checkID string + var mu sync.Mutex + enabled := true + interval := 60 + timeout := 5000 + retry := 30 + maxAttempts := 3 + currentState := 3 + stateType := "soft" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + checkResp := Check{ + ID: "imported-check-id", + Name: "Web Health", + CheckType: "http", + Enabled: &enabled, + IntervalSeconds: &interval, + TimeoutMs: &timeout, + RetryIntervalSeconds: &retry, + MaxCheckAttempts: &maxAttempts, + CurrentState: ¤tState, + CurrentStateType: &stateType, + Config: map[string]any{ + "url": "https://example.com/health", + "method": "GET", + "expected_status": 200, + "verify_ssl": true, + "follow_redirects": true, + }, + InsertedAt: "2024-01-01T00:00:00Z", + } + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks": + checkID = "imported-check-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(checkResp) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/checks/imported-check-id": + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(checkResp) + + case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/checks/imported-check-id": + w.WriteHeader(http.StatusNoContent) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + _ = checkID + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceHTTP(server.URL), + }, + { + ResourceName: "towerops_check.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ping_count", "record_type"}, + }, + }, + }) +} + +func TestAccCheckResource_createError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v1/checks" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "name is required"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(server.URL), + Steps: []resource.TestStep{ + { + Config: testAccCheckResourceHTTP(server.URL), + ExpectError: regexp.MustCompile(`Failed to create check`), + }, + }, + }) +} + +// HCL config helpers + +func testAccCheckResourceHTTP(apiURL string) string { + return fmt.Sprintf(` +provider "towerops" { + token = "test-token" + api_url = %q +} + +resource "towerops_check" "test" { + name = "Web Health" + check_type = "http" + url = "https://example.com/health" + expected_status = 200 +} +`, apiURL) +} + +func testAccCheckResourceHTTPNamed(apiURL, name string) string { + return fmt.Sprintf(` +provider "towerops" { + token = "test-token" + api_url = %q +} + +resource "towerops_check" "test" { + name = %q + check_type = "http" + url = "https://example.com" + expected_status = 200 +} +`, apiURL, name) +} + +func testAccCheckResourceTCP(apiURL string) string { + return fmt.Sprintf(` +provider "towerops" { + token = "test-token" + api_url = %q +} + +resource "towerops_check" "test" { + name = "MySQL Port" + check_type = "tcp" + device_id = "device-123" + host = "10.0.0.5" + port = 3306 +} +`, apiURL) +} + +func testAccCheckResourceDNS(apiURL string) string { + return fmt.Sprintf(` +provider "towerops" { + token = "test-token" + api_url = %q +} + +resource "towerops_check" "test" { + name = "DNS Resolution" + check_type = "dns" + hostname = "google.com" + dns_server = "8.8.8.8" + record_type = "A" +} +`, apiURL) +} + +func testAccCheckResourcePing(apiURL string) string { + return fmt.Sprintf(` +provider "towerops" { + token = "test-token" + api_url = %q +} + +resource "towerops_check" "test" { + name = "Gateway Ping" + check_type = "ping" + device_id = "device-gw" + host = "10.0.0.1" + ping_count = 3 +} +`, apiURL) +} diff --git a/internal/provider/client.go b/internal/provider/client.go index 63ec19a..a80259a 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -559,6 +559,79 @@ func (c *Client) DeleteMaintenanceWindow(id string) error { return err } +// Check represents a TowerOps service check. +type Check struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + CheckType string `json:"check_type"` + Description *string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + AgentTokenID *string `json:"agent_token_id,omitempty"` + IntervalSeconds *int `json:"interval_seconds,omitempty"` + TimeoutMs *int `json:"timeout_ms,omitempty"` + RetryIntervalSeconds *int `json:"retry_interval_seconds,omitempty"` + MaxCheckAttempts *int `json:"max_check_attempts,omitempty"` + Config map[string]any `json:"config"` + CurrentState *int `json:"current_state,omitempty"` + CurrentStateType *string `json:"current_state_type,omitempty"` + LastCheckAt *string `json:"last_check_at,omitempty"` + InsertedAt string `json:"inserted_at,omitempty"` +} + +// CreateCheck creates a new service check. +func (c *Client) CreateCheck(check Check) (*Check, error) { + body := map[string]Check{"check": check} + respBody, err := c.doRequest(http.MethodPost, "/api/v1/checks", body) + if err != nil { + return nil, err + } + + var result Check + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// GetCheck retrieves a check by ID. +func (c *Client) GetCheck(id string) (*Check, error) { + respBody, err := c.doRequest(http.MethodGet, "/api/v1/checks/"+id, nil) + if err != nil { + return nil, err + } + + var result Check + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// UpdateCheck updates an existing check. +func (c *Client) UpdateCheck(id string, check Check) (*Check, error) { + body := map[string]Check{"check": check} + respBody, err := c.doRequest(http.MethodPatch, "/api/v1/checks/"+id, body) + if err != nil { + return nil, err + } + + var result Check + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// DeleteCheck deletes a check. +func (c *Client) DeleteCheck(id string) error { + _, err := c.doRequest(http.MethodDelete, "/api/v1/checks/"+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/provider.go b/internal/provider/provider.go index 4d0b22e..4d2dd3b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -89,6 +89,7 @@ func (p *ToweropsProvider) Resources(ctx context.Context) []func() resource.Reso NewOrganizationResource, NewSiteResource, NewDeviceResource, + NewCheckResource, NewScheduleResource, NewEscalationPolicyResource, NewAgentResource,