diff --git a/docs/runware.md b/docs/runware.md index 50e12ec..26dd7d2 100644 --- a/docs/runware.md +++ b/docs/runware.md @@ -31,5 +31,6 @@ Use of Runware services is subject to our Terms of Service (https://runware.ai/t * [runware preset](runware_preset.md) - Manage named presets * [runware result](runware_result.md) - Wait for and display the result of a task by taskUUID * [runware run](runware_run.md) - Run an inference request against any Runware model +* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments * [runware version](runware_version.md) - Print version information diff --git a/docs/runware_serverless.md b/docs/runware_serverless.md new file mode 100644 index 0000000..23fe1a8 --- /dev/null +++ b/docs/runware_serverless.md @@ -0,0 +1,30 @@ +## runware serverless + +Manage Runware serverless deployments + +### Synopsis + +Deploy, monitor, and manage Runware serverless applications on the platform + +### Options + +``` + -h, --help help for serverless +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware](runware.md) - CLI tool for the Runware API +* [runware serverless deploy](runware_serverless_deploy.md) - Deploy a new serverless application +* [runware serverless gpus](runware_serverless_gpus.md) - List available GPU types and pricing +* [runware serverless open](runware_serverless_open.md) - Open a deployment in the Runware dashboard + diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md new file mode 100644 index 0000000..a21b171 --- /dev/null +++ b/docs/runware_serverless_deploy.md @@ -0,0 +1,44 @@ +## runware serverless deploy + +Deploy a new serverless application + +### Synopsis + +Deploy a new serverless application from a Python file. + +The application settings come from the project +configuration created by 'runware serverless init' or the dashboard. + +``` +runware serverless deploy [file] [flags] +``` + +### Examples + +``` + # deploy the application in the current project + runware serverless deploy + + # deploy a specific Python file + runware serverless deploy ./app.py +``` + +### Options + +``` + -h, --help help for deploy +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments + diff --git a/docs/runware_serverless_gpus.md b/docs/runware_serverless_gpus.md new file mode 100644 index 0000000..5880d13 --- /dev/null +++ b/docs/runware_serverless_gpus.md @@ -0,0 +1,34 @@ +## runware serverless gpus + +List available GPU types and pricing + +``` +runware serverless gpus [flags] +``` + +### Examples + +``` + # list GPU types and per-second pricing + runware serverless gpus +``` + +### Options + +``` + -h, --help help for gpus +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments + diff --git a/docs/runware_serverless_open.md b/docs/runware_serverless_open.md new file mode 100644 index 0000000..ea2434f --- /dev/null +++ b/docs/runware_serverless_open.md @@ -0,0 +1,34 @@ +## runware serverless open + +Open a deployment in the Runware dashboard + +``` +runware serverless open [flags] +``` + +### Examples + +``` + # open a deployment's dashboard page in your browser + runware serverless open my-model-abc +``` + +### Options + +``` + -h, --help help for open +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless](runware_serverless.md) - Manage Runware serverless deployments + diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go new file mode 100644 index 0000000..191723f --- /dev/null +++ b/internal/api/serverless/client.go @@ -0,0 +1,92 @@ +// Package serverless is a REST client for the Runware Serverless control-plane +// API (api.serverless.runware.ai). It is separate from the inference task +// transport in internal/api/transport, which speaks the task-array protocol. +package serverless + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/runware/runware-cli/internal/agents" + "github.com/runware/runware-cli/internal/api/transport" + "github.com/runware/runware-cli/internal/buildinfo" +) + +// defaultTimeout bounds a single REST request. +const defaultTimeout = 30 * time.Second + +// Client talks to the Serverless control-plane REST API. +type Client struct { + apiKey string + baseURL string + userAgent string + logger *slog.Logger + httpClient *http.Client +} + +// NewClient creates a Serverless API client for the given API key and base URL. +func NewClient(apiKey, baseURL string, logger *slog.Logger) *Client { + ua := buildinfo.UserAgent() + if agent := agents.Detect(); agent != "" { + ua += " agent/" + string(agent) + } + return &Client{ + apiKey: apiKey, + baseURL: strings.TrimSuffix(baseURL, "/"), + userAgent: ua, + logger: logger, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +// get performs an authenticated GET on path (e.g. "/v1/gpu-types") and decodes +// the JSON response body into out. +func (c *Client) get(ctx context.Context, path string, out any) error { + if c.apiKey == "" { + return transport.ErrNoAPIKey + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("User-Agent", c.userAgent) + + start := time.Now() + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck,gosec + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "url", c.baseURL+path, + "status", resp.StatusCode, + "elapsed", time.Since(start).Round(time.Millisecond), + "body", string(body), + ) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return parseError(body, resp.StatusCode) + } + + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("failed to parse response (HTTP %d): %w", resp.StatusCode, err) + } + return nil +} diff --git a/internal/api/serverless/errors.go b/internal/api/serverless/errors.go new file mode 100644 index 0000000..8c458b5 --- /dev/null +++ b/internal/api/serverless/errors.go @@ -0,0 +1,51 @@ +package serverless + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/runware/runware-cli/internal/api/transport" +) + +// wireError is the Serverless API error envelope: {"code":int,"message":string}. +type wireError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// parseError converts a non-2xx Serverless API response into a +// *transport.RunwareError so the CLI's shared error rendering (including the +// auth hint on 401/403) applies uniformly. +func parseError(body []byte, statusCode int) error { + var w wireError + if err := json.Unmarshal(body, &w); err != nil || w.Message == "" { + return transport.CreateRunwareError( + rawCodeForStatus(statusCode), + fmt.Sprintf("HTTP %d: %s", statusCode, http.StatusText(statusCode)), + transport.RunwareErrorDetails{StatusCode: statusCode}, + ) + } + return transport.CreateRunwareError( + rawCodeForStatus(statusCode), + w.Message, + transport.RunwareErrorDetails{StatusCode: statusCode}, + ) +} + +// rawCodeForStatus maps an HTTP status to a raw error code string that +// transport.DeriveCode categorises correctly (notably 401/403 -> auth). +func rawCodeForStatus(statusCode int) string { + switch statusCode { + case http.StatusUnauthorized: + return "unauthorized" + case http.StatusForbidden: + return "forbidden" + case http.StatusNotFound: + return "notFound" + case http.StatusConflict: + return "conflict" + default: + return "serverError" + } +} diff --git a/internal/api/serverless/gpu.go b/internal/api/serverless/gpu.go new file mode 100644 index 0000000..c3c7539 --- /dev/null +++ b/internal/api/serverless/gpu.go @@ -0,0 +1,18 @@ +package serverless + +import "context" + +// gpuTypesResponse is the envelope returned by GET /v1/gpu-types. +type gpuTypesResponse struct { + Data []GpuType `json:"data"` +} + +// ListGpuTypes returns the catalogue of supported GPU types and their pricing. +// This endpoint is not workspace-scoped. +func (c *Client) ListGpuTypes(ctx context.Context) ([]GpuType, error) { + var resp gpuTypesResponse + if err := c.get(ctx, "/v1/gpu-types", &resp); err != nil { + return nil, err + } + return resp.Data, nil +} diff --git a/internal/api/serverless/gpu_test.go b/internal/api/serverless/gpu_test.go new file mode 100644 index 0000000..f36a29a --- /dev/null +++ b/internal/api/serverless/gpu_test.go @@ -0,0 +1,66 @@ +package serverless + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/runware/runware-cli/internal/api/transport" +) + +func TestListGpuTypes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/gpu-types" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("missing/incorrect auth header: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"h100","name":"H100","memory":"80GB","availability":"available","pricing":{"currency":"USD","perSecond":"0.000767"}}]}`)) + })) + defer srv.Close() + + c := NewClient("test-key", srv.URL, slog.Default()) + gpus, err := c.ListGpuTypes(context.Background()) + if err != nil { + t.Fatalf("ListGpuTypes: %v", err) + } + if len(gpus) != 1 { + t.Fatalf("expected 1 gpu, got %d", len(gpus)) + } + if gpus[0].ID != "h100" || gpus[0].Pricing.PerSecond != "0.000767" { + t.Errorf("unexpected gpu: %+v", gpus[0]) + } + if gpus[0].Memory == nil || *gpus[0].Memory != "80GB" { + t.Errorf("unexpected memory: %v", gpus[0].Memory) + } +} + +func TestListGpuTypes_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.ListGpuTypes(context.Background()); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + +func TestListGpuTypes_AuthError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":401,"message":"Missing or invalid API key"}`)) + })) + defer srv.Close() + + c := NewClient("bad", srv.URL, slog.Default()) + _, err := c.ListGpuTypes(context.Background()) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeAuth { + t.Errorf("expected CodeAuth, got %v", re.Code) + } +} diff --git a/internal/api/serverless/types.go b/internal/api/serverless/types.go new file mode 100644 index 0000000..bacc06c --- /dev/null +++ b/internal/api/serverless/types.go @@ -0,0 +1,25 @@ +package serverless + +// GpuAvailability is the scheduler provisioning availability for a GPU type. +type GpuAvailability string + +const ( + GpuAvailabilityAvailable GpuAvailability = "available" + GpuAvailabilityReserved GpuAvailability = "reserved" + GpuAvailabilityCustom GpuAvailability = "custom" +) + +// GpuPricing is the price for a GPU type. +type GpuPricing struct { + Currency string `json:"currency"` + PerSecond string `json:"perSecond"` +} + +// GpuType is a supported GPU hardware type and its pricing. +type GpuType struct { + ID string `json:"id"` + Name string `json:"name"` + Memory *string `json:"memory,omitempty"` + Availability GpuAvailability `json:"availability"` + Pricing GpuPricing `json:"pricing"` +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 84889bc..d2583b5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -16,6 +16,7 @@ import ( "github.com/runware/runware-cli/internal/cmd/ping" "github.com/runware/runware-cli/internal/cmd/preset" cmdrun "github.com/runware/runware-cli/internal/cmd/run" + "github.com/runware/runware-cli/internal/cmd/serverless" cmdupload "github.com/runware/runware-cli/internal/cmd/upload" cmdversion "github.com/runware/runware-cli/internal/cmd/version" "github.com/runware/runware-cli/internal/config" @@ -94,6 +95,7 @@ Use of Runware services is subject to our Terms of Service (https://runware.ai/t cmdrun.NewCmd(logger), cmdrun.NewResultCmd(logger), media.NewCmd(logger), + serverless.NewCmd(logger), cmdupload.NewCmd(logger), cmdversion.NewCmd(), cmdcompletion.NewCmd(), diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go new file mode 100644 index 0000000..abf89ba --- /dev/null +++ b/internal/cmd/serverless/apps.go @@ -0,0 +1,15 @@ +package serverless + +import ( + "github.com/spf13/cobra" +) + +// newAppsCmd returns the "serverless apps" command group. +func newAppsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apps", + Short: "Manage deployed serverless applications", + } + cmd.AddCommand(newSecretCmd()) + return cmd +} diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go new file mode 100644 index 0000000..adaf653 --- /dev/null +++ b/internal/cmd/serverless/deploy.go @@ -0,0 +1,27 @@ +package serverless + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newDeployCmd() *cobra.Command { + return &cobra.Command{ + Use: "deploy [file]", + Short: "Deploy a new serverless application", + Long: `Deploy a new serverless application from a Python file. + +The application settings come from the project +configuration created by 'runware serverless init' or the dashboard.`, + Example: ` # deploy the application in the current project + runware serverless deploy + + # deploy a specific Python file + runware serverless deploy ./app.py`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return fmt.Errorf("not implemented yet") + }, + } +} diff --git a/internal/cmd/serverless/gpus.go b/internal/cmd/serverless/gpus.go new file mode 100644 index 0000000..62bdbc6 --- /dev/null +++ b/internal/cmd/serverless/gpus.go @@ -0,0 +1,63 @@ +package serverless + +import ( + "log/slog" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/config" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +// gpuTypesResult wraps the GPU catalogue for display. JSON and YAML output the +// raw GpuType structs; the table renderer flattens them into columns. +type gpuTypesResult []serverlessapi.GpuType + +func (r gpuTypesResult) Headers() []string { + return []string{"ID", "Name", "Memory", "Availability", "Price ($/GPU/s)"} +} + +func (r gpuTypesResult) Rows() [][]any { + rows := make([][]any, len(r)) + for i := range r { + g := &r[i] + memory := "-" + if g.Memory != nil && *g.Memory != "" { + memory = *g.Memory + } + rows[i] = []any{ + g.ID, + g.Name, + memory, + string(g.Availability), + g.Pricing.PerSecond, + } + } + return rows +} + +func newGPUsCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "gpus", + Short: "List available GPU types and pricing", + Example: ` # list GPU types and per-second pricing + runware serverless gpus`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + spin := cmdutil.NewSpinner("Fetching GPU types...") + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + gpus, err := client.ListGpuTypes(cmd.Context()) + if err != nil { + spin.Stop() + return err + } + + spin.Stop() + return output.Print(cmdutil.FormatFor(cmd), gpuTypesResult(gpus)) + }, + } +} diff --git a/internal/cmd/serverless/open.go b/internal/cmd/serverless/open.go new file mode 100644 index 0000000..8998770 --- /dev/null +++ b/internal/cmd/serverless/open.go @@ -0,0 +1,52 @@ +package serverless + +import ( + "fmt" + "os" + "strings" + + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/config" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +// openResult reports the deployment and dashboard URL that was opened. +type openResult struct { + DeploymentID string `json:"deploymentId" yaml:"deploymentId"` + URL string `json:"url" yaml:"url"` +} + +func (r openResult) Headers() []string { + return []string{"Field", "Value"} +} + +func (r openResult) Rows() [][]any { + return [][]any{ + {"Deployment", r.DeploymentID}, + {"URL", r.URL}, + } +} + +func newOpenCmd() *cobra.Command { + return &cobra.Command{ + Use: "open ", + Short: "Open a deployment in the Runware dashboard", + Example: ` # open a deployment's dashboard page in your browser + runware serverless open my-model-abc`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + url := strings.TrimSuffix(config.GetDashboardURL(), "/") + "/serverless/" + id + + if err := cmdutil.OpenBrowser(cmd.Context(), url); err != nil { + fmt.Fprintf(os.Stderr, "Could not open browser automatically: %v\n", err) + } + + return output.Print(cmdutil.FormatFor(cmd), openResult{ + DeploymentID: id, + URL: url, + }) + }, + } +} diff --git a/internal/cmd/serverless/registry.go b/internal/cmd/serverless/registry.go new file mode 100644 index 0000000..21e840c --- /dev/null +++ b/internal/cmd/serverless/registry.go @@ -0,0 +1,13 @@ +package serverless + +import ( + "github.com/spf13/cobra" +) + +// newRegistryCmd returns the "serverless registry" command group. +func newRegistryCmd() *cobra.Command { + return &cobra.Command{ + Use: "registry", + Short: "Manage container registries for serverless deployments", + } +} diff --git a/internal/cmd/serverless/secret.go b/internal/cmd/serverless/secret.go new file mode 100644 index 0000000..5f3ecf7 --- /dev/null +++ b/internal/cmd/serverless/secret.go @@ -0,0 +1,13 @@ +package serverless + +import ( + "github.com/spf13/cobra" +) + +// newSecretCmd returns the "serverless apps secret" command group. +func newSecretCmd() *cobra.Command { + return &cobra.Command{ + Use: "secret", + Short: "Manage secrets for a serverless application", + } +} diff --git a/internal/cmd/serverless/serverless.go b/internal/cmd/serverless/serverless.go new file mode 100644 index 0000000..7bd70c3 --- /dev/null +++ b/internal/cmd/serverless/serverless.go @@ -0,0 +1,25 @@ +// Package serverless implements the "serverless" command group for deploying +// and managing Runware serverless applications. +package serverless + +import ( + "github.com/charmbracelet/log" + "github.com/spf13/cobra" +) + +// NewCmd returns the "serverless" command group. +func NewCmd(logger *log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "serverless", + Short: "Manage Runware serverless deployments", + Long: "Deploy, monitor, and manage Runware serverless applications on the platform", + } + cmd.AddCommand( + newDeployCmd(), + newGPUsCmd(logger), + newOpenCmd(), + newRegistryCmd(), + newAppsCmd(), + ) + return cmd +} diff --git a/internal/cmdutil/browser.go b/internal/cmdutil/browser.go new file mode 100644 index 0000000..d873d27 --- /dev/null +++ b/internal/cmdutil/browser.go @@ -0,0 +1,32 @@ +package cmdutil + +import ( + "context" + "fmt" + "os/exec" + "runtime" +) + +// OpenBrowser opens url in the user's default web browser. It returns an error +// if the platform launcher cannot be started (e.g. in a headless environment). +func OpenBrowser(ctx context.Context, url string) error { + var name string + var args []string + switch runtime.GOOS { + case "darwin": + name = "open" + args = []string{url} + case "windows": + name = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + default: + name = "xdg-open" + args = []string{url} + } + // The launcher name is a fixed per-OS constant and the URL is passed as a + // separate argv element (not shell-interpreted), so there is no injection. + if err := exec.CommandContext(ctx, name, args...).Start(); err != nil { //nolint:gosec + return fmt.Errorf("failed to open browser: %w", err) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index b778904..fcd8887 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,11 +9,13 @@ import ( ) const ( - DefaultBaseURL = "https://api.runware.ai/v1" - DefaultWSBaseURL = "wss://ws-api.runware.ai/v1" - MaskedKeySuffix = "•••••" - DefaultOutputDir = "./outputs" - DefaultFormat = "table" + DefaultBaseURL = "https://api.runware.ai/v1" + DefaultWSBaseURL = "wss://ws-api.runware.ai/v1" + DefaultServerlessBaseURL = "https://api.serverless.runware.ai" + DefaultDashboardURL = "https://my.runware.ai" + MaskedKeySuffix = "•••••" + DefaultOutputDir = "./outputs" + DefaultFormat = "table" // DefaultTransport matches transport.SchemeWS; kept as a literal so the // config package stays free of internal imports. DefaultTransport = "ws" @@ -152,6 +154,25 @@ func GetWSBaseURL() string { return DefaultWSBaseURL } +// GetServerlessBaseURL returns the Serverless API base URL. +// RUNWARE_SERVERLESS_BASE_URL overrides the default; this is not exposed in the +// user-facing config file and is intended for internal testing only. +func GetServerlessBaseURL() string { + if v := os.Getenv("RUNWARE_SERVERLESS_BASE_URL"); v != "" { + return v + } + return DefaultServerlessBaseURL +} + +// GetDashboardURL returns the base URL of the Runware web dashboard. +// RUNWARE_DASHBOARD_URL overrides the default. +func GetDashboardURL() string { + if v := os.Getenv("RUNWARE_DASHBOARD_URL"); v != "" { + return v + } + return DefaultDashboardURL +} + // ConfigDir returns the config directory path. func ConfigDir() string { return configDir