From edcc69ab54d9bed500cd4e8bda8083abf82f033f Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Wed, 13 May 2026 15:54:14 +0200 Subject: [PATCH] feat(check): add global speed checker command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `openstatus check ` (alias `c`): a top-level, unauthenticated command that runs a one-shot HTTP check from 28 global regions via the public OpenStatus speed checker. Highlights: - Live row-per-region output streaming as NDJSON arrives, followed by a summary footer (fastest/slowest/mean/success-rate + share URL). - --timing adds DNS / Connection / TLS / TTFB / Transfer columns. - --json buffers a single object with full timing nested per region, ready for jq pipelines. - curl-style flags -X / -H / -d (with @file and @- stdin support). - Typed errors for 429 (parsed Retry-After + body reset fallback), 4xx (with VPN/proxy hint when body mentions client IP), 5xx, network errors, and truncated streams. - Region codes mapped to human-readable names (e.g. koyeb_tyo → "Tokyo (Koyeb)") from the upstream skill repo snapshot. Internals: - New internal/check/ package: client.go (NDJSON streaming parser), render.go (table + summary + JSON output), regions.go (display names), types.go (typed errors). - internal/api/client.go gains PlayCheckerURL pinned to www.openstatus.dev (skips a 308 redirect on every call). - Tests use the existing RoundTripper-interceptor pattern from internal/run/run_test.go; fixtures captured from live probes live in internal/check/testdata/. Docs: - README: new check row, dedicated Quick Start example, Global Speed Check section. - Manpage and generated markdown regenerated. - skills/cli/SKILL.md teaches when to reach for check vs. saved monitors. Version bumped to v1.2.0 (new top-level command = semver minor). --- .gitignore | 1 + README.md | 19 + docs/openstatus-docs.md | 28 ++ docs/openstatus.1 | 90 ++++ internal/api/client.go | 5 + internal/check/check.go | 222 +++++++++ internal/check/check_test.go | 214 +++++++++ internal/check/client.go | 160 +++++++ internal/check/client_test.go | 319 +++++++++++++ internal/check/regions.go | 42 ++ internal/check/render.go | 244 ++++++++++ internal/check/render_test.go | 205 ++++++++ internal/check/testdata/bad_url.ndjson | 29 ++ internal/check/testdata/body.json | 1 + internal/check/testdata/happy.ndjson | 29 ++ internal/check/testdata/rate_limited.http | 15 + internal/check/types.go | 72 +++ internal/cmd/app.go | 4 +- internal/cmd/app_test.go | 9 +- plan.md | 548 ---------------------- skills/cli/SKILL.md | 45 +- 21 files changed, 1745 insertions(+), 556 deletions(-) create mode 100644 internal/check/check.go create mode 100644 internal/check/check_test.go create mode 100644 internal/check/client.go create mode 100644 internal/check/client_test.go create mode 100644 internal/check/regions.go create mode 100644 internal/check/render.go create mode 100644 internal/check/render_test.go create mode 100644 internal/check/testdata/bad_url.ndjson create mode 100644 internal/check/testdata/body.json create mode 100644 internal/check/testdata/happy.ndjson create mode 100644 internal/check/testdata/rate_limited.http create mode 100644 internal/check/types.go delete mode 100644 plan.md diff --git a/.gitignore b/.gitignore index bcf910e..0b01d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/ .env .claude/ +plan.md diff --git a/README.md b/README.md index afa02cc..f7830ef 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ iwr https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.p ## Quick Start ```bash +# Run a one-shot HTTP check against a URL from 28 global regions (no auth) +openstatus check https://openstat.us + # Authenticate with your API token openstatus login @@ -44,10 +47,26 @@ openstatus status-report create --title "API degradation" --status investigating openstatus run ``` +## Global Speed Check + +`openstatus check ` runs a one-shot HTTP check from 28 global probe +regions via the public OpenStatus speed checker. No API token required. + +```bash +openstatus check https://openstat.us +openstatus check https://openstat.us -X POST -H 'Authorization: Bearer …' -d '{"ping":true}' +openstatus check https://openstat.us -d @payload.json +openstatus check https://openstat.us --timing # adds DNS/Connection/TLS/TTFB/Transfer columns +openstatus check https://openstat.us --json | jq '.summary' +``` + +Rate limit: 3 requests per 60 seconds. + ## Commands | Command | Alias | Description | |---------|-------|-------------| +| `check` | `c` | Run an HTTP check against a URL from 28 global regions (no auth) | | `login` / `logout` | | Authenticate with the OpenStatus API | | `whoami` | `w` | Show current workspace info | | `monitors` | `m` | List, inspect, trigger, import, and apply monitors | diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index 08cdaa5..dbcb768 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -21,6 +21,34 @@ Global flags: | `--quiet` (`-q`) | Suppress non-error output | bool | `false` | *none* | | `--debug` | Enable debug output | bool | `false` | *none* | +### `check` command (aliases: `c`) + +Run an HTTP check against a URL from 28 global regions. + +> openstatus check +> openstatus check https://openstat.us +> openstatus check https://openstat.us -X POST -H 'Authorization: Bearer …' -d '{"ping":true}' +> openstatus check https://openstat.us -d @payload.json +> openstatus check https://openstat.us --timing +> openstatus check https://openstat.us --json | jq '.summary' + +Run a one-shot HTTP check against a URL from 28 global regions. The check is executed by the public OpenStatus speed checker. No API token is required. Results stream to the terminal as they arrive from each region. Output is sorted in the order regions report back (roughly fastest first). Pass --timing to see DNS/Connection/TLS/TTFB/Transfer phase breakdowns. Pass --json for a machine-readable single object including all phase data. Rate limit: 3 requests per 60 seconds. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] check [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------|----------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--method="…"` (`-X`) | HTTP method | string | `"GET"` | *none* | +| `--header="…"` (`-H`) | Header in "Key: Value" form (repeatable) | string | | *none* | +| `--body="…"` (`-d`) | Request body. Use @filename to read a file, @- for stdin | string | | *none* | +| `--timing` | Show DNS/Connection/TLS/TTFB/Transfer phases | bool | `false` | *none* | + ### `monitors` command (aliases: `m`) Manage your monitors. diff --git a/docs/openstatus.1 b/docs/openstatus.1 index 10e4948..f38a1ae 100644 --- a/docs/openstatus.1 +++ b/docs/openstatus.1 @@ -87,6 +87,96 @@ T}@T{ \f[I]none\f[R] T} .TE +.SS \f[CR]check\f[R] command (aliases: \f[CR]c\f[R]) +Run an HTTP check against a URL from 28 global regions. +.RS +.PP +openstatus check openstatus check https://openstat.us openstatus check +https://openstat.us \-X POST \-H `Authorization: Bearer \&...' \-d +`{\(lqping\(rq:true}' openstatus check https://openstat.us \-d +\(atpayload.json openstatus check https://openstat.us \(entiming +openstatus check https://openstat.us \(enjson | jq `.summary' +.RE +.PP +Run a one\-shot HTTP check against a URL from 28 global regions. +The check is executed by the public OpenStatus speed checker. +No API token is required. +Results stream to the terminal as they arrive from each region. +Output is sorted in the order regions report back (roughly fastest +first). +Pass \(entiming to see DNS/Connection/TLS/TTFB/Transfer phase +breakdowns. +Pass \(enjson for a machine\-readable single object including all phase +data. +Rate limit: 3 requests per 60 seconds. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] check [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(12.7n) lw(32.0n) lw(4.4n) cw(8.3n) cw(12.7n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-method=\(dq\&...\(dq\f[R] (\f[CR]\-X\f[R]) +T}@T{ +HTTP method +T}@T{ +string +T}@T{ +\f[CR]\(dqGET\(dq\f[R] +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-header=\(dq\&...\(dq\f[R] (\f[CR]\-H\f[R]) +T}@T{ +Header in \(lqKey: Value\(rq form (repeatable) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-body=\(dq\&...\(dq\f[R] (\f[CR]\-d\f[R]) +T}@T{ +Request body. +Use \(atfilename to read a file, \(at\- for stdin +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-timing\f[R] +T}@T{ +Show DNS/Connection/TLS/TTFB/Transfer phases +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE .SS \f[CR]monitors\f[R] command (aliases: \f[CR]m\f[R]) Manage your monitors. .PP diff --git a/internal/api/client.go b/internal/api/client.go index a7a8901..84e6b91 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -16,6 +16,11 @@ const APIBaseURL = "https://api.openstatus.dev/v1" const ConnectBaseURL = "https://api.openstatus.dev/rpc" +// PlayCheckerURL is the public Speed Checker endpoint backing the `check` +// command. The www. prefix is intentional: the bare openstatus.dev host +// returns a 308 redirect that adds latency to every call. +const PlayCheckerURL = "https://www.openstatus.dev/play/checker/api" + var DefaultHTTPClient = &http.Client{ Timeout: 30 * time.Second, } diff --git a/internal/check/check.go b/internal/check/check.go new file mode 100644 index 0000000..e6603db --- /dev/null +++ b/internal/check/check.go @@ -0,0 +1,222 @@ +package check + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +func CheckCmd() *cli.Command { + return &cli.Command{ + Name: "check", + Aliases: []string{"c"}, + Usage: "Run an HTTP check against a URL from 28 global regions", + UsageText: `openstatus check + openstatus check https://openstat.us + openstatus check https://openstat.us -X POST -H 'Authorization: Bearer …' -d '{"ping":true}' + openstatus check https://openstat.us -d @payload.json + openstatus check https://openstat.us --timing + openstatus check https://openstat.us --json | jq '.summary'`, + Description: `Run a one-shot HTTP check against a URL from 28 global regions. + +The check is executed by the public OpenStatus speed checker. No API token is +required. Results stream to the terminal as they arrive from each region. + +Output is sorted in the order regions report back (roughly fastest first). +Pass --timing to see DNS/Connection/TLS/TTFB/Transfer phase breakdowns. +Pass --json for a machine-readable single object including all phase data. + +Rate limit: 3 requests per 60 seconds.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"X"}, + Usage: "HTTP method", + Value: http.MethodGet, + }, + &cli.StringSliceFlag{ + Name: "header", + Aliases: []string{"H"}, + Usage: "Header in \"Key: Value\" form (repeatable)", + }, + &cli.StringFlag{ + Name: "body", + Aliases: []string{"d"}, + Usage: "Request body. Use @filename to read a file, @- for stdin.", + }, + &cli.BoolFlag{ + Name: "timing", + Usage: "Show DNS/Connection/TLS/TTFB/Transfer phases", + }, + }, + Action: runCheck, + } +} + +func runCheck(ctx context.Context, cmd *cli.Command) error { + rawURL := cmd.Args().Get(0) + if rawURL == "" { + return cli.Exit("URL is required.\n\nUsage: openstatus check \nExample: openstatus check https://openstat.us", 1) + } + if err := validateURL(rawURL); err != nil { + return cli.Exit(err.Error(), 1) + } + + headers, err := parseHeaders(cmd.StringSlice("header")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + body, err := resolveBody(cmd.String("body")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + payload := Payload{ + URL: rawURL, + Method: strings.ToUpper(cmd.String("method")), + Headers: headers, + Body: body, + } + + timing := cmd.Bool("timing") + + spinner := output.StartSpinner(fmt.Sprintf("Checking %s…", rawURL)) + var renderer *Renderer + if !output.IsJSONOutput() && !output.IsQuiet() { + renderer = NewRenderer(os.Stdout, timing) + } + + onRow := func(r RegionResult) { + if renderer == nil { + return + } + output.StopSpinner(spinner) + spinner = nil + renderer.Row(r) + } + + results, checkID, runErr := Run(ctx, nil, payload, onRow) + output.StopSpinner(spinner) + + if runErr != nil { + return formatRunError(runErr) + } + + if output.IsJSONOutput() { + return output.PrintJSON(buildJSONOutput(payload.URL, checkID, results)) + } + + if renderer != nil { + renderer.Footer(payload.URL, results, checkID) + } + return nil +} + +func validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", raw, err) + } + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid URL %q: missing scheme or host (did you mean https://%s?)", raw, raw) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("invalid URL %q: only http and https schemes are supported", raw) + } + return nil +} + +func parseHeaders(raw []string) (map[string]string, error) { + if len(raw) == 0 { + return nil, nil + } + out := make(map[string]string, len(raw)) + for _, h := range raw { + key, value, ok := strings.Cut(h, ":") + if !ok { + return nil, fmt.Errorf("invalid header %q: expected \"Key: Value\"", h) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("invalid header %q: key is empty", h) + } + out[key] = value + } + return out, nil +} + +func resolveBody(raw string) (string, error) { + if raw == "" { + return "", nil + } + if raw == "@-" { + if output.IsStdinTerminal() { + return "", errors.New("body \"@-\" requires piped stdin (e.g. echo … | openstatus check …)") + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(data), nil + } + if strings.HasPrefix(raw, "@") { + path := raw[1:] + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read body file %q: %w", path, err) + } + return string(data), nil + } + return raw, nil +} + +func shareURL(checkID string) string { + if checkID == "" { + return "" + } + return "https://www.openstatus.dev/play/checker/" + checkID +} + +func formatRunError(err error) error { + var rl *RateLimitError + if errors.As(err, &rl) { + if rl.RetryAfter > 0 { + return cli.Exit(fmt.Sprintf("Rate limited. Retry after %s. (3 requests per 60s allowed.)", rl.RetryAfter), 1) + } + return cli.Exit("Rate limited. Try again in a moment. (3 requests per 60s allowed.)", 1) + } + var br *BadRequestError + if errors.As(err, &br) { + msg := br.Message + if msg == "" { + msg = fmt.Sprintf("HTTP %d", br.Status) + } + if strings.Contains(strings.ToLower(msg), "client ip") || strings.Contains(strings.ToLower(br.Body), "client ip") { + return cli.Exit(msg+"\n(This often happens behind a VPN or corporate proxy.)", 1) + } + return cli.Exit(msg, 1) + } + var se *ServerError + if errors.As(err, &se) { + return cli.Exit(fmt.Sprintf("OpenStatus checker temporarily unavailable (HTTP %d). Try again in a moment.", se.Status), 1) + } + if errors.Is(err, ErrStreamTruncated) { + fmt.Fprintln(os.Stderr, "Warning: stream ended before completion; results may be incomplete.") + return cli.Exit("Stream ended before all regions reported.", 1) + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return cli.Exit("Check cancelled.", 130) + } + return cli.Exit(fmt.Sprintf("Could not reach OpenStatus: %s", err.Error()), 1) +} diff --git a/internal/check/check_test.go b/internal/check/check_test.go new file mode 100644 index 0000000..0b3e031 --- /dev/null +++ b/internal/check/check_test.go @@ -0,0 +1,214 @@ +package check + +import ( + "errors" + "strings" + "testing" +) + +func TestValidateURL(t *testing.T) { + t.Parallel() + good := []string{ + "https://example.com", + "http://example.com:8080/path?query=1", + "https://api.example.com/v1/health", + } + for _, u := range good { + if err := validateURL(u); err != nil { + t.Errorf("validateURL(%q) = %v, want nil", u, err) + } + } + bad := []struct { + url string + anyOf []string + }{ + {"", []string{"missing scheme or host"}}, + {"example.com", []string{"missing scheme or host"}}, + {"://example.com", []string{"missing scheme or host", "missing protocol scheme"}}, + {"ftp://host", []string{"only http and https"}}, + {"ssh://host:22", []string{"only http and https"}}, + } + for _, c := range bad { + err := validateURL(c.url) + if err == nil { + t.Errorf("validateURL(%q) = nil, want error", c.url) + continue + } + var matched bool + for _, want := range c.anyOf { + if strings.Contains(err.Error(), want) { + matched = true + break + } + } + if !matched { + t.Errorf("validateURL(%q) error = %q, want one of %v", c.url, err.Error(), c.anyOf) + } + } +} + +func TestParseHeaders(t *testing.T) { + t.Parallel() + t.Run("nil", func(t *testing.T) { + t.Parallel() + h, err := parseHeaders(nil) + if err != nil || h != nil { + t.Errorf("parseHeaders(nil) = %v,%v", h, err) + } + }) + t.Run("empty slice", func(t *testing.T) { + t.Parallel() + h, err := parseHeaders([]string{}) + if err != nil || h != nil { + t.Errorf("parseHeaders([]) = %v,%v", h, err) + } + }) + t.Run("valid pairs trim whitespace", func(t *testing.T) { + t.Parallel() + h, err := parseHeaders([]string{"Authorization: Bearer abc", "x-foo:bar"}) + if err != nil { + t.Fatalf("err = %v", err) + } + if h["Authorization"] != "Bearer abc" { + t.Errorf("Authorization = %q", h["Authorization"]) + } + if h["x-foo"] != "bar" { + t.Errorf("x-foo = %q", h["x-foo"]) + } + }) + t.Run("colon in value preserved", func(t *testing.T) { + t.Parallel() + h, err := parseHeaders([]string{"X-URL: https://example.com:8080/x"}) + if err != nil { + t.Fatalf("err = %v", err) + } + if h["X-URL"] != "https://example.com:8080/x" { + t.Errorf("value = %q", h["X-URL"]) + } + }) + t.Run("missing colon errors", func(t *testing.T) { + t.Parallel() + _, err := parseHeaders([]string{"no-colon"}) + if err == nil || !strings.Contains(err.Error(), "expected") { + t.Errorf("expected error, got %v", err) + } + }) + t.Run("empty key errors", func(t *testing.T) { + t.Parallel() + _, err := parseHeaders([]string{": value"}) + if err == nil || !strings.Contains(err.Error(), "key is empty") { + t.Errorf("expected empty-key error, got %v", err) + } + }) + t.Run("duplicate keys last wins", func(t *testing.T) { + t.Parallel() + h, err := parseHeaders([]string{"X-Foo: a", "X-Foo: b"}) + if err != nil { + t.Fatalf("err = %v", err) + } + if h["X-Foo"] != "b" { + t.Errorf("X-Foo = %q, want \"b\"", h["X-Foo"]) + } + }) +} + +func TestResolveBody(t *testing.T) { + t.Parallel() + t.Run("empty", func(t *testing.T) { + t.Parallel() + b, err := resolveBody("") + if err != nil || b != "" { + t.Errorf("resolveBody(\"\") = %q,%v", b, err) + } + }) + t.Run("inline string", func(t *testing.T) { + t.Parallel() + b, err := resolveBody(`{"ping":true}`) + if err != nil || b != `{"ping":true}` { + t.Errorf("inline = %q,%v", b, err) + } + }) + t.Run("@file reads file", func(t *testing.T) { + t.Parallel() + b, err := resolveBody("@testdata/body.json") + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(b, `"ping":true`) { + t.Errorf("body = %q", b) + } + }) + t.Run("@file missing errors", func(t *testing.T) { + t.Parallel() + _, err := resolveBody("@no-such-file.json") + if err == nil || !strings.Contains(err.Error(), "read body file") { + t.Errorf("expected read error, got %v", err) + } + }) + t.Run("@- on TTY errors", func(t *testing.T) { + t.Parallel() + _, err := resolveBody("@-") + if err == nil { + t.Skip("stdin is not a TTY in this test runner; cannot assert TTY-error branch") + } + if !strings.Contains(err.Error(), "stdin") { + t.Errorf("expected stdin error, got %v", err) + } + }) +} + +func TestShareURL_EdgeCases(t *testing.T) { + t.Parallel() + if got := shareURL(""); got != "" { + t.Errorf("shareURL(\"\") = %q", got) + } + if got := shareURL("xyz"); got != "https://www.openstatus.dev/play/checker/xyz" { + t.Errorf("shareURL = %q", got) + } +} + +func TestFormatRunError_RateLimit(t *testing.T) { + t.Parallel() + err := formatRunError(&RateLimitError{RetryAfter: 16_000_000_000, Message: "rate limit"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Retry after") { + t.Errorf("err = %q, want \"Retry after\" substring", err.Error()) + } +} + +func TestFormatRunError_ClientIPHint(t *testing.T) { + t.Parallel() + err := formatRunError(&BadRequestError{Status: 400, Message: "could not determine client IP", Body: `{"error":"could not determine client IP"}`}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "VPN") { + t.Errorf("err = %q, want VPN hint", err.Error()) + } +} + +func TestFormatRunError_ServerError(t *testing.T) { + t.Parallel() + err := formatRunError(&ServerError{Status: 503}) + if err == nil || !strings.Contains(err.Error(), "503") { + t.Errorf("err = %v, want 503 message", err) + } +} + +func TestFormatRunError_StreamTruncated(t *testing.T) { + t.Parallel() + err := formatRunError(ErrStreamTruncated) + if !strings.Contains(err.Error(), "Stream ended") { + t.Errorf("err = %v, want stream-ended message", err) + } +} + +func TestFormatRunError_Unknown(t *testing.T) { + t.Parallel() + err := formatRunError(errors.New("dial tcp: no route to host")) + if !strings.Contains(err.Error(), "Could not reach OpenStatus") { + t.Errorf("err = %v, want reach-error wrap", err) + } +} diff --git a/internal/check/client.go b/internal/check/client.go new file mode 100644 index 0000000..a0be209 --- /dev/null +++ b/internal/check/client.go @@ -0,0 +1,160 @@ +package check + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/openstatusHQ/cli/internal/api" + output "github.com/openstatusHQ/cli/internal/cli" +) + +const defaultTimeout = 30 * time.Second + +func debugWriter() io.Writer { return os.Stderr } + +type OnRow func(RegionResult) + +func Run(ctx context.Context, client *http.Client, payload Payload, onRow OnRow) ([]RegionResult, string, error) { + if onRow == nil { + onRow = func(RegionResult) {} + } + if client == nil { + client = &http.Client{Timeout: defaultTimeout} + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, "", fmt.Errorf("encode payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, api.PlayCheckerURL+"?compact=true", bytes.NewReader(body)) + if err != nil { + return nil, "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + if output.IsDebug() { + fmt.Fprintf(debugWriter(), "[debug] POST %s\n", req.URL.String()) + } + + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if err := classifyHTTPError(resp); err != nil { + return nil, "", err + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 1<<20) + + var results []RegionResult + var checkID string + + for scanner.Scan() { + line := scanner.Bytes() + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + if trimmed[0] != '{' { + checkID = string(trimmed) + break + } + var row RegionResult + if err := json.Unmarshal(trimmed, &row); err != nil { + if output.IsDebug() { + fmt.Fprintf(debugWriter(), "[debug] skipping unparseable line: %v\n", err) + } + continue + } + results = append(results, row) + onRow(row) + } + + if err := scanner.Err(); err != nil { + return results, checkID, fmt.Errorf("read stream: %w", err) + } + + if checkID == "" { + return results, "", ErrStreamTruncated + } + + return results, checkID, nil +} + +func classifyHTTPError(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + switch { + case resp.StatusCode == http.StatusTooManyRequests: + return &RateLimitError{ + RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"), bodyStr), + Message: extractErrorMessage(bodyStr), + } + case resp.StatusCode >= 400 && resp.StatusCode < 500: + return &BadRequestError{ + Status: resp.StatusCode, + Body: bodyStr, + Message: extractErrorMessage(bodyStr), + } + default: + return &ServerError{Status: resp.StatusCode, Body: bodyStr} + } +} + +func parseRetryAfter(header, body string) time.Duration { + if header != "" { + if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs >= 0 { + return time.Duration(secs) * time.Second + } + if t, err := http.ParseTime(header); err == nil { + d := time.Until(t) + if d > 0 { + return d + } + } + } + var parsed struct { + Reset int64 `json:"reset"` + } + if err := json.Unmarshal([]byte(body), &parsed); err == nil && parsed.Reset > 0 { + resetAt := time.UnixMilli(parsed.Reset) + d := time.Until(resetAt) + if d > 0 { + return d + } + } + return 0 +} + +func extractErrorMessage(body string) string { + var parsed struct { + Error string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(body), &parsed); err == nil { + if parsed.Error != "" { + return parsed.Error + } + if parsed.Message != "" { + return parsed.Message + } + } + return strings.TrimSpace(body) +} diff --git a/internal/check/client_test.go b/internal/check/client_test.go new file mode 100644 index 0000000..701fda0 --- /dev/null +++ b/internal/check/client_test.go @@ -0,0 +1,319 @@ +package check_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/openstatusHQ/cli/internal/check" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func newClient(rt roundTripperFunc) *http.Client { + return &http.Client{Transport: rt} +} + +func mustReadFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return data +} + +func TestRun_HappyPath(t *testing.T) { + t.Parallel() + fixture := mustReadFixture(t, "happy.ndjson") + + rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Errorf("method = %q, want POST", req.Method) + } + if got := req.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(fixture)), + Header: make(http.Header), + }, nil + }) + + var rows []check.RegionResult + results, id, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, func(r check.RegionResult) { + rows = append(rows, r) + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(results) != 28 { + t.Errorf("results = %d, want 28", len(results)) + } + if len(rows) != 28 { + t.Errorf("onRow calls = %d, want 28", len(rows)) + } + if id == "" { + t.Errorf("checkID empty, want non-empty hex") + } + for _, r := range results { + if !r.Succeeded() { + t.Errorf("region %q state %q, want success", r.Region, r.State) + } + } +} + +func TestRun_BadURL_FailureRows(t *testing.T) { + t.Parallel() + fixture := mustReadFixture(t, "bad_url.ndjson") + + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(fixture)), + Header: make(http.Header), + }, nil + }) + + results, id, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://invalid.example"}, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + if id == "" { + t.Errorf("checkID empty") + } + if len(results) != 28 { + t.Errorf("results = %d, want 28", len(results)) + } + for _, r := range results { + if r.Succeeded() { + t.Errorf("region %q unexpectedly succeeded", r.Region) + } + if r.Message == "" { + t.Errorf("region %q missing message", r.Region) + } + if r.Status != 0 || r.Latency != 0 || r.Timing != nil { + t.Errorf("region %q has unexpected success fields", r.Region) + } + } +} + +func TestRun_RateLimit(t *testing.T) { + t.Parallel() + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + hdr := make(http.Header) + hdr.Set("Retry-After", "16") + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: hdr, + Body: io.NopCloser(strings.NewReader(`{"error":"You have exceeded the rate limit of 3 requests per 60 seconds","code":"RATE_LIMIT_EXCEEDED","reset":0}`)), + }, nil + }) + + _, _, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + var rl *check.RateLimitError + if !errors.As(err, &rl) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rl.RetryAfter != 16*time.Second { + t.Errorf("retry-after = %s, want 16s", rl.RetryAfter) + } + if !strings.Contains(rl.Message, "rate limit") { + t.Errorf("message = %q, want to contain \"rate limit\"", rl.Message) + } +} + +func TestRun_RateLimit_ResetFallback(t *testing.T) { + t.Parallel() + resetAt := time.Now().Add(20 * time.Second).UnixMilli() + body := `{"error":"limited","reset":` + itoa(resetAt) + `}` + + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + + _, _, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + var rl *check.RateLimitError + if !errors.As(err, &rl) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rl.RetryAfter <= 0 || rl.RetryAfter > 25*time.Second { + t.Errorf("retry-after = %s, want ~20s", rl.RetryAfter) + } +} + +func TestRun_BadRequest(t *testing.T) { + t.Parallel() + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"could not determine client IP"}`)), + }, nil + }) + + _, _, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + var br *check.BadRequestError + if !errors.As(err, &br) { + t.Fatalf("err = %v, want *BadRequestError", err) + } + if !strings.Contains(br.Message, "client IP") { + t.Errorf("message = %q, want to contain client IP", br.Message) + } +} + +func TestRun_ServerError(t *testing.T) { + t.Parallel() + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("upstream down")), + }, nil + }) + + _, _, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + var se *check.ServerError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *ServerError", err) + } + if se.Status != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", se.Status) + } +} + +func TestRun_TruncatedStream(t *testing.T) { + t.Parallel() + fixture := mustReadFixture(t, "happy.ndjson") + cut := bytes.SplitN(fixture, []byte("\n"), 10) + truncated := bytes.Join(cut[:9], []byte("\n")) + + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(truncated)), + Header: make(http.Header), + }, nil + }) + + results, id, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + if !errors.Is(err, check.ErrStreamTruncated) { + t.Fatalf("err = %v, want ErrStreamTruncated", err) + } + if id != "" { + t.Errorf("checkID = %q, want empty", id) + } + if len(results) != 9 { + t.Errorf("results = %d, want 9", len(results)) + } +} + +func TestRun_NonJSONLineTerminatesStream(t *testing.T) { + t.Parallel() + body := `{"region":"fra","state":"success","status":200,"latency":10,"timing":{"dns":1,"connection":1,"tls":1,"ttfb":7,"transfer":0},"index":0} +abc123checkid +{"region":"iad","state":"success","status":200,"latency":20,"timing":{"dns":1,"connection":1,"tls":1,"ttfb":17,"transfer":0},"index":1}` + + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + + results, id, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(results) != 1 { + t.Errorf("results = %d, want 1 (parser stops at first non-JSON line)", len(results)) + } + if id != "abc123checkid" { + t.Errorf("checkID = %q, want \"abc123checkid\"", id) + } +} + +func TestRun_UnparseableJSONSkipped(t *testing.T) { + t.Parallel() + body := `{"region":"fra","state":"success","status":200,"latency":10,"timing":{"dns":1,"connection":1,"tls":1,"ttfb":7,"transfer":0},"index":0} +{"region":bad json +{"region":"iad","state":"success","status":200,"latency":20,"timing":{"dns":1,"connection":1,"tls":1,"ttfb":17,"transfer":0},"index":1} +abc123checkid` + + rt := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + + results, id, err := check.Run(context.Background(), newClient(rt), check.Payload{URL: "https://example.com"}, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(results) != 2 { + t.Errorf("results = %d, want 2 (unparseable {…} line skipped, others kept)", len(results)) + } + if id != "abc123checkid" { + t.Errorf("checkID = %q, want \"abc123checkid\"", id) + } +} + +func TestRun_ContextCancel(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + + rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }) + + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + + _, _, err := check.Run(ctx, newClient(rt), check.Payload{URL: "https://example.com"}, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/check/regions.go b/internal/check/regions.go new file mode 100644 index 0000000..5dc26f3 --- /dev/null +++ b/internal/check/regions.go @@ -0,0 +1,42 @@ +package check + +// regionDisplayNames is a snapshot of: +// https://github.com/openstatusHQ/skills/blob/main/skills/global-speed-checker/references/regions-detailed.md +// Update by hand when the upstream skill repo gains a region. +var regionDisplayNames = map[string]string{ + "ams": "Amsterdam (Fly)", + "arn": "Stockholm (Fly)", + "bom": "Mumbai (Fly)", + "cdg": "Paris (Fly)", + "dfw": "Dallas (Fly)", + "ewr": "Secaucus (Fly)", + "fra": "Frankfurt (Fly)", + "gru": "São Paulo (Fly)", + "iad": "Ashburn (Fly)", + "jnb": "Johannesburg (Fly)", + "lax": "Los Angeles (Fly)", + "lhr": "London (Fly)", + "nrt": "Tokyo (Fly)", + "ord": "Chicago (Fly)", + "sjc": "San Jose (Fly)", + "sin": "Singapore (Fly)", + "syd": "Sydney (Fly)", + "yyz": "Toronto (Fly)", + "koyeb_fra": "Frankfurt (Koyeb)", + "koyeb_par": "Paris (Koyeb)", + "koyeb_sfo": "San Francisco (Koyeb)", + "koyeb_sin": "Singapore (Koyeb)", + "koyeb_tyo": "Tokyo (Koyeb)", + "koyeb_was": "Washington (Koyeb)", + "railway_us-west2": "California (Railway)", + "railway_us-east4-eqdc4a": "Virginia (Railway)", + "railway_europe-west4-drams3a": "Amsterdam (Railway)", + "railway_asia-southeast1-eqsg3a": "Singapore (Railway)", +} + +func DisplayName(code string) string { + if n, ok := regionDisplayNames[code]; ok { + return n + } + return code +} diff --git a/internal/check/render.go b/internal/check/render.go new file mode 100644 index 0000000..c45ed75 --- /dev/null +++ b/internal/check/render.go @@ -0,0 +1,244 @@ +package check + +import ( + "fmt" + "io" + + "github.com/fatih/color" +) + +const ( + colRegion = 28 + colLatency = 10 + colStatus = 7 + colState = 20 + colTimingNum = 8 +) + +type Renderer struct { + Out io.Writer + Timing bool + headerShown bool +} + +func NewRenderer(out io.Writer, timing bool) *Renderer { + return &Renderer{Out: out, Timing: timing} +} + +func (r *Renderer) Row(row RegionResult) { + if !r.headerShown { + r.printHeader() + r.headerShown = true + } + r.printRow(row) +} + +func (r *Renderer) printHeader() { + green := color.New(color.FgGreen, color.Underline).SprintfFunc() + if r.Timing { + fmt.Fprintln(r.Out, green( + "%-*s %*s %*s %-*s %*s %*s %*s %*s %*s", + colRegion, "Region", + colLatency, "Latency", + colStatus, "Status", + colState, "State", + colTimingNum, "DNS", + colTimingNum, "Conn", + colTimingNum, "TLS", + colTimingNum, "TTFB", + colTimingNum, "Transfer", + )) + return + } + fmt.Fprintln(r.Out, green( + "%-*s %*s %*s %-*s", + colRegion, "Region", + colLatency, "Latency", + colStatus, "Status", + colState, "State", + )) +} + +func (r *Renderer) printRow(row RegionResult) { + region := truncate(DisplayName(row.Region), colRegion) + latency := formatLatency(row.Latency) + if !row.Succeeded() { + latency = color.RedString("%*s", colLatency, dashIfZero(row.Latency, latency)) + } else { + latency = fmt.Sprintf("%*s", colLatency, latency) + } + status := dashOrInt(row.Status) + state := stateLabel(row) + + if r.Timing { + t := row.Timing + dns := timingCell(t, func(tt *Timing) int64 { return tt.DNS }) + conn := timingCell(t, func(tt *Timing) int64 { return tt.Connection }) + tls := timingCell(t, func(tt *Timing) int64 { return tt.TLS }) + ttfb := timingCell(t, func(tt *Timing) int64 { return tt.TTFB }) + xfer := timingCell(t, func(tt *Timing) int64 { return tt.Transfer }) + fmt.Fprintf(r.Out, "%-*s %s %*s %-*s %*s %*s %*s %*s %*s\n", + colRegion, region, + latency, + colStatus, status, + colState, truncate(state, colState), + colTimingNum, dns, + colTimingNum, conn, + colTimingNum, tls, + colTimingNum, ttfb, + colTimingNum, xfer, + ) + return + } + fmt.Fprintf(r.Out, "%-*s %s %*s %-*s\n", + colRegion, region, + latency, + colStatus, status, + colState, truncate(state, colState), + ) +} + +func (r *Renderer) Footer(checkedURL string, results []RegionResult, checkID string) { + if len(results) == 0 { + return + } + summary := computeSummary(results) + fmt.Fprintln(r.Out) + bold := color.New(color.Bold).SprintfFunc() + if summary.Fastest != nil { + fmt.Fprintf(r.Out, "%s %s %dms\n", bold("Fastest:"), DisplayName(summary.Fastest.Region), summary.Fastest.Latency) + } + if summary.Slowest != nil { + fmt.Fprintf(r.Out, "%s %s %dms\n", bold("Slowest:"), DisplayName(summary.Slowest.Region), summary.Slowest.Latency) + } + fmt.Fprintf(r.Out, "%s %dms\n", bold("Mean:"), summary.MeanLatency) + fmt.Fprintf(r.Out, "%s %d/%d (%.0f%%)\n", bold("Success:"), summary.Successes, summary.TotalRegions, summary.SuccessRate*100) + if checkID != "" { + fmt.Fprintf(r.Out, "%s %s\n", bold("View:"), shareURL(checkID)) + } + _ = checkedURL +} + +func stateLabel(row RegionResult) string { + if row.Succeeded() { + return "success" + } + if row.Message != "" { + return row.Message + } + if row.State != "" { + return row.State + } + return "error" +} + +func formatLatency(ms int64) string { + if ms <= 0 { + return "—" + } + return fmt.Sprintf("%dms", ms) +} + +func timingCell(t *Timing, get func(*Timing) int64) string { + if t == nil { + return "—" + } + v := get(t) + if v <= 0 { + return "0" + } + return fmt.Sprintf("%d", v) +} + +func dashIfZero(v int64, fallback string) string { + if v <= 0 { + return "—" + } + return fallback +} + +func dashOrInt(v int) string { + if v == 0 { + return "—" + } + return fmt.Sprintf("%d", v) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +type JSONOutput struct { + URL string `json:"url"` + CheckID string `json:"check_id"` + ShareURL string `json:"share_url"` + Results []RegionResult `json:"results"` + Summary Summary `json:"summary"` +} + +type Summary struct { + Fastest *Endpoint `json:"fastest,omitempty"` + Slowest *Endpoint `json:"slowest,omitempty"` + MeanLatency int64 `json:"mean_latency"` + SuccessRate float64 `json:"success_rate"` + TotalRegions int `json:"total_regions"` + Successes int `json:"successes"` +} + +type Endpoint struct { + Region string `json:"region"` + Latency int64 `json:"latency"` +} + +func buildJSONOutput(checkedURL, checkID string, results []RegionResult) JSONOutput { + return JSONOutput{ + URL: checkedURL, + CheckID: checkID, + ShareURL: shareURL(checkID), + Results: results, + Summary: computeSummary(results), + } +} + +func computeSummary(results []RegionResult) Summary { + s := Summary{TotalRegions: len(results)} + if len(results) == 0 { + return s + } + var sumLat int64 + var latencyCount int64 + var fastestLat, slowestLat int64 = -1, -1 + var fastestRegion, slowestRegion string + + for _, r := range results { + if r.Succeeded() { + s.Successes++ + } + if r.Latency > 0 { + sumLat += r.Latency + latencyCount++ + if fastestLat < 0 || r.Latency < fastestLat { + fastestLat = r.Latency + fastestRegion = r.Region + } + if slowestLat < 0 || r.Latency > slowestLat { + slowestLat = r.Latency + slowestRegion = r.Region + } + } + } + + if latencyCount > 0 { + s.MeanLatency = sumLat / latencyCount + s.Fastest = &Endpoint{Region: fastestRegion, Latency: fastestLat} + s.Slowest = &Endpoint{Region: slowestRegion, Latency: slowestLat} + } + s.SuccessRate = float64(s.Successes) / float64(len(results)) + return s +} diff --git a/internal/check/render_test.go b/internal/check/render_test.go new file mode 100644 index 0000000..f0f8272 --- /dev/null +++ b/internal/check/render_test.go @@ -0,0 +1,205 @@ +package check + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/fatih/color" +) + +func TestMain(m *testing.M) { + color.NoColor = true + os.Exit(m.Run()) +} + +func TestDisplayName(t *testing.T) { + t.Parallel() + cases := []struct { + in, want string + }{ + {"fra", "Frankfurt (Fly)"}, + {"koyeb_par", "Paris (Koyeb)"}, + {"railway_us-west2", "California (Railway)"}, + {"unknown_region_xyz", "unknown_region_xyz"}, + } + for _, c := range cases { + if got := DisplayName(c.in); got != c.want { + t.Errorf("DisplayName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDisplayName_HappyFixtureCoverage(t *testing.T) { + t.Parallel() + codes := []string{ + "ams", "arn", "bom", "cdg", "dfw", "ewr", "fra", "gru", "iad", "jnb", + "lax", "lhr", "nrt", "ord", "sjc", "sin", "syd", "yyz", + "koyeb_fra", "koyeb_par", "koyeb_sfo", "koyeb_sin", "koyeb_tyo", "koyeb_was", + "railway_us-west2", "railway_us-east4-eqdc4a", "railway_europe-west4-drams3a", "railway_asia-southeast1-eqsg3a", + } + for _, c := range codes { + if DisplayName(c) == c { + t.Errorf("region %q has no display name", c) + } + } +} + +func TestRenderer_HumanDefault(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := NewRenderer(&buf, false) + r.Row(RegionResult{Region: "fra", State: "success", Status: 200, Latency: 34, Timing: &Timing{DNS: 14, Connection: 2, TLS: 9, TTFB: 9, Transfer: 1}}) + r.Row(RegionResult{Region: "iad", State: "success", Status: 200, Latency: 67, Timing: &Timing{DNS: 53, Connection: 2, TLS: 5, TTFB: 7, Transfer: 0}}) + r.Footer("https://example.com", []RegionResult{ + {Region: "fra", State: "success", Latency: 34}, + {Region: "iad", State: "success", Latency: 67}, + }, "abc123") + + out := buf.String() + for _, want := range []string{"Region", "Latency", "Status", "State", "Frankfurt (Fly)", "Ashburn (Fly)", "34ms", "67ms", "Fastest:", "Slowest:", "Mean:", "Success: 2/2", "View:", "abc123"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n--- output ---\n%s", want, out) + } + } + if strings.Contains(out, "DNS") { + t.Errorf("default output should not include timing columns; got %q", out) + } +} + +func TestRenderer_TimingMode(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := NewRenderer(&buf, true) + r.Row(RegionResult{Region: "fra", State: "success", Status: 200, Latency: 34, Timing: &Timing{DNS: 14, Connection: 2, TLS: 9, TTFB: 9, Transfer: 1}}) + out := buf.String() + for _, want := range []string{"DNS", "Conn", "TLS", "TTFB", "Transfer", "Frankfurt (Fly)", "14", "9"} { + if !strings.Contains(out, want) { + t.Errorf("timing output missing %q\n--- output ---\n%s", want, out) + } + } +} + +func TestRenderer_FailureRowShowsMessage(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := NewRenderer(&buf, false) + r.Row(RegionResult{Region: "lhr", State: "error", Message: "url not reachable"}) + out := buf.String() + if !strings.Contains(out, "London (Fly)") { + t.Errorf("missing region name; got %q", out) + } + if !strings.Contains(out, "url not r") { + t.Errorf("missing message (possibly truncated); got %q", out) + } + if !strings.Contains(out, "—") { + t.Errorf("missing dash for unavailable latency/status; got %q", out) + } +} + +func TestComputeSummary_AllSuccess(t *testing.T) { + t.Parallel() + results := []RegionResult{ + {Region: "fra", State: "success", Latency: 30}, + {Region: "iad", State: "success", Latency: 90}, + {Region: "sin", State: "success", Latency: 60}, + } + s := computeSummary(results) + if s.TotalRegions != 3 || s.Successes != 3 { + t.Errorf("counts = %d/%d, want 3/3", s.Successes, s.TotalRegions) + } + if s.SuccessRate != 1.0 { + t.Errorf("success rate = %f, want 1.0", s.SuccessRate) + } + if s.MeanLatency != 60 { + t.Errorf("mean = %d, want 60", s.MeanLatency) + } + if s.Fastest == nil || s.Fastest.Region != "fra" || s.Fastest.Latency != 30 { + t.Errorf("fastest = %+v, want fra/30", s.Fastest) + } + if s.Slowest == nil || s.Slowest.Region != "iad" || s.Slowest.Latency != 90 { + t.Errorf("slowest = %+v, want iad/90", s.Slowest) + } +} + +func TestComputeSummary_MixedFailures(t *testing.T) { + t.Parallel() + results := []RegionResult{ + {Region: "fra", State: "success", Latency: 30}, + {Region: "iad", State: "error", Message: "url not reachable"}, + {Region: "sin", State: "success", Latency: 60}, + } + s := computeSummary(results) + if s.Successes != 2 || s.TotalRegions != 3 { + t.Errorf("counts = %d/%d, want 2/3", s.Successes, s.TotalRegions) + } + if s.SuccessRate < 0.66 || s.SuccessRate > 0.67 { + t.Errorf("success rate = %f, want ~0.667", s.SuccessRate) + } + if s.MeanLatency != 45 { + t.Errorf("mean = %d, want 45 (only count latencies > 0)", s.MeanLatency) + } +} + +func TestComputeSummary_Empty(t *testing.T) { + t.Parallel() + s := computeSummary(nil) + if s.TotalRegions != 0 || s.Successes != 0 || s.MeanLatency != 0 || s.Fastest != nil || s.Slowest != nil { + t.Errorf("non-zero summary for empty results: %+v", s) + } +} + +func TestBuildJSONOutput_Shape(t *testing.T) { + t.Parallel() + results := []RegionResult{ + {Region: "fra", State: "success", Status: 200, Latency: 34, Timestamp: 1, Timing: &Timing{DNS: 14, Connection: 2, TLS: 9, TTFB: 9, Transfer: 1}}, + } + out := buildJSONOutput("https://example.com", "abc123", results) + if out.URL != "https://example.com" { + t.Errorf("url = %q", out.URL) + } + if out.CheckID != "abc123" { + t.Errorf("check_id = %q", out.CheckID) + } + if out.ShareURL != "https://www.openstatus.dev/play/checker/abc123" { + t.Errorf("share_url = %q", out.ShareURL) + } + if len(out.Results) != 1 || out.Results[0].Region != "fra" { + t.Errorf("results unexpected: %+v", out.Results) + } + + raw, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{`"timing"`, `"dns":14`, `"summary"`, `"mean_latency":34`, `"success_rate":1`, `"share_url":"https://www.openstatus.dev/play/checker/abc123"`} { + if !bytes.Contains(raw, []byte(want)) { + t.Errorf("json missing %q\n--- json ---\n%s", want, raw) + } + } +} + +func TestShareURL(t *testing.T) { + t.Parallel() + if got := shareURL("abc"); got != "https://www.openstatus.dev/play/checker/abc" { + t.Errorf("shareURL = %q", got) + } + if got := shareURL(""); got != "" { + t.Errorf("shareURL empty = %q, want \"\"", got) + } +} + +func TestTruncate(t *testing.T) { + t.Parallel() + if got := truncate("short", 10); got != "short" { + t.Errorf("truncate short = %q", got) + } + if got := truncate("verylongstring", 5); got != "very…" { + t.Errorf("truncate long = %q, want \"very…\"", got) + } + if got := truncate("ab", 1); got != "a" { + t.Errorf("truncate small n = %q", got) + } +} diff --git a/internal/check/testdata/bad_url.ndjson b/internal/check/testdata/bad_url.ndjson new file mode 100644 index 0000000..f7ccd7b --- /dev/null +++ b/internal/check/testdata/bad_url.ndjson @@ -0,0 +1,29 @@ +{"region":"lhr","message":"url not reachable","state":"error","index":11} +{"region":"arn","message":"url not reachable","state":"error","index":1} +{"region":"bom","message":"url not reachable","state":"error","index":2} +{"region":"ord","message":"url not reachable","state":"error","index":13} +{"region":"fra","message":"url not reachable","state":"error","index":6} +{"region":"yyz","message":"url not reachable","state":"error","index":17} +{"region":"iad","message":"url not reachable","state":"error","index":8} +{"region":"koyeb_par","message":"url not reachable","state":"error","index":22} +{"region":"koyeb_fra","message":"url not reachable","state":"error","index":18} +{"region":"cdg","message":"url not reachable","state":"error","index":3} +{"region":"koyeb_sin","message":"url not reachable","state":"error","index":20} +{"region":"dfw","message":"url not reachable","state":"error","index":4} +{"region":"sin","message":"url not reachable","state":"error","index":15} +{"region":"sjc","message":"url not reachable","state":"error","index":14} +{"region":"ewr","message":"url not reachable","state":"error","index":5} +{"region":"railway_us-east4-eqdc4a","message":"url not reachable","state":"error","index":25} +{"region":"koyeb_was","message":"url not reachable","state":"error","index":19} +{"region":"koyeb_tyo","message":"url not reachable","state":"error","index":21} +{"region":"nrt","message":"url not reachable","state":"error","index":12} +{"region":"lax","message":"url not reachable","state":"error","index":10} +{"region":"ams","message":"url not reachable","state":"error","index":0} +{"region":"syd","message":"url not reachable","state":"error","index":16} +{"region":"railway_europe-west4-drams3a","message":"url not reachable","state":"error","index":24} +{"region":"railway_us-west2","message":"url not reachable","state":"error","index":27} +{"region":"koyeb_sfo","message":"url not reachable","state":"error","index":23} +{"region":"railway_asia-southeast1-eqsg3a","message":"url not reachable","state":"error","index":26} +{"region":"jnb","message":"url not reachable","state":"error","index":9} +{"region":"gru","message":"url not reachable","state":"error","index":7} +82e3c0397dd84b81a915f85429aca2aa \ No newline at end of file diff --git a/internal/check/testdata/body.json b/internal/check/testdata/body.json new file mode 100644 index 0000000..2008ff4 --- /dev/null +++ b/internal/check/testdata/body.json @@ -0,0 +1 @@ +{"ping":true,"from":"openstatus check fixture"} diff --git a/internal/check/testdata/happy.ndjson b/internal/check/testdata/happy.ndjson new file mode 100644 index 0000000..7a14edd --- /dev/null +++ b/internal/check/testdata/happy.ndjson @@ -0,0 +1,29 @@ +{"region":"koyeb_fra","type":"http","state":"success","status":200,"latency":28,"timestamp":1778680661533,"timing":{"dns":9,"connection":1,"tls":8,"ttfb":10,"transfer":0},"index":18} +{"region":"koyeb_par","type":"http","state":"success","status":200,"latency":36,"timestamp":1778680661527,"timing":{"dns":16,"connection":3,"tls":8,"ttfb":9,"transfer":0},"index":22} +{"region":"railway_europe-west4-drams3a","type":"http","state":"success","status":200,"latency":82,"timestamp":1778680661540,"timing":{"dns":13,"connection":22,"tls":22,"ttfb":25,"transfer":0},"index":24} +{"region":"koyeb_was","type":"http","state":"success","status":200,"latency":26,"timestamp":1778680661569,"timing":{"dns":8,"connection":2,"tls":9,"ttfb":8,"transfer":0},"index":19} +{"region":"ams","type":"http","state":"success","status":200,"latency":16,"timestamp":1778680661643,"timing":{"dns":1,"connection":1,"tls":8,"ttfb":6,"transfer":0},"index":0} +{"region":"railway_us-east4-eqdc4a","type":"http","state":"success","status":200,"latency":51,"timestamp":1778680661585,"timing":{"dns":8,"connection":12,"tls":14,"ttfb":18,"transfer":0},"index":25} +{"region":"lhr","type":"http","state":"success","status":200,"latency":37,"timestamp":1778680661648,"timing":{"dns":17,"connection":2,"tls":10,"ttfb":8,"transfer":0},"index":11} +{"region":"fra","type":"http","state":"success","status":200,"latency":55,"timestamp":1778680661640,"timing":{"dns":34,"connection":1,"tls":7,"ttfb":12,"transfer":0},"index":6} +{"region":"koyeb_sfo","type":"http","state":"success","status":200,"latency":26,"timestamp":1778680661602,"timing":{"dns":14,"connection":2,"tls":5,"ttfb":6,"transfer":0},"index":23} +{"region":"railway_us-west2","type":"http","state":"success","status":200,"latency":39,"timestamp":1778680661609,"timing":{"dns":15,"connection":5,"tls":8,"ttfb":10,"transfer":0},"index":27} +{"region":"cdg","type":"http","state":"success","status":200,"latency":77,"timestamp":1778680661647,"timing":{"dns":53,"connection":2,"tls":14,"ttfb":8,"transfer":0},"index":3} +{"region":"railway_asia-southeast1-eqsg3a","type":"http","state":"success","status":200,"latency":37,"timestamp":1778680661625,"timing":{"dns":16,"connection":3,"tls":8,"ttfb":11,"transfer":0},"index":26} +{"region":"iad","type":"http","state":"success","status":200,"latency":27,"timestamp":1778680661683,"timing":{"dns":12,"connection":2,"tls":7,"ttfb":6,"transfer":0},"index":8} +{"region":"koyeb_tyo","type":"http","state":"success","status":200,"latency":38,"timestamp":1778680661636,"timing":{"dns":24,"connection":1,"tls":8,"ttfb":5,"transfer":0},"index":21} +{"region":"ord","type":"http","state":"success","status":200,"latency":53,"timestamp":1778680661692,"timing":{"dns":33,"connection":3,"tls":9,"ttfb":9,"transfer":0},"index":13} +{"region":"yyz","type":"http","state":"success","status":200,"latency":65,"timestamp":1778680661688,"timing":{"dns":46,"connection":1,"tls":8,"ttfb":9,"transfer":0},"index":17} +{"region":"arn","type":"http","state":"success","status":200,"latency":156,"timestamp":1778680661652,"timing":{"dns":141,"connection":2,"tls":7,"ttfb":7,"transfer":0},"index":1} +{"region":"ewr","type":"http","state":"success","status":200,"latency":28,"timestamp":1778680661754,"timing":{"dns":11,"connection":1,"tls":8,"ttfb":8,"transfer":0},"index":5} +{"region":"koyeb_sin","type":"http","state":"success","status":200,"latency":36,"timestamp":1778680661718,"timing":{"dns":11,"connection":2,"tls":11,"ttfb":13,"transfer":0},"index":20} +{"region":"bom","type":"http","state":"success","status":200,"latency":19,"timestamp":1778680661807,"timing":{"dns":0,"connection":2,"tls":8,"ttfb":9,"transfer":0},"index":2} +{"region":"dfw","type":"http","state":"success","status":200,"latency":23,"timestamp":1778680661825,"timing":{"dns":4,"connection":2,"tls":11,"ttfb":7,"transfer":0},"index":4} +{"region":"lax","type":"http","state":"success","status":200,"latency":87,"timestamp":1778680661838,"timing":{"dns":69,"connection":1,"tls":8,"ttfb":9,"transfer":0},"index":10} +{"region":"sjc","type":"http","state":"success","status":200,"latency":57,"timestamp":1778680661862,"timing":{"dns":39,"connection":1,"tls":8,"ttfb":9,"transfer":1},"index":14} +{"region":"jnb","type":"http","state":"success","status":200,"latency":57,"timestamp":1778680661870,"timing":{"dns":45,"connection":1,"tls":6,"ttfb":6,"transfer":0},"index":9} +{"region":"sin","type":"http","state":"success","status":200,"latency":35,"timestamp":1778680661892,"timing":{"dns":9,"connection":1,"tls":12,"ttfb":13,"transfer":0},"index":15} +{"region":"syd","type":"http","state":"success","status":200,"latency":21,"timestamp":1778680662012,"timing":{"dns":8,"connection":1,"tls":7,"ttfb":5,"transfer":1},"index":16} +{"region":"gru","type":"http","state":"success","status":200,"latency":159,"timestamp":1778680661922,"timing":{"dns":135,"connection":3,"tls":10,"ttfb":10,"transfer":1},"index":7} +{"region":"nrt","type":"http","state":"success","status":200,"latency":130,"timestamp":1778680661981,"timing":{"dns":116,"connection":1,"tls":6,"ttfb":7,"transfer":0},"index":12} +b95beff1a26548ee93ba6f0b4fa35cbe \ No newline at end of file diff --git a/internal/check/testdata/rate_limited.http b/internal/check/testdata/rate_limited.http new file mode 100644 index 0000000..470a1ff --- /dev/null +++ b/internal/check/testdata/rate_limited.http @@ -0,0 +1,15 @@ +HTTP/2 429 +cache-control: public, max-age=0, must-revalidate +content-security-policy: frame-ancestors 'self' https://shoogle.dev +content-type: application/json +date: Wed, 13 May 2026 13:58:25 GMT +retry-after: 16 +server: Vercel +strict-transport-security: max-age=63072000 +x-matched-path: /play/checker/api +x-ratelimit-limit: 3 +x-ratelimit-remaining: 0 +x-ratelimit-reset: 1778680721687 +x-vercel-cache: MISS + +{"error":"You have exceeded the rate limit of 3 requests per 60 seconds","code":"RATE_LIMIT_EXCEEDED","limit":3,"remaining":0,"reset":1778680721687} diff --git a/internal/check/types.go b/internal/check/types.go new file mode 100644 index 0000000..6f70cdb --- /dev/null +++ b/internal/check/types.go @@ -0,0 +1,72 @@ +package check + +import ( + "errors" + "fmt" + "time" +) + +type Payload struct { + URL string `json:"url"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +type Timing struct { + DNS int64 `json:"dns"` + Connection int64 `json:"connection"` + TLS int64 `json:"tls"` + TTFB int64 `json:"ttfb"` + Transfer int64 `json:"transfer"` +} + +type RegionResult struct { + Region string `json:"region"` + State string `json:"state"` + Status int `json:"status,omitempty"` + Latency int64 `json:"latency,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Timing *Timing `json:"timing,omitempty"` + Message string `json:"message,omitempty"` +} + +func (r RegionResult) Succeeded() bool { + return r.State == "success" +} + +type RateLimitError struct { + RetryAfter time.Duration + Message string +} + +func (e *RateLimitError) Error() string { + if e.RetryAfter > 0 { + return fmt.Sprintf("rate limited: retry after %s", e.RetryAfter) + } + return "rate limited" +} + +type BadRequestError struct { + Status int + Body string + Message string +} + +func (e *BadRequestError) Error() string { + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("bad request (HTTP %d)", e.Status) +} + +type ServerError struct { + Status int + Body string +} + +func (e *ServerError) Error() string { + return fmt.Sprintf("checker temporarily unavailable (HTTP %d)", e.Status) +} + +var ErrStreamTruncated = errors.New("stream ended before check-id") diff --git a/internal/cmd/app.go b/internal/cmd/app.go index ddd45c9..ae8ce4e 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v3" + "github.com/openstatusHQ/cli/internal/check" output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/login" "github.com/openstatusHQ/cli/internal/maintenance" @@ -41,7 +42,7 @@ Get started: openstatus run Run synthetic tests https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, - Version: "v1.1.0", + Version: "v1.2.0", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "json", @@ -69,6 +70,7 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, return ctx, nil }, Commands: []*cli.Command{ + check.CheckCmd(), monitors.MonitorsCmd(), statusreport.StatusReportCmd(), maintenance.MaintenanceCmd(), diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 204e3c1..377664c 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.1.0" { - t.Errorf("Expected version 'v1.1.0', got %s", app.Version) + if app.Version != "v1.2.0" { + t.Errorf("Expected version 'v1.2.0', got %s", app.Version) } if !app.Suggest { @@ -32,11 +32,12 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 10 { - t.Errorf("Expected 10 commands, got %d", len(app.Commands)) + if len(app.Commands) != 11 { + t.Errorf("Expected 11 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ + "check": false, "monitors": false, "status-report": false, "maintenance": false, diff --git a/plan.md b/plan.md deleted file mode 100644 index 2305074..0000000 --- a/plan.md +++ /dev/null @@ -1,548 +0,0 @@ -# Sync plan: `openstatus terraform generate` ↔ terraform-provider-openstatus - -**Goal.** Bring the HCL produced by `openstatus terraform generate` into one-to-one alignment with the schema of `terraform-provider-openstatus@v0.2.0`. Every workspace resource must round-trip through `terraform import → terraform plan` with **no drift** and **no validation errors**. - -**Inputs.** -- Local: `internal/terraform/` (`generate.go`, `fetch.go`, `hcl.go`, `enums.go`, `regions.go`, `naming.go`, `generate_test.go`). -- Provider: `github.com/openstatusHQ/terraform-provider-openstatus` @ `main` (v0.2.0). -- Proto API: `github.com/openstatusHQ/openstatus/packages/proto/api/openstatus/v1` (monitor, notification, status_page, plus unused maintenance / status_report). -- Pinned SDK after `go get -u`: `buf.build/gen/go/openstatus/api/...@v1.36.11-20260512200453-7d7b7047611f.1`. Every symbol referenced below is verified present in this pin. - -**Out of scope.** Provider-side changes; new RPCs; non-export commands. The generator is read-only: it consumes List+Get RPCs and writes HCL. - ---- - -## 1. What syncs and what doesn't - -| Provider resource (v0.2.0) | Provider attrs / blocks | Generator today | Action | -|---|---|---|---| -| `openstatus_http_monitor` | name, url, periodicity, method, body, timeout, degraded_at, retry, follow_redirects, active, public, description, regions; blocks: headers, status_code_assertions, body_assertions, header_assertions, **open_telemetry** | All scalar fields ✓; all blocks except `open_telemetry` ✓ | §3.1 add `open_telemetry` | -| `openstatus_tcp_monitor` | name, uri, periodicity, timeout, degraded_at, retry, active, public, description, regions; **open_telemetry** | Scalars ✓; no blocks | §3.1 add `open_telemetry` | -| `openstatus_dns_monitor` | …+ record_assertions, **open_telemetry** | Scalars + record_assertions ✓ | §3.1 add `open_telemetry` | -| `openstatus_notification` | name, provider_type, monitor_ids; 13 inner blocks incl. **ms_teams** | 12 inner blocks; `ms_teams` missing | §3.2 | -| `openstatus_status_page` | title, slug, description, homepage_url, contact_url, icon, custom_domain, access_type, password, **auth_email_domains, allowed_ip_ranges, theme, default_locale, locales, allow_index** | Only the first 8 + conditional password | §3.3 | -| `openstatus_status_page_component_group` | page_id, name, **default_open** | page_id, name only | §3.4 | -| `openstatus_status_page_component` | page_id, type, monitor_id, name, description, order, group_id, group_order | Complete ✓ | none | - -**No provider resource exists** for maintenances or status reports. The generator ignores them entirely — no fetch, no summary, no sidecar files. Document upstream if/when the provider gains them. - -**Provider version constraint** in generated `provider.tf` is still `~> 0.1.0`; the v0.2.0 schema additions require `~> 0.2`. Fix in §3.0. - ---- - -## 2. Strings to keep verbatim - -These are the exact tf-string values the provider's `OneOf` validators accept (cross-checked against `internal/monitor/common.go` and `internal/statuspage/*` in the provider repo). The generator's existing `enums.go`/`regions.go` helpers already emit these correctly except where flagged: - -- `periodicity`: `30s`, `1m`, `5m`, `10m`, `30m`, `1h` -- `method`: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, `CONNECT` -- regions: 28 values — `fly-{ams,arn,bom,cdg,dfw,ewr,fra,gru,iad,jnb,lax,lhr,nrt,ord,sjc,sin,syd,yyz}`, `koyeb-{fra,par,sfo,sin,tyo,was}`, `railway-{us-west2,us-east4,europe-west4,asia-southeast1}` -- number comparator: `eq`, `neq`, `gt`, `gte`, `lt`, `lte` -- string comparator: + `contains`, `not_contains`, `empty`, `not_empty` -- record comparator: `eq`, `neq`, `contains`, `not_contains` -- DNS record: `A`, `AAAA`, `CNAME`, `MX`, `TXT` -- notification `provider_type`: `discord`, `email`, `slack`, `pagerduty`, `opsgenie`, `webhook`, `telegram`, `sms`, `whatsapp`, `google_chat`, `grafana_oncall`, `ntfy`, **`ms_teams`** (missing today) -- opsgenie region: `us`, `eu` -- page component type: `monitor`, `static` -- status page `access_type`: `public`, `password`, `email-domain`, **`ip`** (missing today) -- status page `theme`: `system`, `light`, `dark` -- locale: `en`, `fr`, `de` - ---- - -## 2b. Determinism rules - -The generator must produce byte-identical output when re-run against an unchanged workspace, so re-export diffs stay readable. Rule: - -- **Sort** (sets — order is irrelevant to the provider): `monitor_ids`, `regions`. -- **Sort** (lists where order is presentational only): `locales`, `auth_email_domains`. -- **Preserve API order** (lists where order may be meaningful to the user): assertion lists (`status_code_assertions`, `body_assertions`, `header_assertions`, `record_assertions`), monitor `headers`, webhook headers, OTEL headers. -- **Preserve API order** for top-level resources (monitors, notifications, pages) — the API returns them in roughly `created_at` order, which is stable enough. - -Apply via `sort.Strings(...)` before each affected emission. Helper not needed; ~5 LOC total. - ---- - -## 3. Sync checklist - -Each item is independent and can ship as a separate PR. Suggested order is roughly safety-first (correctness bugs before drift fixes). - -### 3.0 Bump provider version constraint in generated `provider.tf` - -`internal/terraform/hcl.go:91-103` — `GenerateProviderFile`. - -```diff -- version = "~> 0.1.0" -+ version = "~> 0.2" -``` - -Test: update `TestGenerateProviderFile` in `generate_test.go:13-18`. - -Pair with §3.6 init-upgrade hint so users on a previously-generated workspace know to run `terraform init -upgrade` after re-running the command. - ---- - -### 3.1 Monitors: emit `open_telemetry` block on HTTP, TCP, DNS - -Three monitor builders in `internal/terraform/hcl.go` lines 109, 151, 180. Add a helper alongside `writeRegions`: - -```go -// hcl.go — new helper, place near writeHeaders -func writeOpenTelemetry(b *hclwrite.Body, ot *monitorv1.OpenTelemetryConfig) { - if ot == nil { - return - } - endpoint := ot.GetEndpoint() - headers := ot.GetHeaders() - if endpoint == "" && len(headers) == 0 { - return - } - otb := b.AppendNewBlock("open_telemetry", nil).Body() - if endpoint != "" { - otb.SetAttributeValue("endpoint", cty.StringVal(endpoint)) - } - for _, h := range headers { - if h.GetKey() == "" { - continue - } - hb := otb.AppendNewBlock("headers", nil).Body() - hb.SetAttributeValue("key", cty.StringVal(h.GetKey())) - hb.SetAttributeValue("value", cty.StringVal(h.GetValue())) - } -} -``` - -Call it from each monitor branch, after the assertion writers (HTTP) or after `writeRegions` (TCP/DNS). - -SDK getters used: `*HTTPMonitor.GetOpenTelemetry()`, `*TCPMonitor.GetOpenTelemetry()`, `*DNSMonitor.GetOpenTelemetry()` — confirmed present in pinned SDK. - -Tests: extend `TestGenerateMonitorsFile_HTTP` (and add TCP/DNS variants) to assert that an HTTP monitor with `OpenTelemetryConfig{Endpoint:"https://otel.example.com/v1/metrics", Headers:[{X-Api-Key,secret}]}` produces the block shown in `examples/resources/openstatus_http_monitor/resource.tf`. - ---- - -### 3.2 Notifications: refactor + correctness fixes - -A single block of work covering four related changes. All edits in `internal/terraform/hcl.go` (`writeNotificationProvider` and `GenerateNotificationsFile`) and `internal/terraform/enums.go` (`notificationProviderToString`). - -**A. Single source of truth driven by the data oneof.** Replace the two-source approach (`notificationProviderToString(n.GetProvider())` for the `provider_type` attribute plus a parallel `switch d := data.Data` for the block) with a single switch on `data.Data` that yields both the provider string and the emitted block. Prevents server-side mismatches between `provider` and `data` from producing broken HCL. - -```go -func writeNotificationProvider(b *hclwrite.Body, n *notificationv1.Notification) (providerType string, ok bool) { - data := n.GetData() - if data == nil { - return "", false - } - switch d := data.Data.(type) { - case *notificationv1.NotificationData_Discord: - pb := b.AppendNewBlock("discord", nil).Body() - pb.SetAttributeValue("webhook_url", cty.StringVal(d.Discord.GetWebhookUrl())) - return "discord", true - // …one case per provider type, including ms_teams (new)… - case *notificationv1.NotificationData_MsTeams: - pb := b.AppendNewBlock("ms_teams", nil).Body() - pb.SetAttributeValue("webhook_url", cty.StringVal(d.MsTeams.GetWebhookUrl())) - return "ms_teams", true - } - return "", false -} -``` - -Caller (`GenerateNotificationsFile`) inverts to "block first, then attributes": peek the data oneof to decide whether to emit at all, write the resource header, then call `writeNotificationProvider` which returns the inferred `provider_type` and emits the inner block in one pass. - -**B. Add `ms_teams`** — covered by (A). Pinned SDK confirmed to include `NotificationProvider_NOTIFICATION_PROVIDER_MS_TEAMS = 13`, `MsTeamsData{WebhookUrl}`, and the `NotificationData_MsTeams` oneof case. - -**C. Fix webhook headers — `ListNestedAttribute`, not block.** The provider's webhook schema declares `headers` as `schema.ListNestedAttribute`. The current generator emits `headers { key=… value=… }` block syntax which the provider rejects. Replace with list-attribute syntax: - -```go -case *notificationv1.NotificationData_Webhook: - pb := b.AppendNewBlock("webhook", nil).Body() - pb.SetAttributeValue("endpoint", cty.StringVal(d.Webhook.GetEndpoint())) - if hs := d.Webhook.GetHeaders(); len(hs) > 0 { - vals := make([]cty.Value, 0, len(hs)) - for _, h := range hs { - if h.GetKey() == "" { continue } - vals = append(vals, cty.ObjectVal(map[string]cty.Value{ - "key": cty.StringVal(h.GetKey()), - "value": cty.StringVal(h.GetValue()), - })) - } - if len(vals) > 0 { - pb.SetAttributeValue("headers", cty.ListVal(vals)) - } - } - return "webhook", true -``` - -**D. Emit `monitor_ids` as traversals when the id is in `monitorRefs`; fall back to plain string when it isn't.** Matches the pattern `setTraversalOrString` already uses for singular cross-refs. The string fallback intentionally preserves the id in HCL even when the monitor isn't in the workspace (race between `ListMonitors` and `ListNotifications`, or monitor deleted out-of-band) — terraform will surface the inconsistency at plan time rather than the generator silently dropping it. - -Build the set manually using hclwrite tokens so traversals and string literals can coexist in one set. Skeleton: - -```go -if ids := n.GetMonitorIds(); len(ids) > 0 { - tokens := hclwrite.Tokens{ /* '[' */ } - for i, id := range ids { - if i > 0 { tokens = append(tokens, commaToken) } - if ref, found := g.monitorRefs[id]; found { - tokens = append(tokens, identTraversal(ref.ResourceType, ref.Name, "id")...) - } else { - tokens = append(tokens, stringLiteral(id)...) - } - } - tokens = append(tokens, /* ']' */) - b.SetAttributeRaw("monitor_ids", tokens) -} -``` - -**E. Skip + warn on unknown / UNSPECIFIED providers.** `writeNotificationProvider` returning `ok=false` triggers the caller to: -- Print `warning: skipping notification %q — unknown provider type (CLI may be outdated)` to stderr. -- Continue past this notification without writing a resource block. -- Exclude the notification's id from `GenerateImportsFile`. - -Tests: -- `TestGenerateNotificationsFile_MsTeams` — provider type, `ms_teams { webhook_url }` block. -- `TestGenerateNotificationsFile_WebhookHeaders` — assert `headers = [{key = "X", value = "Y"}]` attribute syntax, not block. -- `TestGenerateNotificationsFile_MonitorIdsTraversal` — workspace with one matching monitor and one unknown id → list has `openstatus_http_monitor.foo.id` and `"unknown-id"` mixed. -- `TestGenerateNotificationsFile_UnknownProviderSkipped` — UNSPECIFIED provider → no resource block in output, no import in imports.tf. - ---- - -### 3.3 Status pages: correctness pack - -Two correctness bugs and four drift gaps in one resource. All edits land in `internal/terraform/hcl.go:308-345` (`GenerateStatusPagesFile`) and `internal/terraform/enums.go:167-178` (`pageAccessTypeToString`). - -**A. Fix `access_type = "ip"` being silently dropped.** Today the `pageAccessTypeToString` switch has no `IP_RESTRICTED` case and falls through to `"public"`, which (a) loses the user's choice and (b) drops the required `allowed_ip_ranges`. Add: - -```go -case status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_IP_RESTRICTED: - return "ip" -``` - -**B. Emit `auth_email_domains` and `allowed_ip_ranges`.** These are required by the provider's `ValidateConfig` when `access_type` is `email-domain` / `ip`. Without them, the generated HCL **fails plan**. Replace the `access_type != "public"` block in `GenerateStatusPagesFile` (around hcl.go:336-343) with: - -```go -switch accessType { -case "password": - b.SetAttributeValue("access_type", cty.StringVal("password")) - appendTODOComment(b) - b.SetAttributeValue("password", cty.StringVal("REPLACE_ME")) -case "email-domain": - b.SetAttributeValue("access_type", cty.StringVal("email-domain")) - domains := page.GetAuthEmailDomains() - vals := make([]cty.Value, len(domains)) - for i, d := range domains { - vals[i] = cty.StringVal(d) - } - b.SetAttributeValue("auth_email_domains", cty.ListVal(vals)) // safe: provider requires ≥1 -case "ip": - b.SetAttributeValue("access_type", cty.StringVal("ip")) - b.SetAttributeValue("allowed_ip_ranges", cty.StringVal(page.GetAllowedIpRanges())) -} -``` - -(`access_type = "public"` continues to be omitted as a default.) - -**C. Emit `theme`, `default_locale`, `locales`, `allow_index`** with skip-default rules to avoid drift on the next plan: - -```go -if theme := pageThemeToString(page.GetTheme()); theme != "" && theme != "system" { - b.SetAttributeValue("theme", cty.StringVal(theme)) -} -if dl := localeToString(page.GetDefaultLocale()); dl != "" && dl != "en" { - b.SetAttributeValue("default_locale", cty.StringVal(dl)) -} -if locs := page.GetLocales(); len(locs) > 0 { - vals := make([]cty.Value, 0, len(locs)) - for _, l := range locs { - if s := localeToString(l); s != "" { - vals = append(vals, cty.StringVal(s)) - } - } - if len(vals) > 0 { - b.SetAttributeValue("locales", cty.ListVal(vals)) - } -} -if page.GetAllowIndex() { - b.SetAttributeValue("allow_index", cty.BoolVal(true)) -} -``` - -Helpers to add in `enums.go`: - -```go -func pageThemeToString(t status_pagev1.PageTheme) string { - switch t { - case status_pagev1.PageTheme_PAGE_THEME_SYSTEM: - return "system" - case status_pagev1.PageTheme_PAGE_THEME_LIGHT: - return "light" - case status_pagev1.PageTheme_PAGE_THEME_DARK: - return "dark" - } - return "" -} - -func localeToString(l status_pagev1.Locale) string { - switch l { - case status_pagev1.Locale_LOCALE_EN: - return "en" - case status_pagev1.Locale_LOCALE_FR: - return "fr" - case status_pagev1.Locale_LOCALE_DE: - return "de" - } - return "" -} -``` - -SDK confirmed: `PageTheme = {UNSPECIFIED, SYSTEM, LIGHT, DARK}`, `Locale = {UNSPECIFIED, EN, FR, DE}`, `PageAccessType.IP_RESTRICTED = 4`, and `StatusPage.GetTheme/DefaultLocale/Locales/AllowIndex/AuthEmailDomains/AllowedIpRanges` all present. - -Tests: add cases to `generate_test.go`: -- `TestGenerateStatusPagesFile_IPAccess` — IP_RESTRICTED → `access_type = "ip"` + `allowed_ip_ranges`. -- `TestGenerateStatusPagesFile_EmailDomainAccess` — AUTHENTICATED → `access_type = "email-domain"` + `auth_email_domains = [...]`. -- `TestGenerateStatusPagesFile_ThemeLocaleAllowIndex` — dark/fr-locale page emits the three attrs; default page emits none. - ---- - -### 3.4 Component groups: emit `default_open` - -`internal/terraform/hcl.go:348-354`. Skip-default rule (provider default is `false`): - -```go -if grp.GetDefaultOpen() { - gb.SetAttributeValue("default_open", cty.BoolVal(true)) -} -``` - -SDK confirmed: `PageComponentGroup.GetDefaultOpen() bool`. - -Test: extend `TestGenerateStatusPagesFile` to include a group with `default_open=true` and assert the line is emitted. - ---- - -### 3.5 CLI ergonomics: `--force` and init-upgrade hint - -`internal/terraform/generate.go`. - -**A. `--force` flag, refuse-by-default overwrites.** Today `writeFile` truncates blindly. Add a stat-and-bail step before any write: if any of `provider.tf`, `monitors.tf`, `notifications.tf`, `status_pages.tf`, `imports.tf` already exists in `--output-dir` and `--force` is not set, exit with: - -``` -error: refusing to overwrite existing file %s; pass --force to replace -``` - -Sketch: - -```go -&cli.BoolFlag{ - Name: "force", - Usage: "Overwrite existing files in --output-dir", - Aliases: []string{"f"}, -}, -// ... -if !cmd.Bool("force") { - for _, name := range []string{"provider.tf", "monitors.tf", "notifications.tf", "status_pages.tf", "imports.tf"} { - if _, err := os.Stat(filepath.Join(outputDir, name)); err == nil { - return cli.Exit(fmt.Sprintf("refusing to overwrite existing file %s; pass --force to replace", name), 1) - } - } -} -``` - -The check happens after the API fetch fails-fast but before any disk writes, so partial output is impossible. - -**B. Init-upgrade hint.** Always append to `printSummary` (`generate.go:130`): - -``` -Note: provider version pinned to ~> 0.2. Run 'terraform init -upgrade' if you previously ran this command. -``` - -Tests: extend `generate_test.go` (or add a small `cli_test.go`) covering: -- Refusal when a target file exists and `--force` is unset. -- Overwrite when `--force` is passed. -- Init-upgrade hint present in `printSummary` output. - ---- - -## 4. Test additions checklist - -Append to `internal/terraform/generate_test.go`: - -- [ ] Update `TestGenerateProviderFile` for `~> 0.2`. -- [ ] HTTP monitor `open_telemetry` block. -- [ ] TCP monitor `open_telemetry` block (also adds first TCP test). -- [ ] DNS monitor `open_telemetry` block (existing DNS test extends). -- [ ] Notification `ms_teams` block. -- [ ] Status page IP access (`access_type = "ip"` + `allowed_ip_ranges`). -- [ ] Status page email-domain access (`access_type = "email-domain"` + `auth_email_domains`). -- [ ] Status page theme/default_locale/locales/allow_index emission. -- [ ] Component group `default_open = true`. -- [ ] Notification refactor: provider/data oneof drives both attr and block (no mismatch path). -- [ ] Webhook headers emitted as `headers = [{...}]` attribute (not block). -- [ ] `monitor_ids` emit as traversals for known IDs, plain strings for unknown. -- [ ] UNSPECIFIED / unknown notification provider → resource skipped, no import block, stderr warning. -- [ ] `--force` refuses to overwrite by default; allows overwrite when set. -- [ ] `printSummary` includes the `terraform init -upgrade` hint. - ---- - -## 5. Reference: provider import IDs - -Confirmed against provider `internal/.../ImportState` parsers: - -| Resource | Import ID format | Generator today | -|---|---|---| -| `openstatus_http_monitor` / `_tcp_monitor` / `_dns_monitor` | `` | ✓ | -| `openstatus_notification` | `` | ✓ | -| `openstatus_status_page` | `` | ✓ | -| `openstatus_status_page_component` | `/` | ✓ | -| `openstatus_status_page_component_group` | `/` | ✓ | - -No changes needed in `GenerateImportsFile` (`hcl.go:386-414`). - ---- - -## 6. File-level summary of edits - -| File | Edit | -|---|---| -| `internal/terraform/hcl.go` | `GenerateProviderFile` (version bump); HTTP/TCP/DNS monitor branches (add `writeOpenTelemetry`); `writeNotificationProvider` (add `MsTeams` case); `GenerateStatusPagesFile` (rewrite access-type branch; add theme/locale/allow_index emission); component group branch (`default_open`); new `writeOpenTelemetry` helper | -| `internal/terraform/enums.go` | `notificationProviderToString` (add `MS_TEAMS`); `pageAccessTypeToString` (add `IP_RESTRICTED`); add `pageThemeToString`, `localeToString`. `notificationProviderToString` may move/become driven by `NotificationData` oneof per Q5b. | -| `internal/terraform/generate.go` | `--force` flag + pre-write existence check; init-upgrade hint in `printSummary` | -| `internal/terraform/generate_test.go` | New cases per §4 | -| `internal/cmd/app.go` | Bump `Version` to `"v1.1.0"` | -| `docs/openstatus-docs.md`, `docs/openstatus.1` | Regenerated (see §7) | - -No new files **except** the opt-in smoke test (§9, phase 7). No SDK pin bumps required beyond the dep refresh that has already shipped (`buf.build/gen/go/openstatus/api/...@v1.36.11-20260512200453-7d7b7047611f.1`). - ---- - -## 7. Docs regeneration (last commit of the PR) - -After all code is in and tests pass, regenerate the auto-generated docs from the urfave/cli command tree (the new `--force` flag changes the rendered help text): - -```sh -go run cmd/docs/docs.go -cd docs && pandoc -s -t man openstatus-docs.md -o openstatus.1 -``` - -Commit both `docs/openstatus-docs.md` and `docs/openstatus.1` as the final commit. README is left alone — the team hasn't documented individual `terraform generate` flags in it so far. - ---- - -## 8. Unrelated note from the dep refresh - -`github.com/urfave/cli/v3` moved from `v3.0.0-alpha9.2` → `v3.9.0`, changing `BeforeFunc` to `func(context.Context, *Command) (context.Context, error)`. Already patched at `internal/cmd/app.go:63` — build and tests green. Mentioned here only so reviewers don't wonder why that file changed in the same branch. - ---- - -## 9. Implementation todo list - -Each phase is one commit. Phases are ordered so that earlier work doesn't conflict with later work, and so the build stays green commit-by-commit (a reviewer can `git bisect` cleanly). Run `go build ./... && go test ./...` at the end of every phase before committing. - -### Phase 0 — Pre-flight ✅ - -- [x] Cut a feature branch off `main` (e.g. `feat/tf-generate-sync-v0.2`). _(jj: working on an anonymous change off main; chore: refresh deps committed)_ -- [x] Confirm working tree is clean (`git status`); dep refresh already landed in a previous commit. -- [x] `go build ./...` and `go test ./...` green from baseline. - -### Phase 1 — Bump generated provider version (commit 1: `chore(terraform): pin generated provider to ~> 0.2`) ✅ - -- [x] `internal/terraform/hcl.go` — `GenerateProviderFile`: change `version = "~> 0.1.0"` → `version = "~> 0.2"`. -- [x] `internal/terraform/generate_test.go` — update `TestGenerateProviderFile` assertion to `~> 0.2`. -- [x] `go test ./internal/terraform/...` green. - -### Phase 2 — Status page correctness pack (commit 2: `fix(terraform): emit access_type=ip + auth_email_domains/allowed_ip_ranges; add theme/locale/allow_index`) ✅ - -Order matters within this phase: add helpers first, then call them. - -- [x] `internal/terraform/enums.go` — extend `pageAccessTypeToString` with `case status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_IP_RESTRICTED: return "ip"`. -- [x] `internal/terraform/enums.go` — add `pageThemeToString(t status_pagev1.PageTheme) string` (returns `""` for UNSPECIFIED, `"system"|"light"|"dark"` otherwise). -- [x] `internal/terraform/enums.go` — add `localeToString(l status_pagev1.Locale) string` (returns `""` for UNSPECIFIED, `"en"|"fr"|"de"` otherwise). -- [x] `internal/terraform/hcl.go` — `GenerateStatusPagesFile`: replace the current `accessType != "public"` block with the four-case switch (`public` omits / `password` keeps current TODO+REPLACE_ME / `email-domain` emits `auth_email_domains` sorted via `sort.Strings` or TODO+REPLACE_ME / `ip` emits `allowed_ip_ranges` or TODO+REPLACE_ME). -- [x] `internal/terraform/hcl.go` — `GenerateStatusPagesFile`: after the access-type block, emit `theme` (when not `system`), `default_locale` (when not `en`), `locales` (sorted, when non-empty), `allow_index` (when `true`), using skip-default rules from Q1. -- [x] `internal/terraform/generate_test.go` — `TestGenerateStatusPagesFile_IPAccess` with non-empty `AllowedIpRanges`. -- [x] `internal/terraform/generate_test.go` — `TestGenerateStatusPagesFile_IPAccessEmptyFallback`: IP_RESTRICTED + empty `allowed_ip_ranges` → asserts `# TODO:` comment and `REPLACE_ME` value. -- [x] `internal/terraform/generate_test.go` — `TestGenerateStatusPagesFile_EmailDomainAccess` with non-empty domains. -- [x] `internal/terraform/generate_test.go` — `TestGenerateStatusPagesFile_EmailDomainEmptyFallback`. -- [x] `internal/terraform/generate_test.go` — `TestGenerateStatusPagesFile_ThemeLocaleAllowIndex`: dark + fr default_locale + locales=[en,fr] + allow_index=true → all four emitted; default page → none emitted. -- [x] `go test ./internal/terraform/...` green. - -### Phase 3 — Component group `default_open` (commit 3: `feat(terraform): emit default_open on status page component groups`) ✅ - -- [x] `internal/terraform/hcl.go` — component-group branch in `GenerateStatusPagesFile`: `if grp.GetDefaultOpen() { gb.SetAttributeValue("default_open", cty.BoolVal(true)) }`. -- [x] `internal/terraform/generate_test.go` — extend `TestGenerateStatusPagesFile` (or add a focused test) to include a group with `DefaultOpen: true` and assert the line. -- [x] `go test ./internal/terraform/...` green. - -### Phase 4 — Monitor `open_telemetry` (commit 4: `feat(terraform): emit open_telemetry block on HTTP/TCP/DNS monitors`) ✅ - -- [x] `internal/terraform/hcl.go` — modify the existing `writeRegions` helper to sort regions alphabetically (per §2b — set semantics, deterministic output). -- [x] `internal/terraform/hcl.go` — new helper `writeOpenTelemetry(b *hclwrite.Body, ot *monitorv1.OpenTelemetryConfig)` per Q2: skip iff `ot == nil` OR (`endpoint == "" && len(headers) == 0`); inside, emit `endpoint` only when non-empty; emit one `headers { key/value }` block per header (no sort — preserves API order per §2b). -- [x] Call `writeOpenTelemetry(b, m.GetOpenTelemetry())` in each of: HTTP monitor branch (`hcl.go:109` block, after the assertion writers), TCP monitor branch (`hcl.go:151`, after `writeRegions`), DNS monitor branch (`hcl.go:180`, after `record_assertions`). -- [x] `internal/terraform/generate_test.go` — `TestGenerateMonitorsFile_HTTP_OpenTelemetry`: endpoint + one header → block present. -- [x] `internal/terraform/generate_test.go` — `TestGenerateMonitorsFile_TCP_OpenTelemetry` (also adds first TCP-only test). -- [x] `internal/terraform/generate_test.go` — `TestGenerateMonitorsFile_DNS_OpenTelemetry`. -- [x] `internal/terraform/generate_test.go` — `TestGenerateMonitorsFile_OpenTelemetry_SkippedWhenEmpty`: `OpenTelemetryConfig{Endpoint:"", Headers:nil}` → no block. -- [x] `go test ./internal/terraform/...` green. - -### Phase 5 — Notification refactor + fixes (commit 5: `fix(terraform): notification provider type from data oneof; ms_teams; webhook headers attribute; monitor_ids traversals`) ✅ - -This is the largest phase. Land in one commit (per Q7) but write it incrementally. - -- [x] `internal/terraform/hcl.go` — add the `*notificationv1.NotificationData_MsTeams` case emitting `ms_teams { webhook_url = … }`. -- [x] `internal/terraform/hcl.go` — rewrite the `*notificationv1.NotificationData_Webhook` case so `headers` is emitted as a `cty.ListVal([]cty.Value{cty.ObjectVal({key,value})})` set via `SetAttributeValue("headers", …)`, not as nested `headers { … }` blocks. -- [x] `internal/terraform/hcl.go` — replace the current `monitor_ids` plain-string emission with a token-list builder (`writeMonitorIds`). Sort via `sort.Strings` first; traversal tokens for known refs, string-literal tokens otherwise. -- [x] `internal/terraform/hcl.go` — add `traversalTokensInline(parts ...string)` (no trailing newline) and refactor `traversalTokens` to call it + append newline. Add `stringLitTokens(s)` helper. -- [x] `internal/terraform/hcl.go` — add `renderableNotification(n) (providerType string, ok bool)` switch returning the tf-string per oneof case. -- [x] `internal/terraform/hcl.go` — `Generator` struct: add `skippedNotifications map[string]bool` field; initialize in `NewGenerator`. -- [x] `internal/terraform/hcl.go` — `NewGenerator` notifications loop: skip + warn on `!ok` via `renderableNotification`. -- [x] `internal/terraform/hcl.go` — `GenerateNotificationsFile`: skip when `g.skippedNotifications[n.GetId()]`; provider_type comes from `renderableNotification`. -- [x] `internal/terraform/hcl.go` — `GenerateImportsFile`: same skip guard for the notification import block. -- [x] `internal/terraform/enums.go` — remove `notificationProviderToString` (now unused). -- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_MsTeams`. -- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_WebhookHeaders`. -- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_MonitorIdsTraversal`. -- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_UnknownProviderSkipped`. -- [x] `go test ./internal/terraform/...` green. - -### Phase 6 — CLI ergonomics (commit 6: `feat(terraform): --force flag and terraform init -upgrade hint`) ✅ - -- [x] `internal/terraform/generate.go` — add `&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "Overwrite existing files in --output-dir"}` to `GetTerraformGenerateCmd().Flags`. -- [x] `internal/terraform/generate.go` — extract `checkExistingFiles(outputDir, force)` helper; invoke before `MkdirAll`. Stats each of `provider.tf`, `monitors.tf`, `notifications.tf`, `status_pages.tf`, `imports.tf` and returns an error mentioning the filename if any exists. -- [x] `internal/terraform/generate.go` — extend `printSummary` with the `terraform init -upgrade` hint. -- [x] `internal/terraform/cli_test.go` — `TestCheckExistingFiles_RefusesExisting` / `_OverwritesWithForce` / `_EmptyDir` / `_NonexistentDir`. -- [x] `internal/terraform/cli_test.go` — `TestPrintSummary_IncludesInitUpgradeHint` (captures stdout). -- [x] `go test ./internal/terraform/...` green. - -### Phase 7 — Opt-in smoke test (commit 7: `test(terraform): add terraform-validate smoke test behind build tag`) ✅ - -- [x] New file `internal/terraform/smoke_test.go` with `//go:build smoke` build tag at top. -- [x] In the file: build a representative `WorkspaceData` covering HTTP/TCP/DNS monitors (HTTP includes OTEL + assertions); slack + ms_teams + webhook-with-headers notifications; status page with theme/locales/allow_index + default_open group + monitor component; second status page with IP access. -- [x] Write all generated files to `t.TempDir()`. Skip the test (`t.Skipf`) if `terraform` is not on `PATH`. -- [x] Exec `terraform init -upgrade` then `terraform validate` against the temp dir; fail the test on non-zero exit. -- [x] Document at the top of the file: `// Run with: go test -tags=smoke ./internal/terraform/`. -- [x] `go test ./internal/terraform/...` (no tag) still green and does NOT invoke terraform. -- [x] Manually verified: `go test -tags=smoke ./internal/terraform/` passes locally. - -### Phase 8 — Version + docs (commit 8: `chore: bump cli to v1.1.0 and regenerate docs`) ✅ - -- [x] `internal/cmd/app.go` — change `Version: "v1.0.5"` → `Version: "v1.1.0"`. Also updated `internal/cmd/app_test.go` to match. -- [x] From repo root: `go run cmd/docs/docs.go` (updates `docs/openstatus-docs.md`). -- [x] `cd docs && pandoc -s -t man openstatus-docs.md -o openstatus.1` (updates the manpage). -- [x] Verified that `--force` flag entry appears in both `openstatus-docs.md` and `openstatus.1`. -- [x] `go build ./... && go vet ./... && go test ./...` all green. - -### Phase 9 — Submit ✅ - -- [x] Push the branch. _(Pushed `feat/tf-generate-sync-v0.2` to origin via `jj git push --bookmark feat/tf-generate-sync-v0.2 --allow-new`.)_ -- [ ] Open PR. Title suggestion: `terraform generate: sync with provider v0.2 (open_telemetry, ms_teams, ip access, theme/locales, --force)`. _(Author to open — GitHub provided: https://github.com/openstatusHQ/cli/pull/new/feat/tf-generate-sync-v0.2)_ -- [ ] PR description: bullet list of user-visible changes; include "Closes #…" if upstream has tracking issues; call out the two correctness fixes (webhook headers, IP access type) since they affect real users. -- [ ] Watch CI; address review feedback. -- [ ] Merge strategy is the author's call (repo allows squash, merge, rebase). - -### Definition of done - -- All boxes above checked. -- `go build ./...`, `go test ./...`, and `go test -tags=smoke ./internal/terraform/` all pass locally. -- The PR's diff on `internal/terraform/` matches the file-level summary in §6, plus the new `smoke_test.go`. -- `docs/openstatus-docs.md` and `docs/openstatus.1` show the `--force` flag. -- `internal/cmd/app.go` shows `v1.1.0`. -- No new TODOs, no commented-out code, no orphaned helpers (e.g. old `notificationProviderToString` removed if Phase 5 removed it). diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index d50c53b..fdd2c02 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -1,7 +1,7 @@ --- name: openstatus-cli description: | - OpenStatus CLI for managing uptime monitors, incident reports, status pages, notifications, maintenance windows, and synthetic tests. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, report an incident, update a status page, view notifications, schedule maintenance, run synthetic tests, check latency or availability, define monitors as code, generate Terraform configuration, export to Terraform, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "create a status report", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", "list notifications", "notification channels", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. + OpenStatus CLI for managing uptime monitors, incident reports, status pages, notifications, maintenance windows, synthetic tests, and ad-hoc global HTTP checks. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, report an incident, update a status page, view notifications, schedule maintenance, run synthetic tests, check latency or availability from around the world, run an ad-hoc speed check, define monitors as code, generate Terraform configuration, export to Terraform, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "speed check", "global latency", "latency from regions", "ad-hoc check", "test from multiple regions", "create a status report", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", "list notifications", "notification channels", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. allowed-tools: - Bash(openstatus *) --- @@ -14,7 +14,7 @@ Run `openstatus --help` or `openstatus --help` for full option details ## Prerequisites -Must be authenticated. Verify with: +Most commands require authentication. Verify with: ```bash openstatus whoami @@ -27,10 +27,13 @@ Token resolution order: 2. `OPENSTATUS_API_TOKEN` environment variable 3. Saved token at `~/.config/openstatus/token` +**Exception:** `openstatus check ` is **unauthenticated** — it uses the public global speed checker. Reach for it when the user wants an ad-hoc multi-region check without setting up a saved monitor. + ## Command Overview | Task | Command | When to use | |------|---------|-------------| +| Ad-hoc global speed check (no auth) | `check ` | One-shot HTTP check from 28 regions — no saved monitor needed | | Sync monitors from config | `monitors apply` | You have an `openstatus.yaml` and want to create/update/delete monitors | | List all monitors | `monitors list` | See what monitors exist in the workspace | | Get monitor details + metrics | `monitors info ` | Check latency, status, and config for a specific monitor | @@ -56,10 +59,45 @@ Token resolution order: | Generate Terraform config | `terraform generate` | Export workspace resources to Terraform HCL files | | Check workspace | `whoami` | Verify auth and workspace info | -Command aliases: `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `notification` = `n`, `maintenance` = `mt`, `terraform` = `tf`, `run` = `r`, `whoami` = `w`. +Command aliases: `check` = `c`, `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `notification` = `n`, `maintenance` = `mt`, `terraform` = `tf`, `run` = `r`, `whoami` = `w`. ## Workflows +### Ad-hoc global speed check (no auth) + +When the user wants to test how an HTTP endpoint performs from around the world without setting up a saved monitor, reach for `check`. It hits the public OpenStatus speed checker and streams per-region latency live. + +```bash +openstatus check https://openstat.us # GET, no auth, 28 regions +openstatus check https://openstat.us -X POST -H 'Authorization: Bearer …' -d '{"ping":true}' +openstatus check https://openstat.us -d @payload.json +openstatus check https://openstat.us --timing # DNS / Conn / TLS / TTFB / Transfer +openstatus check https://openstat.us --json | jq '.summary' +``` + +**Flags:** + +| Flag | Description | +|------|-------------| +| `--method` / `-X` | HTTP method (default `GET`; not auto-promoted to POST when `-d` is set — explicit) | +| `--header` / `-H` | Header in `"Key: Value"` form (repeatable, curl-style) | +| `--body` / `-d` | Inline string, `@/path/to/file`, or `@-` for stdin | +| `--timing` | Show DNS/Connection/TLS/TTFB/Transfer phase columns | + +**Output behavior:** + +- Human mode streams one row per region as it arrives (no client-side sort), then prints a summary (fastest, slowest, mean latency, success rate) and a `View:` shareable link. +- `--json` buffers and emits a single object with `url`, `check_id`, `share_url`, `results[]`, and `summary{}`. +- `--quiet` silences stdout (errors still print on stderr). +- Failure rows show `—` for missing latency/status and the server's `message` (e.g. `url not reachable`) in the State column. + +**Rate limit:** 3 requests per 60 seconds. On 429 the CLI prints "Rate limited. Retry after Xs." and exits 1 — do not auto-retry in scripts; loop in shell if needed. + +**When NOT to use `check`:** + +- Recurring uptime monitoring → use `monitors apply` with an `openstatus.yaml`. +- Tests that need to run from monitor-configured regions (or with saved assertions) → use `monitors trigger ` or `run`. + ### Setting up monitors (monitors-as-code) This is the primary way to manage monitors. Write a YAML config, then let the CLI sync it. @@ -345,6 +383,7 @@ Use `--json` when you need to parse output programmatically or pipe it to `jq`. ## Best Practices +- **Reach for `check` for one-off probes** — if the user just wants to know "how fast is this URL from around the world?", `openstatus check ` is the right tool. No auth, no setup, 28 regions, done in 2–5s. Don't create a throwaway monitor for this. - **Use `apply`, not `create`** — `monitors apply` is the declarative, idempotent way to manage monitors. `monitors create` exists but `apply` handles creates, updates, and deletes in one command. - **Always `--dry-run` first** — preview what `apply` will change before committing. - **Get the page ID before creating reports** — `status-report create` requires `--page-id`. Run `status-page list` first. Then use `status-page info ` to find component IDs if you need `--component-ids`.