diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index b41a57f..647bfd9 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -67,7 +67,7 @@ Create or update monitors. > openstatus monitors apply --config custom.yaml -y > openstatus monitors apply --dry-run -Creates or updates monitors according to the OpenStatus configuration file. Compares your openstatus.yaml with the current state and applies changes. +Creates or updates monitors according to the OpenStatus configuration file. Compares your openstatus.yaml with the current state and applies changes. DEPRECATED: this command is deprecated. Prefer managing your monitors with Terraform via 'openstatus terraform generate'. Usage: @@ -134,7 +134,7 @@ Import all your monitors. > openstatus monitors import > openstatus monitors import --output monitors.yaml -Import all your monitors from your workspace to a YAML file; it will also create a lock file to manage your monitors with 'apply'. +Import all your monitors from your workspace to a YAML file; it will also create a lock file to manage your monitors with 'apply'. DEPRECATED: the monitors.yaml config file is deprecated for import. Prefer managing monitors declaratively with 'openstatus monitors apply'. Usage: diff --git a/go.mod b/go.mod index 78c5178..6af5836 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.25.0 require github.com/urfave/cli/v3 v3.9.0 // direct require ( - buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260724145211-8d1b4f4069ec.1 - buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260724145211-8d1b4f4069ec.1 + buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260826071311-8210bb95208b.1 + buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20260826071311-8210bb95208b.1 connectrpc.com/connect v1.20.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/huh v1.0.0 @@ -25,13 +25,13 @@ require ( github.com/zclconf/go-cty v1.18.1 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 sigs.k8s.io/yaml v1.6.0 ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect - buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.1 // indirect + buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect diff --git a/go.sum b/go.sum index f4a7c92..e0b0c36 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 h1:t8f+WWZ5WNrZaP5zrpWD8f1tKU7eelJMbIAT9FRX558= -buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1/go.mod h1:/t9AeRQQp2iNkiGHDLfHLW3SzNpYpNPGRZ+Ih8+SOUs= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260724145211-8d1b4f4069ec.1 h1:A3yb5Bf2iuld7SYNcq7I2uXv/zEr58dJFSoQm2HeFOQ= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260724145211-8d1b4f4069ec.1/go.mod h1:MmC+HrHeHglSXd6g09VexkguG+ohgxelutvlXZW/syg= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260724145211-8d1b4f4069ec.1 h1:tERlcNbY6AkmrrDiOT1nlDvEEA+JdSnOQZTDd5BYCb4= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260724145211-8d1b4f4069ec.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.1 h1:fllAS5JX4LI/DuJhHTRJeACHEuVU69STRW/lPPS68/M= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20251209175733-2a1774d88802.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.1 h1:vZuF9KSFuaVk8BJ2oIETVL24WFc5ehiY0kdYJMrds+Q= +buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.12-20230414000709-087bc8072ce4.1/go.mod h1:Y6uGzulgNTMSJafTYzhV2xKPPiUnSoE1AStyVM9mBbI= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260826071311-8210bb95208b.1 h1:c8+IWCmwEhaEVaqWcQoYPAi33WPfGDce4taD+mll0kM= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260826071311-8210bb95208b.1/go.mod h1:X1+npKnwmGjhmqc/f0DB75aqs6IOfEsvakUTV6oDVJY= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20260826071311-8210bb95208b.1 h1:UByAofqyXvii0fPFzno5TE0JA7p38ZEZ6YrMv2HcLhA= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.12-20260826071311-8210bb95208b.1/go.mod h1:0bDFGY01RucPOrga1Y+2eAfIiDrJVFGxs2bMnIyZOAc= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -169,8 +169,8 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/monitor.go b/internal/config/monitor.go index 21ba1aa..7d57ac6 100644 --- a/internal/config/monitor.go +++ b/internal/config/monitor.go @@ -101,6 +101,7 @@ type CoordinateKind string const ( HTTP CoordinateKind = "http" TCP CoordinateKind = "tcp" + ICMP CoordinateKind = "icmp" ) type Region string diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go index 907b4b3..a8f668a 100644 --- a/internal/monitors/monitor_create.go +++ b/internal/monitors/monitor_create.go @@ -27,6 +27,8 @@ func CreateMonitor(ctx context.Context, httpClient *http.Client, apiKey string, return CreateHTTPMonitor(ctx, client, monitor) case config.TCP: return CreateTCPMonitor(ctx, client, monitor) + case config.ICMP: + return CreateICMPMonitor(ctx, client, monitor) default: return Monitor{}, fmt.Errorf("unsupported monitor kind: %s", monitor.Kind) } @@ -64,6 +66,24 @@ func CreateTCPMonitor(ctx context.Context, client monitorv1connect.MonitorServic return tcpMonitorToLocal(resp.GetMonitor()) } +// CreateICMPMonitor creates an ICMP monitor using the SDK +func CreateICMPMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { + icmpMonitor, err := configToICMPMonitor(monitor) + if err != nil { + return Monitor{}, err + } + req := &monitorv1.CreateICMPMonitorRequest{ + Monitor: icmpMonitor, + } + + resp, err := client.CreateICMPMonitor(ctx, req) + if err != nil { + return Monitor{}, fmt.Errorf("failed to create ICMP monitor: %w", err) + } + + return icmpMonitorToLocal(resp.GetMonitor()) +} + func httpMonitorToLocal(m *monitorv1.HTTPMonitor) (Monitor, error) { id, err := strconv.Atoi(m.GetId()) if err != nil { diff --git a/internal/monitors/monitor_create_test.go b/internal/monitors/monitor_create_test.go index a70f9b8..af00bf5 100644 --- a/internal/monitors/monitor_create_test.go +++ b/internal/monitors/monitor_create_test.go @@ -104,6 +104,66 @@ func Test_CreateMonitor(t *testing.T) { } }) + t.Run("Create ICMP monitor successfully", func(t *testing.T) { + body := `{"monitor":{"id":"789","name":"ICMP Monitor","uri":"8.8.8.8","periodicity":"PERIODICITY_5M","regions":["REGION_FLY_IAD"],"active":true}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "ICMP Monitor", + Active: true, + Frequency: config.The5M, + Kind: config.ICMP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + Host: "8.8.8.8", + }, + } + + result, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != 789 { + t.Errorf("Expected ID 789, got %d", result.ID) + } + if result.Name != "ICMP Monitor" { + t.Errorf("Expected name 'ICMP Monitor', got %s", result.Name) + } + if result.URL != "8.8.8.8" { + t.Errorf("Expected URL '8.8.8.8', got %s", result.URL) + } + if result.JobType != "icmp" { + t.Errorf("Expected jobType 'icmp', got %s", result.JobType) + } + }) + + t.Run("Create ICMP monitor fails without host", func(t *testing.T) { + monitor := config.Monitor{ + Name: "ICMP Monitor", + Kind: config.ICMP, + Active: true, + Request: config.Request{ + Host: "", + }, + } + + _, err := monitors.CreateMonitor(context.Background(), &http.Client{}, "test-api-key", monitor) + if err == nil { + t.Error("Expected error for missing ICMP host, got nil") + } + }) + t.Run("Create monitor fails with non-200 status", func(t *testing.T) { body := `{"code":"internal","message":"internal error"}` r := io.NopCloser(bytes.NewReader([]byte(body))) diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index fd6bd15..75dc8b6 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -209,6 +209,13 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, } regions = monitorConfig.GetTcp().GetRegions() privateLocationIDs = monitorConfig.GetTcp().GetPrivateLocationIds() + case monitorConfig.HasIcmp(): + monitor, err = icmpMonitorToLocal(monitorConfig.GetIcmp()) + if err != nil { + return err + } + regions = monitorConfig.GetIcmp().GetRegions() + privateLocationIDs = monitorConfig.GetIcmp().GetPrivateLocationIds() default: if monitorConfig.HasDns() { return fmt.Errorf("DNS monitors are not yet supported in the CLI. Monitor ID: %s", monitorId) diff --git a/internal/monitors/monitor_info_test.go b/internal/monitors/monitor_info_test.go index fccfecd..33586b2 100644 --- a/internal/monitors/monitor_info_test.go +++ b/internal/monitors/monitor_info_test.go @@ -119,6 +119,24 @@ func Test_getMonitorInfo(t *testing.T) { } }) + t.Run("Should work with ICMP monitor and degradedAt", func(t *testing.T) { + interceptor := monitorInfoInterceptor([]routeEntry{ + {"GetMonitor", `{"monitor":{"icmp":{"id":"4001","name":"Gateway Ping","uri":"8.8.8.8","periodicity":"PERIODICITY_5M","regions":["REGION_FLY_IAD"],"active":true,"public":false,"timeout":10000,"degradedAt":"5000"}}}`}, + {"GetMonitorStatus", `{"id":"4001","regions":[{"region":"REGION_FLY_IAD","status":"MONITOR_STATUS_ACTIVE"}]}`}, + {"GetMonitorSummary", `{"id":"4001","totalSuccessful":"500","totalDegraded":"0","totalFailed":"0","p50":"20","p75":"30","p90":"40","p95":"50","p99":"60","timeRange":"TIME_RANGE_1D"}`}, + }) + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "4001", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + t.Run("Should gracefully degrade when status RPC fails", func(t *testing.T) { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { diff --git a/internal/monitors/monitor_update.go b/internal/monitors/monitor_update.go index a4dd3d5..5960d05 100644 --- a/internal/monitors/monitor_update.go +++ b/internal/monitors/monitor_update.go @@ -21,6 +21,8 @@ func UpdateMonitor(ctx context.Context, httpClient *http.Client, apiKey string, return UpdateHTTPMonitor(ctx, client, id, monitor) case config.TCP: return UpdateTCPMonitor(ctx, client, id, monitor) + case config.ICMP: + return UpdateICMPMonitor(ctx, client, id, monitor) default: return Monitor{}, fmt.Errorf("unsupported monitor kind: %s", monitor.Kind) } @@ -64,3 +66,24 @@ func UpdateTCPMonitor(ctx context.Context, client monitorv1connect.MonitorServic return tcpMonitorToLocal(resp.GetMonitor()) } + +// UpdateICMPMonitor updates an ICMP monitor using the SDK +func UpdateICMPMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { + icmpMonitor, err := configToICMPMonitor(monitor) + if err != nil { + return Monitor{}, err + } + icmpMonitor.Id = strconv.Itoa(id) + + req := &monitorv1.UpdateICMPMonitorRequest{ + Id: strconv.Itoa(id), + Monitor: icmpMonitor, + } + + resp, err := client.UpdateICMPMonitor(ctx, req) + if err != nil { + return Monitor{}, fmt.Errorf("failed to update ICMP monitor: %w", err) + } + + return icmpMonitorToLocal(resp.GetMonitor()) +} diff --git a/internal/monitors/monitor_update_test.go b/internal/monitors/monitor_update_test.go index 0e685c0..7c6b402 100644 --- a/internal/monitors/monitor_update_test.go +++ b/internal/monitors/monitor_update_test.go @@ -96,6 +96,47 @@ func Test_UpdateMonitor(t *testing.T) { } }) + t.Run("Update ICMP monitor successfully", func(t *testing.T) { + body := `{"monitor":{"id":"457","name":"Updated ICMP Monitor","uri":"updated.example.com","periodicity":"PERIODICITY_1M","regions":["REGION_FLY_AMS"],"active":true}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Updated ICMP Monitor", + Active: true, + Frequency: config.The1M, + Kind: config.ICMP, + Regions: []config.Region{config.Ams}, + Request: config.Request{ + Host: "updated.example.com", + }, + } + + result, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 457, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != 457 { + t.Errorf("Expected ID 457, got %d", result.ID) + } + if result.JobType != "icmp" { + t.Errorf("Expected jobType 'icmp', got %s", result.JobType) + } + if result.URL != "updated.example.com" { + t.Errorf("Expected URL 'updated.example.com', got %s", result.URL) + } + }) + t.Run("Update monitor fails with error response", func(t *testing.T) { body := `{"code":"not_found","message":"monitor not found"}` r := io.NopCloser(bytes.NewReader([]byte(body))) diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index b7f27f3..37834e8 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "strings" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" @@ -394,13 +395,10 @@ func configToHTTPMonitor(m config.Monitor) *monitorv1.HTTPMonitor { monitor := &monitorv1.HTTPMonitor{ Name: m.Name, - Description: m.Description, Url: m.Request.URL, Method: stringToHTTPMethod(m.Request.Method), Body: m.Request.Body, Periodicity: stringToPeriodicity(m.Frequency), - Active: m.Active, - Public: m.Public, Regions: stringsToRegions(m.Regions), Timeout: m.Timeout, Retry: m.Retry, @@ -409,6 +407,9 @@ func configToHTTPMonitor(m config.Monitor) *monitorv1.HTTPMonitor { BodyAssertions: bodyAssertions, HeaderAssertions: headerAssertions, } + monitor.SetDescription(m.Description) + monitor.SetActive(m.Active) + monitor.SetPublic(m.Public) if m.DegradedAfter > 0 { monitor.DegradedAt = &m.DegradedAfter @@ -432,15 +433,15 @@ func configToTCPMonitor(m config.Monitor) (*monitorv1.TCPMonitor, error) { monitor := &monitorv1.TCPMonitor{ Name: m.Name, - Description: m.Description, Uri: fmt.Sprintf("%s:%d", m.Request.Host, m.Request.Port), Periodicity: stringToPeriodicity(m.Frequency), - Active: m.Active, - Public: m.Public, Regions: stringsToRegions(m.Regions), Timeout: m.Timeout, Retry: m.Retry, } + monitor.SetDescription(m.Description) + monitor.SetActive(m.Active) + monitor.SetPublic(m.Public) if m.DegradedAfter > 0 { monitor.DegradedAt = &m.DegradedAfter @@ -449,6 +450,54 @@ func configToTCPMonitor(m config.Monitor) (*monitorv1.TCPMonitor, error) { return monitor, nil } +// configToICMPMonitor converts config.Monitor to SDK ICMPMonitor +func configToICMPMonitor(m config.Monitor) (*monitorv1.ICMPMonitor, error) { + if m.Request.Host == "" { + return nil, fmt.Errorf("ICMP monitor %q: host is required", m.Name) + } + + monitor := &monitorv1.ICMPMonitor{ + Name: m.Name, + Uri: m.Request.Host, + Periodicity: stringToPeriodicity(m.Frequency), + Regions: stringsToRegions(m.Regions), + Timeout: m.Timeout, + Retry: m.Retry, + } + monitor.SetDescription(m.Description) + monitor.SetActive(m.Active) + monitor.SetPublic(m.Public) + + if m.DegradedAfter > 0 { + monitor.DegradedAt = &m.DegradedAfter + } + + return monitor, nil +} + +// icmpMonitorToLocal converts SDK ICMPMonitor to CLI display Monitor +func icmpMonitorToLocal(m *monitorv1.ICMPMonitor) (Monitor, error) { + id, err := strconv.Atoi(m.GetId()) + if err != nil { + return Monitor{}, fmt.Errorf("invalid monitor ID %q: %w", m.GetId(), err) + } + return Monitor{ + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUri(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Regions: regionsToStrings(m.GetRegions()), + PrivateLocationIDs: m.GetPrivateLocationIds(), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + DegradedAfter: int(m.GetDegradedAt()), + Retry: int(m.GetRetry()), + JobType: "icmp", + }, nil +} + type Monitor struct { ID int `json:"id"` Name string `json:"name"` @@ -533,6 +582,10 @@ type TCPRunResult struct { } `json:"timing"` } +type ICMPRunResult struct { + ErrorMessage string `json:"errorMessage"` +} + func MonitorsCmd() *cli.Command { monitorsCmd := cli.Command{ Name: "monitors", diff --git a/internal/monitors/monitors_list.go b/internal/monitors/monitors_list.go index d4d40aa..0a07802 100644 --- a/internal/monitors/monitors_list.go +++ b/internal/monitors/monitors_list.go @@ -74,6 +74,18 @@ func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceCli } } + for _, monitor := range resp.GetIcmpMonitors() { + if monitor.GetActive() || showAll { + entries = append(entries, monitorListEntry{ + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUri(), + Kind: "icmp", + privateLocationIDs: monitor.GetPrivateLocationIds(), + }) + } + } + // The private-location column is only rendered when something is attached, so workspaces // without the feature keep the original four-column output and pay for no extra request. var allIDs []string diff --git a/internal/monitors/monitors_list_test.go b/internal/monitors/monitors_list_test.go index 9dbc2c9..41f743b 100644 --- a/internal/monitors/monitors_list_test.go +++ b/internal/monitors/monitors_list_test.go @@ -7,14 +7,15 @@ import ( "log" "net/http" "os" + "strings" "testing" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/monitors" ) +// Not parallel: subtests redirect the process-wide os.Stdout. func Test_listMonitors(t *testing.T) { - t.Parallel() - t.Run("Successfully return", func(t *testing.T) { // Connect RPC response format with protobuf content // The response is a ListMonitorsResponse in JSON format with Connect headers @@ -70,4 +71,61 @@ func Test_listMonitors(t *testing.T) { t.Errorf("Expected error, got nothing") } }) + t.Run("Includes ICMP monitors", func(t *testing.T) { + body := `{"icmpMonitors":[{"id":"9","name":"Gateway Ping","uri":"8.8.8.8","periodicity":"PERIODICITY_5M","active":true}]}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + output.SetJSONOutput(true) + t.Cleanup(func() { output.SetJSONOutput(false) }) + + out := captureStdout(t, func() { + if err := monitors.ListMonitorsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, `"kind": "icmp"`) { + t.Errorf("expected an icmp entry in JSON output, got:\n%s", out) + } + if !strings.Contains(out, `"url": "8.8.8.8"`) { + t.Errorf("expected ICMP uri as url in JSON output, got:\n%s", out) + } + }) +} + +func captureStdout(t *testing.T, f func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + original := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = original }) + + f() + + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe: %v", err) + } + os.Stdout = original + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("failed to read pipe: %v", err) + } + return buf.String() } diff --git a/internal/privatelocation/privatelocation.go b/internal/privatelocation/privatelocation.go index c595611..6d35784 100644 --- a/internal/privatelocation/privatelocation.go +++ b/internal/privatelocation/privatelocation.go @@ -237,6 +237,9 @@ func monitorNames(ctx context.Context, client monitorv1connect.MonitorServiceCli for _, m := range resp.GetDnsMonitors() { names[m.GetId()] = m.GetName() } + for _, m := range resp.GetIcmpMonitors() { + names[m.GetId()] = m.GetName() + } return names, nil } diff --git a/internal/privatelocation/privatelocation_info_test.go b/internal/privatelocation/privatelocation_info_test.go index 568fc10..33786b5 100644 --- a/internal/privatelocation/privatelocation_info_test.go +++ b/internal/privatelocation/privatelocation_info_test.go @@ -115,6 +115,26 @@ func Test_GetPrivateLocationInfo(t *testing.T) { } }) + t.Run("Resolves ICMP monitor names", func(t *testing.T) { + icmpBody := `{"privateLocation":{"id":"pl_1","name":"office-paris","monitorIds":["icmp-1"]}}` + icmpMonitors := `{"httpMonitors":[],"tcpMonitors":[],"dnsMonitors":[],"icmpMonitors":[{"id":"icmp-1","name":"gateway-ping","uri":"8.8.8.8","active":true}]}` + + client := jsonResponder(map[string]string{ + getProcedure: icmpBody, + listMonitorProcedure: icmpMonitors, + }, nil) + + out := captureStdout(t, func() { + if err := privatelocation.GetPrivateLocationInfoWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", "pl_1", false); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if !strings.Contains(out, "gateway-ping") { + t.Error("Expected the resolved ICMP monitor name") + } + }) + t.Run("Gives a pl-specific hint when not found", func(t *testing.T) { client := errorResponder(404, `{"code":"not_found","message":"missing"}`) diff --git a/internal/run/run.go b/internal/run/run.go index 839e9e2..61c6b0f 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -100,6 +100,15 @@ func MonitorTrigger(ctx context.Context, httpClient *http.Client, apiKey string, entry.Status = "fail" entry.Error = tcp.ErrorMessage } + case "icmp": + var icmpResult monitors.ICMPRunResult + if err := json.Unmarshal(r, &icmpResult); err != nil { + return runMonitorResult{}, fmt.Errorf("unable to unmarshal: %w", err) + } + if icmpResult.ErrorMessage != "" { + entry.Status = "fail" + entry.Error = icmpResult.ErrorMessage + } case "http": var httpResult monitors.HTTPRunResult if err := json.Unmarshal(r, &httpResult); err != nil { diff --git a/internal/run/run_test.go b/internal/run/run_test.go index 42d82f8..3dc80fb 100644 --- a/internal/run/run_test.go +++ b/internal/run/run_test.go @@ -137,4 +137,76 @@ func Test_run(t *testing.T) { t.Errorf("Monitor Trigger should return error") } }) + t.Run("Successfully run icmp response", func(t *testing.T) { + body := `[ + { + "jobType": "icmp", + "latency": 3, + "region": "ams", + "timestamp": 1730990324626, + "errorMessage": "" + }]` + + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + _, err := run.MonitorTrigger(context.Background(), interceptor.GetHTTPClient(), "", "1") + if err != nil { + t.Error(err) + t.Errorf("Monitor Trigger should return error") + } + }) + t.Run("Fail on icmp errorMessage", func(t *testing.T) { + body := `[ + { + "jobType": "icmp", + "latency": 3, + "region": "ams", + "timestamp": 1730990324626, + "errorMessage": "request timed out" + }]` + + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + res, err := run.MonitorTrigger(context.Background(), interceptor.GetHTTPClient(), "", "1") + if err != nil { + t.Fatalf("Monitor Trigger should not error: %v", err) + } + if len(res.Results) != 1 { + t.Fatalf("expected 1 result, got %d", len(res.Results)) + } + if res.Results[0].Status != "fail" { + t.Errorf("expected status 'fail', got %q", res.Results[0].Status) + } + if res.Results[0].Error != "request timed out" { + t.Errorf("expected error message, got %q", res.Results[0].Error) + } + }) } diff --git a/internal/terraform/cli_test.go b/internal/terraform/cli_test.go index 92dada3..0b3bede 100644 --- a/internal/terraform/cli_test.go +++ b/internal/terraform/cli_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" ) @@ -74,7 +75,7 @@ func TestPrintSummary_IncludesInitUpgradeHint(t *testing.T) { if !strings.Contains(out, "terraform init -upgrade") { t.Errorf("expected init-upgrade hint, got:\n%s", out) } - if !strings.Contains(out, "~> 0.3") { + if !strings.Contains(out, "~> 0.4") { t.Errorf("expected version mention in hint, got:\n%s", out) } } @@ -98,6 +99,25 @@ func TestPrintSummary_CountsPrivateLocations(t *testing.T) { } } +func TestPrintSummary_CountsICMPMonitors(t *testing.T) { + m := &monitorv1.ICMPMonitor{} + m.SetId("777") + m.SetName("Gateway Ping") + + out := captureStdout(t, func() { + printSummary("/tmp/out", &WorkspaceData{ + ICMPMonitors: []*monitorv1.ICMPMonitor{m}, + }) + }) + + if !strings.Contains(out, "1 monitors (0 HTTP, 0 TCP, 0 DNS, 1 ICMP)") { + t.Errorf("expected ICMP monitor count, got:\n%s", out) + } + if !strings.Contains(out, "1 import blocks") { + t.Errorf("expected ICMP monitor to count toward imports, got:\n%s", out) + } +} + func captureStdout(t *testing.T, fn func()) string { t.Helper() orig := os.Stdout diff --git a/internal/terraform/fetch.go b/internal/terraform/fetch.go index 278b418..6863a96 100644 --- a/internal/terraform/fetch.go +++ b/internal/terraform/fetch.go @@ -31,6 +31,7 @@ type WorkspaceData struct { HTTPMonitors []*monitorv1.HTTPMonitor TCPMonitors []*monitorv1.TCPMonitor DNSMonitors []*monitorv1.DNSMonitor + ICMPMonitors []*monitorv1.ICMPMonitor Notifications []*notificationv1.Notification StatusPages []StatusPageData PrivateLocations []*private_locationv1.PrivateLocation @@ -55,6 +56,7 @@ func FetchWorkspaceDataWithHTTPClient(ctx context.Context, httpClient *http.Clie data.HTTPMonitors = monitorResp.GetHttpMonitors() data.TCPMonitors = monitorResp.GetTcpMonitors() data.DNSMonitors = monitorResp.GetDnsMonitors() + data.ICMPMonitors = monitorResp.GetIcmpMonitors() // Notifications notifClient := notificationv1connect.NewNotificationServiceClient(httpClient, api.ConnectBaseURL, interceptor, protoJSON) diff --git a/internal/terraform/fetch_test.go b/internal/terraform/fetch_test.go index 05bfe2b..5549956 100644 --- a/internal/terraform/fetch_test.go +++ b/internal/terraform/fetch_test.go @@ -302,3 +302,21 @@ func TestFetchPrivateLocations_PartialResultsDiscarded(t *testing.T) { t.Errorf("got %d private locations, want 0 — partial results must be discarded", len(data.PrivateLocations)) } } + +func TestFetchICMPMonitors(t *testing.T) { + client, _ := newFetchClient(map[string][]fakeResponse{ + "/ListMonitors": {okResponse(`{"icmpMonitors":[{"id":"777","name":"Gateway Ping","uri":"8.8.8.8","periodicity":"PERIODICITY_1M","active":true}]}`)}, + }) + + data, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := len(data.ICMPMonitors); got != 1 { + t.Fatalf("got %d ICMP monitors, want 1", got) + } + if got := data.ICMPMonitors[0].GetName(); got != "Gateway Ping" { + t.Errorf("got name %q, want 'Gateway Ping'", got) + } +} diff --git a/internal/terraform/generate.go b/internal/terraform/generate.go index ebd9912..e7881fc 100644 --- a/internal/terraform/generate.go +++ b/internal/terraform/generate.go @@ -139,7 +139,8 @@ func printSummary(outputDir string, data *WorkspaceData) { httpCount := len(data.HTTPMonitors) tcpCount := len(data.TCPMonitors) dnsCount := len(data.DNSMonitors) - monitorTotal := httpCount + tcpCount + dnsCount + icmpCount := len(data.ICMPMonitors) + monitorTotal := httpCount + tcpCount + dnsCount + icmpCount notifCount := len(data.Notifications) plCount := len(data.PrivateLocations) @@ -155,7 +156,7 @@ func printSummary(outputDir string, data *WorkspaceData) { fmt.Printf("\nGenerated Terraform configuration in %s\n\n", outputDir) if monitorTotal > 0 { - fmt.Printf(" %d monitors (%d HTTP, %d TCP, %d DNS)\n", monitorTotal, httpCount, tcpCount, dnsCount) + fmt.Printf(" %d monitors (%d HTTP, %d TCP, %d DNS, %d ICMP)\n", monitorTotal, httpCount, tcpCount, dnsCount, icmpCount) } if notifCount > 0 { fmt.Printf(" %d notifications\n", notifCount) @@ -171,5 +172,5 @@ func printSummary(outputDir string, data *WorkspaceData) { fmt.Printf(" cd %s\n", outputDir) fmt.Printf(" terraform init\n") fmt.Printf(" terraform plan\n") - fmt.Printf("\nNote: provider version pinned to ~> 0.3. Run 'terraform init -upgrade' if you previously ran this command.\n") + fmt.Printf("\nNote: provider version pinned to ~> 0.4. Run 'terraform init -upgrade' if you previously ran this command.\n") } diff --git a/internal/terraform/generate_test.go b/internal/terraform/generate_test.go index a9bd4d4..b4ed364 100644 --- a/internal/terraform/generate_test.go +++ b/internal/terraform/generate_test.go @@ -13,7 +13,7 @@ import ( func TestGenerateProviderFile(t *testing.T) { content := string(GenerateProviderFile()) mustContain(t, content, `source = "openstatusHQ/openstatus"`) - mustContain(t, content, `version = "~> 0.3"`) + mustContain(t, content, `version = "~> 0.4"`) mustContain(t, content, `provider "openstatus" {}`) mustContain(t, content, `OPENSTATUS_API_TOKEN`) } @@ -382,6 +382,81 @@ func TestGenerateMonitorsFile_DNS_OpenTelemetry(t *testing.T) { mustContain(t, content, `endpoint = "https://otel.example.com/v1/metrics"`) } +func TestGenerateMonitorsFile_ICMP(t *testing.T) { + m := &monitorv1.ICMPMonitor{} + m.SetId("777") + m.SetName("Gateway Ping") + m.SetUri("8.8.8.8") + m.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_1M) + m.SetTimeout(45000) + m.SetRetry(3) + m.SetActive(true) + m.SetPublic(false) + m.SetRegions([]monitorv1.Region{monitorv1.Region_REGION_FLY_FRA, monitorv1.Region_REGION_FLY_IAD}) + + data := &WorkspaceData{ICMPMonitors: []*monitorv1.ICMPMonitor{m}} + gen := NewGenerator(data) + content := string(gen.GenerateMonitorsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_icmp_monitor" "gateway_ping"`) + mustContain(t, content, `uri = "8.8.8.8"`) + mustContain(t, content, `periodicity = "1m"`) + mustContain(t, content, `active = true`) + mustContain(t, content, `public = false`) + mustContain(t, content, `["fly-fra", "fly-iad"]`) + // timeout and retry are omitted at defaults + mustNotContain(t, content, "timeout") + mustNotContain(t, content, "retry") + if !gen.HasMonitors() { + t.Error("HasMonitors() should be true") + } +} + +func TestGenerateMonitorsFile_ICMP_OpenTelemetry(t *testing.T) { + ot := &monitorv1.OpenTelemetryConfig{} + ot.SetEndpoint("https://otel.example.com/v1/metrics") + + m := &monitorv1.ICMPMonitor{} + m.SetId("1") + m.SetName("ICMP") + m.SetUri("8.8.8.8") + m.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_5M) + m.SetActive(true) + m.SetOpenTelemetry(ot) + + data := &WorkspaceData{ICMPMonitors: []*monitorv1.ICMPMonitor{m}} + gen := NewGenerator(data) + content := string(gen.GenerateMonitorsFile().Bytes()) + + mustContain(t, content, "open_telemetry {") + mustContain(t, content, `endpoint = "https://otel.example.com/v1/metrics"`) +} + +func TestGenerateImportsFile_ICMP(t *testing.T) { + m := &monitorv1.ICMPMonitor{} + m.SetId("12345") + m.SetName("Gateway Ping") + + data := &WorkspaceData{ICMPMonitors: []*monitorv1.ICMPMonitor{m}} + gen := NewGenerator(data) + content := string(gen.GenerateImportsFile().Bytes()) + + mustContain(t, content, "to = openstatus_icmp_monitor.gateway_ping") + mustContain(t, content, `id = "12345"`) +} + +func TestTotalResourceCount_ICMP(t *testing.T) { + data := &WorkspaceData{ICMPMonitors: []*monitorv1.ICMPMonitor{ + {Id: "1"}, + {Id: "2"}, + }} + gen := NewGenerator(data) + + if got := gen.TotalResourceCount(); got != 2 { + t.Errorf("TotalResourceCount() = %d, want 2", got) + } +} + func TestGenerateMonitorsFile_OpenTelemetry_SkippedWhenEmpty(t *testing.T) { m := &monitorv1.HTTPMonitor{} m.SetId("1") diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go index 75dc47d..78561e3 100644 --- a/internal/terraform/hcl.go +++ b/internal/terraform/hcl.go @@ -28,6 +28,7 @@ type Generator struct { httpMonitorNames map[string]string tcpMonitorNames map[string]string dnsMonitorNames map[string]string + icmpMonitorNames map[string]string notifNames map[string]string pageNames map[string]string componentNames map[string]string @@ -46,6 +47,7 @@ func NewGenerator(data *WorkspaceData) *Generator { httpMonitorNames: make(map[string]string), tcpMonitorNames: make(map[string]string), dnsMonitorNames: make(map[string]string), + icmpMonitorNames: make(map[string]string), notifNames: make(map[string]string), pageNames: make(map[string]string), componentNames: make(map[string]string), @@ -69,6 +71,11 @@ func NewGenerator(data *WorkspaceData) *Generator { g.dnsMonitorNames[m.GetId()] = name g.monitorRefs[m.GetId()] = resourceRef{"openstatus_dns_monitor", name} } + for _, m := range data.ICMPMonitors { + name := g.registry.Name("openstatus_icmp_monitor", m.GetName()) + g.icmpMonitorNames[m.GetId()] = name + g.monitorRefs[m.GetId()] = resourceRef{"openstatus_icmp_monitor", name} + } for _, n := range data.Notifications { if _, ok := renderableNotification(n); !ok { g.skippedNotifications[n.GetId()] = true @@ -107,7 +114,7 @@ func GenerateProviderFile() []byte { required_providers { openstatus = { source = "openstatusHQ/openstatus" - version = "~> 0.3" + version = "~> 0.4" } } } @@ -231,6 +238,36 @@ func (g *Generator) GenerateMonitorsFile() *hclwrite.File { body.AppendNewline() } + for _, m := range g.data.ICMPMonitors { + name := g.icmpMonitorNames[m.GetId()] + block := body.AppendNewBlock("resource", []string{"openstatus_icmp_monitor", name}) + b := block.Body() + + b.SetAttributeValue("name", cty.StringVal(m.GetName())) + b.SetAttributeValue("uri", cty.StringVal(m.GetUri())) + b.SetAttributeValue("periodicity", cty.StringVal(periodicityToString(m.GetPeriodicity()))) + + if m.GetTimeout() != 45000 { + b.SetAttributeValue("timeout", cty.NumberIntVal(m.GetTimeout())) + } + if m.GetDegradedAt() != 0 { + b.SetAttributeValue("degraded_at", cty.NumberIntVal(m.GetDegradedAt())) + } + if m.GetRetry() != 3 { + b.SetAttributeValue("retry", cty.NumberIntVal(m.GetRetry())) + } + b.SetAttributeValue("active", cty.BoolVal(m.GetActive())) + b.SetAttributeValue("public", cty.BoolVal(m.GetPublic())) + if m.GetDescription() != "" { + b.SetAttributeValue("description", cty.StringVal(m.GetDescription())) + } + + writeRegions(b, m.GetRegions()) + writeOpenTelemetry(b, m.GetOpenTelemetry()) + + body.AppendNewline() + } + return f } @@ -571,6 +608,9 @@ func (g *Generator) GenerateImportsFile() *hclwrite.File { for _, m := range g.data.DNSMonitors { writeImportBlock(body, "openstatus_dns_monitor", g.dnsMonitorNames[m.GetId()], m.GetId()) } + for _, m := range g.data.ICMPMonitors { + writeImportBlock(body, "openstatus_icmp_monitor", g.icmpMonitorNames[m.GetId()], m.GetId()) + } for _, n := range g.data.Notifications { if g.skippedNotifications[n.GetId()] { continue @@ -595,7 +635,7 @@ func (g *Generator) GenerateImportsFile() *hclwrite.File { } func (g *Generator) TotalResourceCount() int { - count := len(g.data.HTTPMonitors) + len(g.data.TCPMonitors) + len(g.data.DNSMonitors) + len(g.data.Notifications) + count := len(g.data.HTTPMonitors) + len(g.data.TCPMonitors) + len(g.data.DNSMonitors) + len(g.data.ICMPMonitors) + len(g.data.Notifications) for _, sp := range g.data.StatusPages { count += 1 + len(sp.Components) + len(sp.Groups) } @@ -604,7 +644,7 @@ func (g *Generator) TotalResourceCount() int { } func (g *Generator) HasMonitors() bool { - return len(g.data.HTTPMonitors) > 0 || len(g.data.TCPMonitors) > 0 || len(g.data.DNSMonitors) > 0 + return len(g.data.HTTPMonitors) > 0 || len(g.data.TCPMonitors) > 0 || len(g.data.DNSMonitors) > 0 || len(g.data.ICMPMonitors) > 0 } func (g *Generator) HasNotifications() bool { diff --git a/internal/terraform/smoke_test.go b/internal/terraform/smoke_test.go index 6192d53..f853df5 100644 --- a/internal/terraform/smoke_test.go +++ b/internal/terraform/smoke_test.go @@ -120,6 +120,16 @@ func smokeFixture() *WorkspaceData { dnsRecord.SetComparator(monitorv1.RecordComparator_RECORD_COMPARATOR_EQUAL) dnsMon.SetRecordAssertions([]*monitorv1.RecordAssertion{dnsRecord}) + icmpMon := &monitorv1.ICMPMonitor{} + icmpMon.SetId("mon-icmp") + icmpMon.SetName("Gateway Ping") + icmpMon.SetUri("8.8.8.8") + icmpMon.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_1M) + icmpMon.SetTimeout(45000) + icmpMon.SetRetry(3) + icmpMon.SetActive(true) + icmpMon.SetRegions([]monitorv1.Region{monitorv1.Region_REGION_FLY_IAD}) + slackNotif := newNotification("notif-slack", "Slack Alerts", []string{"mon-http"}, func(d *notificationv1.NotificationData) { sd := ¬ificationv1.SlackData{} sd.SetWebhookUrl("https://hooks.example.com/slack") @@ -205,6 +215,7 @@ func smokeFixture() *WorkspaceData { HTTPMonitors: []*monitorv1.HTTPMonitor{httpMon}, TCPMonitors: []*monitorv1.TCPMonitor{tcpMon}, DNSMonitors: []*monitorv1.DNSMonitor{dnsMon}, + ICMPMonitors: []*monitorv1.ICMPMonitor{icmpMon}, Notifications: []*notificationv1.Notification{slackNotif, teamsNotif, webhookNotif}, StatusPages: []StatusPageData{ { diff --git a/skills/cli/references/monitor-config.md b/skills/cli/references/monitor-config.md index 826bbd8..6f36fd5 100644 --- a/skills/cli/references/monitor-config.md +++ b/skills/cli/references/monitor-config.md @@ -12,6 +12,9 @@ monitors: - name: "Database TCP Check" kind: "tcp" # ... monitor fields + - name: "Gateway Ping" + kind: "icmp" + # ... monitor fields ``` ## Monitor Fields @@ -20,7 +23,7 @@ monitors: |-------|------|----------|-------------| | `name` | string | yes | Display name for the monitor | | `description` | string | no | Description of what this monitors | -| `kind` | `"http"` or `"tcp"` | yes | Monitor type | +| `kind` | `"http"`, `"tcp"`, or `"icmp"` | yes | Monitor type | | `active` | bool | no | Whether the monitor runs on schedule (default: true) | | `public` | bool | no | Whether results are publicly visible | | `frequency` | string | yes | Check interval: `"30s"`, `"1m"`, `"5m"`, `"10m"`, `"30m"`, `"1h"` | @@ -49,6 +52,12 @@ monitors: | `host` | string | yes | Hostname to connect to | | `port` | int | yes | Port number | +## Request (ICMP) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `host` | string | yes | Hostname or IP address to ping | + ## Assertions Assertions define pass/fail conditions for each check. @@ -137,6 +146,18 @@ monitors: host: "db.example.com" port: 5432 + - name: "Gateway Ping" + description: "Ping the edge router" + kind: "icmp" + active: true + frequency: "1m" + timeout: 10000 + regions: + - iad + - fra + request: + host: "8.8.8.8" + - name: "Webhook POST" description: "Verify webhook endpoint accepts payloads" kind: "http"