diff --git a/.golangci.yml b/.golangci.yml
index d0cd449..6f2beb5 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -29,7 +29,7 @@ linters:
rules:
- text: "instead of using struct literal"
linters:
- - revive
+ - revive
- text: "should have a package comment"
linters:
- revive
@@ -38,7 +38,7 @@ linters:
- revive
- text: "time-naming"
linters:
- - revive
+ - revive
- text: "error strings should not be capitalized or end with punctuation or a newline"
linters:
- revive
diff --git a/Makefile b/Makefile
index ed8501a..4b26e8a 100644
--- a/Makefile
+++ b/Makefile
@@ -19,6 +19,7 @@ checks:
getdeps:
@mkdir -p ${GOPATH}/bin
@echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)"
+# Will need to make it more error prone in future!
@curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin $(GOLANGCI_LINT_VERSION)
crosscompile:
diff --git a/cmd/login.go b/cmd/login.go
new file mode 100644
index 0000000..2007562
--- /dev/null
+++ b/cmd/login.go
@@ -0,0 +1,185 @@
+// 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 (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "pb/pkg/config"
+ "runtime"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+const defaultCloudURL = "https://staging.parseable.com:8000"
+
+var (
+ loginToken string
+ loginURL string
+ loginUsername string
+ loginPassword string
+ loginProfileName string
+)
+
+func init() {
+ LoginCmd.Flags().StringVar(&loginToken, "token", "", "Auth token for cloud login")
+ LoginCmd.Flags().StringVar(&loginURL, "url", "", "Server URL for self-hosted Parseable")
+ LoginCmd.Flags().StringVar(&loginUsername, "username", "", "Username for self-hosted login")
+ LoginCmd.Flags().StringVar(&loginPassword, "password", "", "Password for self-hosted login")
+ LoginCmd.Flags().StringVar(&loginProfileName, "profile", "default", "Profile name to save as")
+}
+
+var LoginCmd = &cobra.Command{
+ Use: "login",
+ Short: "Login to Parseable",
+ Long: `Login to Parseable cloud or a self-hosted instance.
+
+Cloud login (opens browser):
+ pb login
+
+Cloud login with token:
+ pb login --token
+
+Self-hosted login:
+ pb login --url http://localhost:8000 --username admin --password admin`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ // --- Self-hosted path ---
+ if loginURL != "" {
+ return selfHostedLogin()
+ }
+
+ // --- Cloud path ---
+ return cloudLogin()
+ },
+}
+
+func selfHostedLogin() error {
+ username := loginUsername
+ password := loginPassword
+
+ if username == "" {
+ fmt.Print("Username: ")
+ reader := bufio.NewReader(os.Stdin)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("failed to read username: %w", err)
+ }
+ username = strings.TrimSpace(line)
+ }
+
+ if password == "" {
+ fmt.Print("Password: ")
+ reader := bufio.NewReader(os.Stdin)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("failed to read password: %w", err)
+ }
+ password = strings.TrimSpace(line)
+ }
+
+ if username == "" || password == "" {
+ return fmt.Errorf("username and password are required for self-hosted login")
+ }
+
+ profile := config.Profile{
+ URL: loginURL,
+ Username: username,
+ Password: password,
+ }
+ if err := writeProfile(profile, loginProfileName); err != nil {
+ return fmt.Errorf("failed to save profile: %w", err)
+ }
+
+ fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName)
+ fmt.Printf(" URL: %s\n", loginURL)
+ return nil
+}
+
+func cloudLogin() error {
+ token := loginToken
+
+ if token == "" {
+ loginPageURL := defaultCloudURL + "/login"
+ fmt.Printf("Opening login page: %s\n\n", loginPageURL)
+
+ if err := openBrowser(loginPageURL); err != nil {
+ fmt.Println("Could not open browser automatically. Please visit the URL above and copy your token.")
+ } else {
+ fmt.Println("Browser opened. After logging in, copy your token from the dashboard.")
+ }
+
+ fmt.Print("\nPaste your token here: ")
+ reader := bufio.NewReader(os.Stdin)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("failed to read token: %w", err)
+ }
+ token = strings.TrimSpace(line)
+ if token == "" {
+ return fmt.Errorf("no token provided, login canceled")
+ }
+ }
+
+ profile := config.Profile{
+ URL: defaultCloudURL,
+ Token: token,
+ }
+ if err := writeProfile(profile, loginProfileName); err != nil {
+ return fmt.Errorf("failed to save profile: %w", err)
+ }
+
+ fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName)
+ fmt.Printf(" URL: %s\n", defaultCloudURL)
+ return nil
+}
+
+func writeProfile(profile config.Profile, profileName string) error {
+ fileConfig, err := config.ReadConfigFromFile()
+ if err != nil {
+ newConfig := config.Config{
+ Profiles: map[string]config.Profile{profileName: profile},
+ DefaultProfile: profileName,
+ }
+ return config.WriteConfigToFile(&newConfig)
+ }
+
+ if fileConfig.Profiles == nil {
+ fileConfig.Profiles = make(map[string]config.Profile)
+ }
+ fileConfig.Profiles[profileName] = profile
+ if fileConfig.DefaultProfile == "" {
+ fileConfig.DefaultProfile = profileName
+ }
+ return config.WriteConfigToFile(fileConfig)
+}
+
+func openBrowser(url string) error {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "linux":
+ cmd = exec.Command("xdg-open", url)
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ default:
+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
+ }
+ return cmd.Start()
+}
diff --git a/cmd/logout.go b/cmd/logout.go
new file mode 100644
index 0000000..fcfc162
--- /dev/null
+++ b/cmd/logout.go
@@ -0,0 +1,51 @@
+// 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 (
+ "fmt"
+ "pb/pkg/config"
+
+ "github.com/spf13/cobra"
+)
+
+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 {
+ fileConfig, err := config.ReadConfigFromFile()
+ if err != nil {
+ return fmt.Errorf("no config found — nothing to logout from")
+ }
+
+ profileName := fileConfig.DefaultProfile
+ if _, exists := fileConfig.Profiles[profileName]; !exists {
+ return fmt.Errorf("no active profile found")
+ }
+
+ delete(fileConfig.Profiles, profileName)
+ fileConfig.DefaultProfile = ""
+
+ if err := config.WriteConfigToFile(fileConfig); err != nil {
+ return fmt.Errorf("failed to update config: %w", err)
+ }
+
+ fmt.Printf("Logged out and removed profile '%s'\n", profileName)
+ return nil
+ },
+}
diff --git a/cmd/pre.go b/cmd/pre.go
index d6bac82..b6da793 100644
--- a/cmd/pre.go
+++ b/cmd/pre.go
@@ -35,13 +35,13 @@ func PreRunDefaultProfile(_ *cobra.Command, _ []string) error {
func PreRun() error {
conf, err := config.ReadConfigFromFile()
if os.IsNotExist(err) {
- return errors.New("no config found to run this command. add a profile using pb profile command")
+ return errors.New("no profile configured. run: pb login")
} else if err != nil {
return err
}
if conf.Profiles == nil || conf.DefaultProfile == "" {
- return errors.New("no profile is configured to run this command. please create one using profile command")
+ return errors.New("no profile configured. run: pb login")
}
DefaultProfile = conf.Profiles[conf.DefaultProfile]
diff --git a/cmd/status.go b/cmd/status.go
new file mode 100644
index 0000000..f7d3532
--- /dev/null
+++ b/cmd/status.go
@@ -0,0 +1,58 @@
+// 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 (
+ "fmt"
+ "pb/pkg/analytics"
+ "pb/pkg/config"
+ internalHTTP "pb/pkg/http"
+
+ "github.com/spf13/cobra"
+)
+
+var StatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "Check connection status for the active profile",
+ Example: " pb status",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ fileConfig, err := config.ReadConfigFromFile()
+ if err != nil {
+ return fmt.Errorf("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")
+ }
+
+ 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 {
+ fmt.Printf("Status : ✗ Not connected\n")
+ fmt.Printf("Error : %s\n", err.Error())
+ return nil
+ }
+
+ fmt.Printf("Status : ✓ Connected\n")
+ fmt.Printf("Version : %s\n", about.Version)
+ return nil
+ },
+}
diff --git a/main.go b/main.go
index 26ecd0b..e8875b7 100644
--- a/main.go
+++ b/main.go
@@ -24,7 +24,6 @@ import (
pb "pb/cmd"
"pb/pkg/analytics"
- "pb/pkg/config"
"github.com/spf13/cobra"
)
@@ -42,14 +41,6 @@ var (
versionFlagShort = "v"
)
-func defaultInitialProfile() config.Profile {
- return config.Profile{
- URL: "https://demo.parseable.com",
- Username: "admin",
- Password: "admin",
- }
-}
-
// Root command
var cli = &cobra.Command{
Use: "pb",
@@ -300,6 +291,9 @@ func main() {
cli.AddCommand(cluster)
cli.AddCommand(pb.AutocompleteCmd)
+ cli.AddCommand(pb.LoginCmd)
+ cli.AddCommand(pb.LogoutCmd)
+ cli.AddCommand(pb.StatusCmd)
// Set as command
pb.VersionCmd.Run = func(_ *cobra.Command, _ []string) {
@@ -312,40 +306,6 @@ func main() {
cli.CompletionOptions.HiddenDefaultCmd = true
- // create a default profile if file does not exist
- if previousConfig, err := config.ReadConfigFromFile(); os.IsNotExist(err) {
- conf := config.Config{
- Profiles: map[string]config.Profile{"demo": defaultInitialProfile()},
- DefaultProfile: "demo",
- }
- err = config.WriteConfigToFile(&conf)
- if err != nil {
- fmt.Printf("failed to write to file %v\n", err)
- os.Exit(1)
- }
- } else {
- // Only update the "demo" profile without overwriting other profiles
- demoProfile, exists := previousConfig.Profiles["demo"]
- if exists {
- // Update fields in the demo profile only
- demoProfile.URL = "http://demo.parseable.com"
- demoProfile.Username = "admin"
- demoProfile.Password = "admin"
- previousConfig.Profiles["demo"] = demoProfile
- } else {
- // Add the "demo" profile if it doesn't exist
- previousConfig.Profiles["demo"] = defaultInitialProfile()
- previousConfig.DefaultProfile = "demo" // Optional: set as default if needed
- }
-
- // Write the updated configuration back to file
- err = config.WriteConfigToFile(previousConfig)
- if err != nil {
- fmt.Printf("failed to write to existing file %v\n", err)
- os.Exit(1)
- }
- }
-
err := cli.Execute()
if err != nil {
os.Exit(1)
diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go
index 6c84712..34c5d53 100644
--- a/pkg/analytics/analytics.go
+++ b/pkg/analytics/analytics.go
@@ -53,23 +53,23 @@ type Event struct {
// About struct
type About struct {
- Version string `json:"version"`
- UIVersion string `json:"uiVersion"`
- Commit string `json:"commit"`
- DeploymentID string `json:"deploymentId"`
- UpdateAvailable bool `json:"updateAvailable"`
- LatestVersion string `json:"latestVersion"`
- LLMActive bool `json:"llmActive"`
- LLMProvider string `json:"llmProvider"`
- OIDCActive bool `json:"oidcActive"`
- License string `json:"license"`
- Mode string `json:"mode"`
- Staging string `json:"staging"`
- HotTier string `json:"hotTier"`
- GRPCPort int `json:"grpcPort"`
- Store Store `json:"store"`
- Analytics Analytics `json:"analytics"`
- QueryEngine string `json:"queryEngine"`
+ Version string `json:"version"`
+ UIVersion string `json:"uiVersion"`
+ Commit string `json:"commit"`
+ DeploymentID string `json:"deploymentId"`
+ UpdateAvailable bool `json:"updateAvailable"`
+ LatestVersion string `json:"latestVersion"`
+ LLMActive bool `json:"llmActive"`
+ LLMProvider string `json:"llmProvider"`
+ OIDCActive bool `json:"oidcActive"`
+ License json.RawMessage `json:"license"`
+ Mode string `json:"mode"`
+ Staging string `json:"staging"`
+ HotTier string `json:"hotTier"`
+ GRPCPort int `json:"grpcPort"`
+ Store Store `json:"store"`
+ Analytics Analytics `json:"analytics"`
+ QueryEngine string `json:"queryEngine"`
}
// Store struct
diff --git a/pkg/config/config.go b/pkg/config/config.go
index c57a9cc..cf39cb5 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -49,9 +49,10 @@ type Config struct {
// Profile is the struct that holds the profile configuration
type Profile struct {
- URL string `json:"url"`
- Username string `json:"username"`
- Password string `json:"password,omitempty"`
+ 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"`
}
func (p *Profile) GrpcAddr(port string) string {
diff --git a/pkg/http/http.go b/pkg/http/http.go
index 340d1b1..ee4e6ca 100644
--- a/pkg/http/http.go
+++ b/pkg/http/http.go
@@ -48,7 +48,11 @@ func (client *HTTPClient) NewRequest(method string, path string, body io.Reader)
if err != nil {
return
}
- req.SetBasicAuth(client.Profile.Username, client.Profile.Password)
+ if client.Profile.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+client.Profile.Token)
+ } else {
+ req.SetBasicAuth(client.Profile.Username, client.Profile.Password)
+ }
req.Header.Add("Content-Type", "application/json")
return
}
diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go
index 3d68aa4..b9a98ca 100644
--- a/pkg/installer/installer.go
+++ b/pkg/installer/installer.go
@@ -281,6 +281,8 @@ func applyParseableSecret(ps *ParseableInfo, store ObjectStore, objectStoreConfi
secretManifest = getParseableSecretBlob(ps, objectStoreConfig)
case GcsStore:
secretManifest = getParseableSecretGcs(ps, objectStoreConfig)
+ default:
+ return fmt.Errorf("unsupported object store type: %s", store)
}
// apply the Kubernetes Secret