diff --git a/cli/filters.go b/cli/filters.go index b73d57f..714c082 100644 --- a/cli/filters.go +++ b/cli/filters.go @@ -60,7 +60,7 @@ func parseNameFilter(value string) (commands.DeviceFilter, error) { } // buildAllocateFilters builds a filters slice from CLI flag values. -func buildAllocateFilters(platform, deviceType string, versions, names []string) ([]commands.DeviceFilter, error) { +func buildAllocateFilters(platform, deviceType string, versions []string, name string) ([]commands.DeviceFilter, error) { var filters []commands.DeviceFilter filters = append(filters, commands.DeviceFilter{ @@ -85,10 +85,10 @@ func buildAllocateFilters(platform, deviceType string, versions, names []string) filters = append(filters, f) } - for _, n := range names { - f, err := parseNameFilter(n) + if name != "" { + f, err := parseNameFilter(name) if err != nil { - return nil, fmt.Errorf("invalid --name %q: %w", n, err) + return nil, fmt.Errorf("invalid --name %q: %w", name, err) } filters = append(filters, f) } diff --git a/cli/filters_test.go b/cli/filters_test.go index 12dd12a..0637d3a 100644 --- a/cli/filters_test.go +++ b/cli/filters_test.go @@ -91,7 +91,7 @@ func TestParseNameFilter_JustWildcard(t *testing.T) { } func TestBuildAllocateFilters_PlatformOnly(t *testing.T) { - filters, err := buildAllocateFilters("ios", "", nil, nil) + filters, err := buildAllocateFilters("ios", "", nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -104,7 +104,7 @@ func TestBuildAllocateFilters_PlatformOnly(t *testing.T) { } func TestBuildAllocateFilters_WithType(t *testing.T) { - filters, err := buildAllocateFilters("ios", "real", nil, nil) + filters, err := buildAllocateFilters("ios", "real", nil, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -117,7 +117,7 @@ func TestBuildAllocateFilters_WithType(t *testing.T) { } func TestBuildAllocateFilters_Combined(t *testing.T) { - filters, err := buildAllocateFilters("ios", "real", []string{">=18", "<20"}, []string{"iPhone*"}) + filters, err := buildAllocateFilters("ios", "real", []string{">=18", "<20"}, "iPhone*") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cli/flags.go b/cli/flags.go index 10f350a..0d0fa0f 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -29,7 +29,7 @@ var ( // for remote allocate command fleetType string fleetVersions []string - fleetNames []string + fleetName string fleetWait bool fleetTimeout int diff --git a/cli/remote.go b/cli/remote.go index dd140f6..0927e9d 100644 --- a/cli/remote.go +++ b/cli/remote.go @@ -34,7 +34,7 @@ var remoteAllocateCmd = &cobra.Command{ Short: "Allocate a remote device", Long: `Allocates a device from the remote fleet matching the given filters. -Flags --version and --name can be specified multiple times (all are ANDed). +Flag --version can be specified multiple times (all are ANDed). Version supports comparison operators: --version ">=18" (greater than or equal) @@ -54,7 +54,7 @@ Name supports wildcard prefix matching: return err } - filters, err := buildAllocateFilters(platform, fleetType, fleetVersions, fleetNames) + filters, err := buildAllocateFilters(platform, fleetType, fleetVersions, fleetName) if err != nil { return err } @@ -191,7 +191,7 @@ func init() { _ = remoteAllocateCmd.MarkFlagRequired("platform") remoteAllocateCmd.Flags().StringVar(&fleetType, "type", "", "device type (real)") remoteAllocateCmd.Flags().StringArrayVar(&fleetVersions, "version", nil, "OS version filter (supports >=, >, <=, < prefixes)") - remoteAllocateCmd.Flags().StringArrayVar(&fleetNames, "name", nil, "device name filter (supports trailing * for prefix match)") + remoteAllocateCmd.Flags().StringVar(&fleetName, "name", "", "device name filter (supports trailing * for prefix match)") remoteAllocateCmd.Flags().BoolVar(&fleetWait, "wait", false, "wait for device to finish allocating before returning") remoteAllocateCmd.Flags().IntVar(&fleetTimeout, "timeout", 900, "seconds to wait for allocation (only used with --wait)") diff --git a/commands/remote.go b/commands/remote.go index fdb4d65..d2336d1 100644 --- a/commands/remote.go +++ b/commands/remote.go @@ -3,6 +3,8 @@ package commands import ( "encoding/json" "fmt" + "net/http" + "net/url" "github.com/mobile-next/mobilecli/devices" "github.com/mobile-next/mobilecli/rpc" @@ -16,14 +18,14 @@ type DeviceFilter struct { Value string `json:"value"` } -// fleetAllocateParams is the params object of the fleet.allocate rpc +// fleetAllocateParams is the request body of POST /api/v1/sessions/{sessionId}/devices type fleetAllocateParams struct { Filters []DeviceFilter `json:"filters"` } -// fleetReleaseParams is the params object of the fleet.release rpc -type fleetReleaseParams struct { - DeviceID string `json:"deviceId"` +// createSessionResponse is the response body of POST /api/v1/sessions +type createSessionResponse struct { + ID string `json:"id"` } // devicesListResult is the result object of the devices.list rpc @@ -62,12 +64,20 @@ func (r FleetAllocateResponse) IsAllocating() bool { return r.State == "allocating" } +// FleetAllocateCommand creates a session and allocates a device into it over REST, so the +// allocation is linked to a sessions row from the start (a WebSocket fleet.allocate call has +// no sessionId, leaving the allocation invisible to GET /api/v1/sessions). func FleetAllocateCommand(req FleetAllocateRequest) *CommandResponse { + var session createSessionResponse + if err := rpc.RESTCall(req.Token, http.MethodPost, "/api/v1/sessions", nil, &session); err != nil { + return NewErrorResponse(fmt.Errorf("create session: %w", err)) + } + var result FleetAllocateResponse params := fleetAllocateParams{Filters: req.Filters} - err := rpc.Call(req.Token, "fleet.allocate", params, &result) - if err != nil { - return NewErrorResponse(fmt.Errorf("fleet.allocate: %w", err)) + path := fmt.Sprintf("/api/v1/sessions/%s/devices", session.ID) + if err := rpc.RESTCall(req.Token, http.MethodPost, path, params, &result); err != nil { + return NewErrorResponse(fmt.Errorf("allocate device: %w", err)) } return NewSuccessResponse(result) } @@ -137,11 +147,68 @@ type FleetReleaseRequest struct { Token string } +// sessionDeviceEntry is a device as embedded in a GET /api/v1/sessions session's "devices" list. +type sessionDeviceEntry struct { + Status string `json:"status"` + Info struct { + Serial string `json:"serial"` + } `json:"info"` +} + +// sessionListEntry is one session as returned by GET /api/v1/sessions. +type sessionListEntry struct { + ID string `json:"id"` + Devices []sessionDeviceEntry `json:"devices"` +} + +// sessionsPageResponse is the paginated envelope of GET /api/v1/sessions. +type sessionsPageResponse struct { + Data []sessionListEntry `json:"data"` + NextCursor *string `json:"nextCursor"` +} + +// findOwningSessionID pages through the account's sessions to find the one holding a live +// (not yet released) allocation of deviceID. The release endpoint is session-scoped, and +// fleet.release/devices.list never surface a device's owning sessionId, so this is the only +// way to recover it. +func findOwningSessionID(token, deviceID string) (string, error) { + before := "" + for { + path := "/api/v1/sessions?limit=500" + if before != "" { + path += "&before=" + url.QueryEscape(before) + } + + var page sessionsPageResponse + if err := rpc.RESTCall(token, http.MethodGet, path, nil, &page); err != nil { + return "", fmt.Errorf("list sessions: %w", err) + } + + for _, session := range page.Data { + for _, device := range session.Devices { + if device.Info.Serial == deviceID && device.Status != "released" { + return session.ID, nil + } + } + } + + if page.NextCursor == nil { + return "", fmt.Errorf("device %s is not allocated in any active session", deviceID) + } + before = *page.NextCursor + } +} + func FleetReleaseCommand(req FleetReleaseRequest) *CommandResponse { - err := rpc.Call(req.Token, "fleet.release", fleetReleaseParams{DeviceID: req.DeviceID}, nil) + sessionID, err := findOwningSessionID(req.Token, req.DeviceID) if err != nil { return NewErrorResponse(fmt.Errorf("fleet.release: %w", err)) } + + path := fmt.Sprintf("/api/v1/sessions/%s/devices/%s/release", url.PathEscape(sessionID), url.PathEscape(req.DeviceID)) + if err := rpc.RESTCall(req.Token, http.MethodPost, path, nil, nil); err != nil { + return NewErrorResponse(fmt.Errorf("fleet.release: %w", err)) + } return NewSuccessResponse(nil) } diff --git a/commands/remote_test.go b/commands/remote_test.go index d1b29f6..78e0fc2 100644 --- a/commands/remote_test.go +++ b/commands/remote_test.go @@ -2,6 +2,9 @@ package commands import ( "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/mobile-next/mobilecli/devices" @@ -29,3 +32,162 @@ func TestFindDeviceByAllocation(t *testing.T) { t.Errorf("expected no device for unknown allocation, got %s", found.ID) } } + +func TestFleetAllocateCommandCreatesASessionThenAllocatesIntoIt(t *testing.T) { + var gotPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPaths = append(gotPaths, r.URL.Path) + switch r.URL.Path { + case "/api/v1/sessions": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"id": "session-abc"}) + case "/api/v1/sessions/session-abc/devices": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "allocationId": "alloc-123", + "device": map[string]string{"id": "R83X10DZ2TW", "platform": "ios"}, + }) + default: + t.Errorf("unexpected request path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + response := FleetAllocateCommand(FleetAllocateRequest{ + Filters: []DeviceFilter{{Attribute: "platform", Operator: "EQUALS", Value: "ios"}}, + Token: "my-token", + }) + + if response.Status != "ok" { + t.Fatalf("expected ok, got %+v", response) + } + result, ok := response.Data.(FleetAllocateResponse) + if !ok { + t.Fatalf("expected FleetAllocateResponse, got %T", response.Data) + } + if result.AllocationID != "alloc-123" { + t.Errorf("expected allocationId alloc-123, got %s", result.AllocationID) + } + if result.Device == nil || result.Device.ID != "R83X10DZ2TW" { + t.Errorf("expected device R83X10DZ2TW, got %+v", result.Device) + } + if len(gotPaths) != 2 || gotPaths[0] != "/api/v1/sessions" || gotPaths[1] != "/api/v1/sessions/session-abc/devices" { + t.Errorf("expected create-session then allocate-into-session, got %v", gotPaths) + } +} + +func TestFleetAllocateCommandFailsWhenSessionCreationFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"code": "internal_error", "message": "Failed to create session"}, + }) + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + response := FleetAllocateCommand(FleetAllocateRequest{Token: "my-token"}) + if response.Status != "error" { + t.Fatalf("expected error, got %+v", response) + } + if !strings.Contains(response.Error, "Failed to create session") { + t.Errorf("expected the server's error message, got %q", response.Error) + } +} + +// sessionsPage renders one page of the GET /api/v1/sessions response, embedding the given +// devices (by serial + status) into a single session with the given ID. +func sessionsPage(sessionID string, nextCursor string, devices ...map[string]string) map[string]any { + deviceEntries := make([]map[string]any, len(devices)) + for i, d := range devices { + deviceEntries[i] = map[string]any{ + "status": d["status"], + "info": map[string]string{"serial": d["serial"]}, + } + } + page := map[string]any{ + "object": "list", + "data": []map[string]any{ + {"id": sessionID, "devices": deviceEntries}, + }, + } + if nextCursor != "" { + page["nextCursor"] = nextCursor + } + return page +} + +func TestFleetReleaseCommandFindsOwningSessionThenReleases(t *testing.T) { + var gotPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPaths = append(gotPaths, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions": + _ = json.NewEncoder(w).Encode(sessionsPage("session-abc", "", map[string]string{"serial": "R83X10DZ2TW", "status": "in_use"})) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/session-abc/devices/R83X10DZ2TW/release": + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + response := FleetReleaseCommand(FleetReleaseRequest{DeviceID: "R83X10DZ2TW", Token: "my-token"}) + if response.Status != "ok" { + t.Fatalf("expected ok, got %+v", response) + } + if len(gotPaths) != 2 || gotPaths[1] != "POST /api/v1/sessions/session-abc/devices/R83X10DZ2TW/release" { + t.Errorf("expected a session lookup then a release call, got %v", gotPaths) + } +} + +func TestFleetReleaseCommandPaginatesUntilTheDeviceIsFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Query().Get("before") == "": + _ = json.NewEncoder(w).Encode(sessionsPage("session-old", "2026-01-01T00:00:00Z", map[string]string{"serial": "OTHER_DEVICE", "status": "in_use"})) + case r.Method == http.MethodGet && r.URL.Query().Get("before") == "2026-01-01T00:00:00Z": + _ = json.NewEncoder(w).Encode(sessionsPage("session-target", "", map[string]string{"serial": "R83X10DZ2TW", "status": "in_use"})) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/session-target/devices/R83X10DZ2TW/release": + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + default: + t.Errorf("unexpected request %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + response := FleetReleaseCommand(FleetReleaseRequest{DeviceID: "R83X10DZ2TW", Token: "my-token"}) + if response.Status != "ok" { + t.Fatalf("expected ok, got %+v", response) + } +} + +func TestFleetReleaseCommandFailsWhenDeviceIsNotInAnyActiveSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // same serial, but already released, so it must not match + _ = json.NewEncoder(w).Encode(sessionsPage("session-abc", "", map[string]string{"serial": "R83X10DZ2TW", "status": "released"})) + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + response := FleetReleaseCommand(FleetReleaseRequest{DeviceID: "R83X10DZ2TW", Token: "my-token"}) + if response.Status != "error" { + t.Fatalf("expected error, got %+v", response) + } + if !strings.Contains(response.Error, "R83X10DZ2TW") { + t.Errorf("expected the error to name the device, got %q", response.Error) + } +} diff --git a/rpc/rest.go b/rpc/rest.go new file mode 100644 index 0000000..5b8c5ea --- /dev/null +++ b/rpc/rest.go @@ -0,0 +1,93 @@ +package rpc + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// RESTTimeout is the deadline for a single REST call to the fleet server. +const RESTTimeout = 30 * time.Second + +// GetAPIBaseURL derives the HTTPS REST base URL from the WebSocket fleet server URL +// (same host as MOBILECLI_FLEET_URL, wss->https / ws->http, no path). +func GetAPIBaseURL() (string, error) { + u, err := url.Parse(GetFleetServerURL()) + if err != nil { + return "", fmt.Errorf("failed to parse fleet server URL: %w", err) + } + switch u.Scheme { + case "wss": + u.Scheme = "https" + case "ws": + u.Scheme = "http" + } + u.Path = "" + return u.String(), nil +} + +// restErrorBody mirrors the server's RESTError{Error: RESTErrorBody{Code, Message}} shape. +type restErrorBody struct { + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +// RESTCall makes an authenticated REST call to the fleet server. body is marshaled as the +// JSON request body when non-nil; result is decoded from the JSON response body when non-nil. +func RESTCall(token, method, path string, body any, result any) error { + base, err := GetAPIBaseURL() + if err != nil { + return err + } + + var reqBody io.Reader + if body != nil { + data, marshalErr := json.Marshal(body) + if marshalErr != nil { + return fmt.Errorf("failed to marshal request body: %w", marshalErr) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, base+path, reqBody) + if err != nil { + return fmt.Errorf("failed to build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := &http.Client{Timeout: RESTTimeout} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to call fleet server: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode >= 300 { + var restErr restErrorBody + if json.Unmarshal(data, &restErr) == nil && restErr.Error.Message != "" { + return fmt.Errorf("%s", restErr.Error.Message) + } + return fmt.Errorf("%s %s: unexpected status %d", method, path, resp.StatusCode) + } + + if result != nil && len(data) > 0 { + if err := json.Unmarshal(data, result); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + } + + return nil +} diff --git a/rpc/rest_test.go b/rpc/rest_test.go new file mode 100644 index 0000000..475bd53 --- /dev/null +++ b/rpc/rest_test.go @@ -0,0 +1,116 @@ +package rpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetAPIBaseURLDerivesHTTPSFromTheDefaultWSSFleetURL(t *testing.T) { + t.Setenv("MOBILECLI_FLEET_URL", "") + + got, err := GetAPIBaseURL() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "https://api.mobilenext.ai" { + t.Fatalf("expected https://api.mobilenext.ai, got %s", got) + } +} + +func TestGetAPIBaseURLDerivesHTTPFromAWSFleetURLOverride(t *testing.T) { + t.Setenv("MOBILECLI_FLEET_URL", "ws://localhost:9999/ws") + + got, err := GetAPIBaseURL() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "http://localhost:9999" { + t.Fatalf("expected http://localhost:9999, got %s", got) + } +} + +func TestGetAPIBaseURLRejectsAnUnparseableFleetURL(t *testing.T) { + t.Setenv("MOBILECLI_FLEET_URL", "://missing-scheme") + + _, err := GetAPIBaseURL() + if err == nil { + t.Fatal("expected an error for an unparseable url") + } +} + +func TestRESTCallSendsAuthenticatedRequestAndDecodesResult(t *testing.T) { + var gotMethod, gotPath, gotAuth, gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + body, _ := json.Marshal(map[string]string{"echo": "ok"}) + _ = body + var reqBody map[string]any + _ = json.NewDecoder(r.Body).Decode(&reqBody) + if v, ok := reqBody["filters"]; ok { + b, _ := json.Marshal(v) + gotBody = string(b) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"id": "session-123"}) + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + var result struct { + ID string `json:"id"` + } + err := RESTCall("my-token", http.MethodPost, "/api/v1/sessions/abc/devices", map[string]any{"filters": []string{"x"}}, &result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("expected POST, got %s", gotMethod) + } + if gotPath != "/api/v1/sessions/abc/devices" { + t.Fatalf("expected /api/v1/sessions/abc/devices, got %s", gotPath) + } + if gotAuth != "Bearer my-token" { + t.Fatalf("expected 'Bearer my-token', got %s", gotAuth) + } + if gotBody != `["x"]` { + t.Fatalf("expected filters to be forwarded, got %s", gotBody) + } + if result.ID != "session-123" { + t.Fatalf("expected result to be decoded, got %+v", result) + } +} + +func TestRESTCallReturnsServerErrorMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"code": "insufficient_credits", "message": "account has $0.00 credits"}, + }) + })) + defer server.Close() + + t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http")) + + err := RESTCall("my-token", http.MethodPost, "/api/v1/sessions", nil, nil) + if err == nil { + t.Fatal("expected an error") + } + if err.Error() != "account has $0.00 credits" { + t.Fatalf("expected the server's error message, got %q", err.Error()) + } +} + +func TestRESTCallReportsConnectionFailure(t *testing.T) { + t.Setenv("MOBILECLI_FLEET_URL", "ws://127.0.0.1:1/ws") + + err := RESTCall("my-token", http.MethodGet, "/api/v1/sessions", nil, nil) + if err == nil { + t.Fatal("expected an error when the server is unreachable") + } +}