From 94aed6e958a9d454389deab46d501bb9bf58a98d Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 16:26:12 +0530 Subject: [PATCH 1/4] feat: integrated secret during the ci build-time --- .github/workflows/release.yaml | 4 +- .goreleaser.yml | 2 +- README.md | 54 +++++++++-- buildscripts/gen-ldflags.go | 3 + cmd/cloud.go | 49 ++++++++-- cmd/cloud_profile_test.go | 158 +++++++++++++++++++++++++++++++++ pkg/config/defaults.go | 9 +- pkg/http/http.go | 13 +++ pkg/http/http_test.go | 31 +++++++ pkg/http/refresh.go | 3 + 10 files changed, 307 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 487c07e..07f066e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,7 +27,8 @@ jobs: - name: Validate release configuration env: PB_CLOUD_ORCHESTRATOR_URL: ${{ vars.PB_CLOUD_ORCHESTRATOR_URL }} - run: test -n "$PB_CLOUD_ORCHESTRATOR_URL" + PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN: ${{ secrets.PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN }} + run: test -n "$PB_CLOUD_ORCHESTRATOR_URL" && test -n "$PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 @@ -38,6 +39,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PB_CLOUD_ORCHESTRATOR_URL: ${{ vars.PB_CLOUD_ORCHESTRATOR_URL }} + PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN: ${{ secrets.PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN }} - name: Update Homebrew tap env: diff --git a/.goreleaser.yml b/.goreleaser.yml index 96ed2e8..2e2bfd0 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 }} -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorURL={{ .Env.PB_CLOUD_ORCHESTRATOR_URL }} + - -s -w -X main.Version=v{{ .Version }} -X main.Commit={{ .ShortCommit }} -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorURL={{ .Env.PB_CLOUD_ORCHESTRATOR_URL }} -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorAuthToken={{ .Env.PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN }} archives: - name_template: "pb_{{ .Version }}_{{ .Os }}_{{ .Arch }}" diff --git a/README.md b/README.md index 9e746ee..8311403 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Query production logs. Explore metrics. Stream new events. Save repeatable inves - Metrics metadata exploration: labels, series, cardinality, and TSDB stats - Live event tailing from Parseable datasets - Dataset, user, role, and profile management +- Human-readable terminal output and structured JSON for automation and agents ## Quick Start @@ -122,7 +123,9 @@ go install github.com/parseablehq/pb@latest ## Authentication -`pb login` creates a profile for a Parseable server and stores it locally. You can authenticate with username/password or an API key. +`pb` supports self-hosted Parseable and Parseable Cloud. Authentication details +are stored in a named local profile so subsequent commands can connect without +asking for credentials again. **Interactive login wizard:** @@ -130,15 +133,47 @@ go install github.com/parseablehq/pb@latest pb login ``` -The wizard asks for the server URL, auth method, credentials, and profile name. The first saved profile becomes the default. +Choose one of the following targets in the wizard: -**Add a profile without prompts:** +- **Self-hosted:** enter the Parseable URL and authenticate with a username and + password or an API key. +- **Parseable Cloud:** authenticate through the browser or with a Parseable + Cloud API key. The selected workspace is saved as a local profile. + +**Add a self-hosted profile without prompts:** ```bash pb profile add local http://localhost:8000 admin admin -pb profile add prod https://parseable.example.com +pb profile add local-key http://localhost:8000 --api-key psk_xxx +``` + +**Add a Parseable Cloud API-key profile without prompts:** + +```bash +pb cloud profile add --api-key psk_xxx --name production +``` + +For non-interactive agent or CI authentication, load the API key from the +platform's secret store and request structured output: + +```bash +# Self-hosted +pb profile add agent https://parseable.example.com \ + --api-key "$PARSEABLE_API_KEY" -o json + +# Parseable Cloud +pb cloud profile add --name agent \ + --api-key "$PARSEABLE_CLOUD_API_KEY" -o json ``` +The saved profile can then be used by subsequent `pb` commands without an +interactive login prompt. Do not commit API keys to source control. + +> ⚠️ **Warning for agent access:** Create a dedicated read-only API key or role +> with the minimum permissions required for queries and metadata reads. Do not +> give an agent an administrator or shared human credential. Read-only access +> must be enforced by Parseable on the server. + **Manage profiles:** ```bash @@ -313,20 +348,25 @@ pb promql ps | Query metrics | `pb promql` | Run PromQL, inspect labels/series/cardinality | | Stream events | `pb tail` | Watch new events from a dataset | | Datasets | `pb dataset` | List, inspect, create, and remove datasets | -| Profiles | `pb login`, `pb profile`, `pb logout` | Manage Parseable server connections | +| Profiles | `pb login`, `pb cloud profile`, `pb profile`, `pb logout` | Manage self-hosted and Parseable Cloud connections | | Access control | `pb user`, `pb role` | Manage users and roles | | System | `pb status`, `pb version` | Check connectivity and versions | ## Automation -Use JSON output for scripts and CI: +Commands print human-readable text by default. Commands that support +`-o json` return structured output for scripts, CI, and agent workflows: ```bash +pb status -o json +pb profile list -o json +pb dataset list -o json pb sql run "SELECT count(*) FROM backend" --from=1h --output json pb promql run "up" --dataset otel_metrics --instant --output json ``` -For scripts and CI, omit `-i` so commands print machine-readable output instead of opening the terminal UI. +Use `pb --help` to check output support. For automation, omit `-i` +so SQL and PromQL commands print output instead of opening the terminal UI. ## Documentation diff --git a/buildscripts/gen-ldflags.go b/buildscripts/gen-ldflags.go index 11e9be9..e80adee 100644 --- a/buildscripts/gen-ldflags.go +++ b/buildscripts/gen-ldflags.go @@ -32,6 +32,9 @@ func genLDFlags(version string) string { if orchestratorURL := os.Getenv("PB_CLOUD_ORCHESTRATOR_URL"); orchestratorURL != "" { ldflagsStr += " -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorURL=" + orchestratorURL } + if orchestratorAuthToken := os.Getenv("PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN"); orchestratorAuthToken != "" { + ldflagsStr += " -X github.com/parseablehq/pb/pkg/config.CloudOrchestratorAuthToken=" + orchestratorAuthToken + } return ldflagsStr } diff --git a/cmd/cloud.go b/cmd/cloud.go index cfe7be8..8652e95 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -26,6 +26,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/parseablehq/pb/pkg/config" + internalHTTP "github.com/parseablehq/pb/pkg/http" "github.com/parseablehq/pb/pkg/ui" "github.com/spf13/cobra" ) @@ -41,8 +42,15 @@ var ( cloudProfileName string cloudOrchestratorURL string cloudForceOverwrite bool + cloudOutputFormat string ) +type cloudProfileAddOutput struct { + Status string `json:"status"` + Name string `json:"name"` + Profile profileOutput `json:"profile"` +} + type cloudAPIKeyValidationResponse struct { OrgID string `json:"org_id"` OrgName string `json:"org_name"` @@ -196,11 +204,14 @@ var CloudProfileAddCmd = &cobra.Command{ 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 { + RunE: func(cmd *cobra.Command, _ []string) error { apiKey := strings.TrimSpace(cloudAPIKey) if apiKey == "" { return errors.New("api key is required. pass --api-key") } + if cloudOutputFormat != "text" && cloudOutputFormat != "json" { + return fmt.Errorf("unsupported output format %q (expected text or json)", cloudOutputFormat) + } orchestratorURL := cloudOrchestratorEndpoint() result, err := validateCloudAPIKey(orchestratorURL, apiKey) @@ -226,13 +237,21 @@ var CloudProfileAddCmd = &cobra.Command{ return err } - fmt.Printf("Cloud profile %s added successfully\n", profileName) + if cloudOutputFormat == "json" { + return json.NewEncoder(cmd.OutOrStdout()).Encode(cloudProfileAddOutput{ + Status: "success", + Name: profileName, + Profile: safeProfileOutput(profile), + }) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Cloud profile %s added successfully\n", profileName) if result.WorkspaceName != "" { - fmt.Printf("Workspace: %s (%s)\n", result.WorkspaceName, result.WorkspaceID) + fmt.Fprintf(cmd.OutOrStdout(), "Workspace: %s (%s)\n", result.WorkspaceName, result.WorkspaceID) } else { - fmt.Printf("Workspace: %s\n", result.WorkspaceID) + fmt.Fprintf(cmd.OutOrStdout(), "Workspace: %s\n", result.WorkspaceID) } - fmt.Printf("URL: %s\n", result.URL) + fmt.Fprintf(cmd.OutOrStdout(), "URL: %s\n", result.URL) return nil }, } @@ -242,6 +261,7 @@ func init() { CloudProfileAddCmd.Flags().StringVar(&cloudProfileName, "name", "", "profile name") CloudProfileAddCmd.Flags().StringVar(&cloudOrchestratorURL, "orchestrator-url", config.CloudOrchestratorURL, "Parseable Cloud orchestrator URL") CloudProfileAddCmd.Flags().BoolVar(&cloudForceOverwrite, "force", false, "overwrite existing profile") + CloudProfileAddCmd.Flags().StringVarP(&cloudOutputFormat, "output", "o", "text", "Output format (text|json)") CloudProfileCmd.AddCommand(CloudProfileAddCmd) CloudCmd.AddCommand(CloudProfileCmd) @@ -255,6 +275,9 @@ func cloudOrchestratorEndpoint() string { } func cloudProfileFromDeviceLogin(parent context.Context, orchestratorURL string) (*config.Profile, error) { + if strings.TrimSpace(orchestratorURL) == "" { + return nil, errors.New("cloud orchestrator URL is not configured") + } client := &http.Client{Timeout: 30 * time.Second} device, err := requestCloudDeviceCode(parent, client, orchestratorURL) if err != nil { @@ -394,6 +417,9 @@ func doCloudOAuthTokenRequest(ctx context.Context, client *http.Client, endpoint } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") + if err := internalHTTP.AddCloudOrchestratorAuth(req); err != nil { + return nil, err + } resp, err := client.Do(req) if err != nil { @@ -453,6 +479,9 @@ func doCloudJSON(ctx context.Context, client *http.Client, method, endpoint stri } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") + if err := internalHTTP.AddCloudOrchestratorAuth(req); err != nil { + return err + } resp, err := client.Do(req) if err != nil { return err @@ -472,7 +501,10 @@ func doCloudJSON(ctx context.Context, client *http.Client, method, endpoint stri } func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { - endpoint, err := url.JoinPath(orchestratorURL, "api/v1/apikey/validate") + if strings.TrimSpace(orchestratorURL) == "" { + return nil, errors.New("cloud orchestrator URL is not configured") + } + endpoint, err := url.JoinPath(orchestratorURL, "api/v1/cli/apikey/validate") if err != nil { return nil, fmt.Errorf("invalid orchestrator URL: %w", err) } @@ -480,7 +512,10 @@ func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidation if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+apiKey) + if err := internalHTTP.AddCloudOrchestratorAuth(req); err != nil { + return nil, err + } + req.Header.Set("x-api-key", apiKey) client := http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { diff --git a/cmd/cloud_profile_test.go b/cmd/cloud_profile_test.go index 83b72bb..cd1435b 100644 --- a/cmd/cloud_profile_test.go +++ b/cmd/cloud_profile_test.go @@ -1,11 +1,169 @@ package cmd import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/parseablehq/pb/pkg/config" ) +func TestCloudProfileAddOutputDoesNotExposeCredentials(t *testing.T) { + secret := "psk_secret" + output := cloudProfileAddOutput{ + Status: "success", + Name: "agent", + Profile: safeProfileOutput(config.Profile{ + URL: "https://workspace.example.com", + Cloud: true, + APIKey: secret, + TenantID: "tenant-id", + OrchestratorURL: "https://orchestrator.example.com", + }), + } + + encoded, err := json.Marshal(output) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), secret) { + t.Fatal("cloud profile JSON output exposed the API key") + } +} + +func TestCloudProfileAddSupportsJSONOutput(t *testing.T) { + flag := CloudProfileAddCmd.Flags().Lookup("output") + if flag == nil || flag.Shorthand != "o" || flag.DefValue != "text" { + t.Fatalf("unexpected output flag: %#v", flag) + } +} + +func TestCloudRequestsRequireConfiguredOrchestratorURL(t *testing.T) { + if _, err := cloudProfileFromDeviceLogin(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "URL is not configured") { + t.Fatalf("unexpected device login error: %v", err) + } + if _, err := validateCloudAPIKey("", "api-key"); err == nil || !strings.Contains(err.Error(), "URL is not configured") { + t.Fatalf("unexpected API-key validation error: %v", err) + } +} + +func TestCloudProfileAddJSONFlow(t *testing.T) { + const secret = "psk_agent_secret" + oldAuthToken := config.CloudOrchestratorAuthToken + config.CloudOrchestratorAuthToken = "orchestrator-token" + t.Cleanup(func() { config.CloudOrchestratorAuthToken = oldAuthToken }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/cli/apikey/validate" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer orchestrator-token" { + t.Fatalf("unexpected authorization header %q", got) + } + if got := r.Header.Get("x-api-key"); got != secret { + t.Fatalf("unexpected x-api-key header %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "workspace_id":"workspace-id", + "workspace_name":"Agent Workspace", + "tenant_id":"tenant-id", + "url":"https://workspace.example.com", + "ingest_url":"https://ingest.example.com" + }`)) + })) + t.Cleanup(server.Close) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var stdout bytes.Buffer + CloudCmd.SetOut(&stdout) + CloudCmd.SetErr(&stdout) + CloudCmd.SetArgs([]string{ + "profile", "add", + "--api-key", secret, + "--name", "agent", + "--orchestrator-url", server.URL, + "--output", "json", + }) + t.Cleanup(func() { + CloudCmd.SetArgs(nil) + CloudCmd.SetOut(nil) + CloudCmd.SetErr(nil) + _ = CloudProfileAddCmd.Flags().Set("api-key", "") + _ = CloudProfileAddCmd.Flags().Set("name", "") + _ = CloudProfileAddCmd.Flags().Set("orchestrator-url", config.CloudOrchestratorURL) + _ = CloudProfileAddCmd.Flags().Set("output", "text") + }) + + if err := CloudCmd.Execute(); err != nil { + t.Fatal(err) + } + if strings.Contains(stdout.String(), secret) { + t.Fatal("command output exposed the API key") + } + + var output cloudProfileAddOutput + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("invalid JSON output %q: %v", stdout.String(), err) + } + if output.Status != "success" || output.Name != "agent" || output.Profile.WorkspaceID != "workspace-id" { + t.Fatalf("unexpected output: %#v", output) + } + + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + t.Fatal(err) + } + profile := fileConfig.Profiles["agent"] + if profile.APIKey != secret || profile.WorkspaceID != "workspace-id" { + t.Fatalf("unexpected saved profile: %#v", profile) + } +} + +func TestCloudDeviceRequestsUseOrchestratorBearerToken(t *testing.T) { + oldAuthToken := config.CloudOrchestratorAuthToken + config.CloudOrchestratorAuthToken = "orchestrator-token" + t.Cleanup(func() { config.CloudOrchestratorAuthToken = oldAuthToken }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer orchestrator-token" { + t.Fatalf("unexpected authorization header %q", got) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/cli/device/code": + _, _ = w.Write([]byte(`{"device_code":"device-code","user_code":"user-code"}`)) + case "/api/v1/cli/oauth/token": + _, _ = w.Write([]byte(`{"access_token":"session","refresh_token":"refresh"}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + t.Cleanup(server.Close) + + client := server.Client() + if _, err := requestCloudDeviceCode(context.Background(), client, server.URL); err != nil { + t.Fatal(err) + } + var tokens cloudDeviceTokenResponse + oauthErr, err := doCloudOAuthTokenRequest( + context.Background(), + client, + server.URL+"/api/v1/cli/oauth/token", + []byte(`{"grant_type":"refresh_token","refresh_token":"refresh"}`), + &tokens, + ) + if err != nil { + t.Fatal(err) + } + if oauthErr != nil { + t.Fatalf("unexpected OAuth error: %#v", oauthErr) + } +} + func TestCloudCommandsDoNotExposeDefaultFlag(t *testing.T) { if flag := CloudProfileAddCmd.Flags().Lookup("default"); flag != nil { t.Fatal("cloud profile add still exposes --default") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index a265bdd..51f5124 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -9,9 +9,12 @@ 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" +// CloudOrchestratorURL and CloudOrchestratorAuthToken are populated in release +// binaries through Go's -X linker flag. +var ( + CloudOrchestratorURL string + CloudOrchestratorAuthToken string +) // Service and user-facing URL defaults used by pb. const ( diff --git a/pkg/http/http.go b/pkg/http/http.go index 546cced..bf3fe7e 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -105,6 +105,19 @@ func AddAuthHeaders(req *http.Request, profile *config.Profile) error { return nil } +// AddCloudOrchestratorAuth adds the bearer token required by CLI auth routes. +func AddCloudOrchestratorAuth(req *http.Request) error { + if req == nil { + return errors.New("request is nil") + } + token := strings.TrimSpace(config.CloudOrchestratorAuthToken) + if token == "" { + return errors.New("cloud orchestrator auth token is not configured") + } + req.Header.Set("Authorization", "Bearer "+token) + return nil +} + func debugRequestHeaders(req *http.Request) { if os.Getenv("PB_DEBUG_HTTP_HEADERS") == "" { return diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 9fc33fa..2224bd9 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -38,6 +38,9 @@ func TestNewRequestWithNilProfileReturnsError(t *testing.T) { } func TestCloudSessionRefreshesAndRetriesWorkspaceRequest(t *testing.T) { + oldAuthToken := config.CloudOrchestratorAuthToken + config.CloudOrchestratorAuthToken = "orchestrator-auth-token" + t.Cleanup(func() { config.CloudOrchestratorAuthToken = oldAuthToken }) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) profile := config.Profile{ URL: "https://workspace.example.com", @@ -61,6 +64,9 @@ func TestCloudSessionRefreshesAndRetriesWorkspaceRequest(t *testing.T) { switch req.URL.Host { case "orchestrator.example.com": refreshCalls++ + if got := req.Header.Get("Authorization"); got != "Bearer orchestrator-auth-token" { + t.Fatalf("unexpected orchestrator authorization header %q", got) + } var payload cloudRefreshRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { t.Fatal(err) @@ -142,6 +148,9 @@ func TestCloudSessionDoesNotRefreshCrossOriginRequest(t *testing.T) { } func TestCloudSessionReturnsRefreshPersistenceError(t *testing.T) { + oldAuthToken := config.CloudOrchestratorAuthToken + config.CloudOrchestratorAuthToken = "orchestrator-auth-token" + t.Cleanup(func() { config.CloudOrchestratorAuthToken = oldAuthToken }) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) profile := config.Profile{ URL: "https://workspace.example.com", @@ -186,3 +195,25 @@ func TestCloudSessionReturnsRefreshPersistenceError(t *testing.T) { t.Fatalf("rotated tokens were not retained in memory: %#v", profile) } } + +func TestAddCloudOrchestratorAuth(t *testing.T) { + oldAuthToken := config.CloudOrchestratorAuthToken + t.Cleanup(func() { config.CloudOrchestratorAuthToken = oldAuthToken }) + + req, err := http.NewRequest(http.MethodPost, "https://orchestrator.example.com/api/v1/cli/oauth/token", nil) + if err != nil { + t.Fatal(err) + } + config.CloudOrchestratorAuthToken = "test-token" + if err := AddCloudOrchestratorAuth(req); err != nil { + t.Fatal(err) + } + if got := req.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("unexpected authorization header %q", got) + } + + config.CloudOrchestratorAuthToken = "" + if err := AddCloudOrchestratorAuth(req); err == nil { + t.Fatal("expected missing cloud orchestrator auth token error") + } +} diff --git a/pkg/http/refresh.go b/pkg/http/refresh.go index f31ccf3..f2d0d88 100644 --- a/pkg/http/refresh.go +++ b/pkg/http/refresh.go @@ -101,6 +101,9 @@ func (transport *cloudRefreshTransport) refresh(original *http.Request) error { } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") + if err := AddCloudOrchestratorAuth(req); err != nil { + return err + } resp, err := transport.base.RoundTrip(req) if err != nil { return err From dddef5814dec6ecfe6c84e2a5592fe0273a225a1 Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 16:48:02 +0530 Subject: [PATCH 2/4] fix: resolved the coderabbit suggestion --- cmd/cloud.go | 6 +++--- cmd/cloud_profile_test.go | 2 +- cmd/login.go | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/cloud.go b/cmd/cloud.go index 8652e95..66e8001 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -214,7 +214,7 @@ var CloudProfileAddCmd = &cobra.Command{ } orchestratorURL := cloudOrchestratorEndpoint() - result, err := validateCloudAPIKey(orchestratorURL, apiKey) + result, err := validateCloudAPIKey(cmd.Context(), orchestratorURL, apiKey) if err != nil { return err } @@ -500,7 +500,7 @@ func doCloudJSON(ctx context.Context, client *http.Client, method, endpoint stri return nil } -func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { +func validateCloudAPIKey(ctx context.Context, orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) { if strings.TrimSpace(orchestratorURL) == "" { return nil, errors.New("cloud orchestrator URL is not configured") } @@ -508,7 +508,7 @@ func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidation if err != nil { return nil, fmt.Errorf("invalid orchestrator URL: %w", err) } - req, err := http.NewRequest(http.MethodGet, endpoint, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } diff --git a/cmd/cloud_profile_test.go b/cmd/cloud_profile_test.go index cd1435b..d765667 100644 --- a/cmd/cloud_profile_test.go +++ b/cmd/cloud_profile_test.go @@ -46,7 +46,7 @@ func TestCloudRequestsRequireConfiguredOrchestratorURL(t *testing.T) { if _, err := cloudProfileFromDeviceLogin(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "URL is not configured") { t.Fatalf("unexpected device login error: %v", err) } - if _, err := validateCloudAPIKey("", "api-key"); err == nil || !strings.Contains(err.Error(), "URL is not configured") { + if _, err := validateCloudAPIKey(context.Background(), "", "api-key"); err == nil || !strings.Contains(err.Error(), "URL is not configured") { t.Fatalf("unexpected API-key validation error: %v", err) } } diff --git a/cmd/login.go b/cmd/login.go index d4c0927..c750849 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -16,6 +16,7 @@ package cmd import ( + "context" "fmt" tea "github.com/charmbracelet/bubbletea" @@ -52,7 +53,7 @@ credentials. All settings are saved to ~/.config/pb/config.toml.`, m.Name = cloudProfileNameFromSession(profile) } } else if m.Profile.Cloud { - profile, err := cloudProfileFromAPIKey(m.Profile.APIKey) + profile, err := cloudProfileFromAPIKey(cmd.Context(), m.Profile.APIKey) if err != nil { return err } @@ -75,9 +76,9 @@ func printDeviceLoginSuccess(profileName string) { fmt.Println(" pb profile add [user] [pass]") } -func cloudProfileFromAPIKey(apiKey string) (*config.Profile, error) { +func cloudProfileFromAPIKey(ctx context.Context, apiKey string) (*config.Profile, error) { orchestratorURL := config.CloudOrchestratorURL - result, err := validateCloudAPIKey(orchestratorURL, apiKey) + result, err := validateCloudAPIKey(ctx, orchestratorURL, apiKey) if err != nil { return nil, err } From 46be62438771a67d938c5983ea06be18960f537e Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 17:03:47 +0530 Subject: [PATCH 3/4] fix: removed the go installation from readme --- README.md | 99 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8311403..26d0276 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,6 @@ On macOS, a manually downloaded binary may be blocked on first run. Allow it onc xattr -d com.apple.quarantine /usr/local/bin/pb ``` -**Go install:** - -```bash -go install github.com/parseablehq/pb@latest -``` - **Verify:** `pb --help` ## Authentication @@ -153,27 +147,6 @@ pb profile add local-key http://localhost:8000 --api-key psk_xxx pb cloud profile add --api-key psk_xxx --name production ``` -For non-interactive agent or CI authentication, load the API key from the -platform's secret store and request structured output: - -```bash -# Self-hosted -pb profile add agent https://parseable.example.com \ - --api-key "$PARSEABLE_API_KEY" -o json - -# Parseable Cloud -pb cloud profile add --name agent \ - --api-key "$PARSEABLE_CLOUD_API_KEY" -o json -``` - -The saved profile can then be used by subsequent `pb` commands without an -interactive login prompt. Do not commit API keys to source control. - -> ⚠️ **Warning for agent access:** Create a dedicated read-only API key or role -> with the minimum permissions required for queries and metadata reads. Do not -> give an agent an administrator or shared human credential. Read-only access -> must be enforced by Parseable on the server. - **Manage profiles:** ```bash @@ -368,6 +341,58 @@ pb promql run "up" --dataset otel_metrics --instant --output json Use `pb --help` to check output support. For automation, omit `-i` so SQL and PromQL commands print output instead of opening the terminal UI. +## Using pb with AI agents + +An AI agent uses the same installed `pb` binary and local profiles as a human. +Before starting the agent, a human should install `pb`, configure the intended +profile, and verify the connection: + +```bash +pb login +pb status -o json +``` + +For non-interactive setup, load an API key from a secret store or environment +variable. The profile name is user-defined; it does not need to be `agent`: + +```bash +# Self-hosted +pb profile add automation-readonly https://parseable.example.com \ + --api-key "$PARSEABLE_API_KEY" -o json + +# Parseable Cloud +pb cloud profile add --name automation-readonly \ + --api-key "$PARSEABLE_CLOUD_API_KEY" -o json +``` + +If multiple profiles exist, the human should select the intended one before +handing control to the agent: + +```bash +pb profile default automation-readonly -o json +pb status -o json +``` + +Agents should prefer structured output and read operations: + +```bash +pb status -o json +pb dataset list -o json +pb dataset info -o json +pb sql run "SELECT * FROM LIMIT 10" -o json +pb promql run "up" --dataset --instant -o json +``` + +> ⚠️ **Agent access warning:** Create a dedicated read-only API key or role +> with only the query and metadata permissions the agent needs. Never give an +> agent an administrator key or a shared human credential. Read-only access +> must be enforced by Parseable on the server; CLI instructions alone cannot +> prevent create, update, or delete attempts. + +Do not place API keys in prompts, source control, logs, or agent instructions. +The agent can use the profile after the human configures it; it does not need +to read `~/.config/pb/config.toml` or receive the credential directly. + ## Documentation | Topic | Description | @@ -381,6 +406,26 @@ so SQL and PromQL commands print output instead of opening the terminal UI. See the [contributing guide](CONTRIBUTING.md). +### Building with Parseable Cloud support + +Parseable Cloud builds require the orchestrator URL and bearer token at link +time. Local Cloud-enabled builds can provide both values to `make build`: + +```bash +PB_CLOUD_ORCHESTRATOR_URL='https://orchestrator.example.com' \ +PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN='token-value' \ +make build +``` + +Official releases read these values from GitHub Actions: + +| Name | GitHub Actions type | Purpose | +|---|---|---| +| `PB_CLOUD_ORCHESTRATOR_URL` | Repository variable | Production orchestrator URL | +| `PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN` | Repository secret | Bearer token matching the orchestrator `API_AUTH_TOKEN` | + +The release workflow stops before GoReleaser when either value is missing. + ## License AGPL-3.0 — see [LICENSE](LICENSE). From 078b673725e7420bceebd774900212ed6c0aebd2 Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 20 Jul 2026 17:13:59 +0530 Subject: [PATCH 4/4] fix/restore-needed-changes --- README.md | 76 +++---------------------------------------------------- 1 file changed, 4 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 26d0276..c370969 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,6 @@ On macOS, a manually downloaded binary may be blocked on first run. Allow it onc ```bash xattr -d com.apple.quarantine /usr/local/bin/pb -``` **Verify:** `pb --help` @@ -341,57 +340,10 @@ pb promql run "up" --dataset otel_metrics --instant --output json Use `pb --help` to check output support. For automation, omit `-i` so SQL and PromQL commands print output instead of opening the terminal UI. -## Using pb with AI agents - -An AI agent uses the same installed `pb` binary and local profiles as a human. -Before starting the agent, a human should install `pb`, configure the intended -profile, and verify the connection: - -```bash -pb login -pb status -o json -``` - -For non-interactive setup, load an API key from a secret store or environment -variable. The profile name is user-defined; it does not need to be `agent`: - -```bash -# Self-hosted -pb profile add automation-readonly https://parseable.example.com \ - --api-key "$PARSEABLE_API_KEY" -o json - -# Parseable Cloud -pb cloud profile add --name automation-readonly \ - --api-key "$PARSEABLE_CLOUD_API_KEY" -o json -``` - -If multiple profiles exist, the human should select the intended one before -handing control to the agent: - -```bash -pb profile default automation-readonly -o json -pb status -o json -``` - -Agents should prefer structured output and read operations: - -```bash -pb status -o json -pb dataset list -o json -pb dataset info -o json -pb sql run "SELECT * FROM LIMIT 10" -o json -pb promql run "up" --dataset --instant -o json -``` - -> ⚠️ **Agent access warning:** Create a dedicated read-only API key or role -> with only the query and metadata permissions the agent needs. Never give an -> agent an administrator key or a shared human credential. Read-only access -> must be enforced by Parseable on the server; CLI instructions alone cannot -> prevent create, update, or delete attempts. - -Do not place API keys in prompts, source control, logs, or agent instructions. -The agent can use the profile after the human configures it; it does not need -to read `~/.config/pb/config.toml` or receive the credential directly. +> ⚠️ **Warning for agent access:** Create a dedicated read-only API key or role +> with the minimum permissions required for queries and metadata reads. Do not +> give an agent an administrator or shared human credential. Read-only access +> must be enforced by Parseable on the server. ## Documentation @@ -406,26 +358,6 @@ to read `~/.config/pb/config.toml` or receive the credential directly. See the [contributing guide](CONTRIBUTING.md). -### Building with Parseable Cloud support - -Parseable Cloud builds require the orchestrator URL and bearer token at link -time. Local Cloud-enabled builds can provide both values to `make build`: - -```bash -PB_CLOUD_ORCHESTRATOR_URL='https://orchestrator.example.com' \ -PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN='token-value' \ -make build -``` - -Official releases read these values from GitHub Actions: - -| Name | GitHub Actions type | Purpose | -|---|---|---| -| `PB_CLOUD_ORCHESTRATOR_URL` | Repository variable | Production orchestrator URL | -| `PB_CLOUD_ORCHESTRATOR_AUTH_TOKEN` | Repository secret | Bearer token matching the orchestrator `API_AUTH_TOKEN` | - -The release workflow stops before GoReleaser when either value is missing. - ## License AGPL-3.0 — see [LICENSE](LICENSE).