diff --git a/cmd/hatchet-cli/cli/client.go b/cmd/hatchet-cli/cli/client.go index 9d19953e77..be87597c5c 100644 --- a/cmd/hatchet-cli/cli/client.go +++ b/cmd/hatchet-cli/cli/client.go @@ -1,6 +1,8 @@ package cli import ( + "net" + "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" "github.com/rs/zerolog" @@ -11,12 +13,18 @@ import ( "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" clientconfig "github.com/hatchet-dev/hatchet/pkg/config/client" + "github.com/hatchet-dev/hatchet/pkg/config/loader/loaderutils" "github.com/hatchet-dev/hatchet/pkg/config/shared" ) // NewClientFromProfile creates a new Hatchet client from a profile configuration. // It properly handles TLS settings, host/port, and authentication based on the profile. func NewClientFromProfile(profile *profileconfig.Profile, logger *zerolog.Logger) (client.Client, error) { //nolint:staticcheck + tlsStrategy := profile.TLSStrategy + if tlsStrategy == "" { + tlsStrategy = "tls" + } + // Construct a ClientConfigFile from the profile configFile := &clientconfig.ClientConfigFile{ TenantId: profile.TenantId, @@ -25,15 +33,29 @@ func NewClientFromProfile(profile *profileconfig.Profile, logger *zerolog.Logger ServerURL: profile.ApiServerURL, TLS: clientconfig.ClientTLSConfigFile{ Base: shared.TLSConfigFile{ - TLSStrategy: profile.TLSStrategy, + TLSStrategy: tlsStrategy, }, }, } + // Build the gRPC TLS config from the profile alone. The profile is the + // source of truth for a CLI connection, so we inject this explicitly to keep + // ambient HATCHET_CLIENT_TLS_* env vars (e.g. a local mkcert root CA) from + // silently overriding it and breaking TLS against unrelated endpoints. + tlsServerName := profile.GrpcHostPort + if host, _, err := net.SplitHostPort(profile.GrpcHostPort); err == nil { + tlsServerName = host + } + tlsConfig, err := loaderutils.LoadClientTLSConfig(&configFile.TLS, tlsServerName) + if err != nil { + return nil, err + } + // Create client with the config file and logger return client.NewFromConfigFile( //nolint:staticcheck configFile, - client.WithLogger(logger), //nolint:staticcheck + client.WithLogger(logger), //nolint:staticcheck + client.WithTLSConfig(tlsConfig), //nolint:staticcheck client.WithGRPCHeaders(map[string]string{ analytics.SourceMetadataKey: string(analytics.SourceCLI), }), diff --git a/cmd/hatchet-cli/cli/rate_limits.go b/cmd/hatchet-cli/cli/rate_limits.go index 4c703dd3a2..70251bcd57 100644 --- a/cmd/hatchet-cli/cli/rate_limits.go +++ b/cmd/hatchet-cli/cli/rate_limits.go @@ -1,19 +1,38 @@ package cli import ( + "context" + "fmt" + "strconv" + "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck "github.com/hatchet-dev/hatchet/pkg/client/rest" + "github.com/hatchet-dev/hatchet/pkg/client/types" ) +var rateLimitDurations = []types.RateLimitDuration{ + types.Second, + types.Minute, + types.Hour, + types.Day, + types.Week, + types.Month, + types.Year, +} + var rateLimitsCmd = &cobra.Command{ Use: "rate-limits", Aliases: []string{"rate-limit", "rl", "rls"}, Short: "Manage rate limits", - Long: `Commands for listing and viewing rate limit usage.`, + Long: `Commands for listing, viewing, creating, updating, and deleting rate limits.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } @@ -67,9 +86,249 @@ var rateLimitsListCmd = &cobra.Command{ }, } +var rateLimitsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a rate limit", + Long: `Get details about a single rate limit by key. Outputs raw JSON.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + _, hatchetClient := clientFromCmd(cmd) + + rl := findRateLimitByKey(cmd.Context(), hatchetClient, key) + if rl == nil { + cli.Logger.Fatalf("rate limit with key %q not found", key) + } + + printJSON(rl) + }, +} + +var rateLimitsCreateCmd = &cobra.Command{ + Use: "create", + Aliases: []string{"update", "put", "set"}, + Short: "Create or update a rate limit", + Long: `Create or update (upsert) a rate limit. In --output json mode, all required flags must be set. Otherwise, launches an interactive form.`, + Example: ` # Interactive mode + hatchet rate-limits create --profile local + + # JSON mode (required flags) + hatchet rate-limits create --key my-key --limit 100 --duration minute -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + + key, _ := cmd.Flags().GetString("key") + limit, _ := cmd.Flags().GetInt("limit") + durationStr, _ := cmd.Flags().GetString("duration") + + if !isJSON { + limitStr := "" + if cmd.Flags().Changed("limit") { + limitStr = strconv.Itoa(limit) + } + if durationStr == "" { + durationStr = string(types.Minute) + } + + durationOptions := make([]huh.Option[string], 0, len(rateLimitDurations)) + for _, d := range rateLimitDurations { + durationOptions = append(durationOptions, huh.NewOption(string(d), string(d))) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Rate limit key"). + Value(&key). + Placeholder("my-key"), + ), + huh.NewGroup( + huh.NewInput(). + Title("Limit (max requests per window)"). + Value(&limitStr). + Placeholder("100"). + Validate(func(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return fmt.Errorf("must be a number") + } + if n <= 0 { + return fmt.Errorf("must be greater than 0") + } + return nil + }), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("Window duration"). + Options(durationOptions...). + Value(&durationStr), + ), + ).WithTheme(styles.HatchetTheme()) + if err := form.Run(); err != nil { + cli.Logger.Fatalf("form cancelled: %v", err) + } + + parsed, err := strconv.Atoi(strings.TrimSpace(limitStr)) + if err != nil { + cli.Logger.Fatalf("invalid limit: %v", err) + } + limit = parsed + } + + if key == "" { + cli.Logger.Fatal("--key is required") + } + if limit <= 0 { + cli.Logger.Fatal("--limit is required and must be greater than 0") + } + + duration, err := parseRateLimitDuration(durationStr) + if err != nil { + cli.Logger.Fatalf("%v", err) + } + + if err := hatchetClient.Admin().PutRateLimit(key, &types.RateLimitOpts{ + Max: limit, + Duration: duration, + }); err != nil { + cli.Logger.Fatalf("failed to upsert rate limit: %v", err) + } + + if isJSON { + printJSON(map[string]interface{}{ + "key": key, + "limit": limit, + "duration": string(duration), + }) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Saved rate limit: %s (%d per %s)", key, limit, duration))) + } + }, +} + +var rateLimitsDeleteCmd = &cobra.Command{ + Use: "delete [key]", + Short: "Delete a rate limit", + Long: `Delete a rate limit by key. Omit the key to pick from a list interactively. Use --yes to skip confirmation.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + _, hatchetClient := clientFromCmd(cmd) + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + var key string + if len(args) == 1 { + key = args[0] + } else if !isJSON { + limit := int64(100) + listResp, listErr := hatchetClient.API().RateLimitListWithResponse(ctx, tenantUUID, &rest.RateLimitListParams{ + Limit: &limit, + }) + if listErr != nil { + cli.Logger.Fatalf("failed to list rate limits: %v", listErr) + } + if listResp.JSON200 == nil || listResp.JSON200.Rows == nil || len(*listResp.JSON200.Rows) == 0 { + cli.Logger.Fatal("no rate limits found") + } + + var options []huh.Option[string] + for _, rl := range *listResp.JSON200.Rows { + label := fmt.Sprintf("%s (%d per %s)", rl.Key, rl.LimitValue, rl.Window) + options = append(options, huh.NewOption(label, rl.Key)) + } + + height := len(options) + if height > 10 { + height = 10 + } + form := huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Select a rate limit to delete"). + Options(options...). + Height(height). + Value(&key), + )).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil { + cli.Logger.Fatalf("selection cancelled: %v", formErr) + } + } else { + cli.Logger.Fatal("rate limit key is required in JSON mode") + } + + if key == "" { + cli.Logger.Fatal("rate limit key is required") + } + + if !isJSON && !yes { + if !confirmAction(fmt.Sprintf("Delete rate limit '%s'?", key)) { + fmt.Println("Aborted.") + return + } + } + + resp, err := hatchetClient.API().RateLimitDeleteWithResponse(ctx, tenantUUID, &rest.RateLimitDeleteParams{ + Key: key, + }) + if err != nil { + cli.Logger.Fatalf("failed to delete rate limit: %v", err) + } + if resp.StatusCode() >= 400 { + cli.Logger.Fatalf("failed to delete rate limit (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(map[string]interface{}{"deleted": true, "key": key}) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Deleted rate limit: %s", key))) + } + }, +} + +// findRateLimitByKey looks up a single rate limit by exact key using the list +// endpoint's search filter (there is no dedicated get-by-key endpoint). +func findRateLimitByKey(ctx context.Context, hatchetClient client.Client, key string) *rest.RateLimit { //nolint:staticcheck + tenantUUID := clientTenantUUID(hatchetClient) + limit := int64(100) + resp, err := hatchetClient.API().RateLimitListWithResponse(ctx, tenantUUID, &rest.RateLimitListParams{ + Limit: &limit, + Search: &key, + }) + if err != nil { + cli.Logger.Fatalf("failed to get rate limit: %v", err) + } + if resp.JSON200 == nil || resp.JSON200.Rows == nil { + return nil + } + for i := range *resp.JSON200.Rows { + if (*resp.JSON200.Rows)[i].Key == key { + return &(*resp.JSON200.Rows)[i] + } + } + return nil +} + +// parseRateLimitDuration validates a duration string against the supported set. +func parseRateLimitDuration(s string) (types.RateLimitDuration, error) { + d := types.RateLimitDuration(strings.ToLower(strings.TrimSpace(s))) + for _, valid := range rateLimitDurations { + if d == valid { + return d, nil + } + } + names := make([]string, len(rateLimitDurations)) + for i, valid := range rateLimitDurations { + names[i] = string(valid) + } + return "", fmt.Errorf("invalid duration %q (must be one of: %s)", s, strings.Join(names, ", ")) +} + func init() { rootCmd.AddCommand(rateLimitsCmd) - rateLimitsCmd.AddCommand(rateLimitsListCmd) + rateLimitsCmd.AddCommand(rateLimitsListCmd, rateLimitsGetCmd, rateLimitsCreateCmd, rateLimitsDeleteCmd) rateLimitsCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") rateLimitsCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") @@ -77,4 +336,10 @@ func init() { rateLimitsListCmd.Flags().StringP("search", "s", "", "Search rate limits by key") rateLimitsListCmd.Flags().Int64("limit", 50, "Number of results to return") rateLimitsListCmd.Flags().Int64("offset", 0, "Offset for pagination") + + rateLimitsCreateCmd.Flags().StringP("key", "k", "", "Rate limit key") + rateLimitsCreateCmd.Flags().IntP("limit", "l", 0, "Maximum number of requests allowed within the window") + rateLimitsCreateCmd.Flags().StringP("duration", "d", "", "Window duration: second, minute, hour, day, week, month, year") + + rateLimitsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } diff --git a/cmd/hatchet-cli/cli/rate_limits_e2e_test.go b/cmd/hatchet-cli/cli/rate_limits_e2e_test.go index 2ba6060cb9..e8ae8a4868 100644 --- a/cmd/hatchet-cli/cli/rate_limits_e2e_test.go +++ b/cmd/hatchet-cli/cli/rate_limits_e2e_test.go @@ -38,6 +38,68 @@ func TestRateLimitsListJSON(t *testing.T) { } } +func TestRateLimitsCreateGetDeleteJSON(t *testing.T) { + h := testharness.New(t) + + key := "e2e-test-rate-limit" + + // Create (upsert) a rate limit over the gRPC admin API + createOut := h.RunJSON("rate-limits", "create", + "--key", key, + "--limit", "100", + "--duration", "minute", + ) + + var created struct { + Key string `json:"key"` + Limit int `json:"limit"` + Duration string `json:"duration"` + } + if err := json.Unmarshal(createOut, &created); err != nil { + t.Fatalf("failed to unmarshal rate-limits create output: %v\nOutput: %s", err, createOut) + } + if created.Key != key { + t.Errorf("expected key %q, got %q", key, created.Key) + } + if created.Limit != 100 { + t.Errorf("expected limit 100, got %d", created.Limit) + } + if created.Duration != "minute" { + t.Errorf("expected duration minute, got %q", created.Duration) + } + + // Get the rate limit by key + getOut := h.RunJSON("rate-limits", "get", key) + + var got struct { + Key string `json:"key"` + LimitValue int `json:"limitValue"` + } + if err := json.Unmarshal(getOut, &got); err != nil { + t.Fatalf("failed to unmarshal rate-limits get output: %v\nOutput: %s", err, getOut) + } + if got.Key != key { + t.Errorf("expected key %q, got %q", key, got.Key) + } + + // Delete the rate limit + deleteOut := h.RunJSON("rate-limits", "delete", "--yes", key) + + var deleted struct { + Deleted bool `json:"deleted"` + Key string `json:"key"` + } + if err := json.Unmarshal(deleteOut, &deleted); err != nil { + t.Fatalf("failed to unmarshal rate-limits delete output: %v\nOutput: %s", err, deleteOut) + } + if !deleted.Deleted { + t.Errorf("expected deleted=true, got: %s", deleteOut) + } + if deleted.Key != key { + t.Errorf("expected deleted key %q, got %q", key, deleted.Key) + } +} + func TestRateLimitsTUI(t *testing.T) { h := testharness.New(t) tui := testharness.NewTUI(t, h) diff --git a/pkg/client/admin.go b/pkg/client/admin.go index 34a9e86630..dc457b7ba0 100644 --- a/pkg/client/admin.go +++ b/pkg/client/admin.go @@ -580,6 +580,14 @@ func (a *adminClientImpl) PutRateLimit(key string, opts *types.RateLimitOpts) er putParams.Duration = admincontracts.RateLimitDuration_MINUTE case types.Hour: putParams.Duration = admincontracts.RateLimitDuration_HOUR + case types.Day: + putParams.Duration = admincontracts.RateLimitDuration_DAY + case types.Week: + putParams.Duration = admincontracts.RateLimitDuration_WEEK + case types.Month: + putParams.Duration = admincontracts.RateLimitDuration_MONTH + case types.Year: + putParams.Duration = admincontracts.RateLimitDuration_YEAR default: putParams.Duration = admincontracts.RateLimitDuration_SECOND } diff --git a/pkg/client/client.go b/pkg/client/client.go index f097cf5b08..bec31c03b3 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -243,6 +243,16 @@ func WithGRPCHeaders(headers map[string]string) ClientOpt { } } +// WithTLSConfig sets the gRPC TLS config directly, overriding any config derived +// from environment variables. A nil config connects without TLS (insecure). +// This lets a caller (e.g. the CLI, which is profile-authoritative) avoid having +// ambient HATCHET_CLIENT_TLS_* env vars silently override its intended TLS setup. +func WithTLSConfig(tlsConfig *tls.Config) ClientOpt { + return func(opts *ClientOpts) { + opts.tls = tlsConfig + } +} + type sharedClientOpts struct { tenantId string namespace string