Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
111 changes: 98 additions & 13 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 @@ -112,31 +113,38 @@ 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

`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 +321,77 @@ 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.

## 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
Comment thread
pratik50 marked this conversation as resolved.
Outdated
```

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 <dataset> -o json
pb sql run "SELECT * FROM <dataset> LIMIT 10" -o json
pb promql run "up" --dataset <metrics-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

Expand All @@ -341,6 +406,26 @@ For scripts and CI, omit `-i` so commands print machine-readable output instead

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).
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