Skip to content

[INT-83] add devices list command #1020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 1, 2025
2 changes: 2 additions & 0 deletions cmd/saucectl/saucectl.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/saucelabs/saucectl/internal/cmd/builds"
"github.com/saucelabs/saucectl/internal/cmd/completion"
"github.com/saucelabs/saucectl/internal/cmd/configure"
"github.com/saucelabs/saucectl/internal/cmd/devices"
"github.com/saucelabs/saucectl/internal/cmd/docker"
"github.com/saucelabs/saucectl/internal/cmd/imagerunner"
"github.com/saucelabs/saucectl/internal/cmd/ini"
Expand Down Expand Up @@ -75,6 +76,7 @@ func main() {
apit.Command(cmd.PersistentPreRun),
docker.Command(cmd.PersistentPreRun),
builds.Command(cmd.PersistentPreRun),
devices.Command(cmd.PersistentPreRun),
)

if err := cmd.ExecuteContext(newContext()); err != nil {
Expand Down
58 changes: 58 additions & 0 deletions internal/cmd/devices/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package devices

import (
"errors"
"time"

"github.com/saucelabs/saucectl/internal/credentials"
"github.com/saucelabs/saucectl/internal/devices"
"github.com/saucelabs/saucectl/internal/http"
"github.com/saucelabs/saucectl/internal/region"
"github.com/saucelabs/saucectl/internal/usage"
"github.com/spf13/cobra"
)

var (
devicesReader devices.Reader
devicesStatusesReader devices.StatusReader
devicesTimeout = 1 * time.Minute
)

func Command(preRun func(cmd *cobra.Command, args []string)) *cobra.Command {
var regio string

cmd := &cobra.Command{
Use: "devices",
Short: "Interact with devices",
SilenceUsage: true,
TraverseChildren: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if preRun != nil {
preRun(cmd, args)
}

reg := region.FromString(regio)
if reg == region.None {
return errors.New("invalid region")
}
if reg == region.Staging {
usage.DefaultClient.Enabled = false
}

creds := credentials.Get()
rdcService := http.NewRDCService(reg, creds.Username, creds.AccessKey, devicesTimeout)

devicesReader = &rdcService
devicesStatusesReader = &rdcService

return nil
},
}

flags := cmd.PersistentFlags()
flags.StringVarP(&regio, "region", "r", "us-west-1", "The Sauce Labs region. Options: us-west-1, eu-central-1.")

cmd.AddCommand(ListCommand())

return cmd
}
251 changes: 251 additions & 0 deletions internal/cmd/devices/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package devices

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"

"github.com/jedib0t/go-pretty/v6/table"
cmds "github.com/saucelabs/saucectl/internal/cmd"
"github.com/saucelabs/saucectl/internal/devices"
"github.com/saucelabs/saucectl/internal/devices/devicestatus"
"github.com/saucelabs/saucectl/internal/tables"
"github.com/saucelabs/saucectl/internal/usage"
"github.com/spf13/cobra"
)

const (
JSONOutput = "json"
TextOutput = "text"
)

type deviceWithStatus struct {
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
Status string `json:"status"`
}

type filter struct {
Name string
Os string
Status string
}

type listOptions struct {
Status bool
OutputFormat string
Filter filter
}

func ListCommand() *cobra.Command {
var out string
var nameFilter string
var osFilter string
var addStatus bool
var statusFilter string

cmd := &cobra.Command{
Use: "list",
Aliases: []string{
"ls",
},
Short: "Returns the list of devices",
SilenceUsage: true,
PreRun: func(cmd *cobra.Command, _ []string) {
tracker := usage.DefaultClient

go func() {
tracker.Collect(
cmds.FullName(cmd),
usage.Flags(cmd.Flags()),
)
_ = tracker.Close()
}()
},
RunE: func(cmd *cobra.Command, _ []string) error {
if out != JSONOutput && out != TextOutput {
return errors.New("unknown output format")
}

if statusFilter != "" {
_, err := devicestatus.StrToStatus(statusFilter)
if err != nil {
return err
}

addStatus = true
}

options := listOptions{
Status: addStatus,
OutputFormat: out,
Filter: filter{
Name: nameFilter,
Os: osFilter,
Status: statusFilter,
},
}

return list(cmd.Context(), options)
},
}

flags := cmd.PersistentFlags()
flags.StringVarP(&out, "out", "o", "text", "OutputFormat format to the console. Options: text, json.")
flags.StringVarP(&nameFilter, "name", "n", "", "Filter devices by name.")
flags.StringVar(&osFilter, "os", "", "Filter devices by OS.")
flags.BoolVar(&addStatus, "statuses", false, "Fetch status for devices.")
flags.StringVar(&statusFilter, "status", "", "Filter devices by status. Implies --statuses if not set.")

return cmd
}

