Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
45 changes: 31 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -110,33 +111,39 @@ 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
```

**Go install:**

```bash
go install github.com/parseablehq/pb@latest
```

**Verify:** `pb --help`

## 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:**

```bash
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:

- **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 profile without prompts:**
**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
```

**Manage profiles:**
Expand Down Expand Up @@ -313,20 +320,30 @@ 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 <command> --help` to check output support. For automation, omit `-i`
so SQL and PromQL commands print output instead of opening the terminal UI.

> ⚠️ **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

Expand Down
3 changes: 3 additions & 0 deletions buildscripts/gen-ldflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
55 changes: 45 additions & 10 deletions cmd/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"`
Expand Down Expand Up @@ -196,14 +204,17 @@ 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)
result, err := validateCloudAPIKey(cmd.Context(), orchestratorURL, apiKey)
if err != nil {
return err
}
Expand All @@ -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
},
}
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -471,16 +500,22 @@ func doCloudJSON(ctx context.Context, client *http.Client, method, endpoint stri
return nil
}

func validateCloudAPIKey(orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) {
endpoint, err := url.JoinPath(orchestratorURL, "api/v1/apikey/validate")
func validateCloudAPIKey(ctx context.Context, orchestratorURL, apiKey string) (*cloudAPIKeyValidationResponse, error) {
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)
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
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 {
Expand Down
Loading
Loading