Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/runware_serverless_apps_delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,36 @@

Delete a serverless application

### Synopsis

Soft-delete a serverless application.

The server accepts the delete and returns immediately with status deleting.
Router removal and worker drain are asynchronous; this command does not wait
until the application is deleted.

Confirmation is required unless --yes or --force is passed.

```
runware serverless apps delete <appId> [flags]
```

### Examples

```
# delete an application
# delete an application (prompts for confirmation)
runware serverless apps delete my-app

# skip the confirmation prompt
runware serverless apps delete my-app --yes
```

### Options

```
-h, --help help for delete
--force Skip the confirmation prompt
-h, --help help for delete
-y, --yes Skip the confirmation prompt
```

### Options inherited from parent commands
Expand Down
8 changes: 8 additions & 0 deletions docs/runware_serverless_apps_resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Resume a stopped serverless application

### Synopsis

Resume a stopped serverless application.

The server accepts the resume and returns immediately with status initializing.
Worker start is asynchronous; this command does not wait until the application
is active. The application must be stopped.

```
runware serverless apps resume <appId> [flags]
```
Expand Down
8 changes: 8 additions & 0 deletions docs/runware_serverless_apps_stop.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Stop a serverless application

### Synopsis

Stop a running serverless application.

The server accepts the stop and returns immediately with status stopping.
Worker drain is asynchronous; this command does not wait until the application
is stopped. The application must be active.

```
runware serverless apps stop <appId> [flags]
```
Expand Down
96 changes: 96 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,102 @@ func (c *Client) UpdateDeployment(ctx context.Context, deploymentID string, body
}
}

// StopDeployment accepts a stop and returns the deployment with status
// stopping. Worker drain is asynchronous.
func (c *Client) StopDeployment(ctx context.Context, deploymentID string) (*Deployment, error) {
if c.apiKey == "" {
return nil, transport.ErrNoAPIKey
}

resp, err := c.inner.StopDeploymentWithResponse(ctx, deploymentID)
if err != nil {
return nil, fmt.Errorf("stop deployment: %w", err)
}

c.logResponse(ctx, resp.HTTPResponse, resp.Body)

return acceptedDeployment("stop deployment", resp.StatusCode(), resp.JSON202, resp.Body, lifecycleProblems{
Unauthorized: resp.ApplicationproblemJSON401,
Forbidden: resp.ApplicationproblemJSON403,
NotFound: resp.ApplicationproblemJSON404,
Conflict: resp.ApplicationproblemJSON409,
})
}

// ResumeDeployment accepts a resume and returns the deployment with status
// initializing. Worker start is asynchronous.
func (c *Client) ResumeDeployment(ctx context.Context, deploymentID string) (*Deployment, error) {
if c.apiKey == "" {
return nil, transport.ErrNoAPIKey
}

resp, err := c.inner.ResumeDeploymentWithResponse(ctx, deploymentID)
if err != nil {
return nil, fmt.Errorf("resume deployment: %w", err)
}

c.logResponse(ctx, resp.HTTPResponse, resp.Body)

return acceptedDeployment("resume deployment", resp.StatusCode(), resp.JSON202, resp.Body, lifecycleProblems{
Unauthorized: resp.ApplicationproblemJSON401,
Forbidden: resp.ApplicationproblemJSON403,
NotFound: resp.ApplicationproblemJSON404,
Conflict: resp.ApplicationproblemJSON409,
})
}

// DeleteDeployment accepts a soft delete and returns the deployment with
// status deleting. Router removal and worker drain are asynchronous.
func (c *Client) DeleteDeployment(ctx context.Context, deploymentID string) (*Deployment, error) {
if c.apiKey == "" {
return nil, transport.ErrNoAPIKey
}

resp, err := c.inner.DeleteDeploymentWithResponse(ctx, deploymentID)
if err != nil {
return nil, fmt.Errorf("delete deployment: %w", err)
}

c.logResponse(ctx, resp.HTTPResponse, resp.Body)

return acceptedDeployment("delete deployment", resp.StatusCode(), resp.JSON202, resp.Body, lifecycleProblems{
Unauthorized: resp.ApplicationproblemJSON401,
Forbidden: resp.ApplicationproblemJSON403,
NotFound: resp.ApplicationproblemJSON404,
})
}

// lifecycleProblems are typed RFC 9457 bodies bound by the generated client.
type lifecycleProblems struct {
Unauthorized *gen.ProblemDetails
Forbidden *gen.ProblemDetails
NotFound *gen.ProblemDetails
Conflict *gen.ProblemDetails
}

func acceptedDeployment(op string, status int, dep *Deployment, body []byte, problems lifecycleProblems) (*Deployment, error) {
switch status {
case http.StatusAccepted:
if dep == nil {
return nil, fmt.Errorf("%s: empty 202 response", op)
}
return dep, nil
case http.StatusUnauthorized:
return nil, problemToError(problems.Unauthorized, http.StatusUnauthorized)
case http.StatusForbidden:
return nil, problemToError(problems.Forbidden, http.StatusForbidden)
case http.StatusNotFound:
return nil, problemToError(problems.NotFound, http.StatusNotFound)
case http.StatusConflict:
if problems.Conflict != nil {
return nil, problemToError(problems.Conflict, http.StatusConflict)
}
return nil, problemFromBody(body, status)
default:
return nil, problemFromBody(body, status)
}
}

