From 873addd9cc29e9fe0ff26ef111bb3dc9361135ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Dolph=20=F0=9F=90=BA?= Date: Tue, 6 Oct 2026 12:05:02 +0100 Subject: [PATCH] feat(api): read OPENSTATUS_API_URL to target self-hosted instances (#38) * feat(api): read OPENSTATUS_API_URL to target self-hosted instances * fix(api): read OPENSTATUS_API_URL after .env loads and validate it --------- Co-authored-by: o6uoq <1227896+o6uoq@users.noreply.github.com> --- README.md | 11 ++++ cmd/openstatus/main.go | 4 ++ internal/api/client.go | 38 +++++++++++- internal/api/client_test.go | 91 +++++++++++++++++++++++++++++ internal/cli/errors.go | 6 +- internal/terraform/generate.go | 3 +- internal/terraform/generate_test.go | 11 +++- internal/terraform/hcl.go | 13 ++++- internal/terraform/smoke_test.go | 8 ++- 9 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 internal/api/client_test.go diff --git a/README.md b/README.md index dea7c05..2ad640e 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,17 @@ The CLI resolves your API token in this order: 2. `OPENSTATUS_API_TOKEN` environment variable 3. Saved token at `~/.config/openstatus/token` (written by `openstatus login`) +## Self-Hosted + +Point the CLI at your own API server with `OPENSTATUS_API_URL` (default `https://api.openstatus.dev`): + +```bash +export OPENSTATUS_API_URL=https://api.openstatus.example.com +openstatus whoami +``` + +`openstatus terraform generate` then writes the matching `base_url` into `provider.tf`. The `check` command always uses the public speed checker. + ## Development ### Run Tests diff --git a/cmd/openstatus/main.go b/cmd/openstatus/main.go index 85f31e6..3fbb81c 100644 --- a/cmd/openstatus/main.go +++ b/cmd/openstatus/main.go @@ -5,11 +5,15 @@ import ( "github.com/joho/godotenv" + "github.com/openstatusHQ/cli/internal/api" cmd "github.com/openstatusHQ/cli/internal/cmd" ) func main() { _ = godotenv.Load() + if err := api.LoadBaseURL(); err != nil { + log.Fatal(err) + } app := cmd.NewApp() diff --git a/internal/api/client.go b/internal/api/client.go index 84e6b91..f4d574f 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "net/http" + "net/url" "os" + "strings" "time" "connectrpc.com/connect" @@ -12,9 +14,41 @@ import ( output "github.com/openstatusHQ/cli/internal/cli" ) -const APIBaseURL = "https://api.openstatus.dev/v1" +// DefaultBaseURL is the openstatus Cloud API origin. +const DefaultBaseURL = "https://api.openstatus.dev" -const ConnectBaseURL = "https://api.openstatus.dev/rpc" +// BaseURL is the API origin; LoadBaseURL overrides it from OPENSTATUS_API_URL +// to target a self-hosted instance. +var ( + BaseURL = DefaultBaseURL + APIBaseURL = DefaultBaseURL + "/v1" + ConnectBaseURL = DefaultBaseURL + "/rpc" +) + +// LoadBaseURL applies OPENSTATUS_API_URL. Call it after the .env file is +// loaded so a value set there is honoured. +func LoadBaseURL() error { + base, err := resolveBaseURL(os.Getenv("OPENSTATUS_API_URL")) + if err != nil { + return err + } + BaseURL, APIBaseURL, ConnectBaseURL = base, base+"/v1", base+"/rpc" + return nil +} + +// resolveBaseURL also accepts a trailing /rpc, the form the Node SDK +// documents for the same variable. +func resolveBaseURL(v string) (string, error) { + v = strings.TrimSuffix(strings.TrimRight(strings.TrimSpace(v), "/"), "/rpc") + if v == "" { + return DefaultBaseURL, nil + } + u, err := url.Parse(v) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "", fmt.Errorf("OPENSTATUS_API_URL must be an http(s) URL such as https://api.openstatus.example.com, got %q", v) + } + return v, nil +} // PlayCheckerURL is the public Speed Checker endpoint backing the `check` // command. The www. prefix is intentional: the bare openstatus.dev host diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..8f472e6 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "strings" + "testing" +) + +func TestResolveBaseURL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"unset", "", DefaultBaseURL}, + {"origin", "https://openstatus.example.com", "https://openstatus.example.com"}, + {"trailing slash", "https://openstatus.example.com/", "https://openstatus.example.com"}, + {"node sdk form", "https://openstatus.example.com/rpc", "https://openstatus.example.com"}, + {"node sdk form with slash", "https://openstatus.example.com/rpc/", "https://openstatus.example.com"}, + {"path prefix", "http://localhost:3001/api", "http://localhost:3001/api"}, + {"surrounding whitespace", " https://openstatus.example.com\n", "https://openstatus.example.com"}, + {"crlf", "https://openstatus.example.com\r\n", "https://openstatus.example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveBaseURL(tt.in) + if err != nil { + t.Fatalf("resolveBaseURL(%q) error: %v", tt.in, err) + } + if got != tt.want { + t.Errorf("resolveBaseURL(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestResolveBaseURL_Invalid(t *testing.T) { + for _, in := range []string{ + "openstatus.example.com", + "ftp://openstatus.example.com", + "https://", + "://openstatus.example.com", + } { + t.Run(in, func(t *testing.T) { + _, err := resolveBaseURL(in) + if err == nil { + t.Fatalf("resolveBaseURL(%q) = nil error, want one", in) + } + if !strings.Contains(err.Error(), "OPENSTATUS_API_URL") { + t.Errorf("error %q does not name OPENSTATUS_API_URL", err) + } + }) + } +} + +func TestDefaultEndpointsUnchanged(t *testing.T) { + if APIBaseURL != "https://api.openstatus.dev/v1" { + t.Errorf("APIBaseURL = %q", APIBaseURL) + } + if ConnectBaseURL != "https://api.openstatus.dev/rpc" { + t.Errorf("ConnectBaseURL = %q", ConnectBaseURL) + } +} + +// The variable is read when LoadBaseURL runs, not at package init, so a value +// from the .env file (loaded in main) is honoured. +func TestLoadBaseURL_ReadsEnvAtCallTime(t *testing.T) { + t.Cleanup(func() { + BaseURL, APIBaseURL, ConnectBaseURL = DefaultBaseURL, DefaultBaseURL+"/v1", DefaultBaseURL+"/rpc" + }) + t.Setenv("OPENSTATUS_API_URL", "https://openstatus.example.com") + + if err := LoadBaseURL(); err != nil { + t.Fatalf("LoadBaseURL: %v", err) + } + if BaseURL != "https://openstatus.example.com" || + APIBaseURL != "https://openstatus.example.com/v1" || + ConnectBaseURL != "https://openstatus.example.com/rpc" { + t.Errorf("got %q, %q, %q", BaseURL, APIBaseURL, ConnectBaseURL) + } +} + +func TestLoadBaseURL_InvalidKeepsDefaults(t *testing.T) { + t.Setenv("OPENSTATUS_API_URL", "openstatus.example.com") + + if err := LoadBaseURL(); err == nil { + t.Fatal("LoadBaseURL = nil error, want one") + } + if BaseURL != DefaultBaseURL { + t.Errorf("BaseURL = %q, want default", BaseURL) + } +} diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 03ea1b0..8900832 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -35,16 +35,16 @@ func FormatError(err error, resource string, id string) error { var dnsErr *net.DNSError if errors.As(err, &dnsErr) { - return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection") + return fmt.Errorf("could not reach the openstatus API. Check your internet connection and OPENSTATUS_API_URL") } var netErr *net.OpError if errors.As(err, &netErr) { - return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection") + return fmt.Errorf("could not reach the openstatus API. Check your internet connection and OPENSTATUS_API_URL") } if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "no such host") { - return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection") + return fmt.Errorf("could not reach the openstatus API. Check your internet connection and OPENSTATUS_API_URL") } return err diff --git a/internal/terraform/generate.go b/internal/terraform/generate.go index e7881fc..72aaf58 100644 --- a/internal/terraform/generate.go +++ b/internal/terraform/generate.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v3" + "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/auth" output "github.com/openstatusHQ/cli/internal/cli" ) @@ -70,7 +71,7 @@ func GetTerraformGenerateCmd() *cli.Command { gen := NewGenerator(data) // Always write provider.tf - if err := writeFile(filepath.Join(outputDir, "provider.tf"), GenerateProviderFile()); err != nil { + if err := writeFile(filepath.Join(outputDir, "provider.tf"), GenerateProviderFile(api.BaseURL)); err != nil { return cli.Exit(fmt.Sprintf("failed to write provider.tf: %v", err), 1) } diff --git a/internal/terraform/generate_test.go b/internal/terraform/generate_test.go index b4ed364..d2dd98d 100644 --- a/internal/terraform/generate_test.go +++ b/internal/terraform/generate_test.go @@ -8,14 +8,23 @@ import ( notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" + + "github.com/openstatusHQ/cli/internal/api" ) func TestGenerateProviderFile(t *testing.T) { - content := string(GenerateProviderFile()) + content := string(GenerateProviderFile(api.DefaultBaseURL)) mustContain(t, content, `source = "openstatusHQ/openstatus"`) mustContain(t, content, `version = "~> 0.4"`) mustContain(t, content, `provider "openstatus" {}`) mustContain(t, content, `OPENSTATUS_API_TOKEN`) + mustNotContain(t, content, `base_url`) +} + +func TestGenerateProviderFile_SelfHosted(t *testing.T) { + content := string(GenerateProviderFile("https://openstatus.example.com")) + mustContain(t, content, `base_url = "https://openstatus.example.com/rpc"`) + mustNotContain(t, content, `provider "openstatus" {}`) } func TestGenerateMonitorsFile_HTTP(t *testing.T) { diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go index 78561e3..7247600 100644 --- a/internal/terraform/hcl.go +++ b/internal/terraform/hcl.go @@ -11,6 +11,8 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" + + "github.com/openstatusHQ/cli/internal/api" ) type resourceRef struct { @@ -109,7 +111,13 @@ func NewGenerator(data *WorkspaceData) *Generator { return g } -func GenerateProviderFile() []byte { +// GenerateProviderFile pins base_url when baseURL is not openstatus Cloud, +// since the provider does not read OPENSTATUS_API_URL. +func GenerateProviderFile(baseURL string) []byte { + provider := `provider "openstatus" {}` + if baseURL != api.DefaultBaseURL { + provider = fmt.Sprintf("provider \"openstatus\" {\n base_url = %q\n}", baseURL+"/rpc") + } return []byte(`terraform { required_providers { openstatus = { @@ -120,8 +128,7 @@ func GenerateProviderFile() []byte { } # Set OPENSTATUS_API_TOKEN environment variable or configure api_token below -provider "openstatus" {} -`) +` + provider + "\n") } func (g *Generator) GenerateMonitorsFile() *hclwrite.File { diff --git a/internal/terraform/smoke_test.go b/internal/terraform/smoke_test.go index f853df5..74cd928 100644 --- a/internal/terraform/smoke_test.go +++ b/internal/terraform/smoke_test.go @@ -22,6 +22,8 @@ import ( notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" + + "github.com/openstatusHQ/cli/internal/api" ) func TestSmokeValidate(t *testing.T) { @@ -34,7 +36,7 @@ func TestSmokeValidate(t *testing.T) { gen := NewGenerator(data) files := map[string][]byte{ - "provider.tf": GenerateProviderFile(), + "provider.tf": GenerateProviderFile(api.DefaultBaseURL), "monitors.tf": gen.GenerateMonitorsFile().Bytes(), "notifications.tf": gen.GenerateNotificationsFile().Bytes(), "status_pages.tf": gen.GenerateStatusPagesFile().Bytes(), @@ -42,7 +44,7 @@ func TestSmokeValidate(t *testing.T) { "imports.tf": gen.GenerateImportsFile().Bytes(), } for name, content := range files { - if err := os.WriteFile(filepath.Join(dir, name), content, 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, name), content, 0o644); err != nil { t.Fatalf("writing %s: %v", name, err) } } @@ -51,7 +53,7 @@ func TestSmokeValidate(t *testing.T) { // a legacy ~/.terraform.d/plugins mirror would otherwise resolve the provider // locally and validate the config against a stale schema. cliConfig := filepath.Join(dir, "registry.tfrc") - if err := os.WriteFile(cliConfig, []byte("provider_installation {\n direct {}\n}\n"), 0644); err != nil { + if err := os.WriteFile(cliConfig, []byte("provider_installation {\n direct {}\n}\n"), 0o644); err != nil { t.Fatalf("writing terraform CLI config: %v", err) } env := append(os.Environ(), "TF_CLI_CONFIG_FILE="+cliConfig) -- 2.51.2