From fcb4795bd435e1c901f346fdbee7cd1b544d3d67 Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 26 Jun 2026 16:12:07 +0530 Subject: [PATCH 1/7] feat: add Parseable Cloud API key login --- cmd/cloud.go | 212 ++++++++++++++++++++++++++++++++++++++ cmd/login.go | 31 +++++- cmd/promql.go | 6 +- cmd/queryList.go | 2 +- cmd/tail.go | 20 +++- main.go | 2 + pkg/config/config.go | 10 +- pkg/datasets/datasets.go | 7 +- pkg/http/http.go | 28 ++++- pkg/model/login/login.go | 54 +++++----- pkg/model/promql.go | 13 +-- pkg/model/query.go | 13 +-- pkg/model/savedQueries.go | 5 +- 13 files changed, 335 insertions(+), 68 deletions(-) create mode 100644 cmd/cloud.go diff --git a/cmd/cloud.go b/cmd/cloud.go new file mode 100644 index 0000000..7e08b33 --- /dev/null +++ b/cmd/cloud.go @@ -0,0 +1,212 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/parseablehq/pb/pkg/config" + "github.com/spf13/cobra" +) + +const ( + defaultCloudOrchestratorURL = "https://orchestrator.cloud-staging.parseable.com" + envCloudAPIKey = "PB_CLOUD_API_KEY" + envCloudOrchestratorURL = "PB_CLOUD_ORCHESTRATOR_URL" +) + +var ( + cloudAPIKey string + cloudProfileName string + cloudOrchestratorURL string + cloudSetDefault bool +) + +type cloudAPIKeyValidationResponse struct { + OrgID string `json:"org_id"` + OrgName string `json:"org_name"` + WorkspaceID string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name"` + TenantID string `json:"tenant_id"` + URL string `json:"url"` + IngestURL string `json:"ingest_url"` + State string `json:"state"` + MultiTenant bool `json:"multi_tenant"` +} + +var CloudCmd = &cobra.Command{ + Use: "cloud", + Short: "Manage Parseable Cloud profiles", + Long: "\ncloud command is used to configure Parseable Cloud access.", +} + +var CloudProfileCmd = &cobra.Command{ + Use: "profile", + Short: "Manage Parseable Cloud profiles", + Long: "\ncloud profile command is used to add Parseable Cloud profiles.", +} + +var CloudProfileAddCmd = &cobra.Command{ + Use: "add", + Example: " pb cloud profile add --api-key psk_xxx\n pb cloud profile add --api-key psk_xxx --name prod", + Short: "Add a Parseable Cloud profile", + Long: "\nAdd a Parseable Cloud profile using an API key created in Parseable Cloud.", + RunE: func(_ *cobra.Command, _ []string) error { + apiKey := strings.TrimSpace(cloudAPIKey) + if apiKey == "" { + return fmt.Errorf("api key is required. pass --api-key or set %s", envCloudAPIKey) + } + + orchestratorURL := strings.TrimSpace(cloudOrchestratorURL) + if orchestratorURL == "" { + orchestratorURL = defaultCloudOrchestratorURL + } + + result, err := validateCloudAPIKey(orchestratorURL, apiKey) + if err != nil { + return err + } + + profileName := strings.TrimSpace(cloudProfileName) + if profileName == "" { + profileName = cloudProfileNameFromWorkspace(result) + } + + profile := config.Profile{ + URL: result.URL, + Cloud: true, + APIKey: apiKey, + TenantID: result.TenantID, + IngestURL: result.IngestURL, + WorkspaceID: result.WorkspaceID, + WorkspaceName: result.WorkspaceName, + OrchestratorURL: orchestratorURL, + } + + if err := saveCloudProfile(profileName, profile, cloudSetDefault); err != nil { + return err + } + + fmt.Printf("Cloud profile %s added successfully\n", profileName) + if result.WorkspaceName != "" { + fmt.Printf("Workspace: %s (%s)\n", result.WorkspaceName, result.WorkspaceID) + } else { + fmt.Printf("Workspace: %s\n", result.WorkspaceID) + } + fmt.Printf("URL: %s\n", result.URL) + return nil + }, +} + +func init() { + CloudProfileAddCmd.Flags().StringVar(&cloudAPIKey, "api-key", os.Getenv(envCloudAPIKey), "Parseable Cloud API key") + CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") + CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudOrchestratorURLFromEnv(), "Parseable Cloud orchestrator URL") + CloudProfileAddCmd.Flags().BoolVar(&cloudSetDefault, "default", false, "set this profile as default") + + CloudProfileCmd.AddCommand(CloudProfileAddCmd) + CloudCmd.AddCommand(CloudProfileCmd) +} + +func cloudOrchestratorURLFromEnv() string { + if orchestratorURL := os.Getenv(envCloudOrchestratorURL); orchestratorURL != "" { + return orchestratorURL + } + return defaultCloudOrchestratorURL +} + +func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { + endpoint, err := url.JoinPath(orchestratorURL, "api/v1/apikey/validate") + if err != nil { + return nil, fmt.Errorf("invalid orchestrator URL: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to validate cloud api key: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("cloud api key validation failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var result cloudAPIKeyValidationResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to decode cloud api key validation response: %w", err) + } + + if result.URL == "" || result.TenantID == "" || result.WorkspaceID == "" { + return nil, errors.New("cloud api key validation response missing url, tenant_id, or workspace_id") + } + + return &result, nil +} + +func saveCloudProfile(name string, profile config.Profile, setDefault bool) error { + if name == "" { + return errors.New("profile name is required") + } + + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + fileConfig = &config.Config{ + Profiles: make(map[string]config.Profile), + } + } + + if fileConfig.Profiles == nil { + fileConfig.Profiles = make(map[string]config.Profile) + } + + fileConfig.Profiles[name] = profile + if fileConfig.DefaultProfile == "" || setDefault { + fileConfig.DefaultProfile = name + } + + return config.WriteConfigToFile(fileConfig) +} + +func cloudProfileNameFromWorkspace(result *cloudAPIKeyValidationResponse) string { + name := strings.TrimSpace(result.WorkspaceName) + if name == "" { + name = strings.TrimSpace(result.WorkspaceID) + } + + name = strings.ToLower(name) + name = strings.ReplaceAll(name, " ", "-") + return name +} diff --git a/cmd/login.go b/cmd/login.go index ff94c23..c7eaee3 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -29,8 +29,8 @@ var LoginCmd = &cobra.Command{ Short: "Login to Parseable", Long: `Interactive login wizard for Parseable. -Select self-hosted and enter your server URL, credentials, and a -profile name. All settings are saved to ~/.config/pb/config.toml.`, +Select self-hosted or Parseable Cloud and enter the required +credentials. All settings are saved to ~/.config/pb/config.toml.`, RunE: func(_ *cobra.Command, _ []string) error { _m, err := tea.NewProgram(login.New()).Run() if err != nil { @@ -42,6 +42,14 @@ profile name. All settings are saved to ~/.config/pb/config.toml.`, return nil } + if m.Profile.Cloud { + profile, err := cloudProfileFromAPIKey(m.Profile.APIKey) + if err != nil { + return err + } + m.Profile = *profile + } + if err := writeProfile(m.Profile, m.Name); err != nil { return fmt.Errorf("failed to save profile: %w", err) } @@ -49,6 +57,25 @@ profile name. All settings are saved to ~/.config/pb/config.toml.`, }, } +func cloudProfileFromAPIKey(apiKey string) (*config.Profile, error) { + orchestratorURL := cloudOrchestratorURLFromEnv() + result, err := validateCloudAPIKey(orchestratorURL, apiKey) + if err != nil { + return nil, err + } + + return &config.Profile{ + URL: result.URL, + Cloud: true, + APIKey: apiKey, + TenantID: result.TenantID, + IngestURL: result.IngestURL, + WorkspaceID: result.WorkspaceID, + WorkspaceName: result.WorkspaceName, + OrchestratorURL: orchestratorURL, + }, nil +} + func writeProfile(profile config.Profile, profileName string) error { fileConfig, err := config.ReadConfigFromFile() if err != nil { diff --git a/cmd/promql.go b/cmd/promql.go index b99faa8..45d0c6c 100644 --- a/cmd/promql.go +++ b/cmd/promql.go @@ -195,11 +195,7 @@ func promqlGet(path string, params url.Values) ([]byte, error) { if err != nil { return nil, err } - if DefaultProfile.Token != "" { - req.Header.Set("Authorization", "Bearer "+DefaultProfile.Token) - } else { - req.SetBasicAuth(DefaultProfile.Username, DefaultProfile.Password) - } + internalHTTP.AddAuthHeaders(req, &DefaultProfile) stopSpinner := startSpinner() resp, err := client.Client.Do(req) stopSpinner() diff --git a/cmd/queryList.go b/cmd/queryList.go index 3938462..584d210 100644 --- a/cmd/queryList.go +++ b/cmd/queryList.go @@ -190,7 +190,7 @@ func fetchFilters(client *http.Client, profile *config.Profile) []Item { return nil } - req.SetBasicAuth(profile.Username, profile.Password) + internalHTTP.AddAuthHeaders(req, profile) req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { diff --git a/cmd/tail.go b/cmd/tail.go index e0b17c2..22c88be 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -73,7 +73,7 @@ func tail(profile config.Profile, stream string) error { return err } - authHeader := basicAuth(profile.Username, profile.Password) + authMetadata := tailAuthMetadata(profile) watching := func() { fmt.Fprintf(os.Stderr, "\r\033[K● watching %s... (ctrl+c to stop)", stream) @@ -82,9 +82,7 @@ func tail(profile config.Profile, stream string) error { for { resp, err := flightClient.DoGet( - metadata.NewOutgoingContext(context.Background(), metadata.New(map[string]string{ - "Authorization": "Basic " + authHeader, - })), + metadata.NewOutgoingContext(context.Background(), authMetadata), &flight.Ticket{Ticket: payload}, ) if err != nil { @@ -119,6 +117,20 @@ func tail(profile config.Profile, stream string) error { } } +func tailAuthMetadata(profile config.Profile) metadata.MD { + if profile.Cloud && profile.APIKey != "" { + values := map[string]string{"x-api-key": profile.APIKey} + if profile.TenantID != "" { + values["x-p-tenant"] = profile.TenantID + } + return metadata.New(values) + } + + return metadata.New(map[string]string{ + "Authorization": "Basic " + basicAuth(profile.Username, profile.Password), + }) +} + // isStreamEnd returns true for normal stream termination codes that warrant a reconnect. func isStreamEnd(err error) bool { if err == io.EOF { diff --git a/main.go b/main.go index 3764269..44bf190 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,7 @@ var rootHelpGroups = []rootHelpGroup{ title: "Identity & Access:", commands: []rootHelpCommand{ {name: "login", desc: "Login to Parseable"}, + {name: "cloud", desc: "Manage Parseable Cloud profiles"}, {name: "logout", desc: "Logout from the current Parseable profile"}, {name: "profile", desc: "Manage different Parseable targets"}, {name: "user", desc: "Manage users"}, @@ -258,6 +259,7 @@ func main() { // cli.AddCommand(cluster) cli.AddCommand(pb.LoginCmd) + cli.AddCommand(pb.CloudCmd) cli.AddCommand(pb.LogoutCmd) cli.AddCommand(pb.StatusCmd) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c6bdc2..5d097c8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -70,6 +70,14 @@ type Profile struct { Username string `toml:"username,omitempty" json:"username,omitempty"` Password string `toml:"password,omitempty" json:"password,omitempty"` Token string `toml:"token,omitempty" json:"token,omitempty"` + + Cloud bool `toml:"cloud,omitempty" json:"cloud,omitempty"` + APIKey string `toml:"api_key,omitempty" json:"api_key,omitempty"` + TenantID string `toml:"tenant_id,omitempty" json:"tenant_id,omitempty"` + IngestURL string `toml:"ingest_url,omitempty" json:"ingest_url,omitempty"` + WorkspaceID string `toml:"workspace_id,omitempty" json:"workspace_id,omitempty"` + WorkspaceName string `toml:"workspace_name,omitempty" json:"workspace_name,omitempty"` + OrchestratorURL string `toml:"orchestrator_url,omitempty" json:"orchestrator_url,omitempty"` } func (p *Profile) GrpcAddr(port string) string { @@ -90,7 +98,7 @@ func WriteConfigToFile(config *Config) error { return err } - file, err := os.Create(filePath) + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { fmt.Println("Error creating the file:", err) return err diff --git a/pkg/datasets/datasets.go b/pkg/datasets/datasets.go index 654d971..3252b08 100644 --- a/pkg/datasets/datasets.go +++ b/pkg/datasets/datasets.go @@ -11,6 +11,7 @@ import ( "time" "github.com/parseablehq/pb/pkg/config" + internalHTTP "github.com/parseablehq/pb/pkg/http" ) const ( @@ -82,10 +83,6 @@ func NamesByType(items []Dataset, datasetType string) []string { } func authenticate(req *http.Request, profile config.Profile) { - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) - } else { - req.SetBasicAuth(profile.Username, profile.Password) - } + internalHTTP.AddAuthHeaders(req, &profile) req.Header.Set("Content-Type", "application/json") } diff --git a/pkg/http/http.go b/pkg/http/http.go index aeb6618..5e6dfef 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -25,6 +25,11 @@ import ( "github.com/parseablehq/pb/pkg/config" ) +const ( + apiKeyHeader = "x-api-key" + tenantHeader = "X-P-Tenant" +) + type HTTPClient struct { Client http.Client Profile *config.Profile @@ -49,11 +54,24 @@ func (client *HTTPClient) NewRequest(method string, path string, body io.Reader) if err != nil { return } - if client.Profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+client.Profile.Token) - } else { - req.SetBasicAuth(client.Profile.Username, client.Profile.Password) - } + AddAuthHeaders(req, client.Profile) req.Header.Add("Content-Type", "application/json") return } + +func AddAuthHeaders(req *http.Request, profile *config.Profile) { + if profile.Cloud && profile.APIKey != "" { + req.Header.Set(apiKeyHeader, profile.APIKey) + if profile.TenantID != "" { + req.Header.Set(tenantHeader, profile.TenantID) + } + return + } + + if profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+profile.Token) + return + } + + req.SetBasicAuth(profile.Username, profile.Password) +} diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go index 2645a8a..b8c11ed 100644 --- a/pkg/model/login/login.go +++ b/pkg/model/login/login.go @@ -30,7 +30,6 @@ type step int const ( stepChooseType step = iota - stepCloudSoon stepEnterURL stepChooseAuth stepEnterUsername @@ -152,15 +151,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.urlInput.Focus() return m, textinput.Blink } - m.step = stepCloudSoon + m.errMsg = "" + m.step = stepEnterToken + m.tokenInput.Focus() + return m, textinput.Blink } return m, nil - // ── Coming-soon screen ─────────────────────────────────────────────── - case stepCloudSoon: - m.step = stepChooseType - return m, nil - // ── Step 2: server URL ─────────────────────────────────────────────── case stepEnterURL: switch key.Type { @@ -259,7 +256,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch key.Type { case tea.KeyEsc: m.errMsg = "" - m.step = stepChooseAuth + if m.typeIndex == 1 { + m.step = stepChooseType + } else { + m.step = stepChooseAuth + } m.tokenInput.Blur() return m, nil case tea.KeyEnter: @@ -359,7 +360,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) finalize(name string) (tea.Model, tea.Cmd) { m.Name = name - if m.authIndex == 0 { + if m.typeIndex == 1 { + m.Profile = config.Profile{ + Cloud: true, + APIKey: strings.TrimSpace(m.tokenInput.Value()), + } + } else if m.authIndex == 0 { m.Profile = config.Profile{ URL: m.serverURL, Username: strings.TrimSpace(m.usernameInput.Value()), @@ -413,7 +419,7 @@ func (m Model) View() string { b.WriteString("\n\n") entries := []struct{ label, badge string }{ {"Self-hosted", ""}, - {"Parseable Cloud", " (coming soon)"}, + {"Parseable Cloud", ""}, } for i, e := range entries { if i == m.typeIndex { @@ -427,15 +433,6 @@ func (m Model) View() string { b.WriteString("\n") b.WriteString(hint([2]string{"↑↓", "navigate"}, [2]string{"enter", "select"}, [2]string{"ctrl-c", "quit"})) - case stepCloudSoon: - b.WriteString(labelStyle.Render("PARSEABLE CLOUD")) - b.WriteString("\n\n") - b.WriteString(normalStyle.Render(" We're working on it.")) - b.WriteString("\n") - b.WriteString(dimStyle.Render(" Cloud login is coming soon.")) - b.WriteString("\n\n") - b.WriteString(hint([2]string{"any key", "back"})) - case stepEnterURL: b.WriteString(labelStyle.Render("SERVER URL")) b.WriteString("\n\n ") @@ -476,7 +473,11 @@ func (m Model) View() string { b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) case stepEnterToken: - b.WriteString(labelStyle.Render("API KEY")) + if m.typeIndex == 1 { + b.WriteString(labelStyle.Render("CLOUD API KEY")) + } else { + b.WriteString(labelStyle.Render("API KEY")) + } b.WriteString("\n\n ") b.WriteString(m.tokenInput.View()) b.WriteString("\n\n") @@ -509,9 +510,16 @@ func (m Model) View() string { case stepDone: b.WriteString(successStyle.Render("✓ profile '" + m.Name + "' saved")) b.WriteString("\n\n") - b.WriteString(" " + labelStyle.Render("URL ")) - b.WriteString(normalStyle.Render(m.Profile.URL)) - b.WriteString("\n") + if m.Profile.URL != "" { + b.WriteString(" " + labelStyle.Render("URL ")) + b.WriteString(normalStyle.Render(m.Profile.URL)) + b.WriteString("\n") + } + if m.Profile.Cloud { + b.WriteString(" " + labelStyle.Render("AUTH ")) + b.WriteString(normalStyle.Render("Cloud API key (stored after validation)")) + b.WriteString("\n") + } if m.Profile.Username != "" { b.WriteString(" " + labelStyle.Render("USER ")) b.WriteString(normalStyle.Render(m.Profile.Username)) diff --git a/pkg/model/promql.go b/pkg/model/promql.go index 05c2b1a..e104284 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -44,6 +44,7 @@ import ( table "github.com/evertras/bubble-table/table" "github.com/parseablehq/pb/pkg/config" "github.com/parseablehq/pb/pkg/datasets" + internalHTTP "github.com/parseablehq/pb/pkg/http" "github.com/parseablehq/pb/pkg/ui" "golang.org/x/term" ) @@ -2225,11 +2226,7 @@ func promqlModelFetch(profile config.Profile, path string, params url.Values) ([ if err != nil { return nil, err } - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) - } else { - req.SetBasicAuth(profile.Username, profile.Password) - } + internalHTTP.AddAuthHeaders(req, &profile) resp, err := client.Do(req) if err != nil { @@ -2607,11 +2604,7 @@ func builderHTTPGetCtx(ctx context.Context, profile config.Profile, rawURL strin if err != nil { return nil, err } - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) - } else { - req.SetBasicAuth(profile.Username, profile.Password) - } + internalHTTP.AddAuthHeaders(req, &profile) resp, err := client.Do(req) if err != nil { return nil, err diff --git a/pkg/model/query.go b/pkg/model/query.go index 273aa58..452fdda 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -40,6 +40,7 @@ import ( "github.com/muesli/reflow/truncate" "github.com/parseablehq/pb/pkg/config" "github.com/parseablehq/pb/pkg/datasets" + internalHTTP "github.com/parseablehq/pb/pkg/http" "github.com/parseablehq/pb/pkg/iterator" "github.com/parseablehq/pb/pkg/ui" "golang.org/x/exp/slices" @@ -2700,11 +2701,7 @@ func fetchDataRaw(client *http.Client, profile *config.Profile, query string, st errMsg = err.Error() return } - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) - } else { - req.SetBasicAuth(profile.Username, profile.Password) - } + internalHTTP.AddAuthHeaders(req, profile) req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { @@ -3054,11 +3051,7 @@ func fetchStreamSchema(profile config.Profile, stream string) tea.Cmd { schemaURL, _ := url.JoinPath(profile.URL, "api/v1/logstream", stream, "schema") req, err := http.NewRequest("GET", schemaURL, nil) if err == nil { - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) - } else { - req.SetBasicAuth(profile.Username, profile.Password) - } + internalHTTP.AddAuthHeaders(req, &profile) if resp, err := client.Do(req); err == nil { body, _ := io.ReadAll(resp.Body) resp.Body.Close() diff --git a/pkg/model/savedQueries.go b/pkg/model/savedQueries.go index d387924..b92316f 100644 --- a/pkg/model/savedQueries.go +++ b/pkg/model/savedQueries.go @@ -25,6 +25,7 @@ import ( "time" "github.com/parseablehq/pb/pkg/config" + internalHTTP "github.com/parseablehq/pb/pkg/http" "github.com/parseablehq/pb/pkg/ui" "github.com/charmbracelet/bubbles/key" @@ -305,7 +306,7 @@ func fetchFilters(client *http.Client, profile *config.Profile) []list.Item { return nil } - req.SetBasicAuth(profile.Username, profile.Password) + internalHTTP.AddAuthHeaders(req, profile) req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { @@ -371,7 +372,7 @@ func RunQuery(client *http.Client, profile *config.Profile, query string, startT if err != nil { return "", err } - req.SetBasicAuth(profile.Username, profile.Password) + internalHTTP.AddAuthHeaders(req, profile) req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) From 0e835ef9b5c9136313ae096a3ab3d5f6cfbc2a9b Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 26 Jun 2026 16:39:58 +0530 Subject: [PATCH 2/7] fix: resolved the bugs and ci --- cmd/cloud.go | 14 +++++++++++--- cmd/tail.go | 6 ++++++ pkg/config/config.go | 6 +++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index 7e08b33..da74fed 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -41,6 +41,7 @@ var ( cloudProfileName string cloudOrchestratorURL string cloudSetDefault bool + cloudForceOverwrite bool ) type cloudAPIKeyValidationResponse struct { @@ -104,7 +105,7 @@ var CloudProfileAddCmd = &cobra.Command{ OrchestratorURL: orchestratorURL, } - if err := saveCloudProfile(profileName, profile, cloudSetDefault); err != nil { + if err := saveCloudProfile(profileName, profile, cloudSetDefault, cloudForceOverwrite); err != nil { return err } @@ -124,6 +125,7 @@ func init() { CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudOrchestratorURLFromEnv(), "Parseable Cloud orchestrator URL") CloudProfileAddCmd.Flags().BoolVar(&cloudSetDefault, "default", false, "set this profile as default") + CloudProfileAddCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") CloudProfileCmd.AddCommand(CloudProfileAddCmd) CloudCmd.AddCommand(CloudProfileCmd) @@ -176,22 +178,28 @@ func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidation return &result, nil } -func saveCloudProfile(name string, profile config.Profile, setDefault bool) error { +func saveCloudProfile(name string, profile config.Profile, setDefault, force bool) error { if name == "" { return errors.New("profile name is required") } fileConfig, err := config.ReadConfigFromFile() - if err != nil { + if os.IsNotExist(err) { fileConfig = &config.Config{ Profiles: make(map[string]config.Profile), } + } else if err != nil { + return fmt.Errorf("failed to read config: %w", err) } if fileConfig.Profiles == nil { fileConfig.Profiles = make(map[string]config.Profile) } + if _, exists := fileConfig.Profiles[name]; exists && !force { + return fmt.Errorf("profile %q already exists. use --force to overwrite", name) + } + fileConfig.Profiles[name] = profile if fileConfig.DefaultProfile == "" || setDefault { fileConfig.DefaultProfile = name diff --git a/cmd/tail.go b/cmd/tail.go index 22c88be..91f9464 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -126,6 +126,12 @@ func tailAuthMetadata(profile config.Profile) metadata.MD { return metadata.New(values) } + if profile.Token != "" { + return metadata.New(map[string]string{ + "Authorization": "Bearer " + profile.Token, + }) + } + return metadata.New(map[string]string{ "Authorization": "Basic " + basicAuth(profile.Username, profile.Password), }) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5d097c8..ff32640 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -98,12 +98,16 @@ func WriteConfigToFile(config *Config) error { return err } - file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { fmt.Println("Error creating the file:", err) return err } defer file.Close() + if err := file.Chmod(0o600); err != nil { + fmt.Println("Error setting file permissions:", err) + return err + } // Write the data into the file _, err = file.Write(tomlData) if err != nil { From f0635f6e6dd9e499a8c2ae6ea7eb447ba4f4105d Mon Sep 17 00:00:00 2001 From: Pratik Date: Sun, 12 Jul 2026 20:56:16 +0530 Subject: [PATCH 3/7] feat: add cloud OAuth login and API key authentication --- cmd/cloud.go | 776 +++++++++++++++++++++++++++++++++++++- cmd/dataset_test.go | 4 +- cmd/login.go | 21 +- cmd/logout.go | 92 ++++- cmd/profile.go | 2 +- cmd/promql.go | 193 +++++++--- cmd/queryList.go | 17 +- cmd/status.go | 66 +++- cmd/tail.go | 85 +++-- main.go | 113 ++++++ pkg/config/config.go | 102 ++++- pkg/datasets/datasets.go | 148 +++++++- pkg/http/http.go | 51 ++- pkg/model/login/login.go | 129 +++++-- pkg/model/promql.go | 8 +- pkg/model/query.go | 41 +- pkg/model/savedQueries.go | 25 +- 17 files changed, 1643 insertions(+), 230 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index da74fed..d9dcae2 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -16,13 +16,19 @@ package cmd import ( + "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" "io" + "net" "net/http" "net/url" "os" + "os/exec" + "runtime" "strings" "time" @@ -31,17 +37,32 @@ import ( ) const ( - defaultCloudOrchestratorURL = "https://orchestrator.cloud-staging.parseable.com" - envCloudAPIKey = "PB_CLOUD_API_KEY" - envCloudOrchestratorURL = "PB_CLOUD_ORCHESTRATOR_URL" + cloudDefaultOrchestratorURL = "https://orchestrator.cloud-staging.parseable.com" + cloudDefaultOrchestratorAuthToken = "Add Authbearer token" + cloudDefaultClerkPublishableKey = "pk_test_bW92ZWQtcGlyYW5oYS02My5jbGVyay5hY2NvdW50cy5kZXYk" + cloudDefaultCallbackAddr = "127.0.0.1:9999" ) +const ( + cloudOAuthProfilePath = "api/v1/organizations" + cloudParseableSessionPath = "api/v1/o/code" +) + +const cloudLoginTimeout = 5 * time.Minute + var ( - cloudAPIKey string - cloudProfileName string - cloudOrchestratorURL string - cloudSetDefault bool - cloudForceOverwrite bool + cloudAPIKey string + cloudCallbackAddr string + cloudOrchestratorAuthToken string + cloudClerkPublishableKey string + cloudProfileName string + cloudOrchestratorURL string + cloudWorkspaceURL string + cloudTenantID string + cloudClerkSessionTokenFlag string + cloudSetDefault bool + cloudForceOverwrite bool + cloudDirectCodeExchange bool ) type cloudAPIKeyValidationResponse struct { @@ -56,12 +77,152 @@ type cloudAPIKeyValidationResponse struct { MultiTenant bool `json:"multi_tenant"` } +type cloudLoginCallbackResult struct { + Token string + Err error +} + +type cloudOAuthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type cloudOAuthJWTClaims struct { + Subject string `json:"sub"` + SessionID string `json:"sid"` +} + +type cloudParseableSessionResponse struct { + Session string `json:"session"` + Username string `json:"username"` + UserID string `json:"user_id"` +} + +type cloudOrganizationResponse struct { + OrgID string `json:"org_id"` + OrgName string `json:"org_name"` + Workspaces []cloudWorkspaceResponse `json:"workspaces"` +} + +type cloudOrganizationsResponse struct { + Organizations []cloudOrganizationResponse `json:"organizations"` +} + +type cloudWorkspaceResponse struct { + WorkspaceID string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name"` + OrgName string `json:"org_name"` + PrismURL string `json:"prism_url"` + URL string `json:"url"` + MultiTenant bool `json:"multi_tenant"` + State string `json:"state"` + InvitationStatus string `json:"invitation_status"` +} + var CloudCmd = &cobra.Command{ Use: "cloud", Short: "Manage Parseable Cloud profiles", Long: "\ncloud command is used to configure Parseable Cloud access.", } +var CloudLoginCmd = &cobra.Command{ + Use: "login", + Example: " pb cloud login\n pb cloud login --name prod", + Short: "Login to Parseable Cloud", + Long: "\nLogin to Parseable Cloud using Clerk browser login.", + RunE: func(_ *cobra.Command, _ []string) error { + orchestratorURL := strings.TrimSpace(cloudOrchestratorURL) + if orchestratorURL == "" { + orchestratorURL = cloudDefaultOrchestratorURL + } + + publishableKey := strings.TrimSpace(cloudClerkPublishableKey) + if publishableKey == "" { + publishableKey = cloudDefaultClerkPublishableKey + } + + callbackAddr := strings.TrimSpace(cloudCallbackAddr) + if callbackAddr == "" { + callbackAddr = cloudDefaultCallbackAddr + } + + profile, err := cloudProfileFromBrowserLogin(orchestratorURL, publishableKey, callbackAddr) + if err != nil { + return err + } + + profileName := strings.TrimSpace(cloudProfileName) + if profileName == "" { + profileName = cloudProfileNameFromSession(profile) + } + + if err := saveCloudProfile(profileName, *profile, cloudSetDefault, cloudForceOverwrite); err != nil { + return err + } + + fmt.Printf("Cloud profile %s added successfully\n", profileName) + if profile.WorkspaceName != "" { + fmt.Printf("Workspace: %s (%s)\n", profile.WorkspaceName, profile.WorkspaceID) + } else { + fmt.Printf("Workspace: %s\n", profile.WorkspaceID) + } + fmt.Printf("URL: %s\n", profile.URL) + return nil + }, +} + +func cloudProfileFromBrowserLogin(orchestratorURL, publishableKey, callbackAddr string) (*config.Profile, error) { + if strings.TrimSpace(publishableKey) == "" { + return nil, errors.New("cloud clerk publishable key is required") + } + + state, err := generateCloudLoginState() + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), cloudLoginTimeout) + defer cancel() + + resultCh, stop, loginURL, err := startCloudClerkLoginServer(ctx, callbackAddr, state, publishableKey) + if err != nil { + return nil, err + } + defer stop() + + fmt.Printf("Opening browser for Parseable Cloud login...\n") + if err := openExternalBrowser(loginURL); err != nil { + fmt.Printf("Could not open browser automatically: %v\n", err) + } + fmt.Printf("Login URL:\n%s\n", loginURL) + fmt.Printf("Waiting for Clerk session token on %s\n", loginURL) + + var result cloudLoginCallbackResult + select { + case result = <-resultCh: + case <-ctx.Done(): + return nil, fmt.Errorf("timed out waiting for cloud login callback") + } + if result.Err != nil { + return nil, result.Err + } + + clerkSessionToken := strings.TrimSpace(result.Token) + + if cloudDirectCodeExchange { + return cloudProfileFromDirectCodeExchange(cloudWorkspaceURL, cloudTenantID, clerkSessionToken, clerkSessionToken) + } + + return cloudProfileFromOAuthTokens(orchestratorURL, &cloudOAuthTokenResponse{ + AccessToken: clerkSessionToken, + IDToken: clerkSessionToken, + }) +} + var CloudProfileCmd = &cobra.Command{ Use: "profile", Short: "Manage Parseable Cloud profiles", @@ -76,12 +237,12 @@ var CloudProfileAddCmd = &cobra.Command{ RunE: func(_ *cobra.Command, _ []string) error { apiKey := strings.TrimSpace(cloudAPIKey) if apiKey == "" { - return fmt.Errorf("api key is required. pass --api-key or set %s", envCloudAPIKey) + return errors.New("api key is required. pass --api-key") } orchestratorURL := strings.TrimSpace(cloudOrchestratorURL) if orchestratorURL == "" { - orchestratorURL = defaultCloudOrchestratorURL + orchestratorURL = cloudDefaultOrchestratorURL } result, err := validateCloudAPIKey(orchestratorURL, apiKey) @@ -121,23 +282,29 @@ var CloudProfileAddCmd = &cobra.Command{ } func init() { - CloudProfileAddCmd.Flags().StringVar(&cloudAPIKey, "api-key", os.Getenv(envCloudAPIKey), "Parseable Cloud API key") + CloudLoginCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") + CloudLoginCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") + CloudLoginCmd.Flags().StringVar(&cloudOrchestratorAuthToken, "orchestrator-auth-token", cloudDefaultOrchestratorAuthToken, "Parseable Cloud orchestrator auth token") + CloudLoginCmd.Flags().StringVar(&cloudClerkPublishableKey, "clerk-publishable-key", cloudDefaultClerkPublishableKey, "Clerk publishable key") + CloudLoginCmd.Flags().StringVar(&cloudCallbackAddr, "callback-addr", cloudDefaultCallbackAddr, "local callback listen address") + CloudLoginCmd.Flags().StringVar(&cloudWorkspaceURL, "url", "", "Parseable Cloud workspace URL for direct code exchange") + CloudLoginCmd.Flags().StringVar(&cloudTenantID, "tenant-id", "", "Parseable Cloud tenant/workspace ID for direct code exchange") + CloudLoginCmd.Flags().StringVar(&cloudClerkSessionTokenFlag, "clerk-session-token", "", "Clerk session token header for direct code exchange") + CloudLoginCmd.Flags().BoolVar(&cloudSetDefault, "default", false, "set this profile as default") + CloudLoginCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") + CloudLoginCmd.Flags().BoolVar(&cloudDirectCodeExchange, "direct-code-exchange", false, "skip orchestrator and call workspace /api/v1/o/code with the Clerk session token") + + CloudProfileAddCmd.Flags().StringVar(&cloudAPIKey, "api-key", "", "Parseable Cloud API key") CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") - CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudOrchestratorURLFromEnv(), "Parseable Cloud orchestrator URL") + CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") CloudProfileAddCmd.Flags().BoolVar(&cloudSetDefault, "default", false, "set this profile as default") CloudProfileAddCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") CloudProfileCmd.AddCommand(CloudProfileAddCmd) + CloudCmd.AddCommand(CloudLoginCmd) CloudCmd.AddCommand(CloudProfileCmd) } -func cloudOrchestratorURLFromEnv() string { - if orchestratorURL := os.Getenv(envCloudOrchestratorURL); orchestratorURL != "" { - return orchestratorURL - } - return defaultCloudOrchestratorURL -} - func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { endpoint, err := url.JoinPath(orchestratorURL, "api/v1/apikey/validate") if err != nil { @@ -178,6 +345,563 @@ func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidation return &result, nil } +func cloudProfileFromOAuthTokens(orchestratorURL string, tokens *cloudOAuthTokenResponse) (*config.Profile, error) { + if tokens == nil || strings.TrimSpace(tokens.AccessToken) == "" { + return nil, errors.New("cloud oauth token response missing access_token") + } + clerkSessionToken := cloudClerkSessionToken(tokens) + if strings.TrimSpace(clerkSessionToken) == "" { + return nil, errors.New("cloud oauth token response missing clerk session token") + } + + claims, err := cloudOAuthClaims(tokens) + if err != nil { + return nil, err + } + userID := claims.Subject + + endpoint, err := url.JoinPath(orchestratorURL, cloudOAuthProfilePath) + if err != nil { + return nil, fmt.Errorf("invalid orchestrator URL: %w", err) + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("invalid cloud organization URL: %w", err) + } + query := u.Query() + query.Set("user_id", userID) + u.RawQuery = query.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + authToken := strings.TrimSpace(cloudOrchestratorAuthToken) + if authToken == "" { + authToken = cloudDefaultOrchestratorAuthToken + } + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } else { + req.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + } + req.Header.Set("X-Clerk-Session-Token", clerkSessionToken) + req.Header.Set("Accept", "application/json") + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to resolve cloud oauth profile: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("cloud oauth profile resolution failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + result, err := decodeCloudOrganizationResponse(body) + if err != nil { + return nil, fmt.Errorf("failed to decode cloud organization response: %w", err) + } + + workspace, err := cloudWorkspaceForProfile(result.Workspaces) + if err != nil { + return nil, err + } + + workspaceURL := cloudWorkspaceEndpoint(*workspace) + sessionToken, err := exchangeCloudParseableSession(workspaceURL, workspace.WorkspaceID, clerkSessionToken, clerkSessionToken) + if err != nil { + return nil, err + } + + return &config.Profile{ + URL: workspaceURL, + Cloud: true, + SessionToken: sessionToken, + RefreshToken: tokens.RefreshToken, + TenantID: workspace.WorkspaceID, + WorkspaceID: workspace.WorkspaceID, + WorkspaceName: workspace.WorkspaceName, + OrchestratorURL: orchestratorURL, + ClerkSessionID: claims.SessionID, + }, nil +} + +func cloudProfileFromDirectCodeExchange(workspaceURL, tenantID, code, clerkSessionToken string) (*config.Profile, error) { + workspaceURL = strings.TrimSpace(workspaceURL) + tenantID = strings.TrimSpace(tenantID) + clerkSessionToken = strings.TrimSpace(clerkSessionToken) + if clerkSessionToken == "" { + clerkSessionToken = strings.TrimSpace(cloudClerkSessionTokenFlag) + } + if workspaceURL == "" { + return nil, errors.New("workspace URL is required for direct code exchange. pass --url") + } + if tenantID == "" { + return nil, errors.New("tenant ID is required for direct code exchange. pass --tenant-id") + } + + sessionToken, err := exchangeCloudParseableSession(workspaceURL, tenantID, code, clerkSessionToken) + if err != nil { + return nil, err + } + + return &config.Profile{ + URL: workspaceURL, + Cloud: true, + SessionToken: sessionToken, + TenantID: tenantID, + WorkspaceID: tenantID, + }, nil +} + +func cloudOAuthClaims(tokens *cloudOAuthTokenResponse) (*cloudOAuthJWTClaims, error) { + for _, token := range []string{tokens.IDToken, tokens.AccessToken} { + claims, err := decodeCloudOAuthJWTClaims(token) + if err == nil && claims.Subject != "" { + return claims, nil + } + } + return nil, errors.New("cloud oauth tokens missing user subject") +} + +func cloudClerkSessionToken(tokens *cloudOAuthTokenResponse) string { + if strings.TrimSpace(tokens.IDToken) != "" { + return tokens.IDToken + } + return tokens.AccessToken +} + +func decodeCloudOAuthJWTClaims(token string) (*cloudOAuthJWTClaims, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, errors.New("invalid jwt") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + var claims cloudOAuthJWTClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } + return &claims, nil +} + +func decodeCloudOrganizationResponse(body []byte) (*cloudOrganizationResponse, error) { + var org cloudOrganizationResponse + if err := json.Unmarshal(body, &org); err == nil && len(org.Workspaces) > 0 { + return &org, nil + } + + var orgs []cloudOrganizationResponse + if err := json.Unmarshal(body, &orgs); err == nil { + return cloudOrganizationWithWorkspaces(orgs) + } + + var wrapped cloudOrganizationsResponse + if err := json.Unmarshal(body, &wrapped); err == nil { + return cloudOrganizationWithWorkspaces(wrapped.Organizations) + } + + return nil, errors.New("unknown cloud organization response shape") +} + +func cloudOrganizationWithWorkspaces(orgs []cloudOrganizationResponse) (*cloudOrganizationResponse, error) { + for i := range orgs { + if len(orgs[i].Workspaces) > 0 { + return &orgs[i], nil + } + } + return nil, errors.New("cloud organization response has no workspaces") +} + +func cloudWorkspaceForProfile(workspaces []cloudWorkspaceResponse) (*cloudWorkspaceResponse, error) { + var missingURL, missingID, unusableInvitation int + for _, workspace := range workspaces { + if !cloudWorkspaceInvitationUsable(workspace.InvitationStatus) { + unusableInvitation++ + continue + } + if cloudWorkspaceEndpoint(workspace) == "" { + missingURL++ + continue + } + if strings.TrimSpace(workspace.WorkspaceID) == "" { + missingID++ + continue + } + return &workspace, nil + } + return nil, fmt.Errorf( + "cloud organization response missing usable workspace (total=%d missing_url=%d missing_workspace_id=%d unusable_invitation=%d)", + len(workspaces), missingURL, missingID, unusableInvitation, + ) +} + +func cloudWorkspaceEndpoint(workspace cloudWorkspaceResponse) string { + if prismURL := strings.TrimSpace(workspace.PrismURL); prismURL != "" { + return prismURL + } + return strings.TrimSpace(workspace.URL) +} + +func cloudWorkspaceInvitationUsable(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "", "accepted", "active", "joined", "member": + return true + default: + return false + } +} + +func exchangeCloudParseableSession(prismURL, tenantID, code, bearerToken string) (string, error) { + if strings.TrimSpace(code) == "" { + return "", errors.New("cloud oauth callback missing code") + } + + endpoint, err := url.JoinPath(prismURL, cloudParseableSessionPath) + if err != nil { + return "", fmt.Errorf("invalid cloud workspace URL: %w", err) + } + u, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid cloud session URL: %w", err) + } + query := u.Query() + query.Set("code", code) + u.RawQuery = query.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + if tenantID != "" { + req.Header.Set("x-p-tenant", tenantID) + } + if bearerToken != "" { + req.Header.Set("X-Clerk-Session-Token", bearerToken) + } + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to exchange cloud oauth code for parseable session: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("cloud parseable session exchange failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var result cloudParseableSessionResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("failed to decode cloud parseable session response: %w", err) + } + if strings.TrimSpace(result.Session) == "" { + return "", errors.New("cloud parseable session response missing session") + } + return result.Session, nil +} + +func startCloudClerkLoginServer(ctx context.Context, addr, expectedState, publishableKey string) (<-chan cloudLoginCallbackResult, func(), string, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to listen for cloud login callback on %s: %w", addr, err) + } + loginURL := "http://" + listener.Addr().String() + "/login" + + resultCh := make(chan cloudLoginCallbackResult, 1) + mux := http.NewServeMux() + server := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/login", http.StatusFound) + }) + mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, cloudClerkLoginPage(publishableKey, expectedState)) + }) + mux.HandleFunc("/sso-callback", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, cloudClerkCallbackPage(publishableKey, expectedState)) + }) + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + var payload struct { + State string `json:"state"` + Token string `json:"token"` + } + if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid callback payload", http.StatusBadRequest) + return + } + } else { + query := r.URL.Query() + payload.State = query.Get("state") + payload.Token = query.Get("token") + } + + state := payload.State + if state == "" || state != expectedState { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + + token := strings.TrimSpace(payload.Token) + if token == "" { + http.Error(w, "missing token", http.StatusBadRequest) + return + } + + select { + case resultCh <- cloudLoginCallbackResult{Token: token}: + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) + default: + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) + } + }) + + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + select { + case resultCh <- cloudLoginCallbackResult{Err: err}: + default: + } + } + }() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + + stop := func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + } + + return resultCh, stop, loginURL, nil +} + +func cloudClerkLoginPage(publishableKey, state string) string { + publishableKeyJSON, _ := json.Marshal(publishableKey) + stateJSON, _ := json.Marshal(state) + clerkScriptURLJSON, _ := json.Marshal(cloudClerkScriptURL(publishableKey)) + return fmt.Sprintf(` + + + + + Parseable Cloud Login + + + + +
+ + +`, string(publishableKeyJSON), string(clerkScriptURLJSON), string(stateJSON)) +} + +func cloudClerkCallbackPage(publishableKey, state string) string { + publishableKeyJSON, _ := json.Marshal(publishableKey) + stateJSON, _ := json.Marshal(state) + clerkScriptURLJSON, _ := json.Marshal(cloudClerkScriptURL(publishableKey)) + return fmt.Sprintf(` + + + + + Parseable Cloud Login + + + + + +`, string(publishableKeyJSON), string(clerkScriptURLJSON), string(stateJSON)) +} + +func cloudClerkScriptURL(publishableKey string) string { + host := cloudClerkFrontendHost(publishableKey) + if host == "" { + return "https://cdn.jsdelivr.net/npm/@clerk/clerk-js@latest/dist/clerk.browser.js" + } + return "https://" + host + "/npm/@clerk/clerk-js@latest/dist/clerk.browser.js" +} + +func cloudClerkFrontendHost(publishableKey string) string { + for _, prefix := range []string{"pk_test_", "pk_live_"} { + encoded, ok := strings.CutPrefix(strings.TrimSpace(publishableKey), prefix) + if !ok { + continue + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(encoded) + } + if err != nil { + return "" + } + return strings.TrimSuffix(string(decoded), "$") + } + return "" +} + +func generateCloudLoginState() (string, error) { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", fmt.Errorf("failed to generate cloud login state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf[:]), nil +} + +func openExternalBrowser(rawURL string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + cmd = exec.Command("xdg-open", rawURL) + } + return cmd.Start() +} + func saveCloudProfile(name string, profile config.Profile, setDefault, force bool) error { if name == "" { return errors.New("profile name is required") @@ -218,3 +942,17 @@ func cloudProfileNameFromWorkspace(result *cloudAPIKeyValidationResponse) string name = strings.ReplaceAll(name, " ", "-") return name } + +func cloudProfileNameFromSession(profile *config.Profile) string { + name := strings.TrimSpace(profile.WorkspaceName) + if name == "" { + name = strings.TrimSpace(profile.WorkspaceID) + } + if name == "" { + name = "cloud" + } + + name = strings.ToLower(name) + name = strings.ReplaceAll(name, " ", "-") + return name +} diff --git a/cmd/dataset_test.go b/cmd/dataset_test.go index d8fd6bc..a0f0d7e 100644 --- a/cmd/dataset_test.go +++ b/cmd/dataset_test.go @@ -19,7 +19,7 @@ func TestFetchInfoUsesTelemetryType(t *testing.T) { })) defer server.Close() - client := internalHTTP.DefaultClient(&config.Profile{URL: server.URL}) + client := internalHTTP.DefaultClient(&config.Profile{URL: server.URL, APIKey: "test-api-key"}) got, err := fetchInfo(&client, "fly_logs") if err != nil { t.Fatalf("fetchInfo returned error: %v", err) @@ -63,7 +63,7 @@ func TestFetchInfoReturnsNotFoundError(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() - client := internalHTTP.DefaultClient(&config.Profile{URL: server.URL}) + client := internalHTTP.DefaultClient(&config.Profile{URL: server.URL, APIKey: "test-api-key"}) _, err := fetchInfo(&client, "missing") if err == nil { t.Fatal("expected not found error") diff --git a/cmd/login.go b/cmd/login.go index c7eaee3..a56ca82 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -42,7 +42,20 @@ credentials. All settings are saved to ~/.config/pb/config.toml.`, return nil } - if m.Profile.Cloud { + if m.CloudBrowserLogin { + profile, err := cloudProfileFromBrowserLogin( + cloudDefaultOrchestratorURL, + cloudDefaultClerkPublishableKey, + cloudDefaultCallbackAddr, + ) + if err != nil { + return err + } + m.Profile = *profile + if m.Name == "" { + m.Name = cloudProfileNameFromSession(profile) + } + } else if m.Profile.Cloud { profile, err := cloudProfileFromAPIKey(m.Profile.APIKey) if err != nil { return err @@ -57,8 +70,12 @@ credentials. All settings are saved to ~/.config/pb/config.toml.`, }, } +func init() { + LoginCmd.Flags().StringVar(&cloudOrchestratorAuthToken, "orchestrator-auth-token", cloudDefaultOrchestratorAuthToken, "Parseable Cloud orchestrator auth token") +} + func cloudProfileFromAPIKey(apiKey string) (*config.Profile, error) { - orchestratorURL := cloudOrchestratorURLFromEnv() + orchestratorURL := cloudDefaultOrchestratorURL result, err := validateCloudAPIKey(orchestratorURL, apiKey) if err != nil { return nil, err diff --git a/cmd/logout.go b/cmd/logout.go index b1dc315..6da303b 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -17,8 +17,10 @@ package cmd import ( "bufio" + "encoding/json" "fmt" "os" + "sort" "strings" tea "github.com/charmbracelet/bubbletea" @@ -31,8 +33,20 @@ var LogoutCmd = &cobra.Command{ Use: "logout", Short: "Logout from the current Parseable profile", Long: "Removes the active profile (URL and credentials) from config.", - Example: " pb logout", - RunE: func(_ *cobra.Command, _ []string) error { + Example: " pb logout\n pb logout --yes -o json", + RunE: func(cmd *cobra.Command, _ []string) error { + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + return err + } + if outputFormat == "json" && !yes { + return fmt.Errorf("use --yes with -o json to avoid interactive prompts") + } + fileConfig, err := config.ReadConfigFromFile() if err != nil { return fmt.Errorf("no config found — nothing to logout from") @@ -44,6 +58,9 @@ var LogoutCmd = &cobra.Command{ if len(fileConfig.Profiles) == 0 { return fmt.Errorf("no active profile found") } + if outputFormat == "json" && len(fileConfig.Profiles) > 1 { + return fmt.Errorf("no active profile found") + } selectedProfile, err := selectLogoutProfile(fileConfig.Profiles) if err != nil { return err @@ -56,7 +73,7 @@ var LogoutCmd = &cobra.Command{ activeProfile = fileConfig.Profiles[profileName] } - if !confirmLogout(profileName, activeProfile.URL) { + if !yes && !confirmLogout(profileName, activeProfile.URL) { fmt.Println("Logout canceled") return nil } @@ -72,17 +89,22 @@ var LogoutCmd = &cobra.Command{ newDefaultProfile = name } default: - fmt.Println("Select a new default profile:") - _m, err := tea.NewProgram(defaultprofile.New(fileConfig.Profiles)).Run() - if err != nil { - return fmt.Errorf("error selecting new default profile: %w", err) - } - m := _m.(defaultprofile.Model) - if m.Success { - fileConfig.DefaultProfile = m.Choice - newDefaultProfile = m.Choice + if yes { + fileConfig.DefaultProfile = firstSortedProfileName(fileConfig.Profiles) + newDefaultProfile = fileConfig.DefaultProfile } else { - fileConfig.DefaultProfile = "" + fmt.Println("Select a new default profile:") + _m, err := tea.NewProgram(defaultprofile.New(fileConfig.Profiles)).Run() + if err != nil { + return fmt.Errorf("error selecting new default profile: %w", err) + } + m := _m.(defaultprofile.Model) + if m.Success { + fileConfig.DefaultProfile = m.Choice + newDefaultProfile = m.Choice + } else { + fileConfig.DefaultProfile = "" + } } } @@ -90,6 +112,16 @@ var LogoutCmd = &cobra.Command{ return fmt.Errorf("failed to update config: %w", err) } + if outputFormat == "json" { + return printLogoutJSON(logoutOutput{ + Status: "ok", + Profile: profileName, + URL: activeProfile.URL, + Removed: true, + DefaultProfile: fileConfig.DefaultProfile, + }) + } + fmt.Printf("Logged out and removed profile '%s'\n", profileName) if newDefaultProfile != "" { fmt.Printf("'%s' is now set as the default profile\n", newDefaultProfile) @@ -98,6 +130,35 @@ var LogoutCmd = &cobra.Command{ }, } +type logoutOutput struct { + Status string `json:"status"` + Profile string `json:"profile"` + URL string `json:"url"` + Removed bool `json:"removed"` + DefaultProfile string `json:"default_profile"` +} + +func printLogoutJSON(result logoutOutput) error { + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal logout JSON: %w", err) + } + fmt.Println(string(jsonData)) + return nil +} + +func firstSortedProfileName(profiles map[string]config.Profile) string { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + sort.Strings(names) + if len(names) == 0 { + return "" + } + return names[0] +} + func selectLogoutProfile(profiles map[string]config.Profile) (string, error) { if len(profiles) == 1 { for name := range profiles { @@ -138,3 +199,8 @@ func confirmLogout(profileName, profileURL string) bool { return false } } + +func init() { + LogoutCmd.Flags().Bool("yes", false, "confirm logout without prompting") + LogoutCmd.Flags().StringP("output", "o", "text", "Output format (text|json)") +} diff --git a/cmd/profile.go b/cmd/profile.go index 008f92b..4b63d49 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -119,7 +119,7 @@ var AddProfileCmd = &cobra.Command{ password = args[3] } - profile := config.Profile{URL: url.String(), Username: username, Password: password} + profile := config.Profile{Cloud: false, URL: url.String(), Username: username, Password: password} fileConfig, err := config.ReadConfigFromFile() if err != nil { newConfig := config.Config{ diff --git a/cmd/promql.go b/cmd/promql.go index 45d0c6c..aa62fa5 100644 --- a/cmd/promql.go +++ b/cmd/promql.go @@ -23,6 +23,7 @@ import ( "io" "net/http" "net/url" + "sort" "strings" "time" @@ -44,6 +45,9 @@ var PromqlCmd = &cobra.Command{ Example: " pb promql run -i\n pb promql run \"http_requests_total\" --dataset otel_metrics --from=1h -i", } +// PromqlCardinalityCmd exposes the cardinality parent for shared CLI output setup. +func PromqlCardinalityCmd() *cobra.Command { return promqlCardinalityCmd } + func init() { PromqlCmd.SetHelpFunc(renderPromqlHelp) @@ -115,6 +119,7 @@ func init() { promqlCardinalityActiveSeriesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") // flags: tsdb + promqlActiveQueriesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") promqlTSDBCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") promqlTSDBCmd.Flags().Int("top", 10, "Max entries per category") promqlTSDBCmd.Flags().String("date", "", "Date to analyze (YYYY-MM-DD, defaults to today)") @@ -195,7 +200,9 @@ func promqlGet(path string, params url.Values) ([]byte, error) { if err != nil { return nil, err } - internalHTTP.AddAuthHeaders(req, &DefaultProfile) + if err := internalHTTP.AddAuthHeaders(req, &DefaultProfile); err != nil { + return nil, err + } stopSpinner := startSpinner() resp, err := client.Client.Do(req) stopSpinner() @@ -374,13 +381,16 @@ func promqlInteractiveFromDefault(from string, interactive, fromChanged bool) st // --------------------------------------------------------------------------- var promqlLabelsCmd = &cobra.Command{ - Use: "labels", + Use: "labels [stream]", Short: "List all label names in a metrics stream", - Example: " pb promql labels --dataset otel_metrics", - Args: cobra.NoArgs, + Example: " pb promql labels otel_metrics\n pb promql labels --dataset otel_metrics", + Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 0 { + stream = args[0] + } outputFmt, _ := cmd.Flags().GetString("output") params := url.Values{} @@ -409,8 +419,12 @@ var promqlLabelsCmd = &cobra.Command{ if resp.Status == "error" { return fmt.Errorf("%s", resp.Error) } + if len(resp.Data) == 0 { + fmt.Println("No labels found.") + return nil + } for _, label := range resp.Data { - fmt.Println((&DatasetListItem{Name: label}).Render()) + fmt.Println(ItemOuter.Render(fmt.Sprintf("%s %s", SelectedStyle.Render("•"), StandardStyle.Render(label)))) } return nil }, @@ -421,14 +435,17 @@ var promqlLabelsCmd = &cobra.Command{ // --------------------------------------------------------------------------- var promqlLabelValuesCmd = &cobra.Command{ - Use: "label-values [label_name]", + Use: "label-values [label_name] [stream]", Short: "List distinct values for a label", - Example: " pb promql label-values job --dataset otel_metrics\n pb promql label-values __name__ --dataset otel_metrics", - Args: cobra.ExactArgs(1), + Example: " pb promql label-values job otel_metrics\n pb promql label-values job --dataset otel_metrics\n pb promql label-values __name__ --dataset otel_metrics", + Args: cobra.RangeArgs(1, 2), PreRunE: PreRunDefaultProfile, RunE: func(cmd *cobra.Command, args []string) error { label := args[0] stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 1 { + stream = args[1] + } outputFmt, _ := cmd.Flags().GetString("output") params := url.Values{} @@ -457,8 +474,12 @@ var promqlLabelValuesCmd = &cobra.Command{ if resp.Status == "error" { return fmt.Errorf("%s", resp.Error) } + if len(resp.Data) == 0 { + fmt.Println("No label values found.") + return nil + } for _, v := range resp.Data { - fmt.Println(v) + fmt.Println(ItemOuter.Render(fmt.Sprintf("%s %s", SelectedStyle.Render("•"), StandardStyle.Render(v)))) } fmt.Printf("\nlabel=%s total=%d\n", label, len(resp.Data)) return nil @@ -470,13 +491,16 @@ var promqlLabelValuesCmd = &cobra.Command{ // --------------------------------------------------------------------------- var promqlSeriesCmd = &cobra.Command{ - Use: "series", + Use: "series [stream]", Short: "Find time series matching a label selector", - Example: " pb promql series --match 'http_requests_total' --dataset otel_metrics\n pb promql series --match '{job=\"api\"}' --dataset otel_metrics", - Args: cobra.NoArgs, + Example: " pb promql series otel_metrics --match 'http_requests_total'\n pb promql series --match '{job=\"api\"}' --dataset otel_metrics", + Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 0 { + stream = args[0] + } matchers, _ := cmd.Flags().GetStringArray("match") outputFmt, _ := cmd.Flags().GetString("output") @@ -513,8 +537,12 @@ var promqlSeriesCmd = &cobra.Command{ if resp.Status == "error" { return fmt.Errorf("%s", resp.Error) } + if len(resp.Data) == 0 { + fmt.Println("No series found.") + return nil + } for _, series := range resp.Data { - fmt.Println(formatPromqlLabels(series)) + printPromqlSeries(series) } fmt.Printf("\ntotal=%d\n", len(resp.Data)) return nil @@ -538,13 +566,16 @@ type cardinalityEntry struct { // cardinality label-names var promqlCardinalityLabelNamesCmd = &cobra.Command{ - Use: "label-names", + Use: "label-names [stream]", Short: "Labels with the highest number of distinct values", - Example: " pb promql cardinality label-names --dataset otel_metrics --limit 20", - Args: cobra.NoArgs, + Example: " pb promql cardinality label-names otel_metrics\n pb promql cardinality label-names --dataset otel_metrics --limit 20", + Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 0 { + stream = args[0] + } lookback, _ := cmd.Flags().GetInt("lookback") limit, _ := cmd.Flags().GetInt("limit") selector, _ := cmd.Flags().GetString("selector") @@ -586,14 +617,20 @@ var promqlCardinalityLabelNamesCmd = &cobra.Command{ // cardinality label-values var promqlCardinalityLabelValuesCmd = &cobra.Command{ - Use: "label-values", + Use: "label-values [stream] [label]", Short: "Series count per value for a specific label", - Example: " pb promql cardinality label-values --label job --dataset otel_metrics", - Args: cobra.NoArgs, + Example: " pb promql cardinality label-values otel_metrics service.name\n pb promql cardinality label-values --label job --dataset otel_metrics", + Args: cobra.MaximumNArgs(2), PreRunE: PreRunDefaultProfile, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 0 { + stream = args[0] + } labelName, _ := cmd.Flags().GetString("label") + if len(args) > 1 { + labelName = args[1] + } lookback, _ := cmd.Flags().GetInt("lookback") limit, _ := cmd.Flags().GetInt("limit") outputFmt, _ := cmd.Flags().GetString("output") @@ -677,16 +714,22 @@ func printCardinalityEntryTable(nameHeader, valueHeader string, entries []cardin // cardinality active-series var promqlCardinalityActiveSeriesCmd = &cobra.Command{ - Use: "active-series", + Use: "active-series [stream] [selector]", Short: "List currently active series", - Example: " pb promql cardinality active-series --dataset otel_metrics --selector '{job=\"api\"}'", - Args: cobra.NoArgs, + Example: " pb promql cardinality active-series otel_metrics '{job=\"api\"}'\n pb promql cardinality active-series --dataset otel_metrics --selector '{job=\"api\"}'", + Args: cobra.MaximumNArgs(2), PreRunE: PreRunDefaultProfile, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 0 { + stream = args[0] + } lookback, _ := cmd.Flags().GetInt("lookback") limit, _ := cmd.Flags().GetInt("limit") selector, _ := cmd.Flags().GetString("selector") + if len(args) > 1 { + selector = args[1] + } outputFmt, _ := cmd.Flags().GetString("output") params := url.Values{} @@ -721,9 +764,15 @@ var promqlCardinalityActiveSeriesCmd = &cobra.Command{ if resp.Status == "error" { return fmt.Errorf("%s", resp.Error) } - fmt.Printf("total_active_series=%d\n\n", resp.Data.TotalActiveSeries) + fmt.Println(SelectedStyle.Bold(true).Render("ACTIVE SERIES")) + fmt.Printf("total=%d\n\n", resp.Data.TotalActiveSeries) + if len(resp.Data.Series) == 0 { + fmt.Println("No active series found.") + return nil + } for _, s := range resp.Data.Series { - fmt.Println(formatPromqlLabels(s)) + printPromqlSeries(s) + fmt.Println() } return nil }, @@ -737,14 +786,20 @@ var promqlActiveQueriesCmd = &cobra.Command{ Use: "active-queries", Aliases: []string{"ps"}, Short: "Show currently executing PromQL queries", - Example: " pb promql active-queries", + Example: " pb promql active-queries\n pb promql active-queries -o json", Args: cobra.NoArgs, PreRunE: PreRunDefaultProfile, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + outputFmt, _ := cmd.Flags().GetString("output") + body, err := promqlGet("prometheus/api/v1/status/active_queries", nil) if err != nil { return err } + if outputFmt == "json" { + printRawJSON(body) + return nil + } var resp struct { Status string `json:"status"` @@ -785,13 +840,16 @@ var promqlActiveQueriesCmd = &cobra.Command{ // --------------------------------------------------------------------------- var promqlTSDBCmd = &cobra.Command{ - Use: "tsdb", + Use: "tsdb [stream]", Short: "Show TSDB statistics for a metrics stream", - Example: " pb promql tsdb --dataset otel_metrics\n pb promql tsdb --dataset otel_metrics --top 20 --focus-label job", - Args: cobra.NoArgs, + Example: " pb promql tsdb otel_metrics\n pb promql tsdb --dataset otel_metrics\n pb promql tsdb --dataset otel_metrics --top 20 --focus-label job", + Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { stream, _ := cmd.Flags().GetString("dataset") + if len(args) > 0 { + stream = args[0] + } topN, _ := cmd.Flags().GetInt("top") date, _ := cmd.Flags().GetString("date") focusLabel, _ := cmd.Flags().GetString("focus-label") @@ -816,27 +874,37 @@ var promqlTSDBCmd = &cobra.Command{ return nil } - var resp struct { - Status string `json:"status"` - Data struct { - TotalSeries int `json:"totalSeries"` - TotalLabelValuePairs int `json:"totalLabelValuePairs"` - SeriesByMetric []cardinalityEntry `json:"seriesCountByMetricName"` - SeriesByLabel []cardinalityEntry `json:"seriesCountByLabelName"` - SeriesByFocusLabel []cardinalityEntry `json:"seriesCountByFocusLabelValue"` - LabelValueCount []cardinalityEntry `json:"labelValueCountByLabelName"` - } `json:"data"` - Error string `json:"error,omitempty"` + var envelope struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` + Error string `json:"error,omitempty"` } - if err := json.Unmarshal(body, &resp); err != nil { + if err := json.Unmarshal(body, &envelope); err != nil { fmt.Println(string(body)) return nil } - if resp.Status == "error" { - return fmt.Errorf("%s", resp.Error) + if envelope.Status == "error" { + return fmt.Errorf("%s", envelope.Error) + } + if isEmptyJSONList(envelope.Data) { + fmt.Printf("No TSDB stats found. Pass a metrics dataset name, for example: pb promql tsdb \n") + return nil + } + + var data struct { + TotalSeries int `json:"totalSeries"` + TotalLabelValuePairs int `json:"totalLabelValuePairs"` + SeriesByMetric []cardinalityEntry `json:"seriesCountByMetricName"` + SeriesByLabel []cardinalityEntry `json:"seriesCountByLabelName"` + SeriesByFocusLabel []cardinalityEntry `json:"seriesCountByFocusLabelValue"` + LabelValueCount []cardinalityEntry `json:"labelValueCountByLabelName"` + } + if err := json.Unmarshal(envelope.Data, &data); err != nil { + fmt.Println(string(body)) + return nil } - d := resp.Data + d := data printTSDBSummary(d.TotalSeries, d.TotalLabelValuePairs) if len(d.SeriesByMetric) > 0 { @@ -855,6 +923,11 @@ var promqlTSDBCmd = &cobra.Command{ }, } +func isEmptyJSONList(data json.RawMessage) bool { + var list []json.RawMessage + return json.Unmarshal(data, &list) == nil && len(list) == 0 +} + func printTSDBSummary(totalSeries, totalLabelValuePairs int) { headerStyle := SelectedStyle.Bold(true) bodyStyle := StandardStyle @@ -901,6 +974,7 @@ func formatPromqlLabels(m map[string]string) string { labels = append(labels, k+"=\""+v+"\"") } } + sort.Strings(labels) if len(labels) == 0 { return name } @@ -910,6 +984,25 @@ func formatPromqlLabels(m map[string]string) string { return fmt.Sprintf("%s{%s}", name, strings.Join(labels, ", ")) } +func printPromqlSeries(m map[string]string) { + name := m["__name__"] + if name == "" { + name = "(unnamed series)" + } + fmt.Println(ItemOuter.Render(fmt.Sprintf("%s %s", SelectedStyle.Render("•"), StandardStyle.Render(name)))) + + keys := make([]string, 0, len(m)) + for k := range m { + if k != "__name__" { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, k := range keys { + fmt.Printf(" %s=%q\n", k, m[k]) + } +} + func promqlTS(v any) string { if f, ok := v.(float64); ok { return time.Unix(int64(f), 0).UTC().Format("2006-01-02T15:04:05Z") diff --git a/cmd/queryList.go b/cmd/queryList.go index 584d210..49ba40a 100644 --- a/cmd/queryList.go +++ b/cmd/queryList.go @@ -19,9 +19,7 @@ import ( "encoding/json" "fmt" "io" - "net/http" "strings" - "time" "github.com/parseablehq/pb/pkg/config" internalHTTP "github.com/parseablehq/pb/pkg/http" @@ -52,10 +50,8 @@ var SavedQueryList = &cobra.Command{ userProfile = profile } - client := &http.Client{ - Timeout: time.Second * 60, - } - userSavedQueries := fetchFilters(client, &userProfile) + client := internalHTTP.DefaultClient(&userProfile) + userSavedQueries := fetchFilters(&client) // Collect all filter titles in a slice and join with commas var filterDetails []string @@ -182,17 +178,14 @@ type Item struct { To string `json:"to,omitempty"` } -func fetchFilters(client *http.Client, profile *config.Profile) []Item { - endpoint := fmt.Sprintf("%s/%s", profile.URL, "api/v1/filters") - req, err := http.NewRequest("GET", endpoint, nil) +func fetchFilters(client *internalHTTP.HTTPClient) []Item { + req, err := client.NewRequest("GET", "filters", nil) if err != nil { fmt.Println("Error creating request:", err) return nil } - internalHTTP.AddAuthHeaders(req, profile) - req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := client.Client.Do(req) if err != nil { fmt.Println("Error making request:", err) return nil diff --git a/cmd/status.go b/cmd/status.go index 599deac..2bb07b4 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -16,6 +16,7 @@ package cmd import ( + "encoding/json" "fmt" "strings" @@ -30,8 +31,13 @@ import ( var StatusCmd = &cobra.Command{ Use: "status", Short: "Check connection status for the active profile", - Example: " pb status", - RunE: func(_ *cobra.Command, _ []string) error { + Example: " pb status\n pb status -o json", + RunE: func(cmd *cobra.Command, _ []string) error { + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + fileConfig, err := config.ReadConfigFromFile() if err != nil { return fmt.Errorf("no profile configured. run: pb login") @@ -43,19 +49,43 @@ var StatusCmd = &cobra.Command{ return fmt.Errorf("no active profile. run: pb login") } - fmt.Printf("Profile : %s\n", profileName) - fmt.Printf("URL : %s\n", profile.URL) + if outputFormat != "json" { + fmt.Printf("Profile : %s\n", profileName) + fmt.Printf("URL : %s\n", profile.URL) + } client := internalHTTP.DefaultClient(&profile) about, err := analytics.FetchAbout(&client) if err != nil { statusMessage := statusErrorMessage(err) - errStyle := lipgloss.NewStyle().Foreground(ui.Active.Err).Bold(true) - fmt.Printf("Status : %s\n", errStyle.Render("✗ Not connected")) - fmt.Printf("Error : %s\n", statusMessage) + if outputFormat == "json" { + if jsonErr := printStatusJSON(statusOutput{ + Status: "error", + Healthy: false, + Profile: profileName, + URL: profile.URL, + Error: statusMessage, + }); jsonErr != nil { + return jsonErr + } + } else { + errStyle := lipgloss.NewStyle().Foreground(ui.Active.Err).Bold(true) + fmt.Printf("Status : %s\n", errStyle.Render("✗ Not connected")) + fmt.Printf("Error : %s\n", statusMessage) + } return fmt.Errorf("status check failed: %s", statusMessage) } + if outputFormat == "json" { + return printStatusJSON(statusOutput{ + Status: "ok", + Healthy: true, + Profile: profileName, + URL: profile.URL, + Version: about.Version, + }) + } + okStyle := lipgloss.NewStyle().Foreground(ui.Active.Ok).Bold(true) fmt.Printf("Status : %s\n", okStyle.Render("✓ Connected")) fmt.Printf("Version : %s\n", about.Version) @@ -63,6 +93,24 @@ var StatusCmd = &cobra.Command{ }, } +type statusOutput struct { + Status string `json:"status"` + Healthy bool `json:"healthy"` + Profile string `json:"profile"` + URL string `json:"url"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +func printStatusJSON(result statusOutput) error { + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal status JSON: %w", err) + } + fmt.Println(string(jsonData)) + return nil +} + func statusErrorMessage(err error) string { message := err.Error() if strings.Contains(message, "Status Code: 401") || strings.Contains(message, "Status Code: 403") { @@ -70,3 +118,7 @@ func statusErrorMessage(err error) string { } return message } + +func init() { + StatusCmd.Flags().StringP("output", "o", "text", "Output format (text|json)") +} diff --git a/cmd/tail.go b/cmd/tail.go index 91f9464..cb845e0 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -45,22 +45,36 @@ var TailCmd = &cobra.Command{ Short: "Stream live events from a dataset", Args: cobra.ExactArgs(1), PreRunE: PreRunDefaultProfile, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + if output != "text" && output != "json" { + return fmt.Errorf("unsupported output format %q (expected text or json)", output) + } name := args[0] profile := DefaultProfile - return tail(profile, name) + return tail(profile, name, output == "json") }, } -func tail(profile config.Profile, stream string) error { +func init() { + TailCmd.Flags().StringP("output", "o", "text", "Output format (text|json)") +} + +func tail(profile config.Profile, stream string, jsonOutput bool) error { payload, _ := json.Marshal(struct { Stream string `json:"stream"` }{ Stream: stream, }) - stopConnect := tailSpinner("connecting...") - httpClient := internalHTTP.DefaultClient(&DefaultProfile) + stopConnect := func() {} + if !jsonOutput { + stopConnect = tailSpinner("connecting...") + } + httpClient := internalHTTP.DefaultClient(&profile) about, err := analytics.FetchAbout(&httpClient) stopConnect() if err != nil { @@ -73,10 +87,15 @@ func tail(profile config.Profile, stream string) error { return err } - authMetadata := tailAuthMetadata(profile) + authMetadata, err := tailAuthMetadata(profile) + if err != nil { + return err + } watching := func() { - fmt.Fprintf(os.Stderr, "\r\033[K● watching %s... (ctrl+c to stop)", stream) + if !jsonOutput { + fmt.Fprintf(os.Stderr, "\r\033[K● watching %s... (ctrl+c to stop)", stream) + } } watching() @@ -86,13 +105,17 @@ func tail(profile config.Profile, stream string) error { &flight.Ticket{Ticket: payload}, ) if err != nil { - fmt.Fprint(os.Stderr, "\r\033[K") + if !jsonOutput { + fmt.Fprint(os.Stderr, "\r\033[K") + } return err } records, err := flight.NewRecordReader(resp) if err != nil { - fmt.Fprint(os.Stderr, "\r\033[K") + if !jsonOutput { + fmt.Fprint(os.Stderr, "\r\033[K") + } return err } @@ -103,10 +126,14 @@ func tail(profile config.Profile, stream string) error { if isStreamEnd(err) { break } - fmt.Fprint(os.Stderr, "\r\033[K") + if !jsonOutput { + fmt.Fprint(os.Stderr, "\r\033[K") + } return err } - fmt.Fprint(os.Stderr, "\r\033[K") // clear watching line before printing record + if !jsonOutput { + fmt.Fprint(os.Stderr, "\r\033[K") + } // clear watching line before printing record var buf bytes.Buffer array.RecordToJSON(record, &buf) fmt.Println(buf.String()) @@ -117,24 +144,32 @@ func tail(profile config.Profile, stream string) error { } } -func tailAuthMetadata(profile config.Profile) metadata.MD { - if profile.Cloud && profile.APIKey != "" { - values := map[string]string{"x-api-key": profile.APIKey} - if profile.TenantID != "" { - values["x-p-tenant"] = profile.TenantID - } - return metadata.New(values) +func tailAuthMetadata(profile config.Profile) (metadata.MD, error) { + mode, err := profile.AuthMode() + if err != nil { + return nil, err } - if profile.Token != "" { + switch mode { + case config.AuthCloudAPIKey: + values := map[string]string{"x-api-key": profile.APIKey} + values["x-p-tenant"] = profile.TenantID + return metadata.New(values), nil + case config.AuthCloudOAuth: + values := map[string]string{"cookie": "session=" + profile.SessionToken} + values["x-p-tenant"] = profile.TenantID + return metadata.New(values), nil + case config.AuthSelfHostedAPIKey: return metadata.New(map[string]string{ - "Authorization": "Bearer " + profile.Token, - }) + "x-api-key": profile.APIKey, + }), nil + case config.AuthSelfHostedBasic: + return metadata.New(map[string]string{ + "Authorization": "Basic " + basicAuth(profile.Username, profile.Password), + }), nil + default: + return nil, fmt.Errorf("unsupported auth mode %q", mode) } - - return metadata.New(map[string]string{ - "Authorization": "Basic " + basicAuth(profile.Username, profile.Password), - }) } // isStreamEnd returns true for normal stream termination codes that warrant a reconnect. diff --git a/main.go b/main.go index 44bf190..1ec3609 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ package main import ( "bytes" + "encoding/json" "fmt" "os" "strings" @@ -28,6 +29,7 @@ import ( "github.com/parseablehq/pb/pkg/ui" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // populated at build time @@ -39,6 +41,7 @@ var ( var ( versionFlag = "version" versionFlagShort = "v" + rootOutputFormat string ) // Root command @@ -52,6 +55,12 @@ var cli = &cobra.Command{ pb.PrintVersion(Version, Commit) return nil } + if rootOutputFormat == "json" { + return printCommandJSON(command) + } + if rootOutputFormat != "text" { + return fmt.Errorf("unsupported output format %q (expected text or json)", rootOutputFormat) + } return command.Help() }, PersistentPostRun: func(cmd *cobra.Command, args []string) { @@ -235,6 +244,16 @@ func main() { pb.PromqlCmd.PersistentPreRunE = combinedPreRun pb.PromqlCmd.PersistentPostRun = postRunAnalytics("promql") + configureParentOutput(profile) + configureParentOutput(sql) + configureParentOutput(pb.PromqlCmd) + configureParentOutput(pb.PromqlCardinalityCmd()) + configureParentOutput(dataset) + configureParentOutput(pb.CloudCmd) + configureParentOutput(pb.CloudProfileCmd) + configureParentOutput(user) + configureParentOutput(role) + schema.AddCommand(pb.GenerateSchemaCmd) schema.AddCommand(pb.CreateSchemaCmd) @@ -271,6 +290,8 @@ func main() { cli.AddCommand(pb.VersionCmd) // set as flag cli.Flags().BoolP(versionFlag, versionFlagShort, false, "Print version") + cli.Flags().StringVarP(&rootOutputFormat, "output", "o", "text", "Output format (text|json)") + cli.SetHelpCommand(newHelpCommand()) cli.SetHelpFunc(renderRootHelp) cli.CompletionOptions.HiddenDefaultCmd = true @@ -281,6 +302,98 @@ func main() { } } +type jsonCommand struct { + Command string `json:"command"` + Usage string `json:"usage"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Flags []jsonFlag `json:"flags,omitempty"` + Subcommands []jsonCommand `json:"subcommands,omitempty"` +} + +type jsonFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Usage string `json:"usage"` +} + +func commandJSON(cmd *cobra.Command) jsonCommand { + description := strings.TrimSpace(cmd.Long) + if description == "" { + description = strings.TrimSpace(cmd.Short) + } + result := jsonCommand{ + Command: cmd.CommandPath(), + Usage: cmd.UseLine(), + Description: description, + Aliases: cmd.Aliases, + } + seenFlags := make(map[string]struct{}) + appendFlags := func(flags *pflag.FlagSet) { + flags.VisitAll(func(flag *pflag.Flag) { + if _, exists := seenFlags[flag.Name]; exists || flag.Hidden { + return + } + seenFlags[flag.Name] = struct{}{} + result.Flags = append(result.Flags, jsonFlag{Name: flag.Name, Shorthand: flag.Shorthand, Usage: flag.Usage}) + }) + } + appendFlags(cmd.NonInheritedFlags()) + appendFlags(cmd.InheritedFlags()) + for _, child := range cmd.Commands() { + if child.IsAvailableCommand() && child.Name() != "help" { + result.Subcommands = append(result.Subcommands, commandJSON(child)) + } + } + return result +} + +func printCommandJSON(cmd *cobra.Command) error { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(commandJSON(cmd)) +} + +func configureParentOutput(cmd *cobra.Command) { + var output string + cmd.Flags().StringVarP(&output, "output", "o", "text", "Output format (text|json)") + cmd.PreRunE = func(_ *cobra.Command, _ []string) error { return nil } + cmd.RunE = func(command *cobra.Command, _ []string) error { + switch output { + case "json": + return printCommandJSON(command) + case "text": + return command.Help() + default: + return fmt.Errorf("unsupported output format %q (expected text or json)", output) + } + } +} + +func newHelpCommand() *cobra.Command { + var output string + help := &cobra.Command{ + Use: "help [command]", + Short: "Help about any command", + Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, args []string) error { + target, _, err := command.Root().Find(args) + if err != nil { + return err + } + if output == "json" { + return printCommandJSON(target) + } + if output != "text" { + return fmt.Errorf("unsupported output format %q (expected text or json)", output) + } + return target.Help() + }, + } + help.Flags().StringVarP(&output, "output", "o", "text", "Output format (text|json)") + return help +} + func renderRootHelp(cmd *cobra.Command, _ []string) { if cmd != cli { renderCommandHelp(cmd) diff --git a/pkg/config/config.go b/pkg/config/config.go index ff32640..efa9042 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -69,15 +69,72 @@ type Profile struct { URL string `toml:"url" json:"url"` Username string `toml:"username,omitempty" json:"username,omitempty"` Password string `toml:"password,omitempty" json:"password,omitempty"` - Token string `toml:"token,omitempty" json:"token,omitempty"` - Cloud bool `toml:"cloud,omitempty" json:"cloud,omitempty"` - APIKey string `toml:"api_key,omitempty" json:"api_key,omitempty"` + Cloud bool `toml:"cloud" json:"cloud"` + APIKey string `toml:"apiKey,omitempty" json:"apiKey,omitempty"` + SessionToken string `toml:"session_token,omitempty" json:"session_token,omitempty"` + RefreshToken string `toml:"refresh_token,omitempty" json:"refresh_token,omitempty"` TenantID string `toml:"tenant_id,omitempty" json:"tenant_id,omitempty"` IngestURL string `toml:"ingest_url,omitempty" json:"ingest_url,omitempty"` WorkspaceID string `toml:"workspace_id,omitempty" json:"workspace_id,omitempty"` WorkspaceName string `toml:"workspace_name,omitempty" json:"workspace_name,omitempty"` OrchestratorURL string `toml:"orchestrator_url,omitempty" json:"orchestrator_url,omitempty"` + ClerkSessionID string `toml:"clerk_session_id,omitempty" json:"clerk_session_id,omitempty"` +} + +type AuthMode string + +const ( + // AuthSelfHostedBasic identifies username/password authentication. + AuthSelfHostedBasic AuthMode = "self_hosted_basic" + // AuthSelfHostedAPIKey identifies API-key authentication for a self-hosted server. + AuthSelfHostedAPIKey AuthMode = "self_hosted_api_key" + // AuthCloudAPIKey identifies API-key authentication for Parseable Cloud. + AuthCloudAPIKey AuthMode = "cloud_api_key" + // AuthCloudOAuth identifies browser-based OAuth authentication for Parseable Cloud. + AuthCloudOAuth AuthMode = "cloud_oauth" +) + +func (p Profile) AuthMode() (AuthMode, error) { + hasBasic := p.Username != "" || p.Password != "" + hasAPIKey := p.APIKey != "" + hasCloudOAuth := p.SessionToken != "" + + if p.Cloud { + if p.TenantID == "" { + return "", errors.New("cloud profile missing tenant_id") + } + if hasBasic { + return "", errors.New("cloud profile cannot use self-hosted credentials") + } + switch { + case hasAPIKey && !hasCloudOAuth: + return AuthCloudAPIKey, nil + case hasCloudOAuth && !hasAPIKey: + return AuthCloudOAuth, nil + case hasAPIKey && hasCloudOAuth: + return "", errors.New("cloud profile has both apiKey and session_token") + default: + return "", errors.New("cloud profile missing apiKey or session_token") + } + } + + if hasCloudOAuth || p.TenantID != "" { + return "", errors.New("self-hosted profile cannot use cloud credentials") + } + switch { + case hasBasic && !hasAPIKey: + if p.Username == "" || p.Password == "" { + return "", errors.New("self-hosted basic profile missing username or password") + } + return AuthSelfHostedBasic, nil + case hasAPIKey && !hasBasic: + return AuthSelfHostedAPIKey, nil + case hasBasic && hasAPIKey: + return "", errors.New("self-hosted profile has both basic credentials and api key") + default: + return "", errors.New("self-hosted profile missing credentials") + } } func (p *Profile) GrpcAddr(port string) string { @@ -118,7 +175,7 @@ func WriteConfigToFile(config *Config) error { } // ReadConfigFromFile reads the configuration from the config file -func ReadConfigFromFile() (config *Config, err error) { +func ReadConfigFromFile() (*Config, error) { filePath, err := Path() if err != nil { return &Config{}, err @@ -129,12 +186,43 @@ func ReadConfigFromFile() (config *Config, err error) { return &Config{}, err } - err = toml.Unmarshal(data, &config) - if err != nil { + var config Config + if err := toml.Unmarshal(data, &config); err != nil { return &Config{}, err } + if config.Profiles == nil { + config.Profiles = make(map[string]Profile) + } + applyLegacyAPIKeyConfig(data, &config) + + return &config, nil +} + +func applyLegacyAPIKeyConfig(data []byte, config *Config) { + type legacyProfile struct { + Token string `toml:"token,omitempty"` + APIKey string `toml:"api_key,omitempty"` + } + type legacyConfig struct { + Profiles map[string]legacyProfile + } - return config, nil + var legacy legacyConfig + if err := toml.Unmarshal(data, &legacy); err != nil { + return + } + for name, legacyProfile := range legacy.Profiles { + profile := config.Profiles[name] + if profile.APIKey == "" { + profile.APIKey = legacyProfile.APIKey + } + if profile.APIKey == "" { + profile.APIKey = legacyProfile.Token + } + if profile.APIKey != "" { + config.Profiles[name] = profile + } + } } func GetProfile() (Profile, error) { diff --git a/pkg/datasets/datasets.go b/pkg/datasets/datasets.go index 3252b08..f0dd965 100644 --- a/pkg/datasets/datasets.go +++ b/pkg/datasets/datasets.go @@ -25,6 +25,7 @@ const ( type Dataset struct { Title string `json:"title"` + Name string `json:"name,omitempty"` DatasetType string `json:"datasetType"` DatasetFormat string `json:"datasetFormat"` Ingestion bool `json:"ingestion"` @@ -34,20 +35,37 @@ type homeResponse struct { Datasets []Dataset `json:"datasets"` } +type httpStatusError struct { + statusCode int + status string + body string +} + +func (err httpStatusError) Error() string { + return fmt.Sprintf("HTTP %d: %s", err.statusCode, strings.TrimSpace(err.body)) +} + func FetchHomeDatasets(profile config.Profile) ([]Dataset, error) { + return fetchPrismHomeDatasets(profile) +} + +func fetchPrismHomeDatasets(profile config.Profile) ([]Dataset, error) { reqURL, err := url.JoinPath(profile.URL, "api/prism/v1/home") if err != nil { return nil, err } - client := &http.Client{Timeout: 15 * time.Second} + client := internalHTTP.DefaultClient(&profile) + client.Client.Timeout = 15 * time.Second req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { return nil, err } - authenticate(req, profile) + if err := authenticate(req, profile); err != nil { + return nil, err + } - resp, err := client.Do(req) + resp, err := client.Client.Do(req) if err != nil { return nil, err } @@ -58,19 +76,128 @@ func FetchHomeDatasets(profile config.Profile) ([]Dataset, error) { return nil, err } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + return nil, httpStatusError{statusCode: resp.StatusCode, status: resp.Status, body: string(body)} } var result homeResponse if err := json.Unmarshal(body, &result); err != nil { return nil, err } - sort.Slice(result.Datasets, func(i, j int) bool { - return result.Datasets[i].Title < result.Datasets[j].Title - }) + sortDatasets(result.Datasets) return result.Datasets, nil } +func FetchAPIDatasets(profile config.Profile) ([]Dataset, error) { + client := internalHTTP.DefaultClient(&profile) + req, err := client.NewRequest(http.MethodGet, "logstream", nil) + if err != nil { + return nil, err + } + + resp, err := client.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, httpStatusError{statusCode: resp.StatusCode, status: resp.Status, body: string(body)} + } + + names, err := decodeDatasetNames(body) + if err != nil { + return nil, err + } + + items := make([]Dataset, 0, len(names)) + for _, name := range names { + item := Dataset{Title: name} + if datasetType, err := fetchDatasetType(&client, name); err == nil { + item.DatasetType = datasetType + } + items = append(items, item) + } + sortDatasets(items) + return items, nil +} + +func decodeDatasetNames(body []byte) ([]string, error) { + var names []string + if err := json.Unmarshal(body, &names); err == nil { + sort.Strings(names) + return names, nil + } + + var datasets []Dataset + if err := json.Unmarshal(body, &datasets); err == nil { + names := make([]string, 0, len(datasets)) + for _, item := range datasets { + name := item.Title + if name == "" { + name = item.Name + } + if name != "" { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil + } + + var wrapped struct { + Datasets []string `json:"datasets"` + } + if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Datasets != nil { + sort.Strings(wrapped.Datasets) + return wrapped.Datasets, nil + } + + return nil, fmt.Errorf("failed to decode dataset list response") +} + +func fetchDatasetType(client *internalHTTP.HTTPClient, name string) (string, error) { + req, err := client.NewRequest(http.MethodGet, fmt.Sprintf("logstream/%s/info", name), nil) + if err != nil { + return "", err + } + + resp, err := client.Client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", httpStatusError{statusCode: resp.StatusCode, status: resp.Status, body: string(body)} + } + + var response struct { + StreamType string `json:"streamType"` + TelemetryType string `json:"telemetryType"` + } + if err := json.Unmarshal(body, &response); err != nil { + return "", err + } + if response.TelemetryType != "" { + return response.TelemetryType, nil + } + return response.StreamType, nil +} + +func sortDatasets(items []Dataset) { + sort.Slice(items, func(i, j int) bool { + return items[i].Title < items[j].Title + }) +} + func NamesByType(items []Dataset, datasetType string) []string { var names []string for _, item := range items { @@ -82,7 +209,10 @@ func NamesByType(items []Dataset, datasetType string) []string { return names } -func authenticate(req *http.Request, profile config.Profile) { - internalHTTP.AddAuthHeaders(req, &profile) +func authenticate(req *http.Request, profile config.Profile) error { + if err := internalHTTP.AddAuthHeaders(req, &profile); err != nil { + return err + } req.Header.Set("Content-Type", "application/json") + return nil } diff --git a/pkg/http/http.go b/pkg/http/http.go index 5e6dfef..101f018 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -17,9 +17,13 @@ package cmd import ( + "errors" + "fmt" "io" "net/http" "net/url" + "os" + "strings" "time" "github.com/parseablehq/pb/pkg/config" @@ -54,24 +58,51 @@ func (client *HTTPClient) NewRequest(method string, path string, body io.Reader) if err != nil { return } - AddAuthHeaders(req, client.Profile) + if err = AddAuthHeaders(req, client.Profile); err != nil { + return + } req.Header.Add("Content-Type", "application/json") + debugRequestHeaders(req) return } -func AddAuthHeaders(req *http.Request, profile *config.Profile) { - if profile.Cloud && profile.APIKey != "" { +func AddAuthHeaders(req *http.Request, profile *config.Profile) error { + if profile == nil { + return errors.New("profile is nil") + } + + mode, err := profile.AuthMode() + if err != nil { + return err + } + + switch mode { + case config.AuthCloudAPIKey: req.Header.Set(apiKeyHeader, profile.APIKey) - if profile.TenantID != "" { - req.Header.Set(tenantHeader, profile.TenantID) - } - return + req.Header.Set(tenantHeader, profile.TenantID) + case config.AuthCloudOAuth: + req.AddCookie(&http.Cookie{Name: "session", Value: profile.SessionToken}) + req.Header.Set(tenantHeader, profile.TenantID) + case config.AuthSelfHostedAPIKey: + req.Header.Set(apiKeyHeader, profile.APIKey) + case config.AuthSelfHostedBasic: + req.SetBasicAuth(profile.Username, profile.Password) } + return nil +} - if profile.Token != "" { - req.Header.Set("Authorization", "Bearer "+profile.Token) +func debugRequestHeaders(req *http.Request) { + if os.Getenv("PB_DEBUG_HTTP_HEADERS") == "" { return } - req.SetBasicAuth(profile.Username, profile.Password) + fmt.Fprintf(os.Stderr, "pb debug request: %s %s\n", req.Method, req.URL.String()) + for key, values := range req.Header { + value := strings.Join(values, ",") + switch strings.ToLower(key) { + case "authorization", apiKeyHeader, "cookie": + value = "" + } + fmt.Fprintf(os.Stderr, "pb debug header: %s: %s\n", key, value) + } } diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go index b8c11ed..ad2cfa4 100644 --- a/pkg/model/login/login.go +++ b/pkg/model/login/login.go @@ -31,10 +31,11 @@ type step int const ( stepChooseType step = iota stepEnterURL + stepChooseCloudAuth stepChooseAuth stepEnterUsername stepEnterPassword - stepEnterToken + stepEnterAPIKey stepEnterProfileName stepConfirmReplace stepDone @@ -64,24 +65,26 @@ var ( // Model is the BubbleTea model for the interactive login wizard. type Model struct { - step step - typeIndex int // 0 = self-hosted, 1 = cloud - authIndex int // 0 = username+password, 1 = token - replaceIndex int // 0 = Replace, 1 = Change name + step step + typeIndex int // 0 = self-hosted, 1 = cloud + cloudAuthIndex int // 0 = OAuth browser, 1 = API key + authIndex int // 0 = username+password, 1 = api key + replaceIndex int // 0 = Replace, 1 = Change name urlInput textinput.Model usernameInput textinput.Model passwordInput textinput.Model - tokenInput textinput.Model + apiKeyInput textinput.Model profileNameInput textinput.Model serverURL string errMsg string // Result fields — set when Done is true. - Done bool - Profile config.Profile - Name string + Done bool + Profile config.Profile + Name string + CloudBrowserLogin bool } func newInput(placeholder string, charLimit int) textinput.Model { @@ -104,7 +107,7 @@ func New() Model { passwordInput.EchoMode = textinput.EchoPassword passwordInput.EchoCharacter = '•' - tokenInput := newInput("paste API key here", 512) + apiKeyInput := newInput("paste API key here", 512) profileInput := newInput("e.g. local, staging, prod", 64) profileInput.SetValue("default") @@ -114,7 +117,7 @@ func New() Model { urlInput: urlInput, usernameInput: usernameInput, passwordInput: passwordInput, - tokenInput: tokenInput, + apiKeyInput: apiKeyInput, profileNameInput: profileInput, } } @@ -152,13 +155,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, textinput.Blink } m.errMsg = "" - m.step = stepEnterToken - m.tokenInput.Focus() + m.step = stepChooseCloudAuth + return m, nil + } + return m, nil + + // ── Step 2a: cloud auth method ─────────────────────────────────────── + case stepChooseCloudAuth: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepChooseType + return m, nil + case tea.KeyUp: + if m.cloudAuthIndex > 0 { + m.cloudAuthIndex-- + } + return m, nil + case tea.KeyDown: + if m.cloudAuthIndex < 1 { + m.cloudAuthIndex++ + } + return m, nil + case tea.KeyEnter: + m.errMsg = "" + if m.cloudAuthIndex == 0 { + m.step = stepEnterProfileName + m.profileNameInput.Focus() + } else { + m.step = stepEnterAPIKey + m.apiKeyInput.Focus() + } return m, textinput.Blink } return m, nil - // ── Step 2: server URL ─────────────────────────────────────────────── + // ── Step 2b: server URL ────────────────────────────────────────────── case stepEnterURL: switch key.Type { case tea.KeyEsc: @@ -203,8 +235,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.step = stepEnterUsername m.usernameInput.Focus() } else { - m.step = stepEnterToken - m.tokenInput.Focus() + m.step = stepEnterAPIKey + m.apiKeyInput.Focus() } return m, textinput.Blink } @@ -251,26 +283,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, textinput.Blink } - // ── Step 4c: token ─────────────────────────────────────────────────── - case stepEnterToken: + // ── Step 4c: API key ───────────────────────────────────────────────── + case stepEnterAPIKey: switch key.Type { case tea.KeyEsc: m.errMsg = "" if m.typeIndex == 1 { - m.step = stepChooseType + m.step = stepChooseCloudAuth } else { m.step = stepChooseAuth } - m.tokenInput.Blur() + m.apiKeyInput.Blur() return m, nil case tea.KeyEnter: - if strings.TrimSpace(m.tokenInput.Value()) == "" { + if strings.TrimSpace(m.apiKeyInput.Value()) == "" { m.errMsg = "API key is required" return m, nil } m.errMsg = "" m.step = stepEnterProfileName - m.tokenInput.Blur() + m.apiKeyInput.Blur() m.profileNameInput.Focus() return m, textinput.Blink } @@ -281,12 +313,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEsc: m.errMsg = "" m.profileNameInput.Blur() - if m.authIndex == 0 { + if m.typeIndex == 1 { + if m.cloudAuthIndex == 0 { + m.step = stepChooseCloudAuth + } else { + m.step = stepEnterAPIKey + m.apiKeyInput.Focus() + } + } else if m.authIndex == 0 { m.step = stepEnterPassword m.passwordInput.Focus() } else { - m.step = stepEnterToken - m.tokenInput.Focus() + m.step = stepEnterAPIKey + m.apiKeyInput.Focus() } return m, textinput.Blink case tea.KeyEnter: @@ -350,8 +389,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.usernameInput, cmd = m.usernameInput.Update(msg) case stepEnterPassword: m.passwordInput, cmd = m.passwordInput.Update(msg) - case stepEnterToken: - m.tokenInput, cmd = m.tokenInput.Update(msg) + case stepEnterAPIKey: + m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) case stepEnterProfileName: m.profileNameInput, cmd = m.profileNameInput.Update(msg) } @@ -360,21 +399,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) finalize(name string) (tea.Model, tea.Cmd) { m.Name = name - if m.typeIndex == 1 { + if m.typeIndex == 1 && m.cloudAuthIndex == 0 { + m.CloudBrowserLogin = true + m.Profile = config.Profile{ + Cloud: true, + } + } else if m.typeIndex == 1 { m.Profile = config.Profile{ Cloud: true, - APIKey: strings.TrimSpace(m.tokenInput.Value()), + APIKey: strings.TrimSpace(m.apiKeyInput.Value()), } } else if m.authIndex == 0 { m.Profile = config.Profile{ + Cloud: false, URL: m.serverURL, Username: strings.TrimSpace(m.usernameInput.Value()), Password: m.passwordInput.Value(), } } else { m.Profile = config.Profile{ - URL: m.serverURL, - Token: strings.TrimSpace(m.tokenInput.Value()), + Cloud: false, + URL: m.serverURL, + APIKey: strings.TrimSpace(m.apiKeyInput.Value()), } } m.Done = true @@ -441,6 +487,21 @@ func (m Model) View() string { b.WriteString(renderErr(m.errMsg)) b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) + case stepChooseCloudAuth: + b.WriteString(labelStyle.Render("CLOUD AUTHENTICATION")) + b.WriteString("\n\n") + authEntries := []string{"OAuth (browser)", "API key"} + for i, entry := range authEntries { + if i == m.cloudAuthIndex { + b.WriteString(rowSelected(entry)) + } else { + b.WriteString(rowIdle(entry)) + } + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"↑↓", "navigate"}, [2]string{"enter", "select"})) + case stepChooseAuth: b.WriteString(labelStyle.Render("AUTHENTICATION")) b.WriteString("\n\n") @@ -472,14 +533,14 @@ func (m Model) View() string { b.WriteString(renderErr(m.errMsg)) b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) - case stepEnterToken: + case stepEnterAPIKey: if m.typeIndex == 1 { b.WriteString(labelStyle.Render("CLOUD API KEY")) } else { b.WriteString(labelStyle.Render("API KEY")) } b.WriteString("\n\n ") - b.WriteString(m.tokenInput.View()) + b.WriteString(m.apiKeyInput.View()) b.WriteString("\n\n") b.WriteString(renderErr(m.errMsg)) b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) @@ -525,7 +586,7 @@ func (m Model) View() string { b.WriteString(normalStyle.Render(m.Profile.Username)) b.WriteString("\n") } - if m.Profile.Token != "" { + if m.Profile.APIKey != "" { b.WriteString(" " + labelStyle.Render("AUTH ")) b.WriteString(normalStyle.Render("API key (stored)")) b.WriteString("\n") diff --git a/pkg/model/promql.go b/pkg/model/promql.go index e104284..56c4843 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -2226,7 +2226,9 @@ func promqlModelFetch(profile config.Profile, path string, params url.Values) ([ if err != nil { return nil, err } - internalHTTP.AddAuthHeaders(req, &profile) + if err := internalHTTP.AddAuthHeaders(req, &profile); err != nil { + return nil, err + } resp, err := client.Do(req) if err != nil { @@ -2604,7 +2606,9 @@ func builderHTTPGetCtx(ctx context.Context, profile config.Profile, rawURL strin if err != nil { return nil, err } - internalHTTP.AddAuthHeaders(req, &profile) + if err := internalHTTP.AddAuthHeaders(req, &profile); err != nil { + return nil, err + } resp, err := client.Do(req) if err != nil { return nil, err diff --git a/pkg/model/query.go b/pkg/model/query.go index 452fdda..fec2c00 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -2583,11 +2583,10 @@ func NewFetchTask(profile config.Profile, query string, startTime string, endTim } }() - client := &http.Client{ - Timeout: time.Second * 50, - } + client := internalHTTP.DefaultClient(&profile) + client.Client.Timeout = time.Second * 50 - data, status, errMsg := fetchData(client, &profile, query, startTime, endTime) + data, status, errMsg := fetchData(&client, &profile, query, startTime, endTime) if status == fetchOk { res.data = data.Records @@ -2617,11 +2616,10 @@ func NewSQLWindowFetchTask(profile config.Profile, runID int, baseQuery string, } }() - client := &http.Client{ - Timeout: time.Second * 50, - } + client := internalHTTP.DefaultClient(&profile) + client.Client.Timeout = time.Second * 50 query := injectSQLWindow(baseQuery, fetchLimit, baseOffset+windowIndex*windowSize) - data, status, errMsg := fetchDataRaw(client, &profile, query, startTime, endTime) + data, status, errMsg := fetchDataRaw(&client, &profile, query, startTime, endTime) if status == fetchOk { res.data = data.Records res.schema = data.Fields @@ -2674,14 +2672,14 @@ func IteratorPrev(iter *iterator.QueryIterator[QueryData, FetchResult]) tea.Cmd } } -func fetchData(client *http.Client, profile *config.Profile, query string, startTime string, endTime string) (data QueryData, res FetchResult, errMsg string) { +func fetchData(client *internalHTTP.HTTPClient, profile *config.Profile, query string, startTime string, endTime string) (data QueryData, res FetchResult, errMsg string) { query = quoteUnsafeSQLTableNames(query) query = quoteUnsafeSQLFieldNames(query) query = ensureDefaultSQLLimit(query) return fetchDataRaw(client, profile, query, startTime, endTime) } -func fetchDataRaw(client *http.Client, profile *config.Profile, query string, startTime string, endTime string) (data QueryData, res FetchResult, errMsg string) { +func fetchDataRaw(client *internalHTTP.HTTPClient, _ *config.Profile, query string, startTime string, endTime string) (data QueryData, res FetchResult, errMsg string) { data = QueryData{} res = fetchErr body, err := json.Marshal(map[string]string{ @@ -2694,16 +2692,16 @@ func fetchDataRaw(client *http.Client, profile *config.Profile, query string, st return } - endpoint, _ := url.JoinPath(profile.URL, "api/v1/query") - endpoint += "?fields=true" - req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) + req, err := client.NewRequest("POST", "query", bytes.NewBuffer(body)) if err != nil { errMsg = err.Error() return } - internalHTTP.AddAuthHeaders(req, profile) - req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + queryParams := req.URL.Query() + queryParams.Set("fields", "true") + req.URL.RawQuery = queryParams.Encode() + + resp, err := client.Client.Do(req) if err != nil { errMsg = err.Error() return @@ -3045,14 +3043,17 @@ func fetchStreamSchema(profile config.Profile, stream string) tea.Cmd { if stream == "" { return schemaMsg{} } - client := &http.Client{Timeout: 15 * time.Second} + client := internalHTTP.DefaultClient(&profile) + client.Client.Timeout = 15 * time.Second // Primary: GET /api/v1/logstream/{stream}/schema schemaURL, _ := url.JoinPath(profile.URL, "api/v1/logstream", stream, "schema") req, err := http.NewRequest("GET", schemaURL, nil) if err == nil { - internalHTTP.AddAuthHeaders(req, &profile) - if resp, err := client.Do(req); err == nil { + if err := internalHTTP.AddAuthHeaders(req, &profile); err != nil { + return schemaMsg{errMsg: err.Error()} + } + if resp, err := client.Client.Do(req); err == nil { body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusOK { @@ -3081,7 +3082,7 @@ func fetchStreamSchema(profile config.Profile, stream string) tea.Cmd { // Stream names with hyphens (e.g. my-stream) are invalid bare SQL // identifiers, so single-quote them — same as resolveDatasetPlaceholder. endTime := time.Now().UTC().Format(time.RFC3339) - data, res, errMsg := fetchData(client, &profile, + data, res, errMsg := fetchData(&client, &profile, fmt.Sprintf("SELECT * FROM '%s' LIMIT 1", stream), "2000-01-01T00:00:00+00:00", endTime) if res != fetchOk { diff --git a/pkg/model/savedQueries.go b/pkg/model/savedQueries.go index b92316f..5a08795 100644 --- a/pkg/model/savedQueries.go +++ b/pkg/model/savedQueries.go @@ -22,7 +22,6 @@ import ( "io" "net/http" "strings" - "time" "github.com/parseablehq/pb/pkg/config" internalHTTP "github.com/parseablehq/pb/pkg/http" @@ -283,10 +282,8 @@ func SavedQueriesMenu() *tea.Program { userProfile = profile } - client := &http.Client{ - Timeout: time.Second * 60, - } - userSavedQueries := fetchFilters(client, &userProfile) + client := internalHTTP.DefaultClient(&userProfile) + userSavedQueries := fetchFilters(&client) m := modelSavedQueries{list: list.New(userSavedQueries, itemDelegate{}, 0, 0)} m.list.SetShowTitle(false) @@ -298,17 +295,14 @@ func SavedQueriesMenu() *tea.Program { } // fetchFilters fetches saved SQL queries for the active user from the server -func fetchFilters(client *http.Client, profile *config.Profile) []list.Item { - endpoint := fmt.Sprintf("%s/%s", profile.URL, "api/v1/filters") - req, err := http.NewRequest("GET", endpoint, nil) +func fetchFilters(client *internalHTTP.HTTPClient) []list.Item { + req, err := client.NewRequest("GET", "filters", nil) if err != nil { fmt.Println("Error creating request:", err) return nil } - internalHTTP.AddAuthHeaders(req, profile) - req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := client.Client.Do(req) if err != nil { fmt.Println("Error making request:", err) return nil @@ -358,7 +352,7 @@ func QueryToDelete() Item { return selectedQueryDelete } -func RunQuery(client *http.Client, profile *config.Profile, query string, startTime string, endTime string) (string, error) { +func RunQuery(client *internalHTTP.HTTPClient, _ *config.Profile, query string, startTime string, endTime string) (string, error) { queryTemplate := `{ "query": "%s", "startTime": "%s", @@ -367,15 +361,12 @@ func RunQuery(client *http.Client, profile *config.Profile, query string, startT finalQuery := fmt.Sprintf(queryTemplate, query, startTime, endTime) - endpoint := fmt.Sprintf("%s/%s", profile.URL, "api/v1/query") - req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer([]byte(finalQuery))) + req, err := client.NewRequest("POST", "query", bytes.NewBuffer([]byte(finalQuery))) if err != nil { return "", err } - internalHTTP.AddAuthHeaders(req, profile) - req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := client.Client.Do(req) if err != nil { return "", err } From 19047df92ba233892a52c15aed415b898bd1f85a Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 17 Jul 2026 17:36:28 +0530 Subject: [PATCH 4/7] added the device Oauth flow with major changes --- cmd/cloud.go | 1000 ++++++++++--------------------- cmd/cloud_profile_test.go | 54 ++ cmd/login.go | 23 +- cmd/logout.go | 4 +- cmd/logout_test.go | 45 ++ cmd/profile.go | 111 +++- cmd/promql.go | 7 +- cmd/promql_test.go | 116 +++- cmd/status.go | 22 +- cmd/status_test.go | 64 ++ cmd/tail.go | 29 +- cmd/tail_test.go | 56 ++ pkg/analytics/analytics.go | 48 +- pkg/analytics/analytics_test.go | 59 ++ pkg/config/config.go | 29 +- pkg/datasets/datasets.go | 103 +--- pkg/datasets/datasets_test.go | 28 + pkg/http/http.go | 14 + pkg/http/http_test.go | 142 +++++ pkg/http/refresh.go | 152 +++++ pkg/model/login/login.go | 25 +- pkg/model/login/login_test.go | 17 + pkg/model/promql.go | 17 +- 23 files changed, 1266 insertions(+), 899 deletions(-) create mode 100644 cmd/cloud_profile_test.go create mode 100644 cmd/logout_test.go create mode 100644 cmd/tail_test.go create mode 100644 pkg/analytics/analytics_test.go create mode 100644 pkg/http/http_test.go create mode 100644 pkg/http/refresh.go create mode 100644 pkg/model/login/login_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index d9dcae2..c97ed84 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -4,26 +4,16 @@ // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . package cmd import ( + "bytes" "context" - "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" "io" - "net" "net/http" "net/url" "os" @@ -32,37 +22,26 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/parseablehq/pb/pkg/config" + "github.com/parseablehq/pb/pkg/ui" "github.com/spf13/cobra" ) const ( - cloudDefaultOrchestratorURL = "https://orchestrator.cloud-staging.parseable.com" - cloudDefaultOrchestratorAuthToken = "Add Authbearer token" - cloudDefaultClerkPublishableKey = "pk_test_bW92ZWQtcGlyYW5oYS02My5jbGVyay5hY2NvdW50cy5kZXYk" - cloudDefaultCallbackAddr = "127.0.0.1:9999" -) - -const ( - cloudOAuthProfilePath = "api/v1/organizations" - cloudParseableSessionPath = "api/v1/o/code" + cloudDefaultOrchestratorURL = "https://orchestrator.cloud-staging.parseable.com" + cloudDeviceClientID = "pb-cli" + cloudDefaultPollInterval = 5 * time.Second + cloudDefaultLoginTimeout = 5 * time.Minute ) -const cloudLoginTimeout = 5 * time.Minute - var ( - cloudAPIKey string - cloudCallbackAddr string - cloudOrchestratorAuthToken string - cloudClerkPublishableKey string - cloudProfileName string - cloudOrchestratorURL string - cloudWorkspaceURL string - cloudTenantID string - cloudClerkSessionTokenFlag string - cloudSetDefault bool - cloudForceOverwrite bool - cloudDirectCodeExchange bool + cloudAPIKey string + cloudProfileName string + cloudOrchestratorURL string + cloudForceOverwrite bool ) type cloudAPIKeyValidationResponse struct { @@ -77,50 +56,128 @@ type cloudAPIKeyValidationResponse struct { MultiTenant bool `json:"multi_tenant"` } -type cloudLoginCallbackResult struct { - Token string - Err error +type cloudDeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` } -type cloudOAuthTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` +type cloudDeviceTokenRequest struct { + GrantType string `json:"grant_type"` + DeviceCode string `json:"device_code,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` } -type cloudOAuthJWTClaims struct { - Subject string `json:"sub"` - SessionID string `json:"sid"` +type cloudDeviceTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + Workspace cloudDeviceWorkspaceRef `json:"workspace"` } -type cloudParseableSessionResponse struct { - Session string `json:"session"` - Username string `json:"username"` - UserID string `json:"user_id"` +type cloudDeviceWorkspaceRef struct { + ID string `json:"id"` + URL string `json:"url"` + TenantID string `json:"tenant_id"` } -type cloudOrganizationResponse struct { - OrgID string `json:"org_id"` - OrgName string `json:"org_name"` - Workspaces []cloudWorkspaceResponse `json:"workspaces"` +type cloudOAuthError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` } -type cloudOrganizationsResponse struct { - Organizations []cloudOrganizationResponse `json:"organizations"` +type cloudDeviceLoginResultMsg struct { + tokens *cloudDeviceTokenResponse + err error } -type cloudWorkspaceResponse struct { - WorkspaceID string `json:"workspace_id"` - WorkspaceName string `json:"workspace_name"` - OrgName string `json:"org_name"` - PrismURL string `json:"prism_url"` - URL string `json:"url"` - MultiTenant bool `json:"multi_tenant"` - State string `json:"state"` - InvitationStatus string `json:"invitation_status"` +type cloudDeviceLoginModel struct { + spinner spinner.Model + verificationURL string + userCode string + poll func() (*cloudDeviceTokenResponse, error) + openBrowser tea.Cmd + cancel context.CancelFunc + tokens *cloudDeviceTokenResponse + err error + done bool +} + +var cloudLoginTitleStyle = lipgloss.NewStyle().Bold(true).Foreground( + ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }), +) + +func newCloudDeviceLoginModel( + verificationURL string, + userCode string, + poll func() (*cloudDeviceTokenResponse, error), + openBrowser tea.Cmd, + cancel context.CancelFunc, +) cloudDeviceLoginModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + return cloudDeviceLoginModel{ + spinner: sp, + verificationURL: verificationURL, + userCode: userCode, + poll: poll, + openBrowser: openBrowser, + cancel: cancel, + } +} + +func (m cloudDeviceLoginModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.openBrowser, func() tea.Msg { + tokens, err := m.poll() + return cloudDeviceLoginResultMsg{tokens: tokens, err: err} + }) +} + +func (m cloudDeviceLoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.cancel() + m.err = errors.New("cloud device login aborted") + m.done = true + return m, tea.Quit + } + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case cloudDeviceLoginResultMsg: + m.tokens = msg.tokens + m.err = msg.err + m.done = true + return m, tea.Quit + } + return m, nil +} + +func (m cloudDeviceLoginModel) View() string { + var status string + switch { + case !m.done: + status = m.spinner.View() + " Waiting for authorization... [Ctrl+C to abort]" + case m.err == nil: + status = "✓ Authorization successful!" + default: + status = "✗ Authorization failed." + } + return fmt.Sprintf( + "\n %s\n %s\n\n To authenticate, please visit:\n → %s\n\n Enter this Confirmation Code:\n ❯ %s\n\n %s\n", + cloudLoginTitleStyle.Render("PARSEABLE LOGIN"), + strings.Repeat("─", 60), + m.verificationURL, + m.userCode, + status, + ) } var CloudCmd = &cobra.Command{ @@ -133,24 +190,10 @@ var CloudLoginCmd = &cobra.Command{ Use: "login", Example: " pb cloud login\n pb cloud login --name prod", Short: "Login to Parseable Cloud", - Long: "\nLogin to Parseable Cloud using Clerk browser login.", - RunE: func(_ *cobra.Command, _ []string) error { - orchestratorURL := strings.TrimSpace(cloudOrchestratorURL) - if orchestratorURL == "" { - orchestratorURL = cloudDefaultOrchestratorURL - } - - publishableKey := strings.TrimSpace(cloudClerkPublishableKey) - if publishableKey == "" { - publishableKey = cloudDefaultClerkPublishableKey - } - - callbackAddr := strings.TrimSpace(cloudCallbackAddr) - if callbackAddr == "" { - callbackAddr = cloudDefaultCallbackAddr - } - - profile, err := cloudProfileFromBrowserLogin(orchestratorURL, publishableKey, callbackAddr) + Long: "\nLogin to Parseable Cloud using the device authorization flow.", + RunE: func(cmd *cobra.Command, _ []string) error { + orchestratorURL := cloudOrchestratorEndpoint() + profile, err := cloudProfileFromDeviceLogin(cmd.Context(), orchestratorURL) if err != nil { return err } @@ -159,70 +202,17 @@ var CloudLoginCmd = &cobra.Command{ if profileName == "" { profileName = cloudProfileNameFromSession(profile) } - - if err := saveCloudProfile(profileName, *profile, cloudSetDefault, cloudForceOverwrite); err != nil { + if err := saveCloudProfile(profileName, *profile, cloudForceOverwrite, false); err != nil { return err } fmt.Printf("Cloud profile %s added successfully\n", profileName) - if profile.WorkspaceName != "" { - fmt.Printf("Workspace: %s (%s)\n", profile.WorkspaceName, profile.WorkspaceID) - } else { - fmt.Printf("Workspace: %s\n", profile.WorkspaceID) - } + fmt.Printf("Workspace: %s\n", profile.WorkspaceID) fmt.Printf("URL: %s\n", profile.URL) return nil }, } -func cloudProfileFromBrowserLogin(orchestratorURL, publishableKey, callbackAddr string) (*config.Profile, error) { - if strings.TrimSpace(publishableKey) == "" { - return nil, errors.New("cloud clerk publishable key is required") - } - - state, err := generateCloudLoginState() - if err != nil { - return nil, err - } - - ctx, cancel := context.WithTimeout(context.Background(), cloudLoginTimeout) - defer cancel() - - resultCh, stop, loginURL, err := startCloudClerkLoginServer(ctx, callbackAddr, state, publishableKey) - if err != nil { - return nil, err - } - defer stop() - - fmt.Printf("Opening browser for Parseable Cloud login...\n") - if err := openExternalBrowser(loginURL); err != nil { - fmt.Printf("Could not open browser automatically: %v\n", err) - } - fmt.Printf("Login URL:\n%s\n", loginURL) - fmt.Printf("Waiting for Clerk session token on %s\n", loginURL) - - var result cloudLoginCallbackResult - select { - case result = <-resultCh: - case <-ctx.Done(): - return nil, fmt.Errorf("timed out waiting for cloud login callback") - } - if result.Err != nil { - return nil, result.Err - } - - clerkSessionToken := strings.TrimSpace(result.Token) - - if cloudDirectCodeExchange { - return cloudProfileFromDirectCodeExchange(cloudWorkspaceURL, cloudTenantID, clerkSessionToken, clerkSessionToken) - } - - return cloudProfileFromOAuthTokens(orchestratorURL, &cloudOAuthTokenResponse{ - AccessToken: clerkSessionToken, - IDToken: clerkSessionToken, - }) -} - var CloudProfileCmd = &cobra.Command{ Use: "profile", Short: "Manage Parseable Cloud profiles", @@ -240,11 +230,7 @@ var CloudProfileAddCmd = &cobra.Command{ return errors.New("api key is required. pass --api-key") } - orchestratorURL := strings.TrimSpace(cloudOrchestratorURL) - if orchestratorURL == "" { - orchestratorURL = cloudDefaultOrchestratorURL - } - + orchestratorURL := cloudOrchestratorEndpoint() result, err := validateCloudAPIKey(orchestratorURL, apiKey) if err != nil { return err @@ -254,7 +240,6 @@ var CloudProfileAddCmd = &cobra.Command{ if profileName == "" { profileName = cloudProfileNameFromWorkspace(result) } - profile := config.Profile{ URL: result.URL, Cloud: true, @@ -265,8 +250,7 @@ var CloudProfileAddCmd = &cobra.Command{ WorkspaceName: result.WorkspaceName, OrchestratorURL: orchestratorURL, } - - if err := saveCloudProfile(profileName, profile, cloudSetDefault, cloudForceOverwrite); err != nil { + if err := saveCloudProfile(profileName, profile, cloudForceOverwrite, true); err != nil { return err } @@ -284,20 +268,11 @@ var CloudProfileAddCmd = &cobra.Command{ func init() { CloudLoginCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") CloudLoginCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") - CloudLoginCmd.Flags().StringVar(&cloudOrchestratorAuthToken, "orchestrator-auth-token", cloudDefaultOrchestratorAuthToken, "Parseable Cloud orchestrator auth token") - CloudLoginCmd.Flags().StringVar(&cloudClerkPublishableKey, "clerk-publishable-key", cloudDefaultClerkPublishableKey, "Clerk publishable key") - CloudLoginCmd.Flags().StringVar(&cloudCallbackAddr, "callback-addr", cloudDefaultCallbackAddr, "local callback listen address") - CloudLoginCmd.Flags().StringVar(&cloudWorkspaceURL, "url", "", "Parseable Cloud workspace URL for direct code exchange") - CloudLoginCmd.Flags().StringVar(&cloudTenantID, "tenant-id", "", "Parseable Cloud tenant/workspace ID for direct code exchange") - CloudLoginCmd.Flags().StringVar(&cloudClerkSessionTokenFlag, "clerk-session-token", "", "Clerk session token header for direct code exchange") - CloudLoginCmd.Flags().BoolVar(&cloudSetDefault, "default", false, "set this profile as default") CloudLoginCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") - CloudLoginCmd.Flags().BoolVar(&cloudDirectCodeExchange, "direct-code-exchange", false, "skip orchestrator and call workspace /api/v1/o/code with the Clerk session token") CloudProfileAddCmd.Flags().StringVar(&cloudAPIKey, "api-key", "", "Parseable Cloud API key") CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") - CloudProfileAddCmd.Flags().BoolVar(&cloudSetDefault, "default", false, "set this profile as default") CloudProfileAddCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") CloudProfileCmd.AddCommand(CloudProfileAddCmd) @@ -305,630 +280,283 @@ func init() { CloudCmd.AddCommand(CloudProfileCmd) } -func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { - endpoint, err := url.JoinPath(orchestratorURL, "api/v1/apikey/validate") - if err != nil { - return nil, fmt.Errorf("invalid orchestrator URL: %w", err) +func cloudOrchestratorEndpoint() string { + if endpoint := strings.TrimSpace(cloudOrchestratorURL); endpoint != "" { + return endpoint } + return cloudDefaultOrchestratorURL +} - req, err := http.NewRequest(http.MethodGet, endpoint, nil) +func cloudProfileFromDeviceLogin(parent context.Context, orchestratorURL string) (*config.Profile, error) { + client := &http.Client{Timeout: 30 * time.Second} + device, err := requestCloudDeviceCode(parent, client, orchestratorURL) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+apiKey) - client := http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to validate cloud api key: %w", err) + verificationURL := strings.TrimSpace(device.VerificationURI) + if verificationURL == "" { + return nil, errors.New("cloud device authorization response missing verification URI") } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + timeout := cloudDefaultLoginTimeout + if device.ExpiresIn > 0 { + timeout = time.Duration(device.ExpiresIn) * time.Second } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, fmt.Errorf("cloud api key validation failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + model := newCloudDeviceLoginModel( + verificationURL, + device.UserCode, + func() (*cloudDeviceTokenResponse, error) { + return pollCloudDeviceToken(ctx, client, orchestratorURL, device) + }, + func() tea.Msg { + if err := waitForCloudPoll(ctx, 5*time.Second); err == nil { + _ = openExternalBrowser(verificationURL) + } + return nil + }, + cancel, + ) + result, err := tea.NewProgram(model).Run() + if err != nil { + return nil, fmt.Errorf("failed to render cloud device login: %w", err) } - - var result cloudAPIKeyValidationResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to decode cloud api key validation response: %w", err) + loginResult, ok := result.(cloudDeviceLoginModel) + if !ok { + return nil, errors.New("cloud device login returned an unexpected result") } - - if result.URL == "" || result.TenantID == "" || result.WorkspaceID == "" { - return nil, errors.New("cloud api key validation response missing url, tenant_id, or workspace_id") + if loginResult.err != nil { + return nil, loginResult.err } - - return &result, nil + return cloudProfileFromDeviceTokens(orchestratorURL, loginResult.tokens) } -func cloudProfileFromOAuthTokens(orchestratorURL string, tokens *cloudOAuthTokenResponse) (*config.Profile, error) { - if tokens == nil || strings.TrimSpace(tokens.AccessToken) == "" { - return nil, errors.New("cloud oauth token response missing access_token") - } - clerkSessionToken := cloudClerkSessionToken(tokens) - if strings.TrimSpace(clerkSessionToken) == "" { - return nil, errors.New("cloud oauth token response missing clerk session token") +func openExternalBrowser(rawURL string) error { + var command *exec.Cmd + switch runtime.GOOS { + case "darwin": + command = exec.Command("open", rawURL) + case "windows": + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + command = exec.Command("xdg-open", rawURL) } + return command.Start() +} - claims, err := cloudOAuthClaims(tokens) +func requestCloudDeviceCode(ctx context.Context, client *http.Client, orchestratorURL string) (*cloudDeviceCodeResponse, error) { + body, err := json.Marshal(map[string]string{"client_id": cloudDeviceClientID}) if err != nil { return nil, err } - userID := claims.Subject - - endpoint, err := url.JoinPath(orchestratorURL, cloudOAuthProfilePath) + endpoint, err := url.JoinPath(orchestratorURL, "api/v1/cli/device/code") if err != nil { return nil, fmt.Errorf("invalid orchestrator URL: %w", err) } - u, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("invalid cloud organization URL: %w", err) - } - query := u.Query() - query.Set("user_id", userID) - u.RawQuery = query.Encode() - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - authToken := strings.TrimSpace(cloudOrchestratorAuthToken) - if authToken == "" { - authToken = cloudDefaultOrchestratorAuthToken - } - if authToken != "" { - req.Header.Set("Authorization", "Bearer "+authToken) - } else { - req.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - } - req.Header.Set("X-Clerk-Session-Token", clerkSessionToken) - req.Header.Set("Accept", "application/json") - client := http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to resolve cloud oauth profile: %w", err) + var result cloudDeviceCodeResponse + if err := doCloudJSON(ctx, client, http.MethodPost, endpoint, body, &result); err != nil { + return nil, fmt.Errorf("failed to create cloud device code: %w", err) } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, fmt.Errorf("cloud oauth profile resolution failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + if result.DeviceCode == "" || result.UserCode == "" { + return nil, errors.New("cloud device authorization response missing device_code or user_code") } - - result, err := decodeCloudOrganizationResponse(body) - if err != nil { - return nil, fmt.Errorf("failed to decode cloud organization response: %w", err) - } - - workspace, err := cloudWorkspaceForProfile(result.Workspaces) - if err != nil { - return nil, err - } - - workspaceURL := cloudWorkspaceEndpoint(*workspace) - sessionToken, err := exchangeCloudParseableSession(workspaceURL, workspace.WorkspaceID, clerkSessionToken, clerkSessionToken) - if err != nil { - return nil, err - } - - return &config.Profile{ - URL: workspaceURL, - Cloud: true, - SessionToken: sessionToken, - RefreshToken: tokens.RefreshToken, - TenantID: workspace.WorkspaceID, - WorkspaceID: workspace.WorkspaceID, - WorkspaceName: workspace.WorkspaceName, - OrchestratorURL: orchestratorURL, - ClerkSessionID: claims.SessionID, - }, nil + return &result, nil } -func cloudProfileFromDirectCodeExchange(workspaceURL, tenantID, code, clerkSessionToken string) (*config.Profile, error) { - workspaceURL = strings.TrimSpace(workspaceURL) - tenantID = strings.TrimSpace(tenantID) - clerkSessionToken = strings.TrimSpace(clerkSessionToken) - if clerkSessionToken == "" { - clerkSessionToken = strings.TrimSpace(cloudClerkSessionTokenFlag) - } - if workspaceURL == "" { - return nil, errors.New("workspace URL is required for direct code exchange. pass --url") +func pollCloudDeviceToken(ctx context.Context, client *http.Client, orchestratorURL string, device *cloudDeviceCodeResponse) (*cloudDeviceTokenResponse, error) { + endpoint, err := url.JoinPath(orchestratorURL, "api/v1/cli/oauth/token") + if err != nil { + return nil, fmt.Errorf("invalid orchestrator URL: %w", err) } - if tenantID == "" { - return nil, errors.New("tenant ID is required for direct code exchange. pass --tenant-id") + interval := cloudDefaultPollInterval + if device.Interval > 0 { + interval = time.Duration(device.Interval) * time.Second } - sessionToken, err := exchangeCloudParseableSession(workspaceURL, tenantID, code, clerkSessionToken) - if err != nil { - return nil, err - } + for { + if err := waitForCloudPoll(ctx, interval); err != nil { + return nil, errors.New("cloud device login expired or timed out") + } - return &config.Profile{ - URL: workspaceURL, - Cloud: true, - SessionToken: sessionToken, - TenantID: tenantID, - WorkspaceID: tenantID, - }, nil -} + payload, err := json.Marshal(cloudDeviceTokenRequest{ + GrantType: "urn:ietf:params:oauth:grant-type:device_code", + DeviceCode: device.DeviceCode, + }) + if err != nil { + return nil, err + } + var tokens cloudDeviceTokenResponse + oauthErr, err := doCloudOAuthTokenRequest(ctx, client, endpoint, payload, &tokens) + if err != nil { + return nil, err + } + if oauthErr == nil { + return &tokens, nil + } -func cloudOAuthClaims(tokens *cloudOAuthTokenResponse) (*cloudOAuthJWTClaims, error) { - for _, token := range []string{tokens.IDToken, tokens.AccessToken} { - claims, err := decodeCloudOAuthJWTClaims(token) - if err == nil && claims.Subject != "" { - return claims, nil + switch oauthErr.Error { + case "authorization_pending": + continue + case "expired_token": + return nil, errors.New("cloud device code expired; run login again") + default: + return nil, fmt.Errorf("cloud device login failed: %s", cloudOAuthErrorMessage(oauthErr)) } } - return nil, errors.New("cloud oauth tokens missing user subject") } -func cloudClerkSessionToken(tokens *cloudOAuthTokenResponse) string { - if strings.TrimSpace(tokens.IDToken) != "" { - return tokens.IDToken +func waitForCloudPoll(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil } - return tokens.AccessToken } -func decodeCloudOAuthJWTClaims(token string) (*cloudOAuthJWTClaims, error) { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return nil, errors.New("invalid jwt") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) +func doCloudOAuthTokenRequest(ctx context.Context, client *http.Client, endpoint string, payload []byte, out *cloudDeviceTokenResponse) (*cloudOAuthError, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return nil, err } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") - var claims cloudOAuthJWTClaims - if err := json.Unmarshal(payload, &claims); err != nil { - return nil, err + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("cloud token request failed: %w", err) } - return &claims, nil -} - -func decodeCloudOrganizationResponse(body []byte) (*cloudOrganizationResponse, error) { - var org cloudOrganizationResponse - if err := json.Unmarshal(body, &org); err == nil && len(org.Workspaces) > 0 { - return &org, nil + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err } - - var orgs []cloudOrganizationResponse - if err := json.Unmarshal(body, &orgs); err == nil { - return cloudOrganizationWithWorkspaces(orgs) + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + if err := json.Unmarshal(body, out); err != nil { + return nil, fmt.Errorf("failed to decode cloud token response: %w", err) + } + return nil, nil } - var wrapped cloudOrganizationsResponse - if err := json.Unmarshal(body, &wrapped); err == nil { - return cloudOrganizationWithWorkspaces(wrapped.Organizations) + var oauthErr cloudOAuthError + if err := json.Unmarshal(body, &oauthErr); err != nil || oauthErr.Error == "" { + return nil, fmt.Errorf("cloud token request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } - - return nil, errors.New("unknown cloud organization response shape") + return &oauthErr, nil } -func cloudOrganizationWithWorkspaces(orgs []cloudOrganizationResponse) (*cloudOrganizationResponse, error) { - for i := range orgs { - if len(orgs[i].Workspaces) > 0 { - return &orgs[i], nil - } +func cloudOAuthErrorMessage(oauthErr *cloudOAuthError) string { + if oauthErr.ErrorDescription != "" { + return oauthErr.Error + ": " + oauthErr.ErrorDescription } - return nil, errors.New("cloud organization response has no workspaces") + return oauthErr.Error } -func cloudWorkspaceForProfile(workspaces []cloudWorkspaceResponse) (*cloudWorkspaceResponse, error) { - var missingURL, missingID, unusableInvitation int - for _, workspace := range workspaces { - if !cloudWorkspaceInvitationUsable(workspace.InvitationStatus) { - unusableInvitation++ - continue - } - if cloudWorkspaceEndpoint(workspace) == "" { - missingURL++ - continue - } - if strings.TrimSpace(workspace.WorkspaceID) == "" { - missingID++ - continue - } - return &workspace, nil +func cloudProfileFromDeviceTokens(orchestratorURL string, tokens *cloudDeviceTokenResponse) (*config.Profile, error) { + if tokens == nil || strings.TrimSpace(tokens.AccessToken) == "" { + return nil, errors.New("cloud token response missing access_token") } - return nil, fmt.Errorf( - "cloud organization response missing usable workspace (total=%d missing_url=%d missing_workspace_id=%d unusable_invitation=%d)", - len(workspaces), missingURL, missingID, unusableInvitation, - ) -} - -func cloudWorkspaceEndpoint(workspace cloudWorkspaceResponse) string { - if prismURL := strings.TrimSpace(workspace.PrismURL); prismURL != "" { - return prismURL + if strings.TrimSpace(tokens.RefreshToken) == "" { + return nil, errors.New("cloud token response missing refresh_token") } - return strings.TrimSpace(workspace.URL) -} - -func cloudWorkspaceInvitationUsable(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "", "accepted", "active", "joined", "member": - return true - default: - return false + if strings.TrimSpace(tokens.Workspace.ID) == "" || strings.TrimSpace(tokens.Workspace.URL) == "" || strings.TrimSpace(tokens.Workspace.TenantID) == "" { + return nil, errors.New("cloud token response missing workspace id, url, or tenant_id") } + return &config.Profile{ + URL: tokens.Workspace.URL, + Cloud: true, + SessionToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + TenantID: tokens.Workspace.TenantID, + WorkspaceID: tokens.Workspace.ID, + OrchestratorURL: orchestratorURL, + }, nil } -func exchangeCloudParseableSession(prismURL, tenantID, code, bearerToken string) (string, error) { - if strings.TrimSpace(code) == "" { - return "", errors.New("cloud oauth callback missing code") - } - - endpoint, err := url.JoinPath(prismURL, cloudParseableSessionPath) +func doCloudJSON(ctx context.Context, client *http.Client, method, endpoint string, payload []byte, out interface{}) error { + req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(payload)) if err != nil { - return "", fmt.Errorf("invalid cloud workspace URL: %w", err) + return err } - u, err := url.Parse(endpoint) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("invalid cloud session URL: %w", err) + return err } - query := u.Query() - query.Set("code", code) - u.RawQuery = query.Encode() - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return err } - req.Header.Set("Accept", "application/json") - if tenantID != "" { - req.Header.Set("x-p-tenant", tenantID) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(body))) } - if bearerToken != "" { - req.Header.Set("X-Clerk-Session-Token", bearerToken) + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("failed to decode response: %w", err) } + return nil +} +func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { + endpoint, err := url.JoinPath(orchestratorURL, "api/v1/apikey/validate") + if err != nil { + return nil, fmt.Errorf("invalid orchestrator URL: %w", err) + } + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) client := http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("failed to exchange cloud oauth code for parseable session: %w", err) + return nil, fmt.Errorf("failed to validate cloud api key: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return nil, err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return "", fmt.Errorf("cloud parseable session exchange failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + return nil, fmt.Errorf("cloud api key validation failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } - - var result cloudParseableSessionResponse + var result cloudAPIKeyValidationResponse if err := json.Unmarshal(body, &result); err != nil { - return "", fmt.Errorf("failed to decode cloud parseable session response: %w", err) - } - if strings.TrimSpace(result.Session) == "" { - return "", errors.New("cloud parseable session response missing session") - } - return result.Session, nil -} - -func startCloudClerkLoginServer(ctx context.Context, addr, expectedState, publishableKey string) (<-chan cloudLoginCallbackResult, func(), string, error) { - listener, err := net.Listen("tcp", addr) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to listen for cloud login callback on %s: %w", addr, err) - } - loginURL := "http://" + listener.Addr().String() + "/login" - - resultCh := make(chan cloudLoginCallbackResult, 1) - mux := http.NewServeMux() - server := &http.Server{ - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - } - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/login", http.StatusFound) - }) - mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, cloudClerkLoginPage(publishableKey, expectedState)) - }) - mux.HandleFunc("/sso-callback", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, cloudClerkCallbackPage(publishableKey, expectedState)) - }) - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - var payload struct { - State string `json:"state"` - Token string `json:"token"` - } - if r.Method == http.MethodPost { - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "invalid callback payload", http.StatusBadRequest) - return - } - } else { - query := r.URL.Query() - payload.State = query.Get("state") - payload.Token = query.Get("token") - } - - state := payload.State - if state == "" || state != expectedState { - http.Error(w, "invalid state", http.StatusBadRequest) - return - } - - token := strings.TrimSpace(payload.Token) - if token == "" { - http.Error(w, "missing token", http.StatusBadRequest) - return - } - - select { - case resultCh <- cloudLoginCallbackResult{Token: token}: - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) - default: - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) - } - }) - - go func() { - if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - select { - case resultCh <- cloudLoginCallbackResult{Err: err}: - default: - } - } - }() - - go func() { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = server.Shutdown(shutdownCtx) - }() - - stop := func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = server.Shutdown(shutdownCtx) - } - - return resultCh, stop, loginURL, nil -} - -func cloudClerkLoginPage(publishableKey, state string) string { - publishableKeyJSON, _ := json.Marshal(publishableKey) - stateJSON, _ := json.Marshal(state) - clerkScriptURLJSON, _ := json.Marshal(cloudClerkScriptURL(publishableKey)) - return fmt.Sprintf(` - - - - - Parseable Cloud Login - - - - -
- - -`, string(publishableKeyJSON), string(clerkScriptURLJSON), string(stateJSON)) -} - -func cloudClerkCallbackPage(publishableKey, state string) string { - publishableKeyJSON, _ := json.Marshal(publishableKey) - stateJSON, _ := json.Marshal(state) - clerkScriptURLJSON, _ := json.Marshal(cloudClerkScriptURL(publishableKey)) - return fmt.Sprintf(` - - - - - Parseable Cloud Login - - - - - -`, string(publishableKeyJSON), string(clerkScriptURLJSON), string(stateJSON)) -} - -func cloudClerkScriptURL(publishableKey string) string { - host := cloudClerkFrontendHost(publishableKey) - if host == "" { - return "https://cdn.jsdelivr.net/npm/@clerk/clerk-js@latest/dist/clerk.browser.js" - } - return "https://" + host + "/npm/@clerk/clerk-js@latest/dist/clerk.browser.js" -} - -func cloudClerkFrontendHost(publishableKey string) string { - for _, prefix := range []string{"pk_test_", "pk_live_"} { - encoded, ok := strings.CutPrefix(strings.TrimSpace(publishableKey), prefix) - if !ok { - continue - } - decoded, err := base64.RawURLEncoding.DecodeString(encoded) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(encoded) - } - if err != nil { - return "" - } - return strings.TrimSuffix(string(decoded), "$") - } - return "" -} - -func generateCloudLoginState() (string, error) { - var buf [32]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", fmt.Errorf("failed to generate cloud login state: %w", err) + return nil, fmt.Errorf("failed to decode cloud api key validation response: %w", err) } - return base64.RawURLEncoding.EncodeToString(buf[:]), nil -} - -func openExternalBrowser(rawURL string) error { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", rawURL) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) - default: - cmd = exec.Command("xdg-open", rawURL) + if result.URL == "" || result.TenantID == "" || result.WorkspaceID == "" { + return nil, errors.New("cloud api key validation response missing url, tenant_id, or workspace_id") } - return cmd.Start() + return &result, nil } -func saveCloudProfile(name string, profile config.Profile, setDefault, force bool) error { +func saveCloudProfile(name string, profile config.Profile, force, makeDefault bool) error { if name == "" { return errors.New("profile name is required") } - fileConfig, err := config.ReadConfigFromFile() if os.IsNotExist(err) { - fileConfig = &config.Config{ - Profiles: make(map[string]config.Profile), - } + fileConfig = &config.Config{Profiles: make(map[string]config.Profile)} } else if err != nil { return fmt.Errorf("failed to read config: %w", err) } - if fileConfig.Profiles == nil { fileConfig.Profiles = make(map[string]config.Profile) } - if _, exists := fileConfig.Profiles[name]; exists && !force { return fmt.Errorf("profile %q already exists. use --force to overwrite", name) } - fileConfig.Profiles[name] = profile - if fileConfig.DefaultProfile == "" || setDefault { + if fileConfig.DefaultProfile == "" || makeDefault { fileConfig.DefaultProfile = name } - return config.WriteConfigToFile(fileConfig) } @@ -937,10 +565,7 @@ func cloudProfileNameFromWorkspace(result *cloudAPIKeyValidationResponse) string if name == "" { name = strings.TrimSpace(result.WorkspaceID) } - - name = strings.ToLower(name) - name = strings.ReplaceAll(name, " ", "-") - return name + return normalizeCloudProfileName(name) } func cloudProfileNameFromSession(profile *config.Profile) string { @@ -951,8 +576,9 @@ func cloudProfileNameFromSession(profile *config.Profile) string { if name == "" { name = "cloud" } + return normalizeCloudProfileName(name) +} - name = strings.ToLower(name) - name = strings.ReplaceAll(name, " ", "-") - return name +func normalizeCloudProfileName(name string) string { + return strings.ReplaceAll(strings.ToLower(name), " ", "-") } diff --git a/cmd/cloud_profile_test.go b/cmd/cloud_profile_test.go new file mode 100644 index 0000000..0562cf9 --- /dev/null +++ b/cmd/cloud_profile_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "testing" + + "github.com/parseablehq/pb/pkg/config" +) + +func TestCloudCommandsDoNotExposeDefaultFlag(t *testing.T) { + if flag := CloudLoginCmd.Flags().Lookup("default"); flag != nil { + t.Fatal("cloud login still exposes --default") + } + if flag := CloudProfileAddCmd.Flags().Lookup("default"); flag != nil { + t.Fatal("cloud profile add still exposes --default") + } +} + +func TestSaveCloudProfilePreservesExistingDefault(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + if err := saveCloudProfile("first", config.Profile{URL: "https://first.example.com"}, false, false); err != nil { + t.Fatal(err) + } + if err := saveCloudProfile("second", config.Profile{URL: "https://second.example.com"}, false, false); err != nil { + t.Fatal(err) + } + + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + t.Fatal(err) + } + if fileConfig.DefaultProfile != "first" { + t.Fatalf("default profile changed to %q", fileConfig.DefaultProfile) + } +} + +func TestSaveCloudProfileCanMakeAddedProfileDefault(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + if err := saveCloudProfile("first", config.Profile{URL: "https://first.example.com"}, false, false); err != nil { + t.Fatal(err) + } + if err := saveCloudProfile("second", config.Profile{URL: "https://second.example.com"}, false, true); err != nil { + t.Fatal(err) + } + + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + t.Fatal(err) + } + if fileConfig.DefaultProfile != "second" { + t.Fatalf("added profile was not made default: %q", fileConfig.DefaultProfile) + } +} diff --git a/cmd/login.go b/cmd/login.go index a56ca82..b945843 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -31,7 +31,7 @@ var LoginCmd = &cobra.Command{ Select self-hosted or Parseable Cloud and enter the required credentials. All settings are saved to ~/.config/pb/config.toml.`, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { _m, err := tea.NewProgram(login.New()).Run() if err != nil { return err @@ -42,12 +42,8 @@ credentials. All settings are saved to ~/.config/pb/config.toml.`, return nil } - if m.CloudBrowserLogin { - profile, err := cloudProfileFromBrowserLogin( - cloudDefaultOrchestratorURL, - cloudDefaultClerkPublishableKey, - cloudDefaultCallbackAddr, - ) + if m.CloudDeviceLogin { + profile, err := cloudProfileFromDeviceLogin(cmd.Context(), cloudDefaultOrchestratorURL) if err != nil { return err } @@ -66,12 +62,17 @@ credentials. All settings are saved to ~/.config/pb/config.toml.`, if err := writeProfile(m.Profile, m.Name); err != nil { return fmt.Errorf("failed to save profile: %w", err) } + if m.CloudDeviceLogin { + printDeviceLoginSuccess(m.Name) + } return nil }, } -func init() { - LoginCmd.Flags().StringVar(&cloudOrchestratorAuthToken, "orchestrator-auth-token", cloudDefaultOrchestratorAuthToken, "Parseable Cloud orchestrator auth token") +func printDeviceLoginSuccess(profileName string) { + fmt.Printf(" ✓ Profile '%s' saved.\n\n", profileName) + fmt.Println(" 💡 Tip: You can add more profiles anytime using:") + fmt.Println(" pb profile add [user] [pass]") } func cloudProfileFromAPIKey(apiKey string) (*config.Profile, error) { @@ -107,8 +108,6 @@ func writeProfile(profile config.Profile, profileName string) error { fileConfig.Profiles = make(map[string]config.Profile) } fileConfig.Profiles[profileName] = profile - if fileConfig.DefaultProfile == "" { - fileConfig.DefaultProfile = profileName - } + fileConfig.DefaultProfile = profileName return config.WriteConfigToFile(fileConfig) } diff --git a/cmd/logout.go b/cmd/logout.go index 6da303b..37db252 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -58,8 +58,8 @@ var LogoutCmd = &cobra.Command{ if len(fileConfig.Profiles) == 0 { return fmt.Errorf("no active profile found") } - if outputFormat == "json" && len(fileConfig.Profiles) > 1 { - return fmt.Errorf("no active profile found") + if len(fileConfig.Profiles) > 1 && (yes || outputFormat == "json") { + return fmt.Errorf("no active profile found; set one with: pb profile default ") } selectedProfile, err := selectLogoutProfile(fileConfig.Profiles) if err != nil { diff --git a/cmd/logout_test.go b/cmd/logout_test.go new file mode 100644 index 0000000..d9934e4 --- /dev/null +++ b/cmd/logout_test.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/parseablehq/pb/pkg/config" +) + +func TestLogoutYesDoesNotPromptWhenDefaultIsAmbiguous(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + fileConfig := &config.Config{ + Profiles: map[string]config.Profile{ + "first": {URL: "https://first.example.com"}, + "second": {URL: "https://second.example.com"}, + }, + } + if err := config.WriteConfigToFile(fileConfig); err != nil { + t.Fatal(err) + } + + if err := LogoutCmd.Flags().Set("yes", "true"); err != nil { + t.Fatal(err) + } + if err := LogoutCmd.Flags().Set("output", "text"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = LogoutCmd.Flags().Set("yes", "false") + _ = LogoutCmd.Flags().Set("output", "text") + }) + + err := LogoutCmd.RunE(LogoutCmd, nil) + if err == nil || !strings.Contains(err.Error(), "pb profile default ") { + t.Fatalf("expected non-interactive ambiguity error, got %v", err) + } + + saved, err := config.ReadConfigFromFile() + if err != nil { + t.Fatal(err) + } + if len(saved.Profiles) != 2 || saved.DefaultProfile != "" { + t.Fatalf("config changed after ambiguous logout: %#v", saved) + } +} diff --git a/cmd/profile.go b/cmd/profile.go index 4b63d49..4b3464e 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/url" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -34,31 +35,69 @@ type ProfileListItem struct { title, url, user string } +type profileOutput struct { + URL string `json:"url"` + Username string `json:"username,omitempty"` + Cloud bool `json:"cloud"` + TenantID string `json:"tenant_id,omitempty"` + IngestURL string `json:"ingest_url,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + WorkspaceName string `json:"workspace_name,omitempty"` + OrchestratorURL string `json:"orchestrator_url,omitempty"` +} + +func safeProfileOutput(profile config.Profile) profileOutput { + return profileOutput{ + URL: profile.URL, + Username: profile.Username, + Cloud: profile.Cloud, + TenantID: profile.TenantID, + IngestURL: profile.IngestURL, + WorkspaceID: profile.WorkspaceID, + WorkspaceName: profile.WorkspaceName, + OrchestratorURL: profile.OrchestratorURL, + } +} + +func safeProfilesOutput(profiles map[string]config.Profile) map[string]profileOutput { + result := make(map[string]profileOutput, len(profiles)) + for name, profile := range profiles { + result[name] = safeProfileOutput(profile) + } + return result +} + func (item *ProfileListItem) Render(highlight bool) string { if highlight { - render := fmt.Sprintf( - "%s\n%s\n%s", + lines := []string{ SelectedStyle.Render(item.title), SelectedStyleAlt.Render(fmt.Sprintf("url: %s", item.url)), - SelectedStyleAlt.Render(fmt.Sprintf("user: %s", item.user)), - ) - return SelectedItemOuter.Render(render) + } + if item.user != "" { + lines = append(lines, SelectedStyleAlt.Render(fmt.Sprintf("user: %s", item.user))) + } + return SelectedItemOuter.Render(strings.Join(lines, "\n")) } - render := fmt.Sprintf( - "%s\n%s\n%s", + lines := []string{ StandardStyle.Render(item.title), StandardStyleAlt.Render(fmt.Sprintf("url: %s", item.url)), - StandardStyleAlt.Render(fmt.Sprintf("user: %s", item.user)), - ) - return ItemOuter.Render(render) + } + if item.user != "" { + lines = append(lines, StandardStyleAlt.Render(fmt.Sprintf("user: %s", item.user))) + } + return ItemOuter.Render(strings.Join(lines, "\n")) } // Add an output flag to specify the output format. -var outputFormat string +var ( + outputFormat string + profileAPIKey string +) // Initialize flags func init() { AddProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") + AddProfileCmd.Flags().StringVar(&profileAPIKey, "api-key", "", "API key for self-hosted authentication") RemoveProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") DefaultProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") ListProfileCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") @@ -78,14 +117,17 @@ func outputResult(v interface{}) error { } var AddProfileCmd = &cobra.Command{ - Use: "add profile-name url ", - Example: " pb profile add local_parseable http://0.0.0.0:8000 admin admin", + Use: "add profile-name url [username] [password]", + Example: " pb profile add local_parseable http://0.0.0.0:8000 admin admin\n pb profile add local_parseable http://0.0.0.0:8000 --api-key psk_xxx", Short: "Add a new profile", Long: "Add a new profile to the config file", Args: func(cmd *cobra.Command, args []string) error { if err := cobra.MinimumNArgs(2)(cmd, args); err != nil { return err } + if strings.TrimSpace(profileAPIKey) != "" && len(args) != 2 { + return errors.New("--api-key cannot be combined with username/password") + } return cobra.MaximumNArgs(4)(cmd, args) }, RunE: func(cmd *cobra.Command, args []string) error { @@ -104,22 +146,29 @@ var AddProfileCmd = &cobra.Command{ return commandError } - var username, password string - if len(args) < 4 { - _m, err := tea.NewProgram(credential.New()).Run() - if err != nil { - commandError = fmt.Errorf("error reading credentials: %s", err) - cmd.Annotations["error"] = commandError.Error() - return commandError - } - m := _m.(credential.Model) - username, password = m.Values() + apiKey := strings.TrimSpace(profileAPIKey) + profile := config.Profile{Cloud: false, URL: url.String()} + if apiKey != "" { + profile.APIKey = apiKey } else { - username = args[2] - password = args[3] + var username, password string + if len(args) < 4 { + _m, err := tea.NewProgram(credential.New()).Run() + if err != nil { + commandError = fmt.Errorf("error reading credentials: %s", err) + cmd.Annotations["error"] = commandError.Error() + return commandError + } + m := _m.(credential.Model) + username, password = m.Values() + } else { + username = args[2] + password = args[3] + } + profile.Username = username + profile.Password = password } - profile := config.Profile{Cloud: false, URL: url.String(), Username: username, Password: password} fileConfig, err := config.ReadConfigFromFile() if err != nil { newConfig := config.Config{ @@ -133,9 +182,7 @@ var AddProfileCmd = &cobra.Command{ fileConfig.Profiles = make(map[string]config.Profile) } fileConfig.Profiles[name] = profile - if fileConfig.DefaultProfile == "" { - fileConfig.DefaultProfile = name - } + fileConfig.DefaultProfile = name commandError = config.WriteConfigToFile(fileConfig) } @@ -146,7 +193,7 @@ var AddProfileCmd = &cobra.Command{ } if outputFormat == "json" { - return outputResult(profile) + return outputResult(safeProfileOutput(profile)) } fmt.Printf("Profile %s added successfully\n", name) return nil @@ -320,7 +367,7 @@ var UpdateProfileCmd = &cobra.Command{ } if outputFormat == "json" { - return outputResult(profile) + return outputResult(safeProfileOutput(profile)) } fmt.Printf("Profile '%s' URL updated to %s\n", name, rawURL) return nil @@ -345,7 +392,7 @@ var ListProfileCmd = &cobra.Command{ } if outputFormat == "json" { - commandError := outputResult(fileConfig.Profiles) + commandError := outputResult(safeProfilesOutput(fileConfig.Profiles)) cmd.Annotations["executionTime"] = time.Since(startTime).String() if commandError != nil { cmd.Annotations["error"] = commandError.Error() diff --git a/cmd/promql.go b/cmd/promql.go index aa62fa5..eb670ce 100644 --- a/cmd/promql.go +++ b/cmd/promql.go @@ -184,11 +184,10 @@ func renderPromqlHelp(cmd *cobra.Command, _ []string) { // --------------------------------------------------------------------------- func promqlGet(path string, params url.Values) ([]byte, error) { - client := internalHTTP.DefaultClient(&DefaultProfile) - client.Client.Timeout = 120 * time.Second - client.Client.Transport = &http.Transport{ + client := internalHTTP.DefaultClientWithTransport(&DefaultProfile, &http.Transport{ TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), - } + }) + client.Client.Timeout = 120 * time.Second reqURL, err := url.JoinPath(DefaultProfile.URL, path) if err != nil { return nil, err diff --git a/cmd/promql_test.go b/cmd/promql_test.go index 45fdb6a..58c0379 100644 --- a/cmd/promql_test.go +++ b/cmd/promql_test.go @@ -1,6 +1,15 @@ package cmd -import "testing" +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/parseablehq/pb/pkg/config" + "github.com/spf13/cobra" +) func TestPromqlInteractiveFromDefaultUsesTenMinutes(t *testing.T) { got := promqlInteractiveFromDefault("5m", true, false) @@ -22,3 +31,108 @@ func TestPromqlInteractiveFromDefaultKeepsNonInteractiveDefault(t *testing.T) { t.Fatalf("got %q, want 5m", got) } } + +func TestPromqlPositionalArgumentsOverrideFlags(t *testing.T) { + requests := make(chan *url.URL, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestURL := *r.URL + requests <- &requestURL + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"success","data":[]}`) + })) + defer server.Close() + + originalProfile := DefaultProfile + DefaultProfile = config.Profile{URL: server.URL, APIKey: "test-key"} + t.Cleanup(func() { DefaultProfile = originalProfile }) + + tests := []struct { + name string + cmd *cobra.Command + args []string + flagSetup map[string]string + wantPath string + wantQuery map[string]string + }{ + { + name: "labels stream", + cmd: promqlLabelsCmd, + args: []string{"positional-labels"}, + wantPath: "/prometheus/api/v1/labels", + wantQuery: map[string]string{"stream": "positional-labels"}, + }, + { + name: "label values label and stream", + cmd: promqlLabelValuesCmd, + args: []string{"job", "positional-label-values"}, + wantPath: "/prometheus/api/v1/label/job/values", + wantQuery: map[string]string{"stream": "positional-label-values"}, + }, + { + name: "series stream", + cmd: promqlSeriesCmd, + args: []string{"positional-series"}, + flagSetup: map[string]string{ + "match": "metric_name", + }, + wantPath: "/prometheus/api/v1/series", + wantQuery: map[string]string{ + "stream": "positional-series", + "match[]": "metric_name", + }, + }, + { + name: "cardinality label values stream and label", + cmd: promqlCardinalityLabelValuesCmd, + args: []string{"positional-cardinality", "service.name"}, + wantPath: "/prometheus/api/v1/cardinality/label_values", + wantQuery: map[string]string{"stream": "positional-cardinality", "label_name": "service.name"}, + }, + { + name: "active series stream and selector", + cmd: promqlCardinalityActiveSeriesCmd, + args: []string{"positional-active", `{job="api"}`}, + wantPath: "/prometheus/api/v1/cardinality/active_series", + wantQuery: map[string]string{"stream": "positional-active", "selector": `{job="api"}`}, + }, + { + name: "tsdb stream", + cmd: promqlTSDBCmd, + args: []string{"positional-tsdb"}, + wantPath: "/prometheus/api/v1/status/tsdb", + wantQuery: map[string]string{"stream": "positional-tsdb"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.cmd.Flags().Set("dataset", "flag-dataset"); err != nil { + t.Fatal(err) + } + if err := test.cmd.Flags().Set("output", "json"); err != nil { + t.Fatal(err) + } + for name, value := range test.flagSetup { + if err := test.cmd.Flags().Set(name, value); err != nil { + t.Fatal(err) + } + } + if err := test.cmd.Args(test.cmd, test.args); err != nil { + t.Fatal(err) + } + if err := test.cmd.RunE(test.cmd, test.args); err != nil { + t.Fatal(err) + } + + requestURL := <-requests + if requestURL.Path != test.wantPath { + t.Fatalf("path=%q want=%q", requestURL.Path, test.wantPath) + } + for key, want := range test.wantQuery { + if got := requestURL.Query().Get(key); got != want { + t.Fatalf("query %s=%q want=%q", key, got, want) + } + } + }) + } +} diff --git a/cmd/status.go b/cmd/status.go index 2bb07b4..3670991 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -17,6 +17,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "strings" @@ -40,13 +41,13 @@ var StatusCmd = &cobra.Command{ fileConfig, err := config.ReadConfigFromFile() if err != nil { - return fmt.Errorf("no profile configured. run: pb login") + return statusPreflightError(outputFormat, "no profile configured. run: pb login") } profileName := fileConfig.DefaultProfile profile, exists := fileConfig.Profiles[profileName] if !exists || profileName == "" { - return fmt.Errorf("no active profile. run: pb login") + return statusPreflightError(outputFormat, "no active profile. run: pb login") } if outputFormat != "json" { @@ -96,12 +97,25 @@ var StatusCmd = &cobra.Command{ type statusOutput struct { Status string `json:"status"` Healthy bool `json:"healthy"` - Profile string `json:"profile"` - URL string `json:"url"` + Profile string `json:"profile,omitempty"` + URL string `json:"url,omitempty"` Version string `json:"version,omitempty"` Error string `json:"error,omitempty"` } +func statusPreflightError(outputFormat, message string) error { + if outputFormat == "json" { + if err := printStatusJSON(statusOutput{ + Status: "error", + Healthy: false, + Error: message, + }); err != nil { + return err + } + } + return errors.New(message) +} + func printStatusJSON(result statusOutput) error { jsonData, err := json.MarshalIndent(result, "", " ") if err != nil { diff --git a/cmd/status_test.go b/cmd/status_test.go index 9aaf10c..529fad5 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -1,8 +1,13 @@ package cmd import ( + "encoding/json" "errors" + "io" + "os" "testing" + + "github.com/parseablehq/pb/pkg/config" ) func TestStatusErrorMessageTreatsAuthFailuresAsCredentialErrors(t *testing.T) { @@ -21,3 +26,62 @@ func TestStatusErrorMessageKeepsOtherErrors(t *testing.T) { t.Fatalf("got %q, want %q", got, input.Error()) } } + +func TestStatusJSONWhenConfigIsMissing(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + result, err := runStatusJSON(t) + if err == nil { + t.Fatal("expected nonzero status error") + } + if result.Status != "error" || result.Healthy || result.Error != "no profile configured. run: pb login" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestStatusJSONWhenDefaultProfileIsMissing(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := config.WriteConfigToFile(&config.Config{ + Profiles: map[string]config.Profile{ + "available": {URL: "https://example.com"}, + }, + }); err != nil { + t.Fatal(err) + } + + result, err := runStatusJSON(t) + if err == nil { + t.Fatal("expected nonzero status error") + } + if result.Status != "error" || result.Healthy || result.Error != "no active profile. run: pb login" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func runStatusJSON(t *testing.T) (statusOutput, error) { + t.Helper() + if err := StatusCmd.Flags().Set("output", "json"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = StatusCmd.Flags().Set("output", "text") }) + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + originalStdout := os.Stdout + os.Stdout = writer + commandErr := StatusCmd.RunE(StatusCmd, nil) + _ = writer.Close() + os.Stdout = originalStdout + + data, readErr := io.ReadAll(reader) + _ = reader.Close() + if readErr != nil { + t.Fatal(readErr) + } + var result statusOutput + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("invalid JSON %q: %v", data, err) + } + return result, commandErr +} diff --git a/cmd/tail.go b/cmd/tail.go index cb845e0..1d9904e 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -19,11 +19,14 @@ package cmd import ( "bytes" "context" + "crypto/tls" "encoding/base64" "encoding/json" "fmt" "io" + "net/url" "os" + "strings" "time" "github.com/apache/arrow/go/v13/arrow/array" @@ -34,6 +37,7 @@ import ( "github.com/spf13/cobra" "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -81,8 +85,12 @@ func tail(profile config.Profile, stream string, jsonOutput bool) error { return err } url := profile.GrpcAddr(fmt.Sprint(about.GRPCPort)) + transportCredentials, err := tailTransportCredentials(profile) + if err != nil { + return err + } - flightClient, err := flight.NewClientWithMiddleware(url, nil, nil, grpc.WithTransportCredentials(insecure.NewCredentials())) + flightClient, err := flight.NewClientWithMiddleware(url, nil, nil, grpc.WithTransportCredentials(transportCredentials)) if err != nil { return err } @@ -144,6 +152,25 @@ func tail(profile config.Profile, stream string, jsonOutput bool) error { } } +func tailTransportCredentials(profile config.Profile) (credentials.TransportCredentials, error) { + profileURL, err := url.Parse(profile.URL) + if err != nil || profileURL.Hostname() == "" { + return nil, fmt.Errorf("invalid profile URL %q", profile.URL) + } + + scheme := strings.ToLower(profileURL.Scheme) + if profile.Cloud || scheme == "https" { + return credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: profileURL.Hostname(), + }), nil + } + if scheme == "http" { + return insecure.NewCredentials(), nil + } + return nil, fmt.Errorf("unsupported profile URL scheme %q", profileURL.Scheme) +} + func tailAuthMetadata(profile config.Profile) (metadata.MD, error) { mode, err := profile.AuthMode() if err != nil { diff --git a/cmd/tail_test.go b/cmd/tail_test.go new file mode 100644 index 0000000..ee59a19 --- /dev/null +++ b/cmd/tail_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "testing" + + "github.com/parseablehq/pb/pkg/config" +) + +func TestTailTransportCredentials(t *testing.T) { + tests := []struct { + name string + profile config.Profile + wantProtocol string + wantServer string + }{ + { + name: "cloud always uses TLS", + profile: config.Profile{URL: "https://workspace.example.com", Cloud: true}, + wantProtocol: "tls", + wantServer: "workspace.example.com", + }, + { + name: "self-hosted HTTPS uses TLS", + profile: config.Profile{URL: "https://logs.example.com:8000"}, + wantProtocol: "tls", + wantServer: "logs.example.com", + }, + { + name: "self-hosted HTTP preserves insecure transport", + profile: config.Profile{URL: "http://localhost:8000"}, + wantProtocol: "insecure", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transportCredentials, err := tailTransportCredentials(test.profile) + if err != nil { + t.Fatal(err) + } + info := transportCredentials.Info() + if info.SecurityProtocol != test.wantProtocol { + t.Fatalf("protocol=%q want=%q", info.SecurityProtocol, test.wantProtocol) + } + if info.ServerName != test.wantServer { + t.Fatalf("server name=%q want=%q", info.ServerName, test.wantServer) + } + }) + } +} + +func TestTailTransportCredentialsRejectsInvalidURL(t *testing.T) { + if _, err := tailTransportCredentials(config.Profile{URL: "not-a-url"}); err == nil { + t.Fatal("expected invalid URL error") + } +} diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go index e3fafd5..097ee7c 100644 --- a/pkg/analytics/analytics.go +++ b/pkg/analytics/analytics.go @@ -35,7 +35,6 @@ import ( "github.com/oklog/ulid/v2" "github.com/spf13/cobra" - "github.com/spf13/pflag" "gopkg.in/yaml.v2" ) @@ -148,63 +147,41 @@ func CheckAndCreateULID(_ *cobra.Command, _ []string) error { return nil } -func PostRunAnalytics(cmd *cobra.Command, name string, args []string) { +func PostRunAnalytics(cmd *cobra.Command, name string, _ []string) { executionTime := cmd.Annotations["executionTime"] - commandError := cmd.Annotations["error"] - flags := make(map[string]string) - cmd.Flags().VisitAll(func(flag *pflag.Flag) { - flags[flag.Name] = flag.Value.String() - }) - - // Call SendEvent in PostRunE - err := sendEvent( - name, - append(args, cmd.Name()), - &commandError, // Pass the error here if there was one - executionTime, - flags, - ) + + // Never collect arguments, flag values, errors, or profile data. They may + // contain queries, passwords, API keys, session tokens, or server details. + err := sendEvent(name, executionTime) if err != nil { fmt.Println("Error sending analytics event:", err) } } // sendEvent is a placeholder function to simulate sending an event after command execution. -func sendEvent(commandName string, arguments []string, errors *string, executionTimestamp string, flags map[string]string) error { +func sendEvent(commandName string, executionTimestamp string) error { ulid, err := ReadUULD() if err != nil { return fmt.Errorf("could not load ULID: %v", err) } - profile, err := GetProfile() - if err != nil { - return fmt.Errorf("failed to get profile: %v", err) - } - - httpClient := internalHTTP.DefaultClient(&profile) - - about, _ := FetchAbout(&httpClient) - // if err != nil { - // return fmt.Errorf("failed to get about metadata for profile: %v", err) - // } - // Create the Command struct cmd := Command{ Name: commandName, - Arguments: arguments, - Flags: flags, + Arguments: []string{}, + Flags: map[string]string{}, } // Populate the Event struct with OS details and timestamp event := Event{ - CLIVersion: about.Commit, + CLIVersion: "", ULID: ulid, - CommitHash: about.Commit, + CommitHash: "", OSName: GetOSName(), OSVersion: GetOSVersion(), ReportCreatedAt: GetCurrentTimestamp(), Command: cmd, - Errors: errors, + Errors: nil, ExecutionTimestamp: executionTimestamp, } @@ -226,7 +203,8 @@ func sendEvent(commandName string, arguments []string, errors *string, execution req.Header.Set("X-P-Stream", "pb-usage") // Execute the HTTP request - resp, err := httpClient.Client.Do(req) + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send event: %v", err) } diff --git a/pkg/analytics/analytics_test.go b/pkg/analytics/analytics_test.go new file mode 100644 index 0000000..986efad --- /dev/null +++ b/pkg/analytics/analytics_test.go @@ -0,0 +1,59 @@ +package analytics + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func TestPostRunAnalyticsDoesNotCollectPrivateCommandData(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := CheckAndCreateULID(nil, nil); err != nil { + t.Fatal(err) + } + + var got Event + originalTransport := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if err := json.NewDecoder(req.Body).Decode(&got); err != nil { + t.Fatal(err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + Request: req, + }, nil + }) + t.Cleanup(func() { http.DefaultTransport = originalTransport }) + + cmd := &cobra.Command{Use: "profile"} + cmd.Annotations = map[string]string{ + "error": "secret-error", + "executionTime": "1s", + } + cmd.Flags().String("api-key", "", "") + if err := cmd.Flags().Set("api-key", "secret-api-key"); err != nil { + t.Fatal(err) + } + + PostRunAnalytics(cmd, "profile", []string{"prod", "https://example.com", "user", "secret-password"}) + + if got.Command.Name != "profile" { + t.Fatalf("command name mismatch: %q", got.Command.Name) + } + if len(got.Command.Arguments) != 0 || len(got.Command.Flags) != 0 || got.Errors != nil { + t.Fatalf("private command data collected: %#v", got) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index efa9042..7a1b043 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,6 +24,7 @@ import ( "os" path "path/filepath" "runtime" + "sync" toml "github.com/pelletier/go-toml/v2" ) @@ -31,6 +32,7 @@ import ( var ( configFilename = "config.toml" configAppName = "pb" + configFileMu sync.Mutex ) // Path returns the config file path. @@ -58,6 +60,27 @@ func Path() (string, error) { return path.Join(dir, configAppName, configFilename), nil } +// UpdateCloudTokens persists a rotated cloud session and refresh token. +func UpdateCloudTokens(oldRefreshToken, sessionToken, refreshToken string) error { + configFileMu.Lock() + defer configFileMu.Unlock() + + fileConfig, err := ReadConfigFromFile() + if err != nil { + return err + } + for name, profile := range fileConfig.Profiles { + if !profile.Cloud || profile.RefreshToken != oldRefreshToken { + continue + } + profile.SessionToken = sessionToken + profile.RefreshToken = refreshToken + fileConfig.Profiles[name] = profile + return WriteConfigToFile(fileConfig) + } + return errors.New("cloud profile for refresh token not found") +} + // Config is the struct that holds the configuration type Config struct { Profiles map[string]Profile @@ -79,7 +102,6 @@ type Profile struct { WorkspaceID string `toml:"workspace_id,omitempty" json:"workspace_id,omitempty"` WorkspaceName string `toml:"workspace_name,omitempty" json:"workspace_name,omitempty"` OrchestratorURL string `toml:"orchestrator_url,omitempty" json:"orchestrator_url,omitempty"` - ClerkSessionID string `toml:"clerk_session_id,omitempty" json:"clerk_session_id,omitempty"` } type AuthMode string @@ -144,7 +166,10 @@ func (p *Profile) GrpcAddr(port string) string { // WriteConfigToFile writes the configuration to the config file func WriteConfigToFile(config *Config) error { - tomlData, _ := toml.Marshal(config) + tomlData, err := toml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } filePath, err := Path() if err != nil { return err diff --git a/pkg/datasets/datasets.go b/pkg/datasets/datasets.go index f0dd965..f2df91a 100644 --- a/pkg/datasets/datasets.go +++ b/pkg/datasets/datasets.go @@ -88,108 +88,7 @@ func fetchPrismHomeDatasets(profile config.Profile) ([]Dataset, error) { } func FetchAPIDatasets(profile config.Profile) ([]Dataset, error) { - client := internalHTTP.DefaultClient(&profile) - req, err := client.NewRequest(http.MethodGet, "logstream", nil) - if err != nil { - return nil, err - } - - resp, err := client.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode >= 400 { - return nil, httpStatusError{statusCode: resp.StatusCode, status: resp.Status, body: string(body)} - } - - names, err := decodeDatasetNames(body) - if err != nil { - return nil, err - } - - items := make([]Dataset, 0, len(names)) - for _, name := range names { - item := Dataset{Title: name} - if datasetType, err := fetchDatasetType(&client, name); err == nil { - item.DatasetType = datasetType - } - items = append(items, item) - } - sortDatasets(items) - return items, nil -} - -func decodeDatasetNames(body []byte) ([]string, error) { - var names []string - if err := json.Unmarshal(body, &names); err == nil { - sort.Strings(names) - return names, nil - } - - var datasets []Dataset - if err := json.Unmarshal(body, &datasets); err == nil { - names := make([]string, 0, len(datasets)) - for _, item := range datasets { - name := item.Title - if name == "" { - name = item.Name - } - if name != "" { - names = append(names, name) - } - } - sort.Strings(names) - return names, nil - } - - var wrapped struct { - Datasets []string `json:"datasets"` - } - if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Datasets != nil { - sort.Strings(wrapped.Datasets) - return wrapped.Datasets, nil - } - - return nil, fmt.Errorf("failed to decode dataset list response") -} - -func fetchDatasetType(client *internalHTTP.HTTPClient, name string) (string, error) { - req, err := client.NewRequest(http.MethodGet, fmt.Sprintf("logstream/%s/info", name), nil) - if err != nil { - return "", err - } - - resp, err := client.Client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - if resp.StatusCode >= 400 { - return "", httpStatusError{statusCode: resp.StatusCode, status: resp.Status, body: string(body)} - } - - var response struct { - StreamType string `json:"streamType"` - TelemetryType string `json:"telemetryType"` - } - if err := json.Unmarshal(body, &response); err != nil { - return "", err - } - if response.TelemetryType != "" { - return response.TelemetryType, nil - } - return response.StreamType, nil + return fetchPrismHomeDatasets(profile) } func sortDatasets(items []Dataset) { diff --git a/pkg/datasets/datasets_test.go b/pkg/datasets/datasets_test.go index dd089cc..986f489 100644 --- a/pkg/datasets/datasets_test.go +++ b/pkg/datasets/datasets_test.go @@ -1,8 +1,13 @@ package datasets import ( + "fmt" + "net/http" + "net/http/httptest" "reflect" "testing" + + "github.com/parseablehq/pb/pkg/config" ) func TestNamesByTypeUsesDatasetTypeMetadata(t *testing.T) { @@ -25,3 +30,26 @@ func TestNamesByTypeUsesDatasetTypeMetadata(t *testing.T) { t.Fatalf("metrics mismatch\nwant: %#v\n got: %#v", wantMetrics, gotMetrics) } } + +func TestFetchAPIDatasetsUsesSinglePrismHomeRequest(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if r.URL.Path != "/api/prism/v1/home" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + fmt.Fprint(w, `{"datasets":[{"title":"traces","datasetType":"traces","datasetFormat":"otel-traces","ingestion":false},{"title":"logs","datasetType":"logs","datasetFormat":"otel-logs","ingestion":false}]}`) + })) + defer server.Close() + + items, err := FetchAPIDatasets(config.Profile{URL: server.URL, APIKey: "test-key"}) + if err != nil { + t.Fatal(err) + } + if requestCount != 1 { + t.Fatalf("request count=%d want=1", requestCount) + } + if len(items) != 2 || items[0].Title != "logs" || items[0].DatasetType != TypeLogs || items[1].DatasetType != TypeTraces { + t.Fatalf("unexpected datasets: %#v", items) + } +} diff --git a/pkg/http/http.go b/pkg/http/http.go index 101f018..546cced 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -40,9 +40,20 @@ type HTTPClient struct { } func DefaultClient(profile *config.Profile) HTTPClient { + return DefaultClientWithTransport(profile, http.DefaultTransport) +} + +func DefaultClientWithTransport(profile *config.Profile, transport http.RoundTripper) HTTPClient { + if transport == nil { + transport = http.DefaultTransport + } return HTTPClient{ Client: http.Client{ Timeout: 60 * time.Second, + Transport: &cloudRefreshTransport{ + base: transport, + profile: profile, + }, }, Profile: profile, } @@ -54,6 +65,9 @@ func (client *HTTPClient) baseAPIURL(path string) (x string) { } func (client *HTTPClient) NewRequest(method string, path string, body io.Reader) (req *http.Request, err error) { + if client.Profile == nil { + return nil, errors.New("profile is nil") + } req, err = http.NewRequest(method, client.baseAPIURL(path), body) if err != nil { return diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go new file mode 100644 index 0000000..05efea4 --- /dev/null +++ b/pkg/http/http_test.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/parseablehq/pb/pkg/config" +) + +type testRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn testRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func testHTTPResponse(req *http.Request, statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } +} + +func TestNewRequestWithNilProfileReturnsError(t *testing.T) { + client := DefaultClient(nil) + req, err := client.NewRequest(http.MethodGet, "about", nil) + if err == nil || err.Error() != "profile is nil" { + t.Fatalf("request=%v error=%v", req, err) + } + if req != nil { + t.Fatalf("expected nil request, got %v", req) + } +} + +func TestCloudSessionRefreshesAndRetriesWorkspaceRequest(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + profile := config.Profile{ + URL: "https://workspace.example.com", + Cloud: true, + SessionToken: "old-session", + RefreshToken: "old-refresh", + TenantID: "tenant-id", + WorkspaceID: "workspace-id", + OrchestratorURL: "https://orchestrator.example.com", + } + if err := config.WriteConfigToFile(&config.Config{ + Profiles: map[string]config.Profile{"cloud": profile}, + DefaultProfile: "cloud", + }); err != nil { + t.Fatal(err) + } + + workspaceCalls := 0 + refreshCalls := 0 + base := testRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Host { + case "orchestrator.example.com": + refreshCalls++ + var payload cloudRefreshRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.RefreshToken != "old-refresh" { + t.Fatalf("refresh token=%q", payload.RefreshToken) + } + return testHTTPResponse(req, http.StatusOK, `{"access_token":"new-session","refresh_token":"new-refresh"}`), nil + case "workspace.example.com": + workspaceCalls++ + cookie, _ := req.Cookie("session") + if cookie != nil && cookie.Value == "new-session" { + return testHTTPResponse(req, http.StatusOK, `{}`), nil + } + return testHTTPResponse(req, http.StatusUnauthorized, ``), nil + default: + t.Fatalf("unexpected host: %s", req.URL.Host) + return nil, nil + } + }) + + client := DefaultClientWithTransport(&profile, base) + req, err := client.NewRequest(http.MethodGet, "about", nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK || workspaceCalls != 2 || refreshCalls != 1 { + t.Fatalf("status=%d workspace_calls=%d refresh_calls=%d", resp.StatusCode, workspaceCalls, refreshCalls) + } + if profile.SessionToken != "new-session" || profile.RefreshToken != "new-refresh" { + t.Fatalf("tokens not updated: %#v", profile) + } +} + +func TestCloudSessionDoesNotRefreshCrossOriginRequest(t *testing.T) { + profile := config.Profile{ + URL: "https://workspace.example.com", + Cloud: true, + SessionToken: "session-token", + RefreshToken: "refresh-token", + TenantID: "tenant-id", + WorkspaceID: "workspace-id", + OrchestratorURL: "https://orchestrator.example.com", + } + externalCalls := 0 + refreshCalls := 0 + base := testRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "orchestrator.example.com" { + refreshCalls++ + } else { + externalCalls++ + } + if _, err := req.Cookie("session"); err == nil || req.Header.Get("X-P-Tenant") != "" { + t.Fatal("cloud credentials sent cross-origin") + } + return testHTTPResponse(req, http.StatusUnauthorized, ``), nil + }) + + client := DefaultClientWithTransport(&profile, base) + req, err := http.NewRequest(http.MethodGet, "https://analytics.example.com/event", nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if externalCalls != 1 || refreshCalls != 0 { + t.Fatalf("external_calls=%d refresh_calls=%d", externalCalls, refreshCalls) + } +} diff --git a/pkg/http/refresh.go b/pkg/http/refresh.go new file mode 100644 index 0000000..647ff7d --- /dev/null +++ b/pkg/http/refresh.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/parseablehq/pb/pkg/config" +) + +type cloudRefreshTransport struct { + base http.RoundTripper + profile *config.Profile + mu sync.Mutex +} + +type cloudRefreshRequest struct { + GrantType string `json:"grant_type"` + RefreshToken string `json:"refresh_token"` +} + +type cloudRefreshResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +func (transport *cloudRefreshTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := transport.base.RoundTrip(req) + if err != nil || resp.StatusCode != http.StatusUnauthorized || !transport.isProfileRequest(req) || !transport.canRefresh() { + return resp, err + } + + transport.mu.Lock() + defer transport.mu.Unlock() + + requestToken := requestSessionCookie(req) + if requestToken == transport.profile.SessionToken { + if err := transport.refresh(req); err != nil { + return resp, nil + } + } + + retry, err := cloneRequestForCloudRetry(req) + if err != nil { + return resp, nil + } + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + resp.Body.Close() + return nil, err + } + resp.Body.Close() + retry.Header.Del("Cookie") + if err := AddAuthHeaders(retry, transport.profile); err != nil { + return nil, err + } + return transport.base.RoundTrip(retry) +} + +func (transport *cloudRefreshTransport) isProfileRequest(req *http.Request) bool { + if transport.profile == nil || req == nil || req.URL == nil { + return false + } + profileURL, err := url.Parse(transport.profile.URL) + if err != nil { + return false + } + return strings.EqualFold(req.URL.Scheme, profileURL.Scheme) && + strings.EqualFold(req.URL.Host, profileURL.Host) +} + +func (transport *cloudRefreshTransport) canRefresh() bool { + if transport.profile == nil || transport.profile.RefreshToken == "" || transport.profile.OrchestratorURL == "" { + return false + } + mode, err := transport.profile.AuthMode() + return err == nil && mode == config.AuthCloudOAuth +} + +func (transport *cloudRefreshTransport) refresh(original *http.Request) error { + oldRefreshToken := transport.profile.RefreshToken + payload, err := json.Marshal(cloudRefreshRequest{ + GrantType: "refresh_token", + RefreshToken: oldRefreshToken, + }) + if err != nil { + return err + } + endpoint, err := url.JoinPath(transport.profile.OrchestratorURL, "api/v1/cli/oauth/token") + if err != nil { + return err + } + req, err := http.NewRequestWithContext(original.Context(), http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := transport.base.RoundTrip(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("cloud session refresh failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var result cloudRefreshResponse + if err := json.Unmarshal(body, &result); err != nil { + return err + } + if result.AccessToken == "" || result.RefreshToken == "" { + return fmt.Errorf("cloud session refresh response missing access_token or refresh_token") + } + if err := config.UpdateCloudTokens(oldRefreshToken, result.AccessToken, result.RefreshToken); err != nil { + return err + } + transport.profile.SessionToken = result.AccessToken + transport.profile.RefreshToken = result.RefreshToken + return nil +} + +func requestSessionCookie(req *http.Request) string { + cookie, err := req.Cookie("session") + if err != nil { + return "" + } + return cookie.Value +} + +func cloneRequestForCloudRetry(req *http.Request) (*http.Request, error) { + retry := req.Clone(req.Context()) + if req.Body == nil { + return retry, nil + } + if req.GetBody == nil { + return nil, fmt.Errorf("request body cannot be replayed after cloud session refresh") + } + body, err := req.GetBody() + if err != nil { + return nil, err + } + retry.Body = body + return retry, nil +} diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go index ad2cfa4..f57658e 100644 --- a/pkg/model/login/login.go +++ b/pkg/model/login/login.go @@ -81,10 +81,10 @@ type Model struct { errMsg string // Result fields — set when Done is true. - Done bool - Profile config.Profile - Name string - CloudBrowserLogin bool + Done bool + Profile config.Profile + Name string + CloudDeviceLogin bool } func newInput(placeholder string, charLimit int) textinput.Model { @@ -108,6 +108,8 @@ func New() Model { passwordInput.EchoCharacter = '•' apiKeyInput := newInput("paste API key here", 512) + apiKeyInput.EchoMode = textinput.EchoPassword + apiKeyInput.EchoCharacter = '•' profileInput := newInput("e.g. local, staging, prod", 64) profileInput.SetValue("default") @@ -400,7 +402,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) finalize(name string) (tea.Model, tea.Cmd) { m.Name = name if m.typeIndex == 1 && m.cloudAuthIndex == 0 { - m.CloudBrowserLogin = true + m.CloudDeviceLogin = true m.Profile = config.Profile{ Cloud: true, } @@ -453,6 +455,11 @@ func hint(pairs ...[2]string) string { // card with a fixed UPPERCASE title strip. Each step writes its own // label row + body + hint row, joined into the card. func (m Model) View() string { + // Device authorization and persistence happen after the wizard exits. + if m.step == stepDone && m.CloudDeviceLogin { + return "" + } + var b strings.Builder b.WriteString(titleStyle.Render("PARSEABLE LOGIN")) @@ -490,7 +497,7 @@ func (m Model) View() string { case stepChooseCloudAuth: b.WriteString(labelStyle.Render("CLOUD AUTHENTICATION")) b.WriteString("\n\n") - authEntries := []string{"OAuth (browser)", "API key"} + authEntries := []string{"Device login (browser)", "API key"} for i, entry := range authEntries { if i == m.cloudAuthIndex { b.WriteString(rowSelected(entry)) @@ -592,9 +599,11 @@ func (m Model) View() string { b.WriteString("\n") } b.WriteString("\n") - b.WriteString(dimStyle.Render(" add more profiles:")) + b.WriteString(dimStyle.Render(" Next steps:")) + b.WriteString("\n ") + b.WriteString(hintStyle.Render("pb status Verify active connection")) b.WriteString("\n ") - b.WriteString(hintStyle.Render("pb profile add [user] [pass]")) + b.WriteString(hintStyle.Render("pb dataset list Explore available datasets")) } return lipgloss.NewStyle(). diff --git a/pkg/model/login/login_test.go b/pkg/model/login/login_test.go new file mode 100644 index 0000000..84baf81 --- /dev/null +++ b/pkg/model/login/login_test.go @@ -0,0 +1,17 @@ +package login + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" +) + +func TestAPIKeyInputIsMasked(t *testing.T) { + model := New() + if model.apiKeyInput.EchoMode != textinput.EchoPassword { + t.Fatalf("API key echo mode=%v", model.apiKeyInput.EchoMode) + } + if model.apiKeyInput.EchoCharacter != '•' { + t.Fatalf("API key echo character=%q", model.apiKeyInput.EchoCharacter) + } +} diff --git a/pkg/model/promql.go b/pkg/model/promql.go index 56c4843..47c2b7c 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -2215,12 +2215,10 @@ func promqlModelFetch(profile config.Profile, path string, params url.Values) ([ reqURL += "?" + params.Encode() } - client := &http.Client{ - Timeout: 120 * time.Second, - Transport: &http.Transport{ - TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), - }, - } + client := internalHTTP.DefaultClientWithTransport(&profile, &http.Transport{ + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + }) + client.Client.Timeout = 120 * time.Second req, err := http.NewRequest("GET", reqURL, nil) if err != nil { @@ -2230,7 +2228,7 @@ func promqlModelFetch(profile config.Profile, path string, params url.Values) ([ return nil, err } - resp, err := client.Do(req) + resp, err := client.Client.Do(req) if err != nil { if strings.Contains(err.Error(), "connection reset") { return nil, fmt.Errorf("server reset the connection — query timed out") @@ -2601,7 +2599,8 @@ type promqlLabelListResp struct { } func builderHTTPGetCtx(ctx context.Context, profile config.Profile, rawURL string) ([]byte, error) { - client := &http.Client{Timeout: 30 * time.Second} + client := internalHTTP.DefaultClient(&profile) + client.Client.Timeout = 30 * time.Second req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) if err != nil { return nil, err @@ -2609,7 +2608,7 @@ func builderHTTPGetCtx(ctx context.Context, profile config.Profile, rawURL strin if err := internalHTTP.AddAuthHeaders(req, &profile); err != nil { return nil, err } - resp, err := client.Do(req) + resp, err := client.Client.Do(req) if err != nil { return nil, err } From 36fbedc5804149fee4ec00198e1b746a231d0826 Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 10:51:16 +0530 Subject: [PATCH 5/7] fix: resolved coderabbit suggestion --- cmd/cloud.go | 32 -------------------------- cmd/cloud_profile_test.go | 3 --- cmd/promql_test.go | 18 ++++++++++----- main.go | 21 ++++++++++++----- pkg/analytics/analytics.go | 2 +- pkg/http/http_test.go | 46 ++++++++++++++++++++++++++++++++++++++ pkg/http/refresh.go | 9 ++++---- 7 files changed, 80 insertions(+), 51 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index c97ed84..cee5e80 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -186,33 +186,6 @@ var CloudCmd = &cobra.Command{ Long: "\ncloud command is used to configure Parseable Cloud access.", } -var CloudLoginCmd = &cobra.Command{ - Use: "login", - Example: " pb cloud login\n pb cloud login --name prod", - Short: "Login to Parseable Cloud", - Long: "\nLogin to Parseable Cloud using the device authorization flow.", - RunE: func(cmd *cobra.Command, _ []string) error { - orchestratorURL := cloudOrchestratorEndpoint() - profile, err := cloudProfileFromDeviceLogin(cmd.Context(), orchestratorURL) - if err != nil { - return err - } - - profileName := strings.TrimSpace(cloudProfileName) - if profileName == "" { - profileName = cloudProfileNameFromSession(profile) - } - if err := saveCloudProfile(profileName, *profile, cloudForceOverwrite, false); err != nil { - return err - } - - fmt.Printf("Cloud profile %s added successfully\n", profileName) - fmt.Printf("Workspace: %s\n", profile.WorkspaceID) - fmt.Printf("URL: %s\n", profile.URL) - return nil - }, -} - var CloudProfileCmd = &cobra.Command{ Use: "profile", Short: "Manage Parseable Cloud profiles", @@ -266,17 +239,12 @@ var CloudProfileAddCmd = &cobra.Command{ } func init() { - CloudLoginCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") - CloudLoginCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") - CloudLoginCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") - CloudProfileAddCmd.Flags().StringVar(&cloudAPIKey, "api-key", "", "Parseable Cloud API key") CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") CloudProfileAddCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") CloudProfileCmd.AddCommand(CloudProfileAddCmd) - CloudCmd.AddCommand(CloudLoginCmd) CloudCmd.AddCommand(CloudProfileCmd) } diff --git a/cmd/cloud_profile_test.go b/cmd/cloud_profile_test.go index 0562cf9..83b72bb 100644 --- a/cmd/cloud_profile_test.go +++ b/cmd/cloud_profile_test.go @@ -7,9 +7,6 @@ import ( ) func TestCloudCommandsDoNotExposeDefaultFlag(t *testing.T) { - if flag := CloudLoginCmd.Flags().Lookup("default"); flag != nil { - t.Fatal("cloud login still exposes --default") - } if flag := CloudProfileAddCmd.Flags().Lookup("default"); flag != nil { t.Fatal("cloud profile add still exposes --default") } diff --git a/cmd/promql_test.go b/cmd/promql_test.go index 58c0379..ec05f64 100644 --- a/cmd/promql_test.go +++ b/cmd/promql_test.go @@ -82,16 +82,22 @@ func TestPromqlPositionalArgumentsOverrideFlags(t *testing.T) { }, }, { - name: "cardinality label values stream and label", - cmd: promqlCardinalityLabelValuesCmd, - args: []string{"positional-cardinality", "service.name"}, + name: "cardinality label values stream and label", + cmd: promqlCardinalityLabelValuesCmd, + args: []string{"positional-cardinality", "service.name"}, + flagSetup: map[string]string{ + "label": "flag-label", + }, wantPath: "/prometheus/api/v1/cardinality/label_values", wantQuery: map[string]string{"stream": "positional-cardinality", "label_name": "service.name"}, }, { - name: "active series stream and selector", - cmd: promqlCardinalityActiveSeriesCmd, - args: []string{"positional-active", `{job="api"}`}, + name: "active series stream and selector", + cmd: promqlCardinalityActiveSeriesCmd, + args: []string{"positional-active", `{job="api"}`}, + flagSetup: map[string]string{ + "selector": `{job="flag-value"}`, + }, wantPath: "/prometheus/api/v1/cardinality/active_series", wantQuery: map[string]string{"stream": "positional-active", "selector": `{job="api"}`}, }, diff --git a/main.go b/main.go index 1ec3609..5147f4b 100644 --- a/main.go +++ b/main.go @@ -44,12 +44,14 @@ var ( rootOutputFormat string ) +const analyticsEnabled = false + // Root command var cli = &cobra.Command{ Use: "pb", Short: "\nParseable command line interface", Long: "\npb is the command line interface for Parseable", - PersistentPreRunE: analytics.CheckAndCreateULID, + PersistentPreRunE: analyticsPreRun, RunE: func(command *cobra.Command, _ []string) error { if p, _ := command.Flags().GetBool(versionFlag); p { pb.PrintVersion(Version, Commit) @@ -64,7 +66,7 @@ var cli = &cobra.Command{ return command.Help() }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - if os.Getenv("PB_ANALYTICS") == "disable" { + if !analyticsEnabled || os.Getenv("PB_ANALYTICS") == "disable" { return } go func() { @@ -75,13 +77,20 @@ var cli = &cobra.Command{ func postRunAnalytics(name string) func(*cobra.Command, []string) { return func(cmd *cobra.Command, args []string) { - if os.Getenv("PB_ANALYTICS") == "disable" { + if !analyticsEnabled || os.Getenv("PB_ANALYTICS") == "disable" { return } go analytics.PostRunAnalytics(cmd, name, args) } } +func analyticsPreRun(cmd *cobra.Command, args []string) error { + if !analyticsEnabled { + return nil + } + return analytics.CheckAndCreateULID(cmd, args) +} + type rootHelpCommand struct { name string desc string @@ -546,8 +555,10 @@ func combinedPreRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("error initializing default profile: %w", err) } - if err := analytics.CheckAndCreateULID(cmd, args); err != nil { - return fmt.Errorf("error while creating ulid: %v", err) + if analyticsEnabled { + if err := analytics.CheckAndCreateULID(cmd, args); err != nil { + return fmt.Errorf("error while creating ulid: %v", err) + } } return nil diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go index 097ee7c..43ffb7f 100644 --- a/pkg/analytics/analytics.go +++ b/pkg/analytics/analytics.go @@ -154,7 +154,7 @@ func PostRunAnalytics(cmd *cobra.Command, name string, _ []string) { // contain queries, passwords, API keys, session tokens, or server details. err := sendEvent(name, executionTime) if err != nil { - fmt.Println("Error sending analytics event:", err) + fmt.Fprintln(os.Stderr, "Error sending analytics event:", err) } } diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 05efea4..9fc33fa 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -140,3 +140,49 @@ func TestCloudSessionDoesNotRefreshCrossOriginRequest(t *testing.T) { t.Fatalf("external_calls=%d refresh_calls=%d", externalCalls, refreshCalls) } } + +func TestCloudSessionReturnsRefreshPersistenceError(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + profile := config.Profile{ + URL: "https://workspace.example.com", + Cloud: true, + SessionToken: "old-session", + RefreshToken: "old-refresh", + TenantID: "tenant-id", + WorkspaceID: "workspace-id", + OrchestratorURL: "https://orchestrator.example.com", + } + if err := config.WriteConfigToFile(&config.Config{ + Profiles: map[string]config.Profile{ + "different": { + Cloud: true, + RefreshToken: "different-refresh", + }, + }, + }); err != nil { + t.Fatal(err) + } + + base := testRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "orchestrator.example.com" { + return testHTTPResponse(req, http.StatusOK, `{"access_token":"new-session","refresh_token":"new-refresh"}`), nil + } + return testHTTPResponse(req, http.StatusUnauthorized, ``), nil + }) + + client := DefaultClientWithTransport(&profile, base) + req, err := client.NewRequest(http.MethodGet, "about", nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Client.Do(req) + if err == nil || !strings.Contains(err.Error(), "failed to persist refreshed cloud tokens") { + t.Fatalf("response=%v error=%v", resp, err) + } + if resp != nil { + t.Fatalf("expected nil response, got %v", resp) + } + if profile.SessionToken != "new-session" || profile.RefreshToken != "new-refresh" { + t.Fatalf("rotated tokens were not retained in memory: %#v", profile) + } +} diff --git a/pkg/http/refresh.go b/pkg/http/refresh.go index 647ff7d..f31ccf3 100644 --- a/pkg/http/refresh.go +++ b/pkg/http/refresh.go @@ -41,7 +41,8 @@ func (transport *cloudRefreshTransport) RoundTrip(req *http.Request) (*http.Resp requestToken := requestSessionCookie(req) if requestToken == transport.profile.SessionToken { if err := transport.refresh(req); err != nil { - return resp, nil + resp.Body.Close() + return nil, fmt.Errorf("cloud session refresh failed: %w", err) } } @@ -119,11 +120,11 @@ func (transport *cloudRefreshTransport) refresh(original *http.Request) error { if result.AccessToken == "" || result.RefreshToken == "" { return fmt.Errorf("cloud session refresh response missing access_token or refresh_token") } - if err := config.UpdateCloudTokens(oldRefreshToken, result.AccessToken, result.RefreshToken); err != nil { - return err - } transport.profile.SessionToken = result.AccessToken transport.profile.RefreshToken = result.RefreshToken + if err := config.UpdateCloudTokens(oldRefreshToken, result.AccessToken, result.RefreshToken); err != nil { + return fmt.Errorf("failed to persist refreshed cloud tokens: %w", err) + } return nil } From 94db613b055d021f0472fc91cbbeaad5954a5a33 Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 11:04:13 +0530 Subject: [PATCH 6/7] fix: code rabbit resolve --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 5147f4b..cea8257 100644 --- a/main.go +++ b/main.go @@ -147,7 +147,7 @@ var profile = &cobra.Command{ Use: "profile", Short: "Manage different Parseable targets", Long: "\nuse profile command to configure different Parseable instances. Each profile takes a URL and credentials.", - PersistentPreRunE: analytics.CheckAndCreateULID, + PersistentPreRunE: analyticsPreRun, PersistentPostRun: postRunAnalytics("profile"), } From 08e12aaf05baf608ec82dba2a74cbb10c0d8bf02 Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 12:19:23 +0530 Subject: [PATCH 7/7] chore: centralize service URLs and inject cloud endpoint at build time --- .github/workflows/release.yaml | 6 +++++ .goreleaser.yml | 2 +- buildscripts/gen-ldflags.go | 3 +++ cmd/cloud.go | 11 ++++----- cmd/cluster.go | 3 ++- cmd/login.go | 4 ++-- pkg/analytics/analytics.go | 2 +- pkg/config/defaults.go | 42 ++++++++++++++++++++++++++++++++++ pkg/installer/installer.go | 19 +++++++-------- pkg/installer/uninstaller.go | 3 ++- pkg/model/login/login.go | 2 +- 11 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 pkg/config/defaults.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 74c9a71..487c07e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,6 +24,11 @@ jobs: go-version-file: go.mod cache: false + - name: Validate release configuration + env: + PB_CLOUD_ORCHESTRATOR_URL: ${{ vars.PB_CLOUD_ORCHESTRATOR_URL }} + run: test -n "$PB_CLOUD_ORCHESTRATOR_URL" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -32,6 +37,7 @@ jobs: args: release --parallelism 1 --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PB_CLOUD_ORCHESTRATOR_URL: ${{ vars.PB_CLOUD_ORCHESTRATOR_URL }} - name: Update Homebrew tap env: diff --git a/.goreleaser.yml b/.goreleaser.yml index a2a726c..96ed2e8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -18,7 +18,7 @@ builds: - -trimpath - -tags=kqueue ldflags: - - -s -w -X main.Version=v{{ .Version }} -X main.Commit={{ .ShortCommit }} + - -s -w -X main.Version=v{{ .Version }} -X main.Commit={{ .ShortCommit }} -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorURL={{ .Env.PB_CLOUD_ORCHESTRATOR_URL }} archives: - name_template: "pb_{{ .Version }}_{{ .Os }}_{{ .Arch }}" diff --git a/buildscripts/gen-ldflags.go b/buildscripts/gen-ldflags.go index 5d8bccf..11e9be9 100644 --- a/buildscripts/gen-ldflags.go +++ b/buildscripts/gen-ldflags.go @@ -29,6 +29,9 @@ func genLDFlags(version string) string { var ldflagsStr string ldflagsStr = "-s -w -X main.Version=" + version + " " ldflagsStr = ldflagsStr + "-X main.Commit=" + commitID()[:6] + if orchestratorURL := os.Getenv("PB_CLOUD_ORCHESTRATOR_URL"); orchestratorURL != "" { + ldflagsStr += " -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorURL=" + orchestratorURL + } return ldflagsStr } diff --git a/cmd/cloud.go b/cmd/cloud.go index cee5e80..cfe7be8 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -31,10 +31,9 @@ import ( ) const ( - cloudDefaultOrchestratorURL = "https://orchestrator.cloud-staging.parseable.com" - cloudDeviceClientID = "pb-cli" - cloudDefaultPollInterval = 5 * time.Second - cloudDefaultLoginTimeout = 5 * time.Minute + cloudDeviceClientID = "pb-cli" + cloudDefaultPollInterval = 5 * time.Second + cloudDefaultLoginTimeout = 5 * time.Minute ) var ( @@ -241,7 +240,7 @@ var CloudProfileAddCmd = &cobra.Command{ func init() { CloudProfileAddCmd.Flags().StringVar(&cloudAPIKey, "api-key", "", "Parseable Cloud API key") CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") - CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", cloudDefaultOrchestratorURL, "Parseable Cloud orchestrator URL") + CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", config.CloudOrchestratorURL, "Parseable Cloud orchestrator URL") CloudProfileAddCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") CloudProfileCmd.AddCommand(CloudProfileAddCmd) @@ -252,7 +251,7 @@ func cloudOrchestratorEndpoint() string { if endpoint := strings.TrimSpace(cloudOrchestratorURL); endpoint != "" { return endpoint } - return cloudDefaultOrchestratorURL + return config.CloudOrchestratorURL } func cloudProfileFromDeviceLogin(parent context.Context, orchestratorURL string) (*config.Profile, error) { diff --git a/cmd/cluster.go b/cmd/cluster.go index 5f405ae..4a12483 100644 --- a/cmd/cluster.go +++ b/cmd/cluster.go @@ -23,6 +23,7 @@ import ( "github.com/olekukonko/tablewriter" "github.com/parseablehq/pb/pkg/common" + "github.com/parseablehq/pb/pkg/config" "github.com/parseablehq/pb/pkg/helm" "github.com/parseablehq/pb/pkg/installer" "github.com/spf13/cobra" @@ -196,7 +197,7 @@ func uninstallCluster(entry common.InstallerEntry) error { ReleaseName: entry.Name, Namespace: entry.Namespace, RepoName: "parseable", - RepoURL: "https://charts.parseable.com", + RepoURL: config.HelmChartRepositoryURL, ChartName: "parseable", Version: entry.Version, } diff --git a/cmd/login.go b/cmd/login.go index b945843..d4c0927 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -43,7 +43,7 @@ credentials. All settings are saved to ~/.config/pb/config.toml.`, } if m.CloudDeviceLogin { - profile, err := cloudProfileFromDeviceLogin(cmd.Context(), cloudDefaultOrchestratorURL) + profile, err := cloudProfileFromDeviceLogin(cmd.Context(), config.CloudOrchestratorURL) if err != nil { return err } @@ -76,7 +76,7 @@ func printDeviceLoginSuccess(profileName string) { } func cloudProfileFromAPIKey(apiKey string) (*config.Profile, error) { - orchestratorURL := cloudDefaultOrchestratorURL + orchestratorURL := config.CloudOrchestratorURL result, err := validateCloudAPIKey(orchestratorURL, apiKey) if err != nil { return nil, err diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go index 43ffb7f..af18203 100644 --- a/pkg/analytics/analytics.go +++ b/pkg/analytics/analytics.go @@ -192,7 +192,7 @@ func sendEvent(commandName string, executionTimestamp string) error { } // Define the target URL for the HTTP request - url := "https://analytics.parseable.io:80/pb" + url := config.AnalyticsURL // Create the HTTP POST request req, err := http.NewRequest("POST", url, bytes.NewBuffer(eventJSON)) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go new file mode 100644 index 0000000..a265bdd --- /dev/null +++ b/pkg/config/defaults.go @@ -0,0 +1,42 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package config + +import "fmt" + +// CloudOrchestratorURL uses staging for local/development builds. Release +// builds replace it with the production URL through Go's -X linker flag. +var CloudOrchestratorURL = "ADD ORCHESTRATOR URL HERE" + +// Service and user-facing URL defaults used by pb. +const ( + // Self-hosted defaults used by self-hosted login (placeholder values). + LocalParseableURL = "http://localhost:8000" + + // Analytics is retained in the codebase but disabled for this release by + // analyticsEnabled in main.go. + AnalyticsURL = "https://analytics.parseable.io:80/pb" + + // Installer defaults are retained for future development. The cluster + // install/uninstall commands are not registered in this release, so these + // values are not reachable through the released CLI. + HelmChartRepositoryURL = "https://charts.parseable.com" + GoogleCloudStorageURL = "https://storage.googleapis.com" + DocumentationURL = "https://www.parseable.com/docs/server/introduction" + StreamManagementDocsURL = "https://www.parseable.com/docs/server/api" +) + +// AmazonS3URL is used only by the currently unregistered cluster installer. +func AmazonS3URL(region string) string { + return fmt.Sprintf("https://s3.%s.amazonaws.com", region) +} + +// AzureBlobStorageURL is used only by the currently unregistered cluster installer. +func AzureBlobStorageURL(storageAccount string) string { + return fmt.Sprintf("https://%s.blob.core.windows.net", storageAccount) +} diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index 0b6f981..19b745c 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -33,6 +33,7 @@ import ( "time" "github.com/parseablehq/pb/pkg/common" + "github.com/parseablehq/pb/pkg/config" "github.com/parseablehq/pb/pkg/helm" "github.com/manifoldco/promptui" @@ -95,7 +96,7 @@ func waterFall(verbose bool) { ReleaseName: pbInfo.Name, Namespace: pbInfo.Namespace, RepoName: "parseable", - RepoURL: "https://charts.parseable.com", + RepoURL: config.HelmChartRepositoryURL, ChartName: "parseable", Version: "2.6.6", Values: agentValues, @@ -157,7 +158,7 @@ func waterFall(verbose bool) { ReleaseName: pbInfo.Name, Namespace: pbInfo.Namespace, RepoName: "parseable", - RepoURL: "https://charts.parseable.com", + RepoURL: config.HelmChartRepositoryURL, ChartName: "parseable", Version: "2.6.6", Values: storeConfigs, @@ -536,8 +537,8 @@ func promptStoreConfigs(store ObjectStore, chartValues []string, plan Plan) (Obj // Dynamically construct the URL after Region is set storeValues.S3Store.URL = promptForInputWithDefault( - common.Yellow+" Enter S3 URL (default: https://s3."+storeValues.S3Store.Region+".amazonaws.com): "+common.Reset, - "https://s3."+storeValues.S3Store.Region+".amazonaws.com", + common.Yellow+" Enter S3 URL (default: "+config.AmazonS3URL(storeValues.S3Store.Region)+"): "+common.Reset, + config.AmazonS3URL(storeValues.S3Store.Region), ) sc, err := promptStorageClass() @@ -574,9 +575,9 @@ func promptStoreConfigs(store ObjectStore, chartValues []string, plan Plan) (Obj // Dynamically construct the URL after Region is set storeValues.BlobStore.URL = promptForInputWithDefault( common.Yellow+ - " Enter Blob URL (default: https://"+storeValues.BlobStore.StorageAccountName+".blob.core.windows.net): "+ + " Enter Blob URL (default: "+config.AzureBlobStorageURL(storeValues.BlobStore.StorageAccountName)+"): "+ common.Reset, - "https://"+storeValues.BlobStore.StorageAccountName+".blob.core.windows.net") + config.AzureBlobStorageURL(storeValues.BlobStore.StorageAccountName)) storeValues.StorageClass = sc storeValues.ObjectStore = BlobStore @@ -598,7 +599,7 @@ func promptStoreConfigs(store ObjectStore, chartValues []string, plan Plan) (Obj storeValues.GCSStore = GCS{ Bucket: promptForInputWithDefault(common.Yellow+" Enter GCS Bucket: "+common.Reset, ""), Region: promptForInputWithDefault(common.Yellow+" Enter GCS Region (default: us-east1): "+common.Reset, "us-east1"), - URL: promptForInputWithDefault(common.Yellow+" Enter GCS URL (default: https://storage.googleapis.com):", "https://storage.googleapis.com"), + URL: promptForInputWithDefault(common.Yellow+" Enter GCS URL (default: "+config.GoogleCloudStorageURL+"):", config.GoogleCloudStorageURL), AccessKey: promptForInputWithDefault(common.Yellow+" Enter GCS Access Key: "+common.Reset, ""), SecretKey: promptForInputWithDefault(common.Yellow+" Enter GCS Secret Key: "+common.Reset, ""), } @@ -841,8 +842,8 @@ func printSuccessBanner(pbInfo ParseableInfo, version, ingestorURL, queryURL str fmt.Printf(" • Ingestion URL: %s\n", ingestorURL) fmt.Println("\n" + common.Blue + "🔗 Resources:" + common.Reset) - fmt.Println(common.Blue + " • Documentation: https://www.parseable.com/docs/server/introduction") - fmt.Println(common.Blue + " • Stream Management: https://www.parseable.com/docs/server/api") + fmt.Println(common.Blue + " • Documentation: " + config.DocumentationURL) + fmt.Println(common.Blue + " • Stream Management: " + config.StreamManagementDocsURL) fmt.Println("\n" + common.Blue + "Happy Logging!" + common.Reset) diff --git a/pkg/installer/uninstaller.go b/pkg/installer/uninstaller.go index 072f6ff..054e1f4 100644 --- a/pkg/installer/uninstaller.go +++ b/pkg/installer/uninstaller.go @@ -26,6 +26,7 @@ import ( "github.com/manifoldco/promptui" "github.com/parseablehq/pb/pkg/common" + "github.com/parseablehq/pb/pkg/config" "github.com/parseablehq/pb/pkg/helm" apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -93,7 +94,7 @@ func Uninstaller(verbose bool) error { ReleaseName: selectedCluster.Name, Namespace: selectedCluster.Namespace, RepoName: "parseable", - RepoURL: "https://charts.parseable.com", + RepoURL: config.HelmChartRepositoryURL, ChartName: "parseable", Version: selectedCluster.Version, } diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go index f57658e..a8d9bf7 100644 --- a/pkg/model/login/login.go +++ b/pkg/model/login/login.go @@ -99,7 +99,7 @@ func newInput(placeholder string, charLimit int) textinput.Model { // New returns a fresh login wizard model. func New() Model { - urlInput := newInput("http://localhost:8000", 256) + urlInput := newInput(config.LocalParseableURL, 256) usernameInput := newInput("admin", 64)