diff --git a/cmd/openstatus/main.go b/cmd/openstatus/main.go index 2c606ad..0f675ae 100644 --- a/cmd/openstatus/main.go +++ b/cmd/openstatus/main.go @@ -15,6 +15,7 @@ func main() { app := &cli.Command{ Name: "openstatus", Usage: "This is OpenStatus Command Line Interface", + 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: "v0.0.3", Commands: []*cli.Command{ monitors.MonitorsCmd(), diff --git a/internal/cli/confirmation.go b/internal/cli/confirmation.go new file mode 100644 index 0000000..cbf74a0 --- /dev/null +++ b/internal/cli/confirmation.go @@ -0,0 +1,22 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +func AskForConfirmation(s string) bool { + reader := bufio.NewReader(os.Stdin) + fmt.Printf("%s [y/N]: ", s) + response, err := reader.ReadString('\n') + if err != nil { + panic(err) + } + response = strings.ToLower(strings.TrimSpace(response)) + if response == "y" || response == "yes" { + return true + } + return false +} diff --git a/internal/config/monitor.go b/internal/config/monitor.go new file mode 100644 index 0000000..8e0a470 --- /dev/null +++ b/internal/config/monitor.go @@ -0,0 +1,153 @@ +package config + +type Monitor struct { + // Whether the monitor is active + Active bool `json:"active,omitempty"` + // Assertions to run on the response + Assertions []Assertion `json:"assertions,omitempty"` + // Time in milliseconds to wait before marking the request as degraded + DegradedAfter int64 `json:"degradedAfter,omitempty"` + Description string `json:"description,omitempty"` + Frequency Frequency `json:"frequency"` + Kind CoordinateKind `json:"kind"` + // Name of the monitor + Name string `json:"name"` + // Whether the monitor is public + Public bool `json:"public,omitempty"` + // Regions to run the request in + Regions []Region `json:"regions"` + // The HTTP Request we are sending + Request Request `json:"request"` + // Number of retries to attempt + Retry int64 `json:"retry,omitempty"` + // Time in milliseconds to wait before marking the request as timed out + Timeout int64 `json:"timeout,omitempty"` +} + +type Assertion struct { + // Comparison operator + Compare Compare `json:"compare"` + Kind AssertionKind `json:"kind"` + // Status code to assert + // + // Header value to assert + // + // Text body to assert + Target any `json:"target"` + // Header key to assert + Key string `json:"key,omitempty"` +} + +// The HTTP Request we are sending +type Request struct { + // Body to send with the request + Body string `json:"body,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Method Method `json:"method,omitempty"` + // URL to request + URL string `json:"url,omitempty"` + // Host to connect to + Host string `json:"host,omitempty"` + // Port to connect to + Port float64 `json:"port,omitempty"` +} + +// Comparison operator +type Compare string + +const ( + Contains Compare = "contains" + Empty Compare = "empty" + Eq Compare = "eq" + Gt Compare = "gt" + Gte Compare = "gte" + LTE Compare = "lte" + Lt Compare = "lt" + NotContains Compare = "not_contains" + NotEmpty Compare = "not_empty" + NotEq Compare = "not_eq" +) + +type AssertionKind string + +const ( + Header AssertionKind = "header" + StatusCode AssertionKind = "statusCode" + TextBody AssertionKind = "textBody" +) + +type Frequency string + +const ( + The10M Frequency = "10m" + The1H Frequency = "1h" + The1M Frequency = "1m" + The30M Frequency = "30m" + The30S Frequency = "30s" + The5M Frequency = "5m" +) + +type CoordinateKind string + +const ( + HTTP CoordinateKind = "http" + TCP CoordinateKind = "tcp" +) + +type Region string + +const ( + Ams Region = "ams" + Arn Region = "arn" + Atl Region = "atl" + BOM Region = "bom" + Bog Region = "bog" + Bos Region = "bos" + Cdg Region = "cdg" + Den Region = "den" + Dfw Region = "dfw" + Ewr Region = "ewr" + Eze Region = "eze" + Fra Region = "fra" + Gdl Region = "gdl" + Gig Region = "gig" + Gru Region = "gru" + Hkg Region = "hkg" + Iad Region = "iad" + Jnb Region = "jnb" + Lax Region = "lax" + Lhr Region = "lhr" + Mad Region = "mad" + Mia Region = "mia" + Nrt Region = "nrt" + Ord Region = "ord" + Otp Region = "otp" + Phx Region = "phx" + Private Region = "private" + Qro Region = "qro" + Scl Region = "scl" + Sea Region = "sea" + Sin Region = "sin" + Sjc Region = "sjc" + Syd Region = "syd" + Waw Region = "waw" + Yul Region = "yul" + Yyz Region = "yyz" +) + +type Method string + +const ( + Delete Method = "DELETE" + Get Method = "GET" + Head Method = "HEAD" + Options Method = "OPTIONS" + Patch Method = "PATCH" + Post Method = "POST" + Put Method = "PUT" +) + +type Target struct { + Int *int64 + String *string +} diff --git a/internal/config/openstatus.go b/internal/config/openstatus.go new file mode 100644 index 0000000..558e8a3 --- /dev/null +++ b/internal/config/openstatus.go @@ -0,0 +1,61 @@ +package config + +import ( + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/file" +) + +type Monitors map[string]Monitor + + + +func ReadOpenStatus(path string) ([]Monitor, error) { + f := file.Provider(path) + + // r, _:= f.ReadBytes() + + // fmt.Printf("%v", string(r)) + // for _, line := range string(r) { + // fmt.Println(line) + // } + err := k.Load(f, yaml.Parser()) + + if err != nil { + return nil, err + } + + var out Monitors + + err = k.Unmarshal("", &out) + + for _, value := range out { + for _, assertion := range value.Assertions { + if assertion.Kind == Header || assertion.Kind == TextBody { + assertion.Target = assertion.Target.(string) + } + if assertion.Kind == StatusCode { + assertion.Target = assertion.Target.(int) + } + } + } + + + var monitor []Monitor + for _, value := range out { + for _, assertion := range value.Assertions { + if assertion.Kind == Header || assertion.Kind == TextBody { + assertion.Target = assertion.Target.(string) + } + if assertion.Kind == StatusCode { + assertion.Target = assertion.Target.(int) + } + } + monitor = append(monitor, value) + } + + if err != nil { + return nil, err + } + + return monitor, nil +} diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go new file mode 100644 index 0000000..a194e07 --- /dev/null +++ b/internal/monitors/monitor_create.go @@ -0,0 +1,112 @@ +package monitors + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + + confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/config" + "github.com/urfave/cli/v3" +) + +func CreateMonitor(httpClient *http.Client, apiKey string, monitor config.Monitor) error { + + url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s", monitor.Kind) + + payloadBuf := new(bytes.Buffer) + json.NewEncoder(payloadBuf).Encode(monitor) + req, _ := http.NewRequest(http.MethodPost, url, payloadBuf) + + req.Header.Add("x-openstatus-key", apiKey) + req.Header.Add("Content-Type", "application/json") + + res, err := httpClient.Do(req) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("Failed to create monitor") + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + var monitors Monitor + err = json.Unmarshal(body, &monitors) + if err != nil { + return err + } + + return nil +} + +func GetMonitorCreateCmd() *cli.Command { + monitorInfoCmd := cli.Command{ + Name: "create", + Usage: "Create monitors (beta)", + Description: "Create your monitors from your openstatus.yaml file ", + UsageText: "openstatus monitors create [options]", + + Action: func(ctx context.Context, cmd *cli.Command) error { + + path := cmd.String("config") + + if path != "" { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return cli.Exit("Config does not exist", 1) + } + } + + accept := cmd.Bool("auto-accept") + + monitors, err := config.ReadOpenStatus(path) + if err != nil { + return cli.Exit("Unable to read config file", 1) + } + + if !accept { + if !confirmation.AskForConfirmation(fmt.Sprintf("You are about to create %d monitors do you want to continue", len(monitors))) { + return nil + } + } + for _, value := range monitors { + err = CreateMonitor(http.DefaultClient, cmd.String("access-token"), value) + if err != nil { + return cli.Exit("Unable to create monitor", 1) + } + } + fmt.Printf("%d monitors created successfully\n", len(monitors)) + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Usage: "The configuration file containing monitor information", + DefaultText: "openstatus.yaml", + Value: "openstatus.yaml", + }, + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + &cli.BoolFlag{ + Name: "auto-accept", + Usage: "Automatically accept the prompt", + Aliases: []string{"y"}, + Required: false, + }, + }, + } + return &monitorInfoCmd +} + +// os_3Za36BLXy7pN9ZY36SvSLnjD diff --git a/internal/monitors/monitor_delete.go b/internal/monitors/monitor_delete.go new file mode 100644 index 0000000..6b637f4 --- /dev/null +++ b/internal/monitors/monitor_delete.go @@ -0,0 +1,88 @@ +package monitors + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + + + +func DeleteMonitor(httpClient *http.Client, apiKey string, monitorId string) error { + + if monitorId == "" { + return fmt.Errorf("Monitor ID is required") + } + + url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s", monitorId) + + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return err + } + req.Header.Add("x-openstatus-key", apiKey) + res, err := httpClient.Do(req) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("Failed to delete monitor") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + var r MonitorTriggerResponse + err = json.Unmarshal(body, &r) + if err != nil { + + return err + } + fmt.Printf("Monitor deleted successfully\n") + return nil +} + +func GetMonitorDeleteCmd() *cli.Command { + monitorsCmd := cli.Command{ + Name: "delete", + Usage: "Delete a monitor", + UsageText: "openstatus monitors delete [MonitorID] [options]", + + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + &cli.BoolFlag{ + Name: "auto-accept", + Usage: "Automatically accept the prompt", + Aliases: []string{"y"}, + Required: false, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + monitorId := cmd.Args().Get(0) + + if !cmd.Bool("auto-accept") { + if !confirmation.AskForConfirmation(fmt.Sprintf("You are about to delete monitor: %s, do you want to continue", monitorId)) { + return nil + } + } + err := DeleteMonitor(http.DefaultClient, cmd.String("access-token"), monitorId) + if err != nil { + return cli.Exit("Failed to delete monitor", 1) + } + return nil + }, + } + return &monitorsCmd +} diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index 892ecf2..82d96cf 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -6,7 +6,14 @@ import ( "fmt" "io" "net/http" + "os" + "strings" + // "github.com/logrusorgru/aurora/v4" + "github.com/logrusorgru/aurora/v4" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" "github.com/urfave/cli/v3" ) @@ -18,7 +25,7 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er url := "https://api.openstatus.dev/v1/monitor/" + monitorId - req, _ := http.NewRequest("GET", url, nil) + req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Add("x-openstatus-key", apiKey) @@ -27,19 +34,76 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er return err } if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to get monitor information") + return fmt.Errorf("You don't have permission to access this monitor") } defer res.Body.Close() body, _ := io.ReadAll(res.Body) - var monitors Monitor - err = json.Unmarshal(body, &monitors) + var monitor Monitor + err = json.Unmarshal(body, &monitor) if err != nil { + fmt.Println(err) return err } - fmt.Println("Monitor") - fmt.Printf("ID: %d\nName: %s\nURL: %s\nPeriodicity: %s\nDescription: %s\nMethod: %s\nActive: %t\nPublic: %t\nTimeout: %d\nDegradedAfter: %d\n", monitors.ID, monitors.Name, monitors.URL, monitors.Periodicity, monitors.Description, monitors.Method, monitors.Active, monitors.Public, monitors.Timeout, monitors.DegradedAfter) + // fmt.Println("Monitor") + + fmt.Println(aurora.Bold("Monitor:")) + table := tablewriter.NewTable(os.Stdout, + tablewriter.WithRenderer(renderer.NewBlueprint()), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbolCustom("custom").WithColumn("="), + Borders: tw.Border{ + Top: tw.Off, + Left: tw.Off, + Right: tw.Off, + Bottom: tw.Off, + }, + Settings: tw.Settings{ + Lines: tw.Lines{ // Major internal separator lines + ShowHeaderLine: tw.Off, // Line after header + ShowFooterLine: tw.On, // Line before footer (if footer exists) + }, + Separators: tw.Separators{ // General row and column separators + BetweenRows: tw.Off, // Horizontal lines between data rows + BetweenColumns: tw.On, // Vertical lines between columns + }, + }, + }), + tablewriter.WithRowAlignment(tw.AlignLeft), // Common for Markdown + tablewriter.WithHeaderAlignment(tw.AlignLeft), // + ) + + data := [][]string{ + {"ID",fmt.Sprintf("%d", monitor.ID)}, + {"Name",monitor.Name}, + {"Description",monitor.Description}, + {"Endpoint",monitor.URL}, + + } + if monitor.Method != "" { + data = append(data, []string{"Method", monitor.Method}) + } + + data = append(data, []string{"Frequency",monitor.Periodicity}) + data = append(data, []string{"Locations", strings.Join(monitor.Regions, ",")}) + data = append(data, []string{"Active", fmt.Sprintf("%t", monitor.Active)}) + data = append(data, []string{"Public", fmt.Sprintf("%t", monitor.Public)}) + + if monitor.Timeout > 0 { + data = append(data, []string{"Timeout", fmt.Sprintf("%d ms", monitor.Timeout)}) + } + if monitor.DegradedAfter > 0 { + data = append(data, []string{"Degraded After", fmt.Sprintf("%d", monitor.DegradedAfter)}) + } + + if monitor.Body != "" { + s := fmt.Sprintf("%s", monitor.Body) + data = append(data, []string{"Body", s[:40]}) + } + table.Bulk(data) + table.Render() + return nil } @@ -48,11 +112,10 @@ func GetMonitorInfoCmd() *cli.Command { Name: "info", Usage: "Get monitor information", Action: func(ctx context.Context, cmd *cli.Command) error { - fmt.Println("Monitor information") monitorId := cmd.Args().Get(0) err := GetMonitorInfo(http.DefaultClient, cmd.String("access-token"), monitorId) if err != nil { - return cli.Exit("Failed to get monitor information", 1) + return cli.Exit(err.Error(), 1) } return nil }, diff --git a/internal/monitors/monitor_trigger.go b/internal/monitors/monitor_trigger.go index 45ddf84..ddc82bb 100644 --- a/internal/monitors/monitor_trigger.go +++ b/internal/monitors/monitor_trigger.go @@ -24,7 +24,7 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er url := fmt.Sprintf("https://api.openstatus.dev/v1/monitor/%s/trigger", monitorId) - req, err := http.NewRequest("POST", url, nil) + req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return err } @@ -44,10 +44,9 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er var r MonitorTriggerResponse err = json.Unmarshal(body, &r) if err != nil { - return err } - fmt.Printf("Result ID: %d\n", r.ResultId) + fmt.Printf("Check triggered successfully\n") return nil } @@ -56,6 +55,7 @@ func GetMonitorsTriggerCmd() *cli.Command { monitorsCmd := cli.Command{ Name: "trigger", Usage: "Trigger a monitor test", + UsageText: "openstatus monitors trigger [MonitorId] [options]", Flags: []cli.Flag{ &cli.StringFlag{ Name: "access-token", diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index ee241d0..5fedb6d 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -7,16 +7,32 @@ import ( ) type Monitor struct { - ID int `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Periodicity string `json:"periodicity"` - Description string `json:"description"` - Method string `json:"method"` - Active bool `json:"active"` - Public bool `json:"public"` - Timeout int `json:"timeout"` - DegradedAfter int `json:"degraded_after,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Periodicity string `json:"periodicity"` + Description string `json:"description"` + Method string `json:"method"` + Regions []string `json:"regions"` + Active bool `json:"active"` + Public bool `json:"public"` + Timeout int `json:"timeout"` + DegradedAfter int `json:"degraded_after,omitempty"` + Body string `json:"body"` + Headers []Header `json:"headers,omitempty"` + Assertions []Assertion `json:"assertions,omitempty"` +} + +type Header struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type Assertion struct { + Type string `json:"type"` + Compare string `json:"compare"` + Value string `json:"value"` + Target any `json:"target"` } type Timing struct { @@ -77,6 +93,8 @@ func MonitorsCmd() *cli.Command { Usage: "Manage your monitors", Commands: []*cli.Command{ + GetMonitorCreateCmd(), + GetMonitorDeleteCmd(), GetMonitorInfoCmd(), GetMonitorsListCmd(), GetMonitorsTriggerCmd(), diff --git a/internal/monitors/monitors_list.go b/internal/monitors/monitors_list.go index 3003709..4373d64 100644 --- a/internal/monitors/monitors_list.go +++ b/internal/monitors/monitors_list.go @@ -17,7 +17,7 @@ var allMonitor bool func ListMonitors(httpClient *http.Client, apiKey string) error { url := "https://api.openstatus.dev/v1/monitor" - req, _ := http.NewRequest("GET", url, nil) + req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Add("x-openstatus-key", apiKey) res, err := httpClient.Do(req) if err != nil { @@ -56,6 +56,7 @@ func GetMonitorsListCmd() *cli.Command { monitorsListCmd := cli.Command{ Name: "list", Usage: "List all monitors", + UsageText: "openstatus monitors list [options]", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "all", diff --git a/internal/run/run.go b/internal/run/run.go index de39e9c..4637830 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -13,6 +13,7 @@ import ( "time" "github.com/fatih/color" + "github.com/logrusorgru/aurora/v4" "github.com/openstatusHQ/cli/internal/config" "github.com/openstatusHQ/cli/internal/monitors" "github.com/rodaine/table" @@ -53,7 +54,7 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er if err != nil { return err } - fmt.Println("Results for monitor:", monitorId) + fmt.Println(aurora.Bold(fmt.Sprintf("Monitor: %s", monitorId))) headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() columnFmt := color.New(color.FgYellow).SprintfFunc() @@ -117,7 +118,9 @@ func RunCmd() *cli.Command { runCmd := cli.Command{ Name: "run", Aliases: []string{"r"}, - Usage: "Run your synthetics tests defined in your configuration file", + Usage: "Run your synthetics tests", + UsageText: "openstatus run[options]", + Description: "Run synthetic tests defined your config.openstatus.yaml", Action: func(ctx context.Context, cmd *cli.Command) error { path := cmd.String("config") @@ -134,7 +137,7 @@ func RunCmd() *cli.Command { size := len(conf.Tests.Ids) ch := make(chan error, size) - fmt.Println("Tests are running") + fmt.Print("Tests are running\n\n") var wg sync.WaitGroup @@ -155,7 +158,6 @@ func RunCmd() *cli.Command { if len(ch) > 0 { return cli.Exit("Some tests failed", 1) } - fmt.Println("Test ran succesfully🔥") return nil }, Flags: []cli.Flag{ diff --git a/internal/whoami/whoami.go b/internal/whoami/whoami.go index c4b6f86..b36dcdc 100644 --- a/internal/whoami/whoami.go +++ b/internal/whoami/whoami.go @@ -47,6 +47,7 @@ func WhoamiCmd() *cli.Command { whoamiCmd := cli.Command{ Name: "whoami", Aliases: []string{"w"}, + UsageText: "openstatus whoami [options]", Usage: "Get your current workspace information", Action: func(ctx context.Context, cmd *cli.Command) error { fmt.Println("Your current workspace information")