func list(ctx context.Context, options listOptions) error {
devs, err := devicesReader.GetDevices(ctx)
if err != nil {
return fmt.Errorf("failed to get devices: %w", err)
}

var devsWithStatuses []deviceWithStatus
if options.Status {
res, err := getDevicesWithStatuses(ctx, devs)
if err != nil {
return fmt.Errorf("failed to get devices: %w", err)
}
devsWithStatuses = res
} else {
devsWithStatuses = getDevicesWithEmptyStatuses(devs)
}

var filtered = filterDevices(devsWithStatuses, options.Filter)

switch options.OutputFormat {
case "json":
if err := renderJSON(filtered); err != nil {
return fmt.Errorf("failed to render output: %w", err)
}
case "text":
renderListTable(filtered, len(filtered), options.Status)
}

return nil
}

func filterDevices(devs []deviceWithStatus, filter filter) []deviceWithStatus {
var filtered []deviceWithStatus
for _, dev := range devs {
if filter.Name != "" && !strings.Contains(strings.ToLower(dev.Name), strings.ToLower(filter.Name)) {
continue
}

if filter.Os != "" && !strings.Contains(strings.ToLower(dev.OS), strings.ToLower(filter.Os)) {
continue
}

if filter.Status != "" && !strings.Contains(strings.ToLower(dev.Status), strings.ToLower(filter.Status)) {
continue
}

filtered = append(filtered, dev)
}
return filtered
}

func getDevicesWithStatuses(ctx context.Context, devs []devices.Device) ([]deviceWithStatus, error) {
statuses, err := devicesStatusesReader.GetDevicesStatuses(ctx)
if err != nil {
return []deviceWithStatus{}, fmt.Errorf("failed to get devices statuses: %w", err)
}

var result []deviceWithStatus
for _, dev := range devs {
var searchedStatus devices.DeviceStatus
for _, status := range statuses {
if status.ID == dev.ID {
searchedStatus = status
}
}

result = append(result, deviceWithStatus{
ID: dev.ID,
Name: dev.Name,
OS: dev.OS,
Status: devicestatus.StatusToStr(searchedStatus.Status),
})
}

return result, nil
}

func getDevicesWithEmptyStatuses(devs []devices.Device) []deviceWithStatus {
var result []deviceWithStatus
for _, dev := range devs {
result = append(result, deviceWithStatus{
ID: dev.ID,
Name: dev.Name,
OS: dev.OS,
})
}
return result
}

func renderJSON(val any) error {
return json.NewEncoder(os.Stdout).Encode(val)
}

func renderListTable(devices []deviceWithStatus, total int, status bool) {
if len(devices) == 0 {
println("No devices found")
return
}

t := table.NewWriter()
t.SetStyle(tables.DefaultTableStyle)
t.SuppressEmptyColumns()

if status {
writeDevicesWithStatus(t, devices)
} else {
writeDevices(t, devices)
}

t.SuppressEmptyColumns()
t.AppendFooter(table.Row{
fmt.Sprintf("showing %d devices", total),
})

fmt.Println(t.Render())
}

func writeDevices(t table.Writer, devices []deviceWithStatus) {
t.AppendHeader(table.Row{
"Name", "OS",
})

for _, item := range devices {
// the order of values must match the order of the header
t.AppendRow(table.Row{
item.Name,
item.OS,
})
}
}

func writeDevicesWithStatus(t table.Writer, devices []deviceWithStatus) {
t.AppendHeader(table.Row{
"Name", "OS", "Status",
})

for _, item := range devices {
// the order of values must match the order of the header
t.AppendRow(table.Row{
item.Name,
item.OS,
item.Status,
})
}
}
2 changes: 1 addition & 1 deletion internal/cmd/ini/initializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ var fallbackIOSVirtualDevices = []vmd.VirtualDevice{
type initializer struct {
stdio terminal.Stdio
infoReader framework.MetadataService
deviceReader devices.Reader
deviceReader devices.ByOSReader
vmdReader vmd.Reader
userService iam.UserService
cfg *initConfig
Expand Down
30 changes: 26 additions & 4 deletions internal/devices/devices.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
package devices

import "context"
import (
"context"

"github.com/saucelabs/saucectl/internal/devices/devicestatus"
)

// Device describes a real device that can be used to run tests.
type Device struct {
Name string
OS string
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
}

type DeviceStatus struct {
ID string
Status devicestatus.Status
InUseBy []string
IsPrivateDevice bool
}

// Reader is the interface for retrieving available devices.
type Reader interface {
GetDevices(ctx context.Context, OS string) ([]Device, error)
GetDevices(ctx context.Context) ([]Device, error)
}

// StatusReader is the interface for retrieving available devices' statuses.
type StatusReader interface {
GetDevicesStatuses(ctx context.Context) ([]DeviceStatus, error)
}

// ByOSReader is the interface for retrieving available devices by OS.
type ByOSReader interface {
GetDevicesByOS(ctx context.Context, OS string) ([]Device, error)
}
Loading