Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 47 additions & 7 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 @@ -122,23 +123,57 @@ 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:**

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

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

## 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
49 changes: 42 additions & 7 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,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)
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 @@ -472,15 +501,21 @@ 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)
}
req, err := http.NewRequest(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