From ff3e957bc24ce9d498d6053a72b97d198a3a6265 Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 24 Apr 2026 15:08:03 +0530 Subject: [PATCH 1/3] feat: add login/logout/status commands --- .golangci.yml | 4 +- Makefile | 1 + cmd/login.go | 185 +++++++++++++++++++++++++++++++++++++ cmd/logout.go | 51 ++++++++++ cmd/pre.go | 4 +- cmd/status.go | 58 ++++++++++++ main.go | 46 +-------- pkg/analytics/analytics.go | 34 +++---- pkg/config/config.go | 7 +- pkg/http/http.go | 6 +- pkg/installer/installer.go | 2 + 11 files changed, 330 insertions(+), 68 deletions(-) create mode 100644 cmd/login.go create mode 100644 cmd/logout.go create mode 100644 cmd/status.go 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 From 11a583d9c86d37f256d062899200dc841e358e89 Mon Sep 17 00:00:00 2001 From: Pratik Date: Mon, 27 Apr 2026 11:03:34 +0530 Subject: [PATCH 2/3] fix: rename stream to dataset across all user-facing CLI surfaces --- cmd/{stream.go => dataset.go} | 88 +++++++++++++++++------------------ cmd/generate.go | 14 +++--- cmd/login.go | 8 ++-- cmd/query.go | 4 +- cmd/tail.go | 4 +- main.go | 32 ++++++------- 6 files changed, 75 insertions(+), 75 deletions(-) rename cmd/{stream.go => dataset.go} (85%) diff --git a/cmd/stream.go b/cmd/dataset.go similarity index 85% rename from cmd/stream.go rename to cmd/dataset.go index 5afa8cc..d8ed666 100644 --- a/cmd/stream.go +++ b/cmd/dataset.go @@ -30,8 +30,8 @@ import ( "github.com/spf13/cobra" ) -// StreamStatsData is the data structure for stream stats -type StreamStatsData struct { +// DatasetStatsData is the data structure for dataset stats +type DatasetStatsData struct { Ingestion struct { Count int `json:"count"` Format string `json:"format"` @@ -45,17 +45,17 @@ type StreamStatsData struct { Time time.Time `json:"time"` } -type StreamListItem struct { +type DatasetListItem struct { Name string } -func (item *StreamListItem) Render() string { +func (item *DatasetListItem) Render() string { render := StandardStyle.Render(item.Name) return ItemOuter.Render(render) } -// StreamRetentionData is the data structure for stream retention -type StreamRetentionData []struct { +// DatasetRetentionData is the data structure for dataset retention +type DatasetRetentionData []struct { Description string `json:"description"` Action string `json:"action"` Duration string `json:"duration"` @@ -105,11 +105,11 @@ type RuleConfig struct { Repeats int `json:"repeats"` } -// AddStreamCmd is the parent command for stream -var AddStreamCmd = &cobra.Command{ - Use: "add stream-name", - Example: " pb stream add backend_logs", - Short: "Create a new stream", +// AddDatasetCmd is the parent command for dataset +var AddDatasetCmd = &cobra.Command{ + Use: "add dataset-name", + Example: " pb dataset add backend_logs", + Short: "Create a new dataset", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Capture start time @@ -139,7 +139,7 @@ var AddStreamCmd = &cobra.Command{ cmd.Annotations["executionTime"] = time.Since(startTime).String() if resp.StatusCode == 200 { - fmt.Printf("Created stream %s\n", StyleBold.Render(name)) + fmt.Printf("Created dataset %s\n", StyleBold.Render(name)) } else { bytes, err := io.ReadAll(resp.Body) if err != nil { @@ -155,11 +155,11 @@ var AddStreamCmd = &cobra.Command{ }, } -// StatStreamCmd is the stat command for stream -var StatStreamCmd = &cobra.Command{ - Use: "info stream-name", - Example: " pb stream info backend_logs", - Short: "Get statistics for a stream", +// StatDatasetCmd is the stat command for dataset +var StatDatasetCmd = &cobra.Command{ + Use: "info dataset-name", + Example: " pb dataset info backend_logs", + Short: "Get statistics for a dataset", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Capture start time @@ -201,8 +201,8 @@ var StatStreamCmd = &cobra.Command{ return err } - // Fetch stream type - streamType, err := fetchInfo(&client, name) + // Fetch dataset type + datasetType, err := fetchInfo(&client, name) if err != nil { // Capture error cmd.Annotations["errors"] = fmt.Sprintf("Error: %s", err.Error()) @@ -220,9 +220,9 @@ var StatStreamCmd = &cobra.Command{ "storage_size": humanize.Bytes(uint64(storageSize)), "compression_ratio": fmt.Sprintf("%.2f%%", compressionRatio), }, - "retention": retention, - "alerts": alertsData.Alerts, - "stream_type": streamType, + "retention": retention, + "alerts": alertsData.Alerts, + "dataset_type": datasetType, } jsonData, err := json.MarshalIndent(data, "", " ") @@ -243,7 +243,7 @@ var StatStreamCmd = &cobra.Command{ fmt.Printf(" %-18s %s\n", "Ingestion Size:", humanize.Bytes(uint64(ingestionSize))) fmt.Printf(" %-18s %s\n", "Storage Size:", humanize.Bytes(uint64(storageSize))) fmt.Printf(" %-18s %.2f%s\n", "Compression Ratio:", compressionRatio, "%") - fmt.Printf(" %-18s %s\n", "Stream Type:", streamType) + fmt.Printf(" %-18s %s\n", "Dataset Type:", datasetType) fmt.Println() if isRetentionSet { @@ -254,7 +254,7 @@ var StatStreamCmd = &cobra.Command{ fmt.Println() } } else { - fmt.Println(StyleBold.Render("No retention period set on stream\n")) + fmt.Println(StyleBold.Render("No retention period set on dataset\n")) } if isAlertsSet { @@ -276,7 +276,7 @@ var StatStreamCmd = &cobra.Command{ fmt.Print("\n\n") } } else { - fmt.Println(StyleBold.Render("No alerts set on stream\n")) + fmt.Println(StyleBold.Render("No alerts set on dataset\n")) } } @@ -285,14 +285,14 @@ var StatStreamCmd = &cobra.Command{ } func init() { - StatStreamCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") + StatDatasetCmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") } -var RemoveStreamCmd = &cobra.Command{ - Use: "remove stream-name", +var RemoveDatasetCmd = &cobra.Command{ + Use: "remove dataset-name", Aliases: []string{"rm"}, - Example: " pb stream remove backend_logs", - Short: "Delete a stream", + Example: " pb dataset remove backend_logs", + Short: "Delete a dataset", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Capture start time @@ -322,7 +322,7 @@ var RemoveStreamCmd = &cobra.Command{ cmd.Annotations["executionTime"] = time.Since(startTime).String() if resp.StatusCode == 200 { - fmt.Printf("Successfully deleted stream %s\n", StyleBold.Render(name)) + fmt.Printf("Successfully deleted dataset %s\n", StyleBold.Render(name)) } else { bytes, err := io.ReadAll(resp.Body) if err != nil { @@ -338,11 +338,11 @@ var RemoveStreamCmd = &cobra.Command{ }, } -// ListStreamCmd is the list command for streams -var ListStreamCmd = &cobra.Command{ +// ListDatasetCmd is the list command for datasets +var ListDatasetCmd = &cobra.Command{ Use: "list", - Example: " pb stream list", - Short: "List all streams", + Example: " pb dataset list", + Short: "List all datasets", RunE: func(cmd *cobra.Command, _ []string) error { // Capture start time startTime := time.Now() @@ -366,23 +366,23 @@ var ListStreamCmd = &cobra.Command{ return err } - var streams []StreamListItem + var datasets []DatasetListItem if resp.StatusCode == 200 { bytes, err := io.ReadAll(resp.Body) if err != nil { cmd.Annotations["errors"] = fmt.Sprintf("Error: %s", err.Error()) return err } - if err := json.Unmarshal(bytes, &streams); err != nil { + if err := json.Unmarshal(bytes, &datasets); err != nil { cmd.Annotations["errors"] = fmt.Sprintf("Error: %s", err.Error()) return err } - for _, stream := range streams { - fmt.Println(stream.Render()) + for _, dataset := range datasets { + fmt.Println(dataset.Render()) } } else { - fmt.Printf("Failed to fetch streams. Status Code: %s\n", resp.Status) + fmt.Printf("Failed to fetch datasets. Status Code: %s\n", resp.Status) } return nil @@ -391,10 +391,10 @@ var ListStreamCmd = &cobra.Command{ func init() { // Add the --output flag with default value "text" - ListStreamCmd.Flags().StringP("output", "o", "text", "Output format: 'text' or 'json'") + ListDatasetCmd.Flags().StringP("output", "o", "text", "Output format: 'text' or 'json'") } -func fetchStats(client *internalHTTP.HTTPClient, name string) (data StreamStatsData, err error) { +func fetchStats(client *internalHTTP.HTTPClient, name string) (data DatasetStatsData, err error) { req, err := client.NewRequest("GET", fmt.Sprintf("logstream/%s/stats", name), nil) if err != nil { return @@ -421,7 +421,7 @@ func fetchStats(client *internalHTTP.HTTPClient, name string) (data StreamStatsD return } -func fetchRetention(client *internalHTTP.HTTPClient, name string) (data StreamRetentionData, err error) { +func fetchRetention(client *internalHTTP.HTTPClient, name string) (data DatasetRetentionData, err error) { req, err := client.NewRequest(http.MethodGet, fmt.Sprintf("logstream/%s/retention", name), nil) if err != nil { return @@ -475,7 +475,7 @@ func fetchAlerts(client *internalHTTP.HTTPClient, name string) (data AlertConfig return } -func fetchInfo(client *internalHTTP.HTTPClient, name string) (streamType string, err error) { +func fetchInfo(client *internalHTTP.HTTPClient, name string) (datasetType string, err error) { // Create a new HTTP GET request req, err := client.NewRequest(http.MethodGet, fmt.Sprintf("logstream/%s/info", name), nil) if err != nil { diff --git a/cmd/generate.go b/cmd/generate.go index 94b8d34..b0e10ab 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -98,17 +98,17 @@ var GenerateSchemaCmd = &cobra.Command{ var CreateSchemaCmd = &cobra.Command{ Use: "create", - Short: "Create Schema for a Parseable stream", - Example: "pb schema create --stream=my_stream --file=schema.json", + Short: "Create Schema for a Parseable dataset", + Example: "pb schema create --dataset=my_dataset --file=schema.json", RunE: func(cmd *cobra.Command, _ []string) error { - // Get the stream name from the `--stream` flag - streamName, err := cmd.Flags().GetString("stream") + // Get the dataset name from the `--dataset` flag + streamName, err := cmd.Flags().GetString("dataset") if err != nil { - return fmt.Errorf(common.Red+"failed to read stream flag: %w"+common.Reset, err) + return fmt.Errorf(common.Red+"failed to read dataset flag: %w"+common.Reset, err) } if streamName == "" { - return fmt.Errorf(common.Red + "stream flag is required" + common.Reset) + return fmt.Errorf(common.Red + "dataset flag is required" + common.Reset) } // Get the file path from the `--file` flag @@ -171,6 +171,6 @@ var CreateSchemaCmd = &cobra.Command{ func init() { // Add the `--file` flag to the command GenerateSchemaCmd.Flags().StringP("file", "f", "", "Path to the JSON file to generate schema") - CreateSchemaCmd.Flags().StringP("stream", "s", "", "Name of the stream to associate with the schema") + CreateSchemaCmd.Flags().StringP("dataset", "s", "", "Name of the dataset to associate with the schema") CreateSchemaCmd.Flags().StringP("file", "f", "", "Path to the JSON file to create schema") } diff --git a/cmd/login.go b/cmd/login.go index 2007562..c0382cb 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" ) -const defaultCloudURL = "https://staging.parseable.com:8000" +const cloudURL = "https://app.parseable.com" var ( loginToken string @@ -115,7 +115,7 @@ func cloudLogin() error { token := loginToken if token == "" { - loginPageURL := defaultCloudURL + "/login" + loginPageURL := cloudURL + "/login" fmt.Printf("Opening login page: %s\n\n", loginPageURL) if err := openBrowser(loginPageURL); err != nil { @@ -137,7 +137,7 @@ func cloudLogin() error { } profile := config.Profile{ - URL: defaultCloudURL, + URL: cloudURL, Token: token, } if err := writeProfile(profile, loginProfileName); err != nil { @@ -145,7 +145,7 @@ func cloudLogin() error { } fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName) - fmt.Printf(" URL: %s\n", defaultCloudURL) + fmt.Printf(" URL: %s\n", cloudURL) return nil } diff --git a/cmd/query.go b/cmd/query.go index 49afd93..d3abee1 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -48,8 +48,8 @@ var ( var query = &cobra.Command{ Use: "run [query] [flags]", Example: " pb query run \"select * from frontend\" --from=10m --to=now", - Short: "Run SQL query on a log stream", - Long: "\nRun SQL query on a log stream. Default output format is text. Use --output flag to set output format to json.", + Short: "Run SQL query on a dataset", + Long: "\nRun SQL query on a dataset. Default output format is text. Use --output flag to set output format to json.", Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, RunE: func(command *cobra.Command, args []string) error { diff --git a/cmd/tail.go b/cmd/tail.go index 6ccde73..892433a 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -35,9 +35,9 @@ import ( ) var TailCmd = &cobra.Command{ - Use: "tail stream-name", + Use: "tail dataset-name", Example: " pb tail backend_logs", - Short: "Stream live events from a log stream", + Short: "Stream live events from a dataset", Args: cobra.ExactArgs(1), PreRunE: PreRunDefaultProfile, RunE: func(_ *cobra.Command, args []string) error { diff --git a/main.go b/main.go index e8875b7..5cd61a6 100644 --- a/main.go +++ b/main.go @@ -85,16 +85,16 @@ var profile = &cobra.Command{ var schema = &cobra.Command{ Use: "schema", - Short: "Generate or create schemas for JSON data or Parseable streams", + Short: "Generate or create schemas for JSON data or Parseable datasets", Long: `The "schema" command allows you to either: - Generate a schema automatically from a JSON file for analysis or integration. - - Create a custom schema for Parseable streams (PB streams) to structure and process your data. + - Create a custom schema for Parseable datasets to structure and process your data. Examples: - To generate a schema from a JSON file: pb schema generate --file=data.json - - To create a schema for a PB stream: - pb schema create --stream-name=my_stream --config=data.json + - To create a schema for a dataset: + pb schema create --dataset=my_dataset --config=data.json `, PersistentPreRunE: combinedPreRun, PersistentPostRun: func(cmd *cobra.Command, args []string) { @@ -143,10 +143,10 @@ var role = &cobra.Command{ }, } -var stream = &cobra.Command{ - Use: "stream", - Short: "Manage streams", - Long: "\nstream command is used to manage streams.", +var dataset = &cobra.Command{ + Use: "dataset", + Short: "Manage datasets", + Long: "\ndataset command is used to manage datasets.", PersistentPreRunE: combinedPreRun, PersistentPostRun: func(cmd *cobra.Command, args []string) { if os.Getenv("PB_ANALYTICS") == "disable" { @@ -155,15 +155,15 @@ var stream = &cobra.Command{ wg.Add(1) go func() { defer wg.Done() - analytics.PostRunAnalytics(cmd, "stream", args) + analytics.PostRunAnalytics(cmd, "dataset", args) }() }, } var query = &cobra.Command{ Use: "query", - Short: "Run SQL query on a log stream", - Long: "\nRun SQL query on a log stream. Default output format is json. Use -i flag to open interactive table view.", + Short: "Run SQL query on a dataset", + Long: "\nRun SQL query on a dataset. Default output format is json. Use -i flag to open interactive table view.", PersistentPreRunE: combinedPreRun, PersistentPostRun: func(cmd *cobra.Command, args []string) { if os.Getenv("PB_ANALYTICS") == "disable" { @@ -260,10 +260,10 @@ func main() { role.AddCommand(pb.RemoveRoleCmd) role.AddCommand(pb.ListRoleCmd) - stream.AddCommand(pb.AddStreamCmd) - stream.AddCommand(pb.RemoveStreamCmd) - stream.AddCommand(pb.ListStreamCmd) - stream.AddCommand(pb.StatStreamCmd) + dataset.AddCommand(pb.AddDatasetCmd) + dataset.AddCommand(pb.RemoveDatasetCmd) + dataset.AddCommand(pb.ListDatasetCmd) + dataset.AddCommand(pb.StatDatasetCmd) query.AddCommand(pb.QueryCmd) query.AddCommand(pb.SavedQueryList) @@ -284,7 +284,7 @@ func main() { cli.AddCommand(profile) cli.AddCommand(query) - cli.AddCommand(stream) + cli.AddCommand(dataset) cli.AddCommand(user) cli.AddCommand(role) cli.AddCommand(pb.TailCmd) From 22a4fa365482cc169cce5956b3e0483ce15f9736 Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 1 May 2026 12:10:14 +0530 Subject: [PATCH 3/3] refactor: restructure CLI commands and fix API response parsing --- cmd/dataset.go | 63 ++++++++++++++++++++------------------ cmd/query.go | 18 ++++++----- cmd/role.go | 23 +++++++------- cmd/tail.go | 63 ++++++++++++++++++++++++++++---------- cmd/user.go | 32 +++++++++++++++---- pkg/installer/installer.go | 35 ++++++++++++++++++--- 6 files changed, 157 insertions(+), 77 deletions(-) diff --git a/cmd/dataset.go b/cmd/dataset.go index d8ed666..5f19327 100644 --- a/cmd/dataset.go +++ b/cmd/dataset.go @@ -17,13 +17,10 @@ package cmd import ( "encoding/json" - "errors" "fmt" "io" "net/http" internalHTTP "pb/pkg/http" - "strconv" - "strings" "time" "github.com/dustin/go-humanize" @@ -35,11 +32,11 @@ type DatasetStatsData struct { Ingestion struct { Count int `json:"count"` Format string `json:"format"` - Size string `json:"size"` + Size uint64 `json:"size"` } `json:"ingestion"` Storage struct { Format string `json:"format"` - Size string `json:"size"` + Size uint64 `json:"size"` } `json:"storage"` Stream string `json:"stream"` Time time.Time `json:"time"` @@ -181,9 +178,12 @@ var StatDatasetCmd = &cobra.Command{ } ingestionCount := stats.Ingestion.Count - ingestionSize, _ := strconv.Atoi(strings.TrimRight(stats.Ingestion.Size, " Bytes")) - storageSize, _ := strconv.Atoi(strings.TrimRight(stats.Storage.Size, " Bytes")) - compressionRatio := 100 - (float64(storageSize) / float64(ingestionSize) * 100) + ingestionSize := stats.Ingestion.Size + storageSize := stats.Storage.Size + var compressionRatio float64 + if ingestionSize > 0 { + compressionRatio = 100 - (float64(storageSize) / float64(ingestionSize) * 100) + } // Fetch retention data retention, err := fetchRetention(&client, name) @@ -411,12 +411,13 @@ func fetchStats(client *internalHTTP.HTTPClient, name string) (data DatasetStats } defer resp.Body.Close() - if resp.StatusCode == 200 { + switch resp.StatusCode { + case http.StatusOK: err = json.Unmarshal(bytes, &data) - } else { - body := string(bytes) - body = fmt.Sprintf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) - err = errors.New(body) + case http.StatusNotFound: + // stream exists but has no stats yet (empty stream) + default: + err = fmt.Errorf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, string(bytes)) } return } @@ -438,12 +439,13 @@ func fetchRetention(client *internalHTTP.HTTPClient, name string) (data DatasetR } defer resp.Body.Close() - if resp.StatusCode == 200 { + switch resp.StatusCode { + case http.StatusOK: err = json.Unmarshal(bytes, &data) - } else { - body := string(bytes) - body = fmt.Sprintf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) - err = errors.New(body) + case http.StatusNotFound: + // no retention configured + default: + err = fmt.Errorf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, string(bytes)) } return } @@ -465,12 +467,13 @@ func fetchAlerts(client *internalHTTP.HTTPClient, name string) (data AlertConfig } defer resp.Body.Close() - if resp.StatusCode == 200 { + switch resp.StatusCode { + case http.StatusOK: err = json.Unmarshal(bytes, &data) - } else { - body := string(bytes) - body = fmt.Sprintf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) - err = errors.New(body) + case http.StatusNotFound: + // no alerts configured + default: + err = fmt.Errorf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, string(bytes)) } return } @@ -496,7 +499,8 @@ func fetchInfo(client *internalHTTP.HTTPClient, name string) (datasetType string } // Check for successful status code - if resp.StatusCode == http.StatusOK { + switch resp.StatusCode { + case http.StatusOK: // Define a struct to parse the response var response struct { StreamType string `json:"stream_type"` @@ -509,10 +513,11 @@ func fetchInfo(client *internalHTTP.HTTPClient, name string) (datasetType string // Return the extracted stream_type return response.StreamType, nil + case http.StatusNotFound: + // endpoint not available on this server version or stream has no type info + return "unknown", nil + default: + // Handle non-200 responses + return "", fmt.Errorf("Request Failed\nStatus Code: %d\nResponse: %s\n", resp.StatusCode, string(bytes)) } - - // Handle non-200 responses - body := string(bytes) - errMsg := fmt.Sprintf("Request failed\nStatus Code: %d\nResponse: %s\n", resp.StatusCode, body) - return "", errors.New(errMsg) } diff --git a/cmd/query.go b/cmd/query.go index d3abee1..2e46468 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -112,14 +112,16 @@ func init() { var QueryCmd = query func fetchData(client *internalHTTP.HTTPClient, query string, startTime, endTime, outputFormat string) error { - queryTemplate := `{ - "query": "%s", - "startTime": "%s", - "endTime": "%s" - }` - finalQuery := fmt.Sprintf(queryTemplate, query, startTime, endTime) - - req, err := client.NewRequest("POST", "query", bytes.NewBuffer([]byte(finalQuery))) + body, err := json.Marshal(struct { + Query string `json:"query"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + }{Query: query, StartTime: startTime, EndTime: endTime}) + if err != nil { + return fmt.Errorf("failed to build request body: %w", err) + } + + req, err := client.NewRequest("POST", "query", bytes.NewBuffer(body)) if err != nil { return fmt.Errorf("failed to create new request: %w", err) } diff --git a/cmd/role.go b/cmd/role.go index dbd1cbd..143049c 100644 --- a/cmd/role.go +++ b/cmd/role.go @@ -34,7 +34,6 @@ import ( type RoleResource struct { Stream string `json:"stream,omitempty"` - Tag string `json:"tag,omitempty"` } type RoleData struct { @@ -53,11 +52,6 @@ func (user *RoleData) Render() string { s.WriteString(StandardStyleAlt.Render(user.Resource.Stream)) s.WriteString("\n") } - if user.Resource.Tag != "" { - s.WriteString(StandardStyle.Render("Tag: ")) - s.WriteString(StandardStyleAlt.Render(user.Resource.Tag)) - s.WriteString("\n") - } } return s.String() @@ -98,7 +92,6 @@ var AddRoleCmd = &cobra.Command{ m := _m.(role.Model) privilege := m.Selection.Value() stream := m.Stream.Value() - tag := m.Tag.Value() if !m.Success { fmt.Println("aborted by user") @@ -112,7 +105,7 @@ var AddRoleCmd = &cobra.Command{ case "writer", "ingestor": roleData.Resource = &RoleResource{Stream: stream} case "reader": - roleData.Resource = &RoleResource{Stream: stream, Tag: tag} + roleData.Resource = &RoleResource{Stream: stream} } roleDataJSON, _ := json.Marshal([]RoleData{roleData}) putBody = bytes.NewBuffer(roleDataJSON) @@ -287,10 +280,13 @@ func fetchRoles(client *internalHTTP.HTTPClient, data *[]string) error { defer resp.Body.Close() if resp.StatusCode == 200 { - err = json.Unmarshal(bytes, data) - if err != nil { + var roleMap map[string]json.RawMessage + if err = json.Unmarshal(bytes, &roleMap); err != nil { return err } + for name := range roleMap { + *data = append(*data, name) + } } else { body := string(bytes) return fmt.Errorf("request failed\nstatus code: %s\nresponse: %s", resp.Status, body) @@ -317,10 +313,13 @@ func fetchSpecificRole(client *internalHTTP.HTTPClient, role string) (res []Role defer resp.Body.Close() if resp.StatusCode == 200 { - err = json.Unmarshal(bytes, &res) - if err != nil { + var wrapper struct { + Actions []RoleData `json:"actions"` + } + if err = json.Unmarshal(bytes, &wrapper); err != nil { return } + res = wrapper.Actions } else { body := string(bytes) err = fmt.Errorf("request failed\nstatus code: %s\nresponse: %s", resp.Status, body) diff --git a/cmd/tail.go b/cmd/tail.go index 892433a..6889786 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -22,16 +22,20 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "pb/pkg/analytics" "pb/pkg/config" internalHTTP "pb/pkg/http" + "time" "github.com/apache/arrow/go/v13/arrow/array" "github.com/apache/arrow/go/v13/arrow/flight" "github.com/spf13/cobra" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" ) var TailCmd = &cobra.Command{ @@ -62,34 +66,59 @@ func tail(profile config.Profile, stream string) error { } url := profile.GrpcAddr(fmt.Sprint(about.GRPCPort)) - client, err := flight.NewClientWithMiddleware(url, nil, nil, grpc.WithTransportCredentials(insecure.NewCredentials())) + flightClient, err := flight.NewClientWithMiddleware(url, nil, nil, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return err } authHeader := basicAuth(profile.Username, profile.Password) - resp, err := client.DoGet(metadata.NewOutgoingContext(context.Background(), metadata.New(map[string]string{"Authorization": "Basic " + authHeader})), &flight.Ticket{ - Ticket: payload, - }) - if err != nil { - return err - } - - records, err := flight.NewRecordReader(resp) - if err != nil { - return err - } - defer records.Release() for { - record, err := records.Read() + resp, err := flightClient.DoGet( + metadata.NewOutgoingContext(context.Background(), metadata.New(map[string]string{ + "Authorization": "Basic " + authHeader, + })), + &flight.Ticket{Ticket: payload}, + ) + if err != nil { + return err + } + + records, err := flight.NewRecordReader(resp) if err != nil { return err } - var buf bytes.Buffer - array.RecordToJSON(record, &buf) - fmt.Println(buf.String()) + + for { + record, err := records.Read() + if err != nil { + records.Release() + if isStreamEnd(err) { + break + } + return err + } + var buf bytes.Buffer + array.RecordToJSON(record, &buf) + fmt.Println(buf.String()) + } + + time.Sleep(500 * time.Millisecond) + } +} + +// isStreamEnd returns true for normal stream termination codes that warrant a reconnect. +func isStreamEnd(err error) bool { + if err == io.EOF { + return true + } + if s, ok := status.FromError(err); ok { + switch s.Code() { + case codes.Canceled, codes.Unavailable, codes.OK: + return true + } } + return false } func basicAuth(username, password string) string { diff --git a/cmd/user.go b/cmd/user.go index fc02679..dca5ee8 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -35,7 +35,22 @@ type UserData struct { Method string `json:"method"` } -type UserRoleData map[string][]RoleData +// UserRoleAction is a single privilege entry within a named role +type UserRoleAction struct { + Privilege string `json:"privilege"` + Resource *RoleResource `json:"resource,omitempty"` +} + +// UserServerRole is a named role definition returned by the server +type UserServerRole struct { + Actions []UserRoleAction `json:"actions"` +} + +// UserRolesResponse is the response from GET /user/{name}/role +type UserRolesResponse struct { + DirectRoles map[string]UserServerRole `json:"roles"` + GroupRoles map[string]map[string]UserServerRole `json:"group_roles"` +} var ( roleFlag = "role" @@ -289,11 +304,16 @@ var ListUserCmd = &cobra.Command{ userID := user.ID client := &client go func() { - var userRolesData UserRoleData - userRolesData, out.err = fetchUserRoles(client, userID) + var rolesResp UserRolesResponse + rolesResp, out.err = fetchUserRoles(client, userID) if out.err == nil { - for role := range userRolesData { - out.data = append(out.data, role) + for roleName := range rolesResp.DirectRoles { + out.data = append(out.data, roleName) + } + for _, groupRoles := range rolesResp.GroupRoles { + for roleName := range groupRoles { + out.data = append(out.data, roleName) + } } } wsg.Done() @@ -393,7 +413,7 @@ func fetchUsers(client *internalHTTP.HTTPClient) (res []UserData, err error) { return } -func fetchUserRoles(client *internalHTTP.HTTPClient, user string) (res UserRoleData, err error) { +func fetchUserRoles(client *internalHTTP.HTTPClient, user string) (res UserRolesResponse, err error) { req, err := client.NewRequest("GET", fmt.Sprintf("user/%s/role", user), nil) if err != nil { return diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index b9a98ca..0699bd5 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -26,6 +26,7 @@ import ( "net" "os" "os/exec" + "regexp" "runtime" "strings" "sync" @@ -71,6 +72,7 @@ func waterFall(verbose bool) { if plan.Name == "Playground" { chartValues = append(chartValues, "parseable.store=local-store") chartValues = append(chartValues, "parseable.localModeSecret.enabled=true") + chartValues = append(chartValues, "parseable.auditLogging.enabled=false") // Prompt for namespace and credentials pbInfo, err := promptNamespaceAndCredentials() @@ -95,7 +97,7 @@ func waterFall(verbose bool) { RepoName: "parseable", RepoURL: "https://charts.parseable.com", ChartName: "parseable", - Version: "1.6.6", + Version: "2.6.6", Values: agentValues, Verbose: verbose, } @@ -120,6 +122,7 @@ func waterFall(verbose bool) { // pb supports only distributed deployments chartValues = append(chartValues, "parseable.highAvailability.enabled=true") + chartValues = append(chartValues, "parseable.auditLogging.enabled=false") // Prompt for namespace and credentials pbInfo, err := promptNamespaceAndCredentials() @@ -156,7 +159,7 @@ func waterFall(verbose bool) { RepoName: "parseable", RepoURL: "https://charts.parseable.com", ChartName: "parseable", - Version: "1.6.6", + Version: "2.6.6", Values: storeConfigs, Verbose: verbose, } @@ -227,6 +230,12 @@ func promptStorageClass() (string, error) { } // promptNamespaceAndCredentials prompts the user for namespace and credentials +var helmReleaseNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$`) + +func isValidReleaseName(name string) bool { + return len(name) <= 53 && helmReleaseNameRe.MatchString(name) +} + func promptNamespaceAndCredentials() (*ParseableInfo, error) { // Prompt user for release name fmt.Print(common.Yellow + "Enter the Name for deployment: " + common.Reset) @@ -236,6 +245,9 @@ func promptNamespaceAndCredentials() (*ParseableInfo, error) { return nil, fmt.Errorf("failed to read namespace: %w", err) } name = strings.TrimSpace(name) + if !isValidReleaseName(name) { + return nil, fmt.Errorf("invalid deployment name %q: must be lowercase alphanumeric and hyphens only, max 53 chars (e.g. parseable-test)", name) + } // Prompt user for namespace fmt.Print(common.Yellow + "Enter the Kubernetes namespace for deployment: " + common.Reset) @@ -667,10 +679,23 @@ func applyManifest(manifest string) error { return fmt.Errorf("failed to get GVR: %w", err) } - // Apply the manifest using the dynamic client - _, err = dynamicClient.Resource(gvr).Namespace(namespace).Create(context.TODO(), &obj, metav1.CreateOptions{}) + // Apply the manifest: create if new, update if it already exists + resourceClient := dynamicClient.Resource(gvr).Namespace(namespace) + existing, err := resourceClient.Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) if err != nil { - return fmt.Errorf("failed to apply manifest: %w", err) + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to check existing resource: %w", err) + } + _, err = resourceClient.Create(context.TODO(), &obj, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to apply manifest: %w", err) + } + } else { + obj.SetResourceVersion(existing.GetResourceVersion()) + _, err = resourceClient.Update(context.TODO(), &obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update manifest: %w", err) + } } return nil }