diff --git a/agent/cmd/start.go b/agent/cmd/start.go index b44aa5b0e1..dfd7137520 100644 --- a/agent/cmd/start.go +++ b/agent/cmd/start.go @@ -20,6 +20,7 @@ var StartCmd = cobra.Command{ Short: "Start the local agent", Long: "Start the local agent", Run: func(cmd *cobra.Command, args []string) { + ctx := cmd.Context() cfg, err := config.LoadConfig() if err != nil { fmt.Fprintln(os.Stderr, err) @@ -28,7 +29,11 @@ var StartCmd = cobra.Command{ log.Printf("starting agent [%s] connecting to %s", cfg.Name, cfg.ServerURL) - initialization.Start(cfg) + err = initialization.Start(ctx, cfg) + if err != nil { + fmt.Fprintln(os.Stderr, err) + ExitCLI(1) + } }, } diff --git a/agent/initialization/start.go b/agent/initialization/start.go index 65403d5ce9..7cb57610b2 100644 --- a/agent/initialization/start.go +++ b/agent/initialization/start.go @@ -3,7 +3,6 @@ package initialization import ( "context" "fmt" - "log" "github.com/kubeshop/tracetest/agent/client" "github.com/kubeshop/tracetest/agent/config" @@ -12,15 +11,14 @@ import ( ) // Start the agent with given configuration -func Start(config config.Config) { +func Start(ctx context.Context, config config.Config) error { fmt.Println("Starting agent") - ctx := context.Background() client, err := client.Connect(ctx, config.ServerURL, client.WithAPIKey(config.APIKey), client.WithAgentName(config.Name), ) if err != nil { - log.Fatal(err) + return err } triggerWorker := workers.NewTriggerWorker(client) @@ -35,8 +33,10 @@ func Start(config config.Config) { err = client.Start(ctx) if err != nil { - log.Fatal(err) + return err } + fmt.Println("Agent started! Do not close the terminal.") client.WaitUntilDisconnected() + return nil } diff --git a/agent/workers/trigger.go b/agent/workers/trigger.go index 746fef9a7b..5a66c3e5ea 100644 --- a/agent/workers/trigger.go +++ b/agent/workers/trigger.go @@ -30,6 +30,7 @@ func NewTriggerWorker(client *client.Client) *TriggerWorker { } func (w *TriggerWorker) Trigger(ctx context.Context, triggerRequest *proto.TriggerRequest) error { + fmt.Println("Trigger handled by agent") triggerConfig := convertProtoToTrigger(triggerRequest.Trigger) triggerer, err := w.registry.Get(triggerConfig.Type) if err != nil { diff --git a/api/version.yaml b/api/version.yaml index bc5b476bba..b8d79547aa 100644 --- a/api/version.yaml +++ b/api/version.yaml @@ -7,3 +7,11 @@ components: version: type: string example: 1.0.0 + type: + type: string + enum: + - oss + uiEndpoint: + type: string + agentEndpoint: + type: string diff --git a/cli/actions/configure_action.go b/cli/actions/configure_action.go deleted file mode 100644 index 32cc80dce3..0000000000 --- a/cli/actions/configure_action.go +++ /dev/null @@ -1,114 +0,0 @@ -package actions - -import ( - "context" - "fmt" - "os" - "path" - "path/filepath" - - "github.com/kubeshop/tracetest/cli/config" - "github.com/kubeshop/tracetest/cli/ui" - "gopkg.in/yaml.v3" -) - -type ConfigureConfig struct { - Global bool - SetValues ConfigureConfigSetValues -} - -type ConfigureConfigSetValues struct { - Endpoint string -} - -type configureAction struct { - config config.Config -} - -func NewConfigureAction(config config.Config) configureAction { - return configureAction{ - config: config, - } -} - -func (a configureAction) Run(ctx context.Context, args ConfigureConfig) error { - ui := ui.DefaultUI - existingConfig := a.loadExistingConfig(args) - - var serverURL string - if args.SetValues.Endpoint != "" { - serverURL = args.SetValues.Endpoint - } else { - serverURL = ui.TextInput("Enter your Tracetest server URL", existingConfig.URL()) - } - - if err := config.ValidateServerURL(serverURL); err != nil { - return err - } - - scheme, endpoint, err := config.ParseServerURL(serverURL) - if err != nil { - return err - } - - config := config.Config{ - Scheme: scheme, - Endpoint: endpoint, - } - - err = a.saveConfiguration(ctx, config, args) - if err != nil { - return fmt.Errorf("could not save configuration: %w", err) - } - - return nil -} - -func (a configureAction) loadExistingConfig(args ConfigureConfig) config.Config { - configPath, err := a.getConfigurationPath(args) - if err != nil { - return config.Config{} - } - - c, err := config.LoadConfig(configPath) - if err != nil { - return config.Config{} - } - - return c -} - -func (a configureAction) saveConfiguration(ctx context.Context, config config.Config, args ConfigureConfig) error { - configPath, err := a.getConfigurationPath(args) - if err != nil { - return fmt.Errorf("could not get configuration path: %w", err) - } - - configYml, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("could not marshal configuration into yml: %w", err) - } - - if _, err := os.Stat(configPath); os.IsNotExist(err) { - os.MkdirAll(filepath.Dir(configPath), 0700) // Ensure folder exists - } - err = os.WriteFile(configPath, configYml, 0755) - if err != nil { - return fmt.Errorf("could not write file: %w", err) - } - - return nil -} - -func (a configureAction) getConfigurationPath(args ConfigureConfig) (string, error) { - configPath := "./config.yml" - if args.Global { - homePath, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("could not get user home dir: %w", err) - } - configPath = path.Join(homePath, ".tracetest/config.yml") - } - - return configPath, nil -} diff --git a/cli/cmd/config.go b/cli/cmd/config.go index b689714ba2..e7f67ed2d1 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -5,12 +5,10 @@ import ( "fmt" "os" - "github.com/kubeshop/tracetest/cli/actions" "github.com/kubeshop/tracetest/cli/analytics" "github.com/kubeshop/tracetest/cli/config" "github.com/kubeshop/tracetest/cli/formatters" "github.com/kubeshop/tracetest/cli/openapi" - "github.com/kubeshop/tracetest/cli/utils" "github.com/spf13/cobra" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -22,6 +20,9 @@ var ( openapiClient = &openapi.APIClient{} versionText string isVersionMatch bool + + // only available in dev mode + isCloudEnabled = os.Getenv("TRACETEST_DEV") == "true" ) type setupConfig struct { @@ -75,7 +76,7 @@ func setupCommand(options ...setupOption) func(cmd *cobra.Command, args []string func overrideConfig() { if overrideEndpoint != "" { - scheme, endpoint, err := config.ParseServerURL(overrideEndpoint) + scheme, endpoint, _, err := config.ParseServerURL(overrideEndpoint) if err != nil { msg := fmt.Sprintf("cannot parse endpoint %s", overrideEndpoint) cliLogger.Error(msg, zap.Error(err)) @@ -87,7 +88,7 @@ func overrideConfig() { } func setupRunners() { - c := utils.GetAPIClient(cliConfig) + c := config.GetAPIClient(cliConfig) *openapiClient = *c } @@ -154,10 +155,10 @@ func teardownCommand(cmd *cobra.Command, args []string) { } func setupVersion() { - versionText, isVersionMatch = actions.GetVersion( + versionText, isVersionMatch = config.GetVersion( context.Background(), cliConfig, - utils.GetAPIClient(cliConfig), + config.GetAPIClient(cliConfig), ) } diff --git a/cli/cmd/configure_cmd.go b/cli/cmd/configure_cmd.go index 040792d4a2..39c7a716e8 100644 --- a/cli/cmd/configure_cmd.go +++ b/cli/cmd/configure_cmd.go @@ -4,12 +4,16 @@ import ( "context" "net/url" - "github.com/kubeshop/tracetest/cli/actions" + "github.com/kubeshop/tracetest/cli/config" "github.com/spf13/cobra" ) var configParams = &configureParameters{} +var ( + configurator = config.NewConfigurator(resources) +) + var configureCmd = &cobra.Command{ GroupID: cmdGroupConfig.ID, Use: "configure", @@ -18,17 +22,17 @@ var configureCmd = &cobra.Command{ PreRun: setupLogger, Run: WithResultHandler(WithParamsHandler(configParams)(func(cmd *cobra.Command, _ []string) (string, error) { ctx := context.Background() - action := actions.NewConfigureAction(cliConfig) - - actionConfig := actions.ConfigureConfig{ - Global: configParams.Global, + flags := config.ConfigFlags{} + config, err := config.LoadConfig("") + if err != nil { + return "", err } if flagProvided(cmd, "endpoint") { - actionConfig.SetValues.Endpoint = configParams.Endpoint + flags.Endpoint = configParams.Endpoint } - err := action.Run(ctx, actionConfig) + err = configurator.Start(ctx, config, flags) return "", err })), PostRun: teardownCommand, diff --git a/cli/cmd/legacy_test_cmd.go b/cli/cmd/legacy_test_cmd.go index 87bcba242d..adcefe46c9 100644 --- a/cli/cmd/legacy_test_cmd.go +++ b/cli/cmd/legacy_test_cmd.go @@ -9,7 +9,7 @@ import ( ) var testCmd = &cobra.Command{ - GroupID: cmdGroupTests.ID, + GroupID: cmdGroupResources.ID, Use: "test", Short: "Manage your tracetest tests", Long: "Manage your tracetest tests", diff --git a/cli/cmd/middleware.go b/cli/cmd/middleware.go index 4e922ca3d6..24df569dab 100644 --- a/cli/cmd/middleware.go +++ b/cli/cmd/middleware.go @@ -16,15 +16,7 @@ func WithResultHandler(runFn RunFn) CobraRunFn { res, err := runFn(cmd, args) if err != nil { - fmt.Fprintf(os.Stderr, ` -Version -%s - -An error ocurred when executing the command - -%s -`, versionText, err.Error()) - ExitCLI(1) + OnError(err) return } @@ -34,6 +26,18 @@ An error ocurred when executing the command } } +func OnError(err error) { + fmt.Fprintf(os.Stderr, ` +Version +%s + +An error ocurred when executing the command + +%s +`, versionText, err.Error()) + ExitCLI(1) +} + func WithParamsHandler(validators ...Validator) MiddlewareWrapper { return func(runFn RunFn) RunFn { return func(cmd *cobra.Command, args []string) (string, error) { diff --git a/cli/cmd/resource_run_cmd.go b/cli/cmd/resource_run_cmd.go index 2deca8110a..c8fffad93d 100644 --- a/cli/cmd/resource_run_cmd.go +++ b/cli/cmd/resource_run_cmd.go @@ -5,9 +5,9 @@ import ( "fmt" "strings" + "github.com/kubeshop/tracetest/cli/config" "github.com/kubeshop/tracetest/cli/openapi" "github.com/kubeshop/tracetest/cli/runner" - "github.com/kubeshop/tracetest/cli/utils" "github.com/spf13/cobra" ) @@ -34,7 +34,7 @@ func init() { orchestrator := runner.Orchestrator( cliLogger, - utils.GetAPIClient(cliConfig), + config.GetAPIClient(cliConfig), variableSetClient, ) diff --git a/cli/cmd/resource_select_cmd.go b/cli/cmd/resource_select_cmd.go new file mode 100644 index 0000000000..b3ed0d0db8 --- /dev/null +++ b/cli/cmd/resource_select_cmd.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/kubeshop/tracetest/cli/config" + "github.com/kubeshop/tracetest/cli/pkg/resourcemanager" + "github.com/spf13/cobra" +) + +type selectableFn func(ctx context.Context, cfg config.Config) + +var ( + selectParams = &resourceIDParameters{} + selectCmd *cobra.Command + selectable = strings.Join([]string{"organization"}, "|") + selectableMap = map[string]selectableFn{ + "organization": func(ctx context.Context, cfg config.Config) { + configurator.ShowOrganizationSelector(ctx, cfg) + }} +) + +func init() { + selectCmd = &cobra.Command{ + GroupID: cmdGroupCloud.ID, + Use: "select " + selectable, + Short: "Select resources", + Long: "Select resources to your Tracetest CLI config", + PreRun: setupCommand(), + Run: WithResourceMiddleware(func(_ *cobra.Command, args []string) (string, error) { + resourceType := resourceParams.ResourceName + ctx := context.Background() + + selectableFn, ok := selectableMap[resourceType] + if !ok { + return "", fmt.Errorf("resource type %s not selectable. Selectable resources are %s", resourceType, selectable) + } + + resourceClient, err := resources.Get(resourceType) + if err != nil { + return "", err + } + + resultFormat, err := resourcemanager.Formats.GetWithFallback(output, "yaml") + if err != nil { + return "", err + } + + result, err := resourceClient.Get(ctx, selectParams.ResourceID, resultFormat) + if errors.Is(err, resourcemanager.ErrNotFound) { + return result, nil + } + if err != nil { + return "", err + } + + selectableFn(ctx, cliConfig) + return "", nil + }), + PostRun: teardownCommand, + } + + if isCloudEnabled { + rootCmd.AddCommand(selectCmd) + } +} diff --git a/cli/cmd/resources.go b/cli/cmd/resources.go index 53c14cadd8..b5d8ded208 100644 --- a/cli/cmd/resources.go +++ b/cli/cmd/resources.go @@ -253,10 +253,37 @@ var ( Register(variableSetClient). Register(testSuiteClient). Register(testClient). + Register(organizationsClient). + Register(environmentClient). // deprecated resources Register(deprecatedEnvironmentClient). Register(deprecatedTransactionsClient) + + organizationsClient = resourcemanager.NewClient( + httpClient, cliLogger, + "organization", "organizations", + resourcemanager.WithTableConfig(resourcemanager.TableConfig{ + Cells: []resourcemanager.TableCellConfig{ + {Header: "ID", Path: "id"}, + {Header: "NAME", Path: "name"}, + }, + }), + resourcemanager.WithListPath("elements"), + ) + + environmentClient = resourcemanager.NewClient( + httpClient, cliLogger, + "env", "environments", + resourcemanager.WithTableConfig(resourcemanager.TableConfig{ + Cells: []resourcemanager.TableCellConfig{ + {Header: "ID", Path: "id"}, + {Header: "NAME", Path: "name"}, + }, + }), + resourcemanager.WithPrefixGetter(func() string { return fmt.Sprintf("/organizations/%s/", cliConfig.OrganizationID) }), + resourcemanager.WithListPath("elements"), + ) ) func resourceList() string { @@ -271,6 +298,9 @@ func setupResources() { extraHeaders := http.Header{} extraHeaders.Set("x-client-id", analytics.ClientID()) extraHeaders.Set("x-source", "cli") + extraHeaders.Set("x-organization-id", cliConfig.OrganizationID) + extraHeaders.Set("x-environment-id", cliConfig.EnvironmentID) + extraHeaders.Set("Authorization", fmt.Sprintf("Bearer %s", cliConfig.Jwt)) // To avoid a ciruclar reference initialization when setting up the registry and its resources, // we create the resources with a pointer to an unconfigured HTTPClient. @@ -278,7 +308,7 @@ func setupResources() { // We take this chance to configure the HTTPClient with the correct URL and headers. // To make this configuration propagate to all the resources, we need to replace the pointer to the HTTPClient. // For more details, see https://github.com/kubeshop/tracetest/pull/2832#discussion_r1245616804 - hc := resourcemanager.NewHTTPClient(cliConfig.URL(), extraHeaders) + hc := resourcemanager.NewHTTPClient(fmt.Sprintf("%s%s", cliConfig.URL(), cliConfig.Path()), extraHeaders) *httpClient = *hc } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 2de6247c29..11a9f2014b 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -61,30 +61,30 @@ var ( Title: "Resources", } - cmdGroupTests = &cobra.Group{ - ID: "tests", - Title: "Tests", - } - cmdGroupMisc = &cobra.Group{ ID: "misc", Title: "Misc", } + + cmdGroupCloud = &cobra.Group{ + ID: "cloud", + Title: "Cloud", + } ) func init() { rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "", fmt.Sprintf("output format [%s]", outputFormatsString)) rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "config.yml", "config file will be used by the CLI") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "display debug information") - rootCmd.PersistentFlags().StringVarP(&overrideEndpoint, "server-url", "s", "", "server url") - rootCmd.AddGroup( - cmdGroupConfig, - cmdGroupResources, - cmdGroupTests, - cmdGroupMisc, - ) + groups := []*cobra.Group{cmdGroupConfig, cmdGroupResources, cmdGroupMisc} + + if isCloudEnabled { + groups = append(groups, cmdGroupCloud) + } + + rootCmd.AddGroup(groups...) rootCmd.SetCompletionCommandGroupID(cmdGroupConfig.ID) rootCmd.SetHelpCommandGroupID(cmdGroupMisc.ID) diff --git a/cli/cmd/start_cmd.go b/cli/cmd/start_cmd.go new file mode 100644 index 0000000000..afd0f0362c --- /dev/null +++ b/cli/cmd/start_cmd.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "context" + + "github.com/kubeshop/tracetest/cli/pkg/starter" + "github.com/spf13/cobra" +) + +var ( + start = starter.NewStarter(configurator, resources) +) + +var startCmd = &cobra.Command{ + GroupID: cmdGroupCloud.ID, + Use: "start", + Short: "Start Tracetest", + Long: "Start using Tracetest", + PreRun: setupCommand(), + Run: WithResultHandler((func(_ *cobra.Command, _ []string) (string, error) { + ctx := context.Background() + + err := start.Run(ctx, cliConfig) + return "", err + })), + PostRun: teardownCommand, +} + +func init() { + if isCloudEnabled { + rootCmd.AddCommand(startCmd) + } +} diff --git a/cli/utils/api.go b/cli/config/api.go similarity index 57% rename from cli/utils/api.go rename to cli/config/api.go index eee39bd24a..f8573dcf9a 100644 --- a/cli/utils/api.go +++ b/cli/config/api.go @@ -1,10 +1,10 @@ -package utils +package config import ( + "fmt" "strings" "github.com/kubeshop/tracetest/cli/analytics" - "github.com/kubeshop/tracetest/cli/config" "github.com/kubeshop/tracetest/cli/openapi" ) @@ -16,18 +16,20 @@ type ListArgs struct { All bool } -func GetAPIClient(cliConfig config.Config) *openapi.APIClient { +func GetAPIClient(cliConfig Config) *openapi.APIClient { config := openapi.NewConfiguration() config.AddDefaultHeader("x-client-id", analytics.ClientID()) config.AddDefaultHeader("x-source", "cli") + config.AddDefaultHeader("x-organization-id", cliConfig.OrganizationID) + config.AddDefaultHeader("x-environment-id", cliConfig.EnvironmentID) + config.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", cliConfig.Jwt)) + config.Scheme = cliConfig.Scheme config.Host = strings.TrimSuffix(cliConfig.Endpoint, "/") - if cliConfig.ServerPath != nil { - config.Servers = []openapi.ServerConfiguration{ - { - URL: *cliConfig.ServerPath, - }, - } + config.Servers = []openapi.ServerConfiguration{ + { + URL: cliConfig.Path(), + }, } return openapi.NewAPIClient(config) diff --git a/cli/config/config.go b/cli/config/config.go index 8fa0a9484f..497e90dbea 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -1,24 +1,42 @@ package config import ( + "context" "encoding/json" "fmt" "os" + "path" "path/filepath" "strings" + "github.com/goware/urlx" "github.com/spf13/viper" + "gopkg.in/yaml.v2" ) var ( - Version = "dev" - Env = "dev" + Version = "dev" + Env = "dev" + DefaultCloudAPIEndpoint = "http://localhost:8090" ) +type ConfigFlags struct { + Endpoint string +} + type Config struct { - Scheme string `yaml:"scheme"` - Endpoint string `yaml:"endpoint"` - ServerPath *string `yaml:"serverPath,omitempty"` + Scheme string `yaml:"scheme"` + Endpoint string `yaml:"endpoint"` + ServerPath *string `yaml:"serverPath,omitempty"` + OrganizationID string `yaml:"organizationID,omitempty"` + EnvironmentID string `yaml:"environmentID,omitempty"` + Token string `yaml:"token,omitempty"` + Jwt string `yaml:"jwt,omitempty"` + + // cloud config + CloudAPIEndpoint string `yaml:"-"` + AgentEndpoint string `yaml:"agentEndpoint,omitempty"` + UIEndpoint string `yaml:"uIEndpoint,omitempty"` } func (c Config) URL() string { @@ -29,6 +47,19 @@ func (c Config) URL() string { return fmt.Sprintf("%s://%s", c.Scheme, strings.TrimSuffix(c.Endpoint, "/")) } +func (c Config) Path() string { + pathPrefix := "/api" + if c.ServerPath != nil { + pathPrefix = *c.ServerPath + } + + if pathPrefix == "/" { + return "" + } + + return pathPrefix +} + func (c Config) IsEmpty() bool { thisConfigJson, _ := json.Marshal(c) emptyConfigJson, _ := json.Marshal(Config{}) @@ -46,6 +77,10 @@ func LoadConfig(configFile string) (Config, error) { return config, nil } + if config.CloudAPIEndpoint == "" { + config.CloudAPIEndpoint = DefaultCloudAPIEndpoint + } + homePath, err := os.UserHomeDir() if err != nil { return Config{}, fmt.Errorf("could not get user home path") @@ -83,14 +118,49 @@ func ValidateServerURL(serverURL string) error { return nil } -func ParseServerURL(serverURL string) (scheme, endpoint string, err error) { - urlParts := strings.Split(serverURL, "://") - if len(urlParts) != 2 { - return "", "", fmt.Errorf("invalid server url") +func ParseServerURL(serverURL string) (scheme, endpoint string, serverPath *string, err error) { + url, err := urlx.Parse(serverURL) + if err != nil { + return "", "", nil, fmt.Errorf("could not parse server URL: %w", err) + } + + var path *string + if url.Path != "" { + path = &url.Path + } + + return url.Scheme, url.Host, path, nil +} + +func Save(ctx context.Context, config Config) error { + configPath, err := GetConfigurationPath() + if err != nil { + return fmt.Errorf("could not get configuration path: %w", err) + } + + configYml, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("could not marshal configuration into yml: %w", err) + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + os.MkdirAll(filepath.Dir(configPath), 0700) // Ensure folder exists + } + err = os.WriteFile(configPath, configYml, 0755) + if err != nil { + return fmt.Errorf("could not write file: %w", err) + } + + return nil +} + +func GetConfigurationPath() (string, error) { + homePath, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not get user home dir: %w", err) } - scheme = urlParts[0] - endpoint = strings.TrimSuffix(urlParts[1], "/") + configPath := path.Join(homePath, ".tracetest/config.yml") - return scheme, endpoint, nil + return configPath, nil } diff --git a/cli/config/configurator.go b/cli/config/configurator.go new file mode 100644 index 0000000000..89430886c4 --- /dev/null +++ b/cli/config/configurator.go @@ -0,0 +1,160 @@ +package config + +import ( + "context" + "fmt" + "net/http" + + "github.com/kubeshop/tracetest/cli/analytics" + "github.com/kubeshop/tracetest/cli/pkg/oauth" + "github.com/kubeshop/tracetest/cli/pkg/resourcemanager" + cliUI "github.com/kubeshop/tracetest/cli/ui" +) + +type onFinishFn func(context.Context, Config) + +type Configurator struct { + resources *resourcemanager.Registry + ui cliUI.UI + onFinish onFinishFn +} + +func NewConfigurator(resources *resourcemanager.Registry) Configurator { + ui := cliUI.DefaultUI + onFinish := func(_ context.Context, _ Config) { + ui.Success("Successfully configured Tracetest CLI") + ui.Finish() + } + + return Configurator{resources, ui, onFinish} +} + +func (c Configurator) WithOnFinish(onFinish onFinishFn) Configurator { + c.onFinish = onFinish + return c +} + +func (c Configurator) Start(ctx context.Context, prev Config, flags ConfigFlags) error { + var serverURL string + if flags.Endpoint != "" { + serverURL = flags.Endpoint + } else { + path := "" + if prev.ServerPath != nil { + path = *prev.ServerPath + } + serverURL = c.ui.TextInput("Enter your Tracetest server URL", fmt.Sprintf("%s%s", prev.URL(), path)) + } + + if err := ValidateServerURL(serverURL); err != nil { + return err + } + + scheme, endpoint, path, err := ParseServerURL(serverURL) + if err != nil { + return err + } + + cfg := Config{ + Scheme: scheme, + Endpoint: endpoint, + ServerPath: path, + } + + client := GetAPIClient(cfg) + version, err := getVersionMetadata(ctx, client) + if err != nil { + return fmt.Errorf("cannot get version metadata: %w", err) + } + + cfg.AgentEndpoint = version.GetAgentEndpoint() + cfg.UIEndpoint = version.GetUiEndpoint() + + serverType := version.GetType() + if serverType == "oss" { + err := Save(ctx, cfg) + if err != nil { + return fmt.Errorf("could not save configuration: %w", err) + } + + c.ui.Success("Successfully configured Tracetest CLI") + return nil + } + + if prev.Jwt != "" { + cfg.Jwt = prev.Jwt + cfg.Token = prev.Token + + c.ShowOrganizationSelector(ctx, cfg) + return nil + } + + oauthServer := oauth.NewOAuthServer(cfg.URL(), cfg.UIEndpoint) + err = oauthServer.WithOnSuccess(c.onOAuthSuccess(ctx, cfg)). + WithOnFailure(c.onOAuthFailure). + GetAuthJWT() + + return err +} + +func (c Configurator) onOAuthSuccess(ctx context.Context, cfg Config) func(token, jwt string) { + return func(token, jwt string) { + cfg.Jwt = jwt + cfg.Token = token + + c.ShowOrganizationSelector(ctx, cfg) + } +} + +func (c Configurator) onOAuthFailure(err error) { + c.ui.Exit(err.Error()) +} + +func (c Configurator) ShowOrganizationSelector(ctx context.Context, cfg Config) { + cfg, err := c.organizationSelector(ctx, cfg) + if err != nil { + c.ui.Exit(err.Error()) + return + } + + cfg, err = c.environmentSelector(ctx, cfg) + if err != nil { + c.ui.Exit(err.Error()) + return + } + + err = Save(ctx, cfg) + if err != nil { + c.ui.Exit(err.Error()) + return + } + + c.onFinish(ctx, cfg) +} + +func (c Configurator) ShowEnvironmentSelector(ctx context.Context, cfg Config) { + cfg, err := c.environmentSelector(ctx, cfg) + if err != nil { + c.ui.Exit(err.Error()) + return + } + + err = Save(ctx, cfg) + if err != nil { + c.ui.Exit(err.Error()) + return + } + + c.onFinish(ctx, cfg) +} + +func SetupHttpClient(cfg Config) *resourcemanager.HTTPClient { + extraHeaders := http.Header{} + extraHeaders.Set("x-client-id", analytics.ClientID()) + extraHeaders.Set("x-source", "cli") + extraHeaders.Set("x-organization-id", cfg.OrganizationID) + extraHeaders.Set("x-environment-id", cfg.EnvironmentID) + extraHeaders.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Jwt)) + + return resourcemanager.NewHTTPClient(fmt.Sprintf("%s%s", cfg.URL(), cfg.Path()), extraHeaders) +} diff --git a/cli/config/selector.go b/cli/config/selector.go new file mode 100644 index 0000000000..5a7241d975 --- /dev/null +++ b/cli/config/selector.go @@ -0,0 +1,113 @@ +package config + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/kubeshop/tracetest/cli/pkg/resourcemanager" + cliUI "github.com/kubeshop/tracetest/cli/ui" +) + +type entry struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (c Configurator) organizationSelector(ctx context.Context, cfg Config) (Config, error) { + resource, err := c.resources.Get("organization") + if err != nil { + return cfg, err + } + + elements, err := getElements(ctx, resource, cfg) + if err != nil { + return cfg, err + } + + if len(elements) == 1 { + cfg.OrganizationID = elements[0].ID + c.ui.Println(fmt.Sprintf("Defaulting to only available Organization: %s", elements[0].Name)) + return cfg, nil + } + + options := make([]cliUI.Option, len(elements)) + for i, org := range elements { + options[i] = cliUI.Option{ + Text: org.Name, + Fn: func(o entry) func(ui cliUI.UI) { + return func(ui cliUI.UI) { + cfg.OrganizationID = o.ID + } + }(org), + } + } + + option := c.ui.Select("What Organization do you want to use?", options, 0) + option.Fn(c.ui) + + return cfg, nil +} + +func (c Configurator) environmentSelector(ctx context.Context, cfg Config) (Config, error) { + resource, err := c.resources.Get("env") + if err != nil { + return cfg, err + } + resource = resource.WithOptions(resourcemanager.WithPrefixGetter(func() string { + return fmt.Sprintf("/organizations/%s/", cfg.OrganizationID) + })) + + elements, err := getElements(ctx, resource, cfg) + if err != nil { + return cfg, err + } + + if len(elements) == 1 { + cfg.EnvironmentID = elements[0].ID + c.ui.Println(fmt.Sprintf("Defaulting to only available Environment: %s", elements[0].Name)) + return cfg, nil + } + + options := make([]cliUI.Option, len(elements)) + for i, env := range elements { + options[i] = cliUI.Option{ + Text: env.Name, + Fn: func(e entry) func(ui cliUI.UI) { + return func(ui cliUI.UI) { + cfg.EnvironmentID = e.ID + } + }(env), + } + } + + option := c.ui.Select("What Environment do you want to use?", options, 0) + option.Fn(c.ui) + return cfg, err +} + +type entryList struct { + Elements []entry `json:"elements"` +} + +func getElements(ctx context.Context, resource resourcemanager.Client, cfg Config) ([]entry, error) { + resource = resource.WithHttpClient(SetupHttpClient(cfg)) + + var list entryList + resultFormat, err := resourcemanager.Formats.GetWithFallback("json", "json") + if err != nil { + return []entry{}, err + } + + envs, err := resource.List(ctx, resourcemanager.ListOption{}, resultFormat) + if err != nil { + return []entry{}, err + } + + err = json.Unmarshal([]byte(envs), &list) + if err != nil { + return []entry{}, err + } + + return list.Elements, nil +} diff --git a/cli/actions/version.go b/cli/config/version.go similarity index 62% rename from cli/actions/version.go rename to cli/config/version.go index 868466f4f7..8675b44635 100644 --- a/cli/actions/version.go +++ b/cli/config/version.go @@ -1,15 +1,14 @@ -package actions +package config import ( "context" "fmt" - "github.com/kubeshop/tracetest/cli/config" "github.com/kubeshop/tracetest/cli/openapi" ) -func GetVersion(ctx context.Context, cfg config.Config, client *openapi.APIClient) (string, bool) { - result := fmt.Sprintf(`CLI: %s`, config.Version) +func GetVersion(ctx context.Context, cfg Config, client *openapi.APIClient) (string, bool) { + result := fmt.Sprintf(`CLI: %s`, Version) if cfg.IsEmpty() { return result + ` @@ -22,7 +21,7 @@ Server: Not Configured`, false Server: Failed to get the server version - %s`, err.Error()), false } - isVersionMatch := version == config.Version + isVersionMatch := version == Version if isVersionMatch { version += ` ✔️ Version match` @@ -42,3 +41,14 @@ func getServerVersion(ctx context.Context, client *openapi.APIClient) (string, e return resp.GetVersion(), nil } + +func getVersionMetadata(ctx context.Context, client *openapi.APIClient) (*openapi.Version, error) { + resp, _, err := client.ApiApi. + GetVersion(ctx). + Execute() + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/cli/go.mod b/cli/go.mod index 6fabf55791..f5aba83b32 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -11,6 +11,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/denisbrodbeck/machineid v1.0.1 github.com/goccy/go-yaml v1.11.0 + github.com/goware/urlx v0.3.2 github.com/kubeshop/tracetest/server v0.0.0-20230512142545-cb5e526e06f9 github.com/pterm/pterm v0.12.55 github.com/spf13/cobra v1.7.0 @@ -27,6 +28,8 @@ require ( require ( atomicgo.dev/cursor v0.1.1 // indirect atomicgo.dev/keyboard v0.2.9 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect diff --git a/cli/go.sum b/cli/go.sum index 57c2f17cec..a91c2c06b7 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -55,7 +55,9 @@ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYew github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/alecthomas/participle/v2 v2.0.0-alpha8 h1:X6nuChfgfQXNTE+ZdjTFSfnSNr8E07LSVLAqIIjtsGI= @@ -205,6 +207,7 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/goware/urlx v0.3.2 h1:gdoo4kBHlkqZNaf6XlQ12LGtQOmpKJrR04Rc3RnpJEo= +github.com/goware/urlx v0.3.2/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 h1:BZHcxBETFHIdVyhyEfOvn/RdU/QGdLI4y34qQGjGWO0= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= diff --git a/cli/installer/installer.go b/cli/installer/installer.go index 524bc64d87..c05e8644bd 100644 --- a/cli/installer/installer.go +++ b/cli/installer/installer.go @@ -1,6 +1,7 @@ package installer import ( + cliConfig "github.com/kubeshop/tracetest/cli/config" cliUI "github.com/kubeshop/tracetest/cli/ui" ) @@ -16,7 +17,7 @@ const createIssueMsg = "If you need help, please create an issue: https://github func Start() { ui := cliUI.DefaultUI - ui.Banner() + ui.Banner(cliConfig.Version) ui.Println(` Hi! Welcome to the TraceTest server installer. I'll help you set up your TraceTest server by asking you a few questions diff --git a/cli/openapi/model_version.go b/cli/openapi/model_version.go index d463c4884e..90429ff391 100644 --- a/cli/openapi/model_version.go +++ b/cli/openapi/model_version.go @@ -19,7 +19,10 @@ var _ MappedNullable = &Version{} // Version struct for Version type Version struct { - Version *string `json:"version,omitempty"` + Version *string `json:"version,omitempty"` + Type *string `json:"type,omitempty"` + UiEndpoint *string `json:"uiEndpoint,omitempty"` + AgentEndpoint *string `json:"agentEndpoint,omitempty"` } // NewVersion instantiates a new Version object @@ -71,6 +74,102 @@ func (o *Version) SetVersion(v string) { o.Version = &v } +// GetType returns the Type field value if set, zero value otherwise. +func (o *Version) GetType() string { + if o == nil || isNil(o.Type) { + var ret string + return ret + } + return *o.Type +} + +// GetTypeOk returns a tuple with the Type field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Version) GetTypeOk() (*string, bool) { + if o == nil || isNil(o.Type) { + return nil, false + } + return o.Type, true +} + +// HasType returns a boolean if a field has been set. +func (o *Version) HasType() bool { + if o != nil && !isNil(o.Type) { + return true + } + + return false +} + +// SetType gets a reference to the given string and assigns it to the Type field. +func (o *Version) SetType(v string) { + o.Type = &v +} + +// GetUiEndpoint returns the UiEndpoint field value if set, zero value otherwise. +func (o *Version) GetUiEndpoint() string { + if o == nil || isNil(o.UiEndpoint) { + var ret string + return ret + } + return *o.UiEndpoint +} + +// GetUiEndpointOk returns a tuple with the UiEndpoint field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Version) GetUiEndpointOk() (*string, bool) { + if o == nil || isNil(o.UiEndpoint) { + return nil, false + } + return o.UiEndpoint, true +} + +// HasUiEndpoint returns a boolean if a field has been set. +func (o *Version) HasUiEndpoint() bool { + if o != nil && !isNil(o.UiEndpoint) { + return true + } + + return false +} + +// SetUiEndpoint gets a reference to the given string and assigns it to the UiEndpoint field. +func (o *Version) SetUiEndpoint(v string) { + o.UiEndpoint = &v +} + +// GetAgentEndpoint returns the AgentEndpoint field value if set, zero value otherwise. +func (o *Version) GetAgentEndpoint() string { + if o == nil || isNil(o.AgentEndpoint) { + var ret string + return ret + } + return *o.AgentEndpoint +} + +// GetAgentEndpointOk returns a tuple with the AgentEndpoint field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Version) GetAgentEndpointOk() (*string, bool) { + if o == nil || isNil(o.AgentEndpoint) { + return nil, false + } + return o.AgentEndpoint, true +} + +// HasAgentEndpoint returns a boolean if a field has been set. +func (o *Version) HasAgentEndpoint() bool { + if o != nil && !isNil(o.AgentEndpoint) { + return true + } + + return false +} + +// SetAgentEndpoint gets a reference to the given string and assigns it to the AgentEndpoint field. +func (o *Version) SetAgentEndpoint(v string) { + o.AgentEndpoint = &v +} + func (o Version) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -84,6 +183,15 @@ func (o Version) ToMap() (map[string]interface{}, error) { if !isNil(o.Version) { toSerialize["version"] = o.Version } + if !isNil(o.Type) { + toSerialize["type"] = o.Type + } + if !isNil(o.UiEndpoint) { + toSerialize["uiEndpoint"] = o.UiEndpoint + } + if !isNil(o.AgentEndpoint) { + toSerialize["agentEndpoint"] = o.AgentEndpoint + } return toSerialize, nil } diff --git a/cli/pkg/oauth/oauth.go b/cli/pkg/oauth/oauth.go new file mode 100644 index 0000000000..9780001dd0 --- /dev/null +++ b/cli/pkg/oauth/oauth.go @@ -0,0 +1,157 @@ +package oauth + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os/exec" + "runtime" +) + +type OnAuthSuccess func(token string, jwt string) +type OnAuthFailure func(err error) + +type OAuthServer struct { + endpoint string + frontendEndpoint string + onSuccess OnAuthSuccess + onFailure OnAuthFailure + port int +} + +type Option func(*OAuthServer) + +func NewOAuthServer(endpoint, frontendEndpoint string) *OAuthServer { + return &OAuthServer{ + endpoint: endpoint, + frontendEndpoint: frontendEndpoint, + } +} + +func (s *OAuthServer) WithOnSuccess(onSuccess OnAuthSuccess) *OAuthServer { + s.onSuccess = onSuccess + return s +} + +func (s *OAuthServer) WithOnFailure(onFailure OnAuthFailure) *OAuthServer { + s.onFailure = onFailure + return s +} + +func (s *OAuthServer) GetAuthJWT() error { + url, err := s.getUrl() + if err != nil { + return fmt.Errorf("failed to start oauth server: %w", err) + } + + loginUrl := fmt.Sprintf("%s/oauth?callback=%s", s.frontendEndpoint, url) + + err = openBrowser(loginUrl) + if err != nil { + return fmt.Errorf("failed to open the oauth url: %s", loginUrl) + } + + return s.start() +} + +type JWTResponse struct { + Jwt string `json:"jwt"` +} + +func (s *OAuthServer) ExchangeToken(token string) (string, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/tokens/%s/exchange", s.endpoint, token), nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to exchange token: %w", err) + } + + if res.StatusCode != http.StatusCreated { + return "", fmt.Errorf("failed to exchange token: %s", res.Status) + } + + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + var jwtResponse JWTResponse + err = json.Unmarshal(body, &jwtResponse) + if err != nil { + return "", fmt.Errorf("failed to unmarshal response body: %w", err) + } + + return jwtResponse.Jwt, nil +} + +func (s *OAuthServer) getUrl() (string, error) { + port, err := getFreePort() + if err != nil { + return "", fmt.Errorf("failed to get free port: %w", err) + } + + s.port = port + return fmt.Sprintf("http://localhost:%d", port), nil +} + +func (s *OAuthServer) start() error { + http.HandleFunc("/", s.callback) + return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil) +} + +func (s *OAuthServer) callback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", s.frontendEndpoint) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) + + go s.handleResult(r) +} + +func (s *OAuthServer) handleResult(r *http.Request) { + tokenId := r.URL.Query().Get("tokenId") + if tokenId == "" { + s.onFailure(fmt.Errorf("tokenId not found")) + return + } + + jwt, err := s.ExchangeToken(tokenId) + if err != nil { + s.onFailure(err) + return + } + + s.onSuccess(tokenId, jwt) +} + +func getFreePort() (port int, err error) { + var a *net.TCPAddr + if a, err = net.ResolveTCPAddr("tcp", "localhost:0"); err == nil { + var l *net.TCPListener + if l, err = net.ListenTCP("tcp", a); err == nil { + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil + } + } + return +} + +func openBrowser(u string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", u).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start() + case "darwin": + return exec.Command("open", u).Start() + default: + return fmt.Errorf("unsupported platform") + } +} diff --git a/cli/pkg/resourcemanager/apply.go b/cli/pkg/resourcemanager/apply.go index f4aa027981..c8772b0bc2 100644 --- a/cli/pkg/resourcemanager/apply.go +++ b/cli/pkg/resourcemanager/apply.go @@ -76,7 +76,12 @@ func (c Client) Apply(ctx context.Context, inputFile fileutil.File, requestedFor zap.String("contents", string(inputFile.Contents())), ) - url := c.client.url(c.resourceNamePlural) + prefix := "" + if c.options.prefixGetterFn != nil { + prefix = c.options.prefixGetterFn() + } + + url := c.client.url(c.resourceNamePlural, prefix, "") req, err := http.NewRequestWithContext(ctx, http.MethodPut, url.String(), inputFile.Reader()) if err != nil { return "", fmt.Errorf("cannot build Apply request: %w", err) @@ -160,5 +165,5 @@ func (c Client) Apply(ctx context.Context, inputFile fileutil.File, requestedFor } } - return requestedFormat.Format(string(body), c.options.tableConfig) + return requestedFormat.Format(string(body), c.options.tableConfig, c.options.listPath) } diff --git a/cli/pkg/resourcemanager/client.go b/cli/pkg/resourcemanager/client.go index 13fbeb7752..b143443021 100644 --- a/cli/pkg/resourcemanager/client.go +++ b/cli/pkg/resourcemanager/client.go @@ -46,8 +46,8 @@ func NewHTTPClient(baseURL string, extraHeaders http.Header) *HTTPClient { } } -func (c HTTPClient) url(resourceName string, extra ...string) *url.URL { - urlStr := c.baseURL + path.Join("/api", resourceName, strings.Join(extra, "/")) +func (c HTTPClient) url(resourceName, prefix string, extra ...string) *url.URL { + urlStr := c.baseURL + path.Join("/", prefix, resourceName, strings.Join(extra, "/")) url, _ := url.Parse(urlStr) return url } @@ -82,6 +82,19 @@ func NewClient( return c } +func (c Client) WithHttpClient(HTTPClient *HTTPClient) Client { + c.client = HTTPClient + return c +} + +func (c Client) WithOptions(opts ...option) Client { + for _, opt := range opts { + opt(&c.options) + } + + return c +} + func (c Client) resourceType() string { if c.options.resourceType != "" { return c.options.resourceType diff --git a/cli/pkg/resourcemanager/delete.go b/cli/pkg/resourcemanager/delete.go index 3ecaa8e58e..77753c02f1 100644 --- a/cli/pkg/resourcemanager/delete.go +++ b/cli/pkg/resourcemanager/delete.go @@ -11,7 +11,12 @@ import ( const VerbDelete Verb = "delete" func (c Client) Delete(ctx context.Context, id string, format Format) (string, error) { - url := c.client.url(c.resourceNamePlural, id) + prefix := "" + if c.options.prefixGetterFn != nil { + prefix = c.options.prefixGetterFn() + } + + url := c.client.url(c.resourceNamePlural, prefix, id) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil) if err != nil { return "", fmt.Errorf("cannot build Delete request: %w", err) diff --git a/cli/pkg/resourcemanager/format.go b/cli/pkg/resourcemanager/format.go index e2db674306..1706b65467 100644 --- a/cli/pkg/resourcemanager/format.go +++ b/cli/pkg/resourcemanager/format.go @@ -138,8 +138,8 @@ func (p prettyFormat) String() string { // The path is a dot-separated list of keys, e.g. "metadata.name". See github.com/Jeffail/gabs. func (p prettyFormat) Format(data string, opts ...any) (string, error) { // we expect only one option - TableConfig - if len(opts) != 1 { - return "", fmt.Errorf("expected 1 option, got %d", len(opts)) + if len(opts) != 2 { + return "", fmt.Errorf("expected 2 options, got %d", len(opts)) } tableConfig, ok := opts[0].(TableConfig) @@ -147,6 +147,18 @@ func (p prettyFormat) Format(data string, opts ...any) (string, error) { return "", fmt.Errorf("expected option to be a []TableCellConfig, got %T", opts[0]) } + listPath := "" + if len(opts) > 1 { + listPath, ok = opts[1].(string) + if !ok { + return "", fmt.Errorf("expected option to be a string, got %T", opts[1]) + } + } + + if listPath == "" { + listPath = "items" + } + parsed, err := gabs.ParseJSON([]byte(data)) if err != nil { return "", err @@ -161,7 +173,7 @@ func (p prettyFormat) Format(data string, opts ...any) (string, error) { } // iterate over parsed data and build table body - body := buildTableBody(parsed, tableConfig) + body := buildTableBody(parsed, tableConfig, listPath) // configure output table table := simpletable.New() @@ -172,8 +184,8 @@ func (p prettyFormat) Format(data string, opts ...any) (string, error) { return table.String(), nil } -func buildTableBody(parsed *gabs.Container, tableConfig TableConfig) [][]*simpletable.Cell { - items := parsed.Path("items") +func buildTableBody(parsed *gabs.Container, tableConfig TableConfig, listPath string) [][]*simpletable.Cell { + items := parsed.Path(listPath) // if items is nil, we assume that the parsed data is a single item if items == nil { body := make([][]*simpletable.Cell, 0, 1) diff --git a/cli/pkg/resourcemanager/get.go b/cli/pkg/resourcemanager/get.go index f266b7183f..101f493cc5 100644 --- a/cli/pkg/resourcemanager/get.go +++ b/cli/pkg/resourcemanager/get.go @@ -13,8 +13,14 @@ import ( const VerbGet Verb = "get" +type prefixGetterFn func() string + func (c Client) Get(ctx context.Context, id string, format Format) (string, error) { - url := c.client.url(c.resourceNamePlural, id) + prefix := "" + if c.options.prefixGetterFn != nil { + prefix = c.options.prefixGetterFn() + } + url := c.client.url(c.resourceNamePlural, prefix, id) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) if err != nil { return "", fmt.Errorf("cannot build Get request: %w", err) @@ -47,7 +53,6 @@ func (c Client) Get(ctx context.Context, id string, format Format) (string, erro } return "", fmt.Errorf("could not Get resource: %w", err) - } body, err := io.ReadAll(resp.Body) @@ -55,5 +60,5 @@ func (c Client) Get(ctx context.Context, id string, format Format) (string, erro return "", fmt.Errorf("cannot read Get response: %w", err) } - return format.Format(string(body), c.options.tableConfig) + return format.Format(string(body), c.options.tableConfig, c.options.listPath) } diff --git a/cli/pkg/resourcemanager/list.go b/cli/pkg/resourcemanager/list.go index d1ebc7b7ca..78644e900e 100644 --- a/cli/pkg/resourcemanager/list.go +++ b/cli/pkg/resourcemanager/list.go @@ -18,7 +18,11 @@ type ListOption struct { const VerbList Verb = "list" func (c Client) List(ctx context.Context, opt ListOption, format Format) (string, error) { - url := c.client.url(c.resourceNamePlural) + prefix := "" + if c.options.prefixGetterFn != nil { + prefix = c.options.prefixGetterFn() + } + url := c.client.url(c.resourceNamePlural, prefix) q := url.Query() q.Add("skip", fmt.Sprintf("%d", opt.Skip)) @@ -55,5 +59,5 @@ func (c Client) List(ctx context.Context, opt ListOption, format Format) (string return "", fmt.Errorf("cannot read List response: %w", err) } - return format.Format(string(body), c.options.tableConfig) + return format.Format(string(body), c.options.tableConfig, c.options.listPath) } diff --git a/cli/pkg/resourcemanager/options.go b/cli/pkg/resourcemanager/options.go index 2eb0b3daf2..0d4f1f3827 100644 --- a/cli/pkg/resourcemanager/options.go +++ b/cli/pkg/resourcemanager/options.go @@ -2,7 +2,9 @@ package resourcemanager type options struct { applyPreProcessor applyPreProcessorFn + prefixGetterFn prefixGetterFn tableConfig TableConfig + listPath string deleteSuccessMsg string resourceType string deprecatedAlias string @@ -17,6 +19,18 @@ func WithApplyPreProcessor(preProcessor applyPreProcessorFn) option { } } +func WithPrefixGetter(prefixGetterFn prefixGetterFn) option { + return func(o *options) { + o.prefixGetterFn = prefixGetterFn + } +} + +func WithListPath(path string) option { + return func(o *options) { + o.listPath = path + } +} + func WithDeleteSuccessMessage(deleteSuccessMssg string) option { return func(o *options) { o.deleteSuccessMsg = deleteSuccessMssg diff --git a/cli/pkg/starter/starter.go b/cli/pkg/starter/starter.go new file mode 100644 index 0000000000..2256120816 --- /dev/null +++ b/cli/pkg/starter/starter.go @@ -0,0 +1,95 @@ +package starter + +import ( + "context" + "encoding/json" + "fmt" + + agentConfig "github.com/kubeshop/tracetest/agent/config" + "github.com/kubeshop/tracetest/agent/initialization" + + "github.com/kubeshop/tracetest/cli/config" + "github.com/kubeshop/tracetest/cli/pkg/resourcemanager" + "github.com/kubeshop/tracetest/cli/ui" +) + +type Starter struct { + configurator config.Configurator + resources *resourcemanager.Registry + ui ui.UI +} + +func NewStarter(configurator config.Configurator, resources *resourcemanager.Registry) *Starter { + ui := ui.DefaultUI + return &Starter{configurator, resources, ui} +} + +func (s *Starter) Run(ctx context.Context, cfg config.Config) error { + flags := config.ConfigFlags{ + Endpoint: cfg.CloudAPIEndpoint, + } + s.ui.Banner(config.Version) + + return s.configurator.WithOnFinish(s.onStartAgent).Start(ctx, cfg, flags) +} + +func (s *Starter) onStartAgent(ctx context.Context, cfg config.Config) { + env, err := s.getEnvironment(ctx, cfg) + if err != nil { + s.ui.Error(err.Error()) + } + + s.ui.Println(fmt.Sprintf("Connecting Agent to environment %s...", env.Name)) + err = startAgent(ctx, cfg.AgentEndpoint, env.AgentApiKey) + if err != nil { + s.ui.Error(err.Error()) + } +} + +type environment struct { + ID string `json:"id"` + Name string `json:"name"` + AgentApiKey string `json:"agentApiKey"` + OrganizationID string `json:"organizationID"` +} + +func (s *Starter) getEnvironment(ctx context.Context, cfg config.Config) (environment, error) { + resource, err := s.resources.Get("env") + if err != nil { + return environment{}, err + } + + resource = resource. + WithHttpClient(config.SetupHttpClient(cfg)). + WithOptions(resourcemanager.WithPrefixGetter(func() string { + return fmt.Sprintf("/organizations/%s/", cfg.OrganizationID) + })) + + resultFormat, err := resourcemanager.Formats.GetWithFallback("json", "json") + if err != nil { + return environment{}, err + } + + raw, err := resource.Get(ctx, cfg.EnvironmentID, resultFormat) + if err != nil { + return environment{}, err + } + + var env environment + err = json.Unmarshal([]byte(raw), &env) + if err != nil { + return environment{}, err + } + + return env, nil +} + +func startAgent(ctx context.Context, endpoint, agentApiKey string) error { + cfg := agentConfig.Config{ + ServerURL: endpoint, + APIKey: agentApiKey, + Name: "local", + } + + return initialization.Start(ctx, cfg) +} diff --git a/cli/ui/ui.go b/cli/ui/ui.go index e640835a2e..879f697180 100644 --- a/cli/ui/ui.go +++ b/cli/ui/ui.go @@ -4,7 +4,6 @@ import ( "fmt" "os" - "github.com/kubeshop/tracetest/cli/config" "github.com/pterm/pterm" "github.com/pterm/pterm/putils" ) @@ -12,7 +11,7 @@ import ( var DefaultUI UI = &ptermUI{} type UI interface { - Banner() + Banner(version string) Panic(error) Exit(string) @@ -21,6 +20,7 @@ type UI interface { Warning(string) Info(string) Success(string) + Finish() Println(string) Title(string) @@ -40,14 +40,14 @@ type Option struct { type ptermUI struct{} -func (ui ptermUI) Banner() { +func (ui ptermUI) Banner(version string) { pterm.Print("\n\n") pterm.DefaultBigText. WithLetters(putils.LettersFromString("TraceTest")). Render() - pterm.Print(fmt.Sprintf("Version: %s", config.Version)) + pterm.Print(fmt.Sprintf("Version: %s", version)) pterm.Print("\n\n") @@ -57,6 +57,10 @@ func (ui ptermUI) Panic(err error) { pterm.Error.WithFatal(true).Println(err) } +func (ui ptermUI) Finish() { + os.Exit(0) +} + func (ui ptermUI) Exit(msg string) { pterm.Error.Println(msg) os.Exit(1) diff --git a/cli/utils/run_state.go b/cli/utils/run_state.go deleted file mode 100644 index 0f7131d425..0000000000 --- a/cli/utils/run_state.go +++ /dev/null @@ -1,13 +0,0 @@ -package utils - -func RunStateIsFinished(state string) bool { - return RunStateIsFailed(state) || state == "FINISHED" -} - -func RunStateIsFailed(state string) bool { - return state == "TRIGGER_FAILED" || - state == "TRACE_FAILED" || - state == "ASSERTION_FAILED" || - state == "ANALYZING_ERROR" || - state == "FAILED" // this one is for backwards compatibility -} diff --git a/go.work.sum b/go.work.sum index 9b590b73be..df0f8a6e72 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,4 @@ + atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= @@ -196,7 +197,6 @@ github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuz github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/mostynb/go-grpc-compression v1.1.17/go.mod h1:FUSBr0QjKqQgoDG/e0yiqlR6aqyXC39+g/hFLDfSsEY= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= @@ -210,13 +210,10 @@ github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0ua github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI= github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/shirou/gopsutil/v3 v3.22.8/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807/go.mod h1:7jxmlfBCDBXRzr0eAQJ48XC1hBu1np4CS5+cHEYfwpc= github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= go.etcd.io/etcd/api/v3 v3.5.6/go.mod h1:KFtNaxGDw4Yx/BA4iPPwevUTAuqcsPxzyX8PHydchN8= @@ -228,7 +225,6 @@ go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/collector/semconv v0.60.0/go.mod h1:aRkHuJ/OshtDFYluKEtnG5nkKTsy1HZuvZVHmakx+Vo= go.opentelemetry.io/contrib v0.20.0 h1:ubFQUn0VCZ0gPwIoJfBJVpeBlyRMxu8Mm/huKWYd9p0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.34.0/go.mod h1:fk1+icoN47ytLSgkoWHLJrtVTSQ+HgmkNgPTKrk/Nsc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.34.0 h1:9NkMW03wwEzPtP/KciZ4Ozu/Uz5ZA7kfqXJIObnrjGU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.34.0/go.mod h1:548ZsYzmT4PL4zWKRd8q/N4z0Wxzn/ZxUE+lkEpwWQA= go.opentelemetry.io/contrib/zpages v0.34.0/go.mod h1:zuVCe4eoOREH+liRJLCtGITqL3NiUvkdr6U/4j9iQRg= go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo= @@ -295,5 +291,4 @@ google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCD google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= diff --git a/server/http/controller.go b/server/http/controller.go index 2f92ba31bf..30e5c39468 100644 --- a/server/http/controller.go +++ b/server/http/controller.go @@ -703,7 +703,10 @@ func (c *controller) TestConnection(ctx context.Context, dataStore openapi.DataS func (c *controller) GetVersion(ctx context.Context) (openapi.ImplResponse, error) { version := openapi.Version{ - Version: c.version, + Version: c.version, + Type: "oss", + UiEndpoint: "", + AgentEndpoint: "", } return openapi.Response(http.StatusOK, version), nil diff --git a/server/openapi/model_version.go b/server/openapi/model_version.go index 369f121cb6..18f5e4c0f4 100644 --- a/server/openapi/model_version.go +++ b/server/openapi/model_version.go @@ -11,6 +11,12 @@ package openapi type Version struct { Version string `json:"version,omitempty"` + + Type string `json:"type,omitempty"` + + UiEndpoint string `json:"uiEndpoint,omitempty"` + + AgentEndpoint string `json:"agentEndpoint,omitempty"` } // AssertVersionRequired checks if the required fields are not zero-ed diff --git a/web/src/types/Generated.types.ts b/web/src/types/Generated.types.ts index 0faed69d2c..dc12d79251 100644 --- a/web/src/types/Generated.types.ts +++ b/web/src/types/Generated.types.ts @@ -2113,6 +2113,10 @@ export interface external { Version: { /** @example 1.0.0 */ version?: string; + /** @enum {string} */ + type?: "oss"; + uiEndpoint?: string; + agentEndpoint?: string; }; }; };