diff --git a/internal/cmd/app.go b/internal/cmd/app.go index d814585..b8e0c8f 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -13,7 +13,7 @@ func NewApp() *cli.Command { Suggest: true, Usage: "This is OpenStatus Command Line Interface, the OpenStatus.dev CLI", Description: "OpenStatus is a command line interface for managing your monitors and triggering your synthetics tests. \n\nPlease report any issues at https://github.com/openstatusHQ/cli/issues/new", - Version: "v1.0.0", + Version: "v1.0.1", Commands: []*cli.Command{ monitors.MonitorsCmd(), run.RunCmd(), diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 74fb057..2b14563 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -20,8 +20,8 @@ func Test_NewApp(t *testing.T) { t.Errorf("Expected app name 'openstatus', got %s", app.Name) } - if app.Version != "v1.0.0" { - t.Errorf("Expected version 'v1.0.0', got %s", app.Version) + if app.Version != "v1.0.1" { + t.Errorf("Expected version 'v1.0.1', got %s", app.Version) } if !app.Suggest { diff --git a/internal/config/monitor.go b/internal/config/monitor.go index a1629ad..e614b89 100644 --- a/internal/config/monitor.go +++ b/internal/config/monitor.go @@ -48,6 +48,8 @@ type Request struct { Method Method `json:"method,omitempty" ,yaml:"method,omitempty"` // URL to request URL string `json:"url,omitempty" ,yaml:"url,omitempty"` + // Whether to follow HTTP redirects (defaults to true when not specified) + FollowRedirects *bool `json:"followRedirects,omitempty" ,yaml:"followRedirects,omitempty"` // Host to connect to Host string `json:"host,omitempty" ,yaml:"host,omitempty"` // Port to connect to diff --git a/internal/config/openstatus_test.go b/internal/config/openstatus_test.go index 736f108..3264e26 100644 --- a/internal/config/openstatus_test.go +++ b/internal/config/openstatus_test.go @@ -97,6 +97,94 @@ func Test_ReadOpenStatus(t *testing.T) { }) } +func Test_ReadOpenStatus_FollowRedirects(t *testing.T) { + t.Run("followRedirects false is parsed correctly", func(t *testing.T) { + yaml := ` +"redirect-monitor": + active: true + frequency: 10m + kind: http + name: Redirect Monitor + regions: + - iad + request: + method: GET + url: https://example.com + followRedirects: false +` + f, err := os.CreateTemp(".", "openstatus*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + + if _, err := f.Write([]byte(yaml)); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + out, err := config.ReadOpenStatus(f.Name()) + if err != nil { + t.Fatal(err) + } + + monitor, exists := out["redirect-monitor"] + if !exists { + t.Fatal("Expected 'redirect-monitor' to exist in output") + } + + if monitor.Request.FollowRedirects == nil { + t.Fatal("Expected FollowRedirects to be non-nil") + } + if *monitor.Request.FollowRedirects != false { + t.Errorf("Expected FollowRedirects to be false, got %v", *monitor.Request.FollowRedirects) + } + }) + + t.Run("followRedirects omitted is nil", func(t *testing.T) { + yaml := ` +"no-redirect-field": + active: true + frequency: 10m + kind: http + name: No Redirect Field + regions: + - iad + request: + method: GET + url: https://example.com +` + f, err := os.CreateTemp(".", "openstatus*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + + if _, err := f.Write([]byte(yaml)); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + out, err := config.ReadOpenStatus(f.Name()) + if err != nil { + t.Fatal(err) + } + + monitor, exists := out["no-redirect-field"] + if !exists { + t.Fatal("Expected 'no-redirect-field' to exist in output") + } + + if monitor.Request.FollowRedirects != nil { + t.Errorf("Expected FollowRedirects to be nil, got %v", *monitor.Request.FollowRedirects) + } + }) +} + func Test_ParseConfigMonitorsToMonitor(t *testing.T) { t.Run("Parse monitors map to slice", func(t *testing.T) { monitors := config.Monitors{ diff --git a/internal/monitors/monitor_create_test.go b/internal/monitors/monitor_create_test.go index b41a63e..c840fa7 100644 --- a/internal/monitors/monitor_create_test.go +++ b/internal/monitors/monitor_create_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "encoding/json" "io" "net/http" "testing" @@ -209,3 +210,103 @@ func Test_CreateMonitor(t *testing.T) { } }) } + +func boolPtr(b bool) *bool { + return &b +} + +func Test_CreateMonitor_FollowRedirects(t *testing.T) { + t.Parallel() + + t.Run("followRedirects false is sent in request body", func(t *testing.T) { + var capturedBody map[string]json.RawMessage + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + json.Unmarshal(bodyBytes, &capturedBody) + + respBody := `{"monitor":{"id":"100","name":"Redirect Monitor","url":"https://example.com","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD"],"active":true,"followRedirects":false}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(respBody))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Redirect Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + FollowRedirects: boolPtr(false), + }, + } + + _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + var monitorPayload map[string]json.RawMessage + json.Unmarshal(capturedBody["monitor"], &monitorPayload) + + followRedirectsRaw, exists := monitorPayload["followRedirects"] + if !exists { + t.Fatal("Expected followRedirects to be present in request body") + } + + var followRedirects bool + json.Unmarshal(followRedirectsRaw, &followRedirects) + if followRedirects != false { + t.Errorf("Expected followRedirects to be false, got %v", followRedirects) + } + }) + + t.Run("followRedirects nil is omitted from request body", func(t *testing.T) { + var capturedBody map[string]json.RawMessage + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + json.Unmarshal(bodyBytes, &capturedBody) + + respBody := `{"monitor":{"id":"101","name":"Default Monitor","url":"https://example.com","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD"],"active":true}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(respBody))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Default Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + } + + _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + var monitorPayload map[string]json.RawMessage + json.Unmarshal(capturedBody["monitor"], &monitorPayload) + + if _, exists := monitorPayload["followRedirects"]; exists { + t.Error("Expected followRedirects to be absent from request body when nil") + } + }) +} diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index 6222026..d909960 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -34,11 +34,14 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er monitorConfig := resp.GetMonitor() var monitor Monitor + var regions []monitorv1.Region switch { case monitorConfig.HasHttp(): monitor = httpMonitorToLocal(monitorConfig.GetHttp()) + regions = monitorConfig.GetHttp().GetRegions() case monitorConfig.HasTcp(): monitor = tcpMonitorToLocal(monitorConfig.GetTcp()) + regions = monitorConfig.GetTcp().GetRegions() default: return fmt.Errorf("unknown monitor type") } @@ -80,7 +83,17 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er } data = append(data, []string{"Frequency", monitor.Periodicity}) - data = append(data, []string{"Locations", strings.Join(monitor.Regions, ",")}) + + // Group regions by provider and display each provider on its own row + regionGroups := groupRegionsByProvider(regions) + providers := []string{"Fly.io", "Koyeb", "Railway"} + for _, provider := range providers { + codes := regionGroups[provider] + if len(codes) > 0 { + data = append(data, []string{fmt.Sprintf("Locations (%s)", provider), strings.Join(codes, ", ")}) + } + } + data = append(data, []string{"Active", fmt.Sprintf("%t", monitor.Active)}) data = append(data, []string{"Public", fmt.Sprintf("%t", monitor.Public)}) diff --git a/internal/monitors/monitor_update_test.go b/internal/monitors/monitor_update_test.go index c0ef6f5..210b053 100644 --- a/internal/monitors/monitor_update_test.go +++ b/internal/monitors/monitor_update_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "encoding/json" "io" "net/http" "testing" @@ -200,3 +201,99 @@ func Test_UpdateMonitor(t *testing.T) { } }) } + +func Test_UpdateMonitor_FollowRedirects(t *testing.T) { + t.Parallel() + + t.Run("followRedirects false is sent in update request body", func(t *testing.T) { + var capturedBody map[string]json.RawMessage + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + json.Unmarshal(bodyBytes, &capturedBody) + + respBody := `{"monitor":{"id":"123","name":"Redirect Monitor","url":"https://example.com","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD"],"active":true,"followRedirects":false}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(respBody))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Redirect Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + FollowRedirects: boolPtr(false), + }, + } + + _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + var monitorPayload map[string]json.RawMessage + json.Unmarshal(capturedBody["monitor"], &monitorPayload) + + followRedirectsRaw, exists := monitorPayload["followRedirects"] + if !exists { + t.Fatal("Expected followRedirects to be present in update request body") + } + + var followRedirects bool + json.Unmarshal(followRedirectsRaw, &followRedirects) + if followRedirects != false { + t.Errorf("Expected followRedirects to be false, got %v", followRedirects) + } + }) + + t.Run("followRedirects nil is omitted from update request body", func(t *testing.T) { + var capturedBody map[string]json.RawMessage + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + bodyBytes, _ := io.ReadAll(req.Body) + json.Unmarshal(bodyBytes, &capturedBody) + + respBody := `{"monitor":{"id":"124","name":"Default Monitor","url":"https://example.com","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD"],"active":true}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(respBody))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Default Monitor", + Active: true, + Frequency: config.The10M, + Kind: config.HTTP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + URL: "https://example.com", + Method: config.Get, + }, + } + + _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 124, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + var monitorPayload map[string]json.RawMessage + json.Unmarshal(capturedBody["monitor"], &monitorPayload) + + if _, exists := monitorPayload["followRedirects"]; exists { + t.Error("Expected followRedirects to be absent from update request body when nil") + } + }) +} diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 1ac8e70..ba4339c 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" @@ -100,6 +101,7 @@ func httpMethodToString(m monitorv1.HTTPMethod) string { // regionToString converts SDK Region enum to string func regionToString(r monitorv1.Region) string { switch r { + // Fly.io regions case monitorv1.Region_REGION_FLY_AMS: return "ams" case monitorv1.Region_REGION_FLY_ARN: @@ -136,6 +138,22 @@ func regionToString(r monitorv1.Region) string { return "syd" case monitorv1.Region_REGION_FLY_YYZ: return "yyz" + // Koyeb regions + case monitorv1.Region_REGION_KOYEB_SFO: + return "sfo" + case monitorv1.Region_REGION_KOYEB_WAS: + return "was" + case monitorv1.Region_REGION_KOYEB_FRA: + return "fra" + case monitorv1.Region_REGION_KOYEB_PAR: + return "par" + case monitorv1.Region_REGION_KOYEB_SIN: + return "sin" + case monitorv1.Region_REGION_KOYEB_TYO: + return "tyo" + // Railway regions + case monitorv1.Region_REGION_RAILWAY_US_WEST2: + return "us-west2" default: return r.String() } @@ -145,11 +163,32 @@ func regionToString(r monitorv1.Region) string { func regionsToStrings(regions []monitorv1.Region) []string { result := make([]string, len(regions)) for i, r := range regions { - result[i] = r.String() + result[i] = regionToString(r) } return result } +// groupRegionsByProvider categorizes regions into Fly.io, Koyeb, and Railway groups +func groupRegionsByProvider(regions []monitorv1.Region) map[string][]string { + groups := map[string][]string{ + "Fly.io": {}, + "Koyeb": {}, + "Railway": {}, + } + for _, r := range regions { + code := regionToString(r) + switch { + case strings.HasPrefix(r.String(), "REGION_FLY_"): + groups["Fly.io"] = append(groups["Fly.io"], code) + case strings.HasPrefix(r.String(), "REGION_KOYEB_"): + groups["Koyeb"] = append(groups["Koyeb"], code) + case strings.HasPrefix(r.String(), "REGION_RAILWAY_"): + groups["Railway"] = append(groups["Railway"], code) + } + } + return groups +} + // Inverse converter functions (config types → SDK types) // stringToPeriodicity converts config.Frequency to SDK Periodicity @@ -364,6 +403,10 @@ func configToHTTPMonitor(m config.Monitor) *monitorv1.HTTPMonitor { monitor.DegradedAt = &m.DegradedAfter } + if m.Request.FollowRedirects != nil { + monitor.FollowRedirects = m.Request.FollowRedirects + } + return monitor }