diff --git a/README.md b/README.md index 4e9e137..2f50bb7 100644 --- a/README.md +++ b/README.md @@ -152,10 +152,37 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `-s, --stealth` - Launch browser in stealth mode to avoid detection - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode + - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) + - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) + - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser - `-y, --yes` - Skip confirmation prompt - `kernel browsers view ` - Get live view URL for a browser +### Browser Pools + +- `kernel browser-pools list` - List browser pools + - `-o, --output json` - Output raw JSON response +- `kernel browser-pools create` - Create a browser pool + - `--name ` - Optional unique name for the pool + - `--size ` - Number of browsers in the pool (required) + - `--fill-rate ` - Percentage of the pool to fill per minute + - `--timeout ` - Idle timeout for browsers acquired from the pool + - `--stealth`, `--headless`, `--kiosk` - Default pool configuration + - `--profile-id`, `--profile-name`, `--save-changes`, `--proxy-id`, `--extension`, `--viewport` - Same semantics as `kernel browsers create` +- `kernel browser-pools get ` - Get pool details + - `-o, --output json` - Output raw JSON response +- `kernel browser-pools update ` - Update pool configuration + - Same flags as create plus `--discard-all-idle` to discard all idle browsers in the pool and refill at the specified fill rate +- `kernel browser-pools delete ` - Delete a pool + - `--force` - Force delete even if browsers are leased +- `kernel browser-pools acquire ` - Acquire a browser from the pool + - `--timeout ` - Acquire timeout before returning 204 +- `kernel browser-pools release ` - Release a browser back to the pool + - `--session-id ` - Browser session ID to release (required) + - `--reuse` - Reuse the browser instance (default: true) +- `kernel browser-pools flush ` - Destroy all idle browsers in the pool + ### Browser Logs - `kernel browsers logs stream ` - Stream browser logs diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go new file mode 100644 index 0000000..76b5f3e --- /dev/null +++ b/cmd/browser_pools.go @@ -0,0 +1,694 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/onkernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// BrowserPoolsService defines the subset of the Kernel SDK browser pools client that we use. +type BrowserPoolsService interface { + List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.BrowserPool, err error) + New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (res *kernel.BrowserPool, err error) + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserPool, err error) + Update(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserPool, err error) + Delete(ctx context.Context, id string, body kernel.BrowserPoolDeleteParams, opts ...option.RequestOption) (err error) + Acquire(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (res *kernel.BrowserPoolAcquireResponse, err error) + Release(ctx context.Context, id string, body kernel.BrowserPoolReleaseParams, opts ...option.RequestOption) (err error) + Flush(ctx context.Context, id string, opts ...option.RequestOption) (err error) +} + +type BrowserPoolsCmd struct { + client BrowserPoolsService +} + +type BrowserPoolsListInput struct { + Output string +} + +func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + pools, err := c.client.List(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if pools == nil { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*pools, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + + if pools == nil || len(*pools) == 0 { + pterm.Info.Println("No browser pools found") + return nil + } + + tableData := pterm.TableData{ + {"ID", "Name", "Available", "Acquired", "Created At", "Size"}, + } + + for _, p := range *pools { + name := p.Name + if name == "" { + name = "-" + } + tableData = append(tableData, []string{ + p.ID, + name, + fmt.Sprintf("%d", p.AvailableCount), + fmt.Sprintf("%d", p.AcquiredCount), + util.FormatLocal(p.CreatedAt), + fmt.Sprintf("%d", p.BrowserPoolConfig.Size), + }) + } + + PrintTableNoPad(tableData, true) + return nil +} + +type BrowserPoolsCreateInput struct { + Name string + Size int64 + FillRate int64 + TimeoutSeconds int64 + Stealth BoolFlag + Headless BoolFlag + Kiosk BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + ProxyID string + Extensions []string + Viewport string +} + +func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) error { + req := kernel.BrowserPoolRequestParam{ + Size: in.Size, + } + + if in.Name != "" { + req.Name = kernel.String(in.Name) + } + if in.FillRate > 0 { + req.FillRatePerMinute = kernel.Int(in.FillRate) + } + if in.TimeoutSeconds > 0 { + req.TimeoutSeconds = kernel.Int(in.TimeoutSeconds) + } + if in.Stealth.Set { + req.Stealth = kernel.Bool(in.Stealth.Value) + } + if in.Headless.Set { + req.Headless = kernel.Bool(in.Headless.Value) + } + if in.Kiosk.Set { + req.KioskMode = kernel.Bool(in.Kiosk.Value) + } + + // Profile + profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) + if err != nil { + pterm.Error.Println(err.Error()) + return nil + } + if profile != nil { + req.Profile = *profile + } + + if in.ProxyID != "" { + req.ProxyID = kernel.String(in.ProxyID) + } + + // Extensions + req.Extensions = buildExtensionsParam(in.Extensions) + + // Viewport + viewport, err := buildViewportParam(in.Viewport) + if err != nil { + pterm.Error.Println(err.Error()) + return nil + } + if viewport != nil { + req.Viewport = *viewport + } + + params := kernel.BrowserPoolNewParams{ + BrowserPoolRequest: req, + } + + pool, err := c.client.New(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if pool.Name != "" { + pterm.Success.Printf("Created browser pool %s (%s)\n", pool.Name, pool.ID) + } else { + pterm.Success.Printf("Created browser pool %s\n", pool.ID) + } + return nil +} + +type BrowserPoolsGetInput struct { + IDOrName string + Output string +} + +func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } + + pool, err := c.client.Get(ctx, in.IDOrName) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + bs, err := json.MarshalIndent(pool, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + + name := pool.Name + if name == "" { + name = "-" + } + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", pool.ID}, + {"Name", name}, + {"Size", fmt.Sprintf("%d", pool.BrowserPoolConfig.Size)}, + {"Available", fmt.Sprintf("%d", pool.AvailableCount)}, + {"Acquired", fmt.Sprintf("%d", pool.AcquiredCount)}, + {"Timeout", fmt.Sprintf("%d", pool.BrowserPoolConfig.TimeoutSeconds)}, + {"Created At", util.FormatLocal(pool.CreatedAt)}, + } + PrintTableNoPad(tableData, true) + return nil +} + +type BrowserPoolsUpdateInput struct { + IDOrName string + Name string + Size int64 + FillRate int64 + TimeoutSeconds int64 + Stealth BoolFlag + Headless BoolFlag + Kiosk BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + ProxyID string + Extensions []string + Viewport string + DiscardAllIdle BoolFlag +} + +func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) error { + req := kernel.BrowserPoolUpdateRequestParam{} + + if in.Name != "" { + req.Name = kernel.String(in.Name) + } + if in.Size > 0 { + req.Size = in.Size + } + if in.FillRate > 0 { + req.FillRatePerMinute = kernel.Int(in.FillRate) + } + if in.TimeoutSeconds > 0 { + req.TimeoutSeconds = kernel.Int(in.TimeoutSeconds) + } + if in.Stealth.Set { + req.Stealth = kernel.Bool(in.Stealth.Value) + } + if in.Headless.Set { + req.Headless = kernel.Bool(in.Headless.Value) + } + if in.Kiosk.Set { + req.KioskMode = kernel.Bool(in.Kiosk.Value) + } + if in.DiscardAllIdle.Set { + req.DiscardAllIdle = kernel.Bool(in.DiscardAllIdle.Value) + } + + // Profile + profile, err := buildProfileParam(in.ProfileID, in.ProfileName, in.ProfileSaveChanges) + if err != nil { + pterm.Error.Println(err.Error()) + return nil + } + if profile != nil { + req.Profile = *profile + } + + if in.ProxyID != "" { + req.ProxyID = kernel.String(in.ProxyID) + } + + // Extensions + req.Extensions = buildExtensionsParam(in.Extensions) + + // Viewport + viewport, err := buildViewportParam(in.Viewport) + if err != nil { + pterm.Error.Println(err.Error()) + return nil + } + if viewport != nil { + req.Viewport = *viewport + } + + params := kernel.BrowserPoolUpdateParams{ + BrowserPoolUpdateRequest: req, + } + + pool, err := c.client.Update(ctx, in.IDOrName, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if pool.Name != "" { + pterm.Success.Printf("Updated browser pool %s (%s)\n", pool.Name, pool.ID) + } else { + pterm.Success.Printf("Updated browser pool %s\n", pool.ID) + } + return nil +} + +type BrowserPoolsDeleteInput struct { + IDOrName string + Force bool +} + +func (c BrowserPoolsCmd) Delete(ctx context.Context, in BrowserPoolsDeleteInput) error { + params := kernel.BrowserPoolDeleteParams{} + if in.Force { + params.Force = kernel.Bool(true) + } + err := c.client.Delete(ctx, in.IDOrName, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted browser pool %s\n", in.IDOrName) + return nil +} + +type BrowserPoolsAcquireInput struct { + IDOrName string + TimeoutSeconds int64 +} + +func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInput) error { + req := kernel.BrowserPoolAcquireRequestParam{} + if in.TimeoutSeconds > 0 { + req.AcquireTimeoutSeconds = kernel.Int(in.TimeoutSeconds) + } + params := kernel.BrowserPoolAcquireParams{ + BrowserPoolAcquireRequest: req, + } + resp, err := c.client.Acquire(ctx, in.IDOrName, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if resp == nil { + pterm.Warning.Println("Acquire request timed out (no browser available). Retry to continue waiting.") + return nil + } + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"Session ID", resp.SessionID}, + {"CDP WebSocket URL", resp.CdpWsURL}, + {"Live View URL", resp.BrowserLiveViewURL}, + } + PrintTableNoPad(tableData, true) + return nil +} + +type BrowserPoolsReleaseInput struct { + IDOrName string + SessionID string + Reuse BoolFlag +} + +func (c BrowserPoolsCmd) Release(ctx context.Context, in BrowserPoolsReleaseInput) error { + req := kernel.BrowserPoolReleaseRequestParam{ + SessionID: in.SessionID, + } + if in.Reuse.Set { + req.Reuse = kernel.Bool(in.Reuse.Value) + } + params := kernel.BrowserPoolReleaseParams{ + BrowserPoolReleaseRequest: req, + } + err := c.client.Release(ctx, in.IDOrName, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Released browser %s back to pool %s\n", in.SessionID, in.IDOrName) + return nil +} + +type BrowserPoolsFlushInput struct { + IDOrName string +} + +func (c BrowserPoolsCmd) Flush(ctx context.Context, in BrowserPoolsFlushInput) error { + err := c.client.Flush(ctx, in.IDOrName) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Flushed idle browsers from pool %s\n", in.IDOrName) + return nil +} + +var browserPoolsCmd = &cobra.Command{ + Use: "browser-pools", + Aliases: []string{"browser-pool", "pool", "pools"}, + Short: "Manage browser pools", + Long: "Commands for managing Kernel browser pools", +} + +var browserPoolsListCmd = &cobra.Command{ + Use: "list", + Short: "List browser pools", + RunE: runBrowserPoolsList, +} + +var browserPoolsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new browser pool", + RunE: runBrowserPoolsCreate, +} + +var browserPoolsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details of a browser pool", + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsGet, +} + +var browserPoolsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a browser pool", + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsUpdate, +} + +var browserPoolsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a browser pool", + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsDelete, +} + +var browserPoolsAcquireCmd = &cobra.Command{ + Use: "acquire ", + Short: "Acquire a browser from the pool", + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsAcquire, +} + +var browserPoolsReleaseCmd = &cobra.Command{ + Use: "release ", + Short: "Release a browser back to the pool", + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsRelease, +} + +var browserPoolsFlushCmd = &cobra.Command{ + Use: "flush ", + Short: "Flush idle browsers from the pool", + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsFlush, +} + +func init() { + // list flags + browserPoolsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // create flags + browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool") + browserPoolsCreateCmd.Flags().Int64("size", 0, "Number of browsers in the pool") + _ = browserPoolsCreateCmd.MarkFlagRequired("size") + browserPoolsCreateCmd.Flags().Int64("fill-rate", 0, "Fill rate per minute") + browserPoolsCreateCmd.Flags().Int64("timeout", 0, "Idle timeout in seconds") + browserPoolsCreateCmd.Flags().Bool("stealth", false, "Enable stealth mode") + browserPoolsCreateCmd.Flags().Bool("headless", false, "Enable headless mode") + browserPoolsCreateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") + browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") + browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") + browserPoolsCreateCmd.Flags().Bool("save-changes", false, "Save changes to profile") + browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") + browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") + browserPoolsCreateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") + + // get flags + browserPoolsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // update flags + browserPoolsUpdateCmd.Flags().String("name", "", "Update the pool name") + browserPoolsUpdateCmd.Flags().Int64("size", 0, "Number of browsers in the pool") + browserPoolsUpdateCmd.Flags().Int64("fill-rate", 0, "Fill rate per minute") + browserPoolsUpdateCmd.Flags().Int64("timeout", 0, "Idle timeout in seconds") + browserPoolsUpdateCmd.Flags().Bool("stealth", false, "Enable stealth mode") + browserPoolsUpdateCmd.Flags().Bool("headless", false, "Enable headless mode") + browserPoolsUpdateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") + browserPoolsUpdateCmd.Flags().String("profile-id", "", "Profile ID") + browserPoolsUpdateCmd.Flags().String("profile-name", "", "Profile name") + browserPoolsUpdateCmd.Flags().Bool("save-changes", false, "Save changes to profile") + browserPoolsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID") + browserPoolsUpdateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") + browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") + browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") + + // delete flags + browserPoolsDeleteCmd.Flags().Bool("force", false, "Force delete even if browsers are leased") + + // acquire flags + browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds") + + // release flags + browserPoolsReleaseCmd.Flags().String("session-id", "", "Browser session ID to release") + _ = browserPoolsReleaseCmd.MarkFlagRequired("session-id") + browserPoolsReleaseCmd.Flags().Bool("reuse", true, "Reuse the browser instance") + + browserPoolsCmd.AddCommand(browserPoolsListCmd) + browserPoolsCmd.AddCommand(browserPoolsCreateCmd) + browserPoolsCmd.AddCommand(browserPoolsGetCmd) + browserPoolsCmd.AddCommand(browserPoolsUpdateCmd) + browserPoolsCmd.AddCommand(browserPoolsDeleteCmd) + browserPoolsCmd.AddCommand(browserPoolsAcquireCmd) + browserPoolsCmd.AddCommand(browserPoolsReleaseCmd) + browserPoolsCmd.AddCommand(browserPoolsFlushCmd) +} + +func runBrowserPoolsList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + out, _ := cmd.Flags().GetString("output") + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.List(cmd.Context(), BrowserPoolsListInput{Output: out}) +} + +func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + name, _ := cmd.Flags().GetString("name") + size, _ := cmd.Flags().GetInt64("size") + fillRate, _ := cmd.Flags().GetInt64("fill-rate") + timeout, _ := cmd.Flags().GetInt64("timeout") + stealth, _ := cmd.Flags().GetBool("stealth") + headless, _ := cmd.Flags().GetBool("headless") + kiosk, _ := cmd.Flags().GetBool("kiosk") + profileID, _ := cmd.Flags().GetString("profile-id") + profileName, _ := cmd.Flags().GetString("profile-name") + saveChanges, _ := cmd.Flags().GetBool("save-changes") + proxyID, _ := cmd.Flags().GetString("proxy-id") + extensions, _ := cmd.Flags().GetStringSlice("extension") + viewport, _ := cmd.Flags().GetString("viewport") + + in := BrowserPoolsCreateInput{ + Name: name, + Size: size, + FillRate: fillRate, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + ProxyID: proxyID, + Extensions: extensions, + Viewport: viewport, + } + + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Create(cmd.Context(), in) +} + +func runBrowserPoolsGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + out, _ := cmd.Flags().GetString("output") + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Get(cmd.Context(), BrowserPoolsGetInput{IDOrName: args[0], Output: out}) +} + +func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + name, _ := cmd.Flags().GetString("name") + size, _ := cmd.Flags().GetInt64("size") + fillRate, _ := cmd.Flags().GetInt64("fill-rate") + timeout, _ := cmd.Flags().GetInt64("timeout") + stealth, _ := cmd.Flags().GetBool("stealth") + headless, _ := cmd.Flags().GetBool("headless") + kiosk, _ := cmd.Flags().GetBool("kiosk") + profileID, _ := cmd.Flags().GetString("profile-id") + profileName, _ := cmd.Flags().GetString("profile-name") + saveChanges, _ := cmd.Flags().GetBool("save-changes") + proxyID, _ := cmd.Flags().GetString("proxy-id") + extensions, _ := cmd.Flags().GetStringSlice("extension") + viewport, _ := cmd.Flags().GetString("viewport") + discardIdle, _ := cmd.Flags().GetBool("discard-all-idle") + + in := BrowserPoolsUpdateInput{ + IDOrName: args[0], + Name: name, + Size: size, + FillRate: fillRate, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealth}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headless}, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + ProxyID: proxyID, + Extensions: extensions, + Viewport: viewport, + DiscardAllIdle: BoolFlag{Set: cmd.Flags().Changed("discard-all-idle"), Value: discardIdle}, + } + + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Update(cmd.Context(), in) +} + +func runBrowserPoolsDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + force, _ := cmd.Flags().GetBool("force") + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Delete(cmd.Context(), BrowserPoolsDeleteInput{IDOrName: args[0], Force: force}) +} + +func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + timeout, _ := cmd.Flags().GetInt64("timeout") + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{IDOrName: args[0], TimeoutSeconds: timeout}) +} + +func runBrowserPoolsRelease(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + sessionID, _ := cmd.Flags().GetString("session-id") + reuse, _ := cmd.Flags().GetBool("reuse") + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Release(cmd.Context(), BrowserPoolsReleaseInput{ + IDOrName: args[0], + SessionID: sessionID, + Reuse: BoolFlag{Set: cmd.Flags().Changed("reuse"), Value: reuse}, + }) +} + +func runBrowserPoolsFlush(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + c := BrowserPoolsCmd{client: &client.BrowserPools} + return c.Flush(cmd.Context(), BrowserPoolsFlushInput{IDOrName: args[0]}) +} + +func buildProfileParam(profileID, profileName string, saveChanges BoolFlag) (*kernel.BrowserProfileParam, error) { + if profileID != "" && profileName != "" { + return nil, fmt.Errorf("must specify at most one of --profile-id or --profile-name") + } + if profileID == "" && profileName == "" { + return nil, nil + } + + profile := kernel.BrowserProfileParam{ + SaveChanges: kernel.Bool(saveChanges.Value), + } + if profileID != "" { + profile.ID = kernel.String(profileID) + } else if profileName != "" { + profile.Name = kernel.String(profileName) + } + return &profile, nil +} + +func buildExtensionsParam(extensions []string) []kernel.BrowserExtensionParam { + if len(extensions) == 0 { + return nil + } + + var result []kernel.BrowserExtensionParam + for _, ext := range extensions { + val := strings.TrimSpace(ext) + if val == "" { + continue + } + item := kernel.BrowserExtensionParam{} + if cuidRegex.MatchString(val) { + item.ID = kernel.String(val) + } else { + item.Name = kernel.String(val) + } + result = append(result, item) + } + return result +} + +func buildViewportParam(viewport string) (*kernel.BrowserViewportParam, error) { + if viewport == "" { + return nil, nil + } + + width, height, refreshRate, err := parseViewport(viewport) + if err != nil { + return nil, fmt.Errorf("invalid viewport format: %v", err) + } + + vp := kernel.BrowserViewportParam{ + Width: width, + Height: height, + } + if refreshRate > 0 { + vp.RefreshRate = kernel.Int(refreshRate) + } + return &vp, nil +} diff --git a/cmd/browsers.go b/cmd/browsers.go index ff57a77..de60548 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -23,6 +23,7 @@ import ( "github.com/onkernel/kernel-go-sdk/shared" "github.com/pterm/pterm" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // BrowsersService defines the subset of the Kernel SDK browser client that we use. @@ -227,7 +228,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { return nil } - if browsers == nil || len(browsers) == 0 { + if len(browsers) == 0 { pterm.Info.Println("No running browsers found") return nil } @@ -300,7 +301,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { pterm.Error.Println("must specify at most one of --profile-id or --profile-name") return nil } else if in.ProfileID != "" || in.ProfileName != "" { - params.Profile = kernel.BrowserNewParamsProfile{ + params.Profile = kernel.BrowserProfileParam{ SaveChanges: kernel.Opt(in.ProfileSaveChanges.Value), } if in.ProfileID != "" { @@ -322,7 +323,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if val == "" { continue } - item := kernel.BrowserNewParamsExtension{} + item := kernel.BrowserExtensionParam{} if cuidRegex.MatchString(val) { item.ID = kernel.Opt(val) } else { @@ -339,7 +340,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { pterm.Error.Printf("Invalid viewport format: %v\n", err) return nil } - params.Viewport = kernel.BrowserNewParamsViewport{ + params.Viewport = kernel.BrowserViewportParam{ Width: width, Height: height, } @@ -353,27 +354,31 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return util.CleanedUpSdkError{Err: err} } + printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Persistence, browser.Profile) + return nil +} + +func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, persistence kernel.BrowserPersistence, profile kernel.Profile) { tableData := pterm.TableData{ {"Property", "Value"}, - {"Session ID", browser.SessionID}, - {"CDP WebSocket URL", browser.CdpWsURL}, + {"Session ID", sessionID}, + {"CDP WebSocket URL", cdpURL}, } - if browser.BrowserLiveViewURL != "" { - tableData = append(tableData, []string{"Live View URL", browser.BrowserLiveViewURL}) + if liveViewURL != "" { + tableData = append(tableData, []string{"Live View URL", liveViewURL}) } - if browser.Persistence.ID != "" { - tableData = append(tableData, []string{"Persistent ID", browser.Persistence.ID}) + if persistence.ID != "" { + tableData = append(tableData, []string{"Persistent ID", persistence.ID}) } - if browser.Profile.ID != "" || browser.Profile.Name != "" { - profVal := browser.Profile.Name + if profile.ID != "" || profile.Name != "" { + profVal := profile.Name if profVal == "" { - profVal = browser.Profile.ID + profVal = profile.ID } tableData = append(tableData, []string{"Profile", profVal}) } PrintTableNoPad(tableData, true) - return nil } func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { @@ -2043,6 +2048,8 @@ func init() { browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") + browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") + browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") // Add flags for delete command browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") @@ -2085,6 +2092,77 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") + poolID, _ := cmd.Flags().GetString("pool-id") + poolName, _ := cmd.Flags().GetString("pool-name") + + if poolID != "" && poolName != "" { + pterm.Error.Println("must specify at most one of --pool-id or --pool-name") + return nil + } + + if poolID != "" || poolName != "" { + // When using a pool, configuration comes from the pool itself. + allowedFlags := map[string]bool{ + "pool-id": true, + "pool-name": true, + "timeout": true, + // Global persistent flags that don't configure browsers + "no-color": true, + "log-level": true, + } + + // Check if any browser configuration flags were set (which would conflict). + var conflicts []string + cmd.Flags().Visit(func(f *pflag.Flag) { + if !allowedFlags[f.Name] { + conflicts = append(conflicts, "--"+f.Name) + } + }) + + if len(conflicts) > 0 { + flagLabel := "--pool-id" + if poolName != "" { + flagLabel = "--pool-name" + } + pterm.Warning.Printf("You specified %s, but also provided browser configuration flags: %s\n", flagLabel, strings.Join(conflicts, ", ")) + pterm.Info.Println("When using a pool, all browser configuration comes from the pool itself.") + pterm.Info.Println("The conflicting flags will be ignored.") + + result, _ := pterm.DefaultInteractiveConfirm.Show("Continue with pool configuration?") + if !result { + pterm.Info.Println("Cancelled. Remove conflicting flags or omit the pool flag.") + return nil + } + pterm.Success.Println("Proceeding with pool configuration...") + } + + pool := poolID + if pool == "" { + pool = poolName + } + + pterm.Info.Printf("Acquiring browser from pool %s...\n", pool) + poolSvc := client.BrowserPools + + req := kernel.BrowserPoolAcquireRequestParam{} + if cmd.Flags().Changed("timeout") && timeout > 0 { + req.AcquireTimeoutSeconds = kernel.Int(int64(timeout)) + } + acquireParams := kernel.BrowserPoolAcquireParams{ + BrowserPoolAcquireRequest: req, + } + + resp, err := (&poolSvc).Acquire(cmd.Context(), pool, acquireParams) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if resp == nil { + pterm.Error.Println("Acquire request timed out (no browser available). Retry to continue waiting.") + return nil + } + printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Persistence, resp.Profile) + return nil + } // Handle interactive viewport selection if viewportInteractive { diff --git a/cmd/root.go b/cmd/root.go index f23f4a1..06cce7c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -123,6 +123,7 @@ func init() { rootCmd.AddCommand(deployCmd) rootCmd.AddCommand(invokeCmd) rootCmd.AddCommand(browsersCmd) + rootCmd.AddCommand(browserPoolsCmd) rootCmd.AddCommand(appCmd) rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) diff --git a/go.mod b/go.mod index cdbc232..b17f5cc 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,12 @@ require ( github.com/charmbracelet/fang v0.2.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/onkernel/kernel-go-sdk v0.20.0 + github.com/onkernel/kernel-go-sdk v0.21.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 github.com/spf13/cobra v1.9.1 + github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.11.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/oauth2 v0.30.0 @@ -46,7 +47,6 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index e1ca8b8..f5b74b9 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/onkernel/kernel-go-sdk v0.20.0 h1:KBMBjs54QlzlbQOFZLcN0PHD2QR8wJIrpzEfBaf6YZ0= -github.com/onkernel/kernel-go-sdk v0.20.0/go.mod h1:t80buN1uCA/hwvm4D2SpjTJzZWcV7bWOFo9d7qdXD8M= +github.com/onkernel/kernel-go-sdk v0.21.0 h1:ah1uBl71pk5DJmge0Z8eyyk1dZw6ik9ETuyd+3tIrl4= +github.com/onkernel/kernel-go-sdk v0.21.0/go.mod h1:t80buN1uCA/hwvm4D2SpjTJzZWcV7bWOFo9d7qdXD8M= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/scripts/test-browser-pools.sh b/scripts/test-browser-pools.sh new file mode 100755 index 0000000..0334eea --- /dev/null +++ b/scripts/test-browser-pools.sh @@ -0,0 +1,190 @@ +#!/bin/bash + +set -e + +# Browser Pool Lifecycle Test +# +# This script tests the full lifecycle of browser pools: +# 1. Create a pool +# 2. Acquire a browser from it +# 3. Use the browser (simulated with sleep) +# 4. Release the browser back to the pool +# 5. Check pool state +# 6. Flush idle browsers +# 7. Delete the pool + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +KERNEL="${KERNEL:-kernel}" # Use $KERNEL env var or default to 'kernel' +POOL_NAME="test-pool-$(date +%s)" +POOL_SIZE=2 +SLEEP_TIME=5 + +# Helper functions +log_step() { + echo -e "${BLUE}==>${NC} $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +log_info() { + echo -e "${YELLOW}ℹ${NC} $1" +} + +# Check if kernel CLI is available +if ! command -v "$KERNEL" &> /dev/null && [ ! -x "$KERNEL" ]; then + log_error "kernel CLI not found at '$KERNEL'. Please install it or set KERNEL env var." + exit 1 +fi + +# Cleanup function (only runs if script exits early/unexpectedly) +cleanup() { + if [ -n "$POOL_ID" ]; then + echo "" + log_step "Script exited early - cleaning up pool $POOL_ID" + "$KERNEL" browser-pools delete "$POOL_ID" --force --no-color || true + log_info "Cleanup complete (used --force to ensure deletion)" + fi +} + +trap cleanup EXIT + +echo "" +log_step "Starting browser pool integration test" +echo "" + +# Step 1: Create a pool +log_step "Step 1: Creating browser pool with name '$POOL_NAME' and size $POOL_SIZE" +"$KERNEL" browser-pools create \ + --name "$POOL_NAME" \ + --size "$POOL_SIZE" \ + --timeout 300 \ + --no-color + +# Extract pool ID using the list command +POOL_ID=$("$KERNEL" browser-pools list --output json --no-color | jq -r ".[] | select(.name == \"$POOL_NAME\") | .id") + +if [ -z "$POOL_ID" ]; then + log_error "Failed to create pool or extract pool ID" + exit 1 +fi + +log_success "Created pool: $POOL_ID" +echo "" + +# Step 2: List pools to verify +log_step "Step 2: Listing all pools" +"$KERNEL" browser-pools list --no-color +echo "" + +# Step 3: Get pool details +log_step "Step 3: Getting pool details" +"$KERNEL" browser-pools get "$POOL_ID" --no-color +echo "" + +# Wait for pool to be ready +log_info "Waiting for pool to initialize..." +sleep 3 + +# Step 4: Acquire a browser from the pool +log_step "Step 4: Acquiring a browser from the pool" +ACQUIRE_OUTPUT=$("$KERNEL" browser-pools acquire "$POOL_ID" --timeout 10 --no-color 2>&1) + +if echo "$ACQUIRE_OUTPUT" | grep -q "timed out"; then + log_error "Failed to acquire browser (timeout or no browsers available)" + exit 1 +fi + +# Parse the session ID from the table output (format: "Session ID | ") +SESSION_ID=$(echo "$ACQUIRE_OUTPUT" | grep "Session ID" | awk -F'|' '{print $2}' | xargs) + +if [ -z "$SESSION_ID" ]; then + log_error "Failed to extract session ID from acquire response" + echo "Response: $ACQUIRE_OUTPUT" + exit 1 +fi + +log_success "Acquired browser with session ID: $SESSION_ID" +echo "" + +# Step 5: Get pool details again to see the acquired browser +log_step "Step 5: Checking pool state (should show 1 acquired)" +POOL_DETAILS=$("$KERNEL" browser-pools get "$POOL_ID" --output json --no-color) +ACQUIRED_COUNT=$(echo "$POOL_DETAILS" | jq -r '.acquired_count // .acquiredCount // 0') +AVAILABLE_COUNT=$(echo "$POOL_DETAILS" | jq -r '.available_count // .availableCount // 0') + +log_info "Acquired: $ACQUIRED_COUNT, Available: $AVAILABLE_COUNT" +"$KERNEL" browser-pools get "$POOL_ID" --no-color +echo "" + +# Step 6: Sleep to simulate usage +log_step "Step 6: Simulating browser usage (sleeping for ${SLEEP_TIME}s)" +sleep "$SLEEP_TIME" +log_success "Usage simulation complete" +echo "" + +# Step 7: Release the browser back to the pool +log_step "Step 7: Releasing browser back to pool" +"$KERNEL" browser-pools release "$POOL_ID" \ + --session-id "$SESSION_ID" \ + --reuse \ + --no-color + +log_success "Browser released" +echo "" + +# Step 8: Get pool details again +log_step "Step 8: Checking pool state after release" +"$KERNEL" browser-pools get "$POOL_ID" --no-color +echo "" + +# Step 9: Flush the pool +log_step "Step 9: Flushing idle browsers from pool" +"$KERNEL" browser-pools flush "$POOL_ID" --no-color +log_success "Pool flushed" +echo "" + +# Step 10: Delete the pool (should succeed if browsers are properly released) +log_step "Step 10: Deleting the pool" +DELETED_POOL_ID="$POOL_ID" +set +e # Temporarily disable exit-on-error to see the result +"$KERNEL" browser-pools delete "$POOL_ID" --no-color +DELETE_EXIT=$? +set -e # Re-enable exit-on-error + +if [ $DELETE_EXIT -eq 0 ]; then + log_success "Pool deleted successfully" + POOL_ID="" # Clear so cleanup doesn't try again + echo "" +else + log_error "Failed to delete pool - browsers may still be in acquired state" + log_info "This suggests the release operation hasn't fully completed" + log_info "Pool $POOL_ID left for debugging (clean up manually if needed)" + POOL_ID="" # Clear to prevent cleanup trap from trying + echo "" +fi + +# Verify deletion +log_step "Verifying pool deletion" +if "$KERNEL" browser-pools list --output json --no-color | jq -e ".[] | select(.id == \"$DELETED_POOL_ID\") | .id" > /dev/null 2>&1; then + log_error "Pool may still exist" +else + log_success "Pool successfully deleted and no longer exists" +fi + +echo "" +log_success "Integration test completed successfully!" +echo "" +