// ListEndpoints returns a page of endpoints for a deployment.
func (c *Client) ListEndpoints(ctx context.Context, deploymentID string, params *ListEndpointsParams) (Page[Endpoint], error) {
if c.apiKey == "" {
Expand Down
147 changes: 147 additions & 0 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,153 @@ func TestUpdateDeployment_NoAPIKey(t *testing.T) {
}
}

type lifecycleOp struct {
name string
method string
path string
status string
call func(*Client, context.Context, string) (*Deployment, error)
has409 bool
}

func lifecycleOps() []lifecycleOp {
return []lifecycleOp{
{
name: "StopDeployment",
method: http.MethodPost,
path: "/v1/deployments/" + testDeploymentID + "/stop",
status: "stopping",
call: (*Client).StopDeployment,
has409: true,
},
{
name: "ResumeDeployment",
method: http.MethodPost,
path: "/v1/deployments/" + testDeploymentID + "/resume",
status: "initializing",
call: (*Client).ResumeDeployment,
has409: true,
},
{
name: "DeleteDeployment",
method: http.MethodDelete,
path: "/v1/deployments/" + testDeploymentID,
status: "deleting",
call: (*Client).DeleteDeployment,
},
}
}

func TestLifecycleDeployments(t *testing.T) {
for _, op := range lifecycleOps() {
t.Run(op.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != op.method || r.URL.Path != op.path {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(lifecycleDeploymentJSON(op.status)))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
dep, err := op.call(c, context.Background(), testDeploymentID)
if err != nil {
t.Fatalf("%s: %v", op.name, err)
}
if dep.DeploymentId != testDeploymentID || string(dep.Status) != op.status {
t.Errorf("unexpected deployment: %+v", dep)
}
})
}
}

func TestLifecycleDeployments_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"No deployment 'missing' exists"}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
for _, op := range lifecycleOps() {
t.Run(op.name, func(t *testing.T) {
_, err := op.call(c, context.Background(), "missing")
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.Code != transport.CodeNotFound {
t.Errorf("expected CodeNotFound, got %v", re.Code)
}
if re.StatusCode != http.StatusNotFound {
t.Errorf("expected status 404, got %d", re.StatusCode)
}
if re.Message != "No deployment 'missing' exists" {
t.Errorf("unexpected message: %q", re.Message)
}
})
}
}

func TestLifecycleDeployments_Conflict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"type":"about:blank","title":"Conflict","status":409,"detail":"Deployment is not in the required status"}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
for _, op := range lifecycleOps() {
if !op.has409 {
continue
}
t.Run(op.name, func(t *testing.T) {
_, err := op.call(c, context.Background(), testDeploymentID)
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.Code != transport.CodeValidation {
t.Errorf("expected CodeValidation, got %v", re.Code)
}
if re.StatusCode != http.StatusConflict {
t.Errorf("expected status 409, got %d", re.StatusCode)
}
if re.Message != "Deployment is not in the required status" {
t.Errorf("unexpected message: %q", re.Message)
}
})
}
}

func TestLifecycleDeployments_NoAPIKey(t *testing.T) {
c := NewClient("", "https://example.invalid", slog.Default())
for _, op := range lifecycleOps() {
t.Run(op.name, func(t *testing.T) {
if _, err := op.call(c, context.Background(), testDeploymentID); !errors.Is(err, transport.ErrNoAPIKey) {
t.Fatalf("expected ErrNoAPIKey, got %v", err)
}
})
}
}

func lifecycleDeploymentJSON(status string) string {
return `{
"deploymentId":"my-app",
"deploymentName":"My App",
"status":"` + status + `",
"configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"computeType":"gpu"},
"environmentVariables":[],
"secrets":[],
"createdAt":"2026-07-30T12:00:00Z",
"updatedAt":"2026-07-30T12:00:00Z"
}`
}

func TestListEndpoints(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
want := "/v1/deployments/" + testDeploymentID + "/endpoints"
Expand Down
36 changes: 3 additions & 33 deletions internal/cmd/serverless/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ func newAppsCmd(logger *log.Logger) *cobra.Command {
newAppsWorkersCmd(logger),
newAppsScaleCmd(logger),
newAppsUsageCmd(),
newAppsStopCmd(),
newAppsResumeCmd(),
newAppsDeleteCmd(),
newAppsStopCmd(logger),
newAppsResumeCmd(logger),
newAppsDeleteCmd(logger),
)
return cmd
}
Expand Down Expand Up @@ -355,33 +355,3 @@ func newAppsUsageCmd() *cobra.Command {
cobra.ExactArgs(1),
)
}

func newAppsStopCmd() *cobra.Command {
return stubLeaf(
"stop <appId>",
"Stop a serverless application",
` # stop a running application
runware serverless apps stop my-app`,
cobra.ExactArgs(1),
)
}

func newAppsResumeCmd() *cobra.Command {
return stubLeaf(
"resume <appId>",
"Resume a stopped serverless application",
` # resume a stopped application
runware serverless apps resume my-app`,
cobra.ExactArgs(1),
)
}

func newAppsDeleteCmd() *cobra.Command {
return stubLeaf(
"delete <appId>",
"Delete a serverless application",
` # delete an application
runware serverless apps delete my-app`,
cobra.ExactArgs(1),
)
}
Loading