diff --git a/docs/runware_serverless_apps_delete.md b/docs/runware_serverless_apps_delete.md index e53b456..11dd767 100644 --- a/docs/runware_serverless_apps_delete.md +++ b/docs/runware_serverless_apps_delete.md @@ -2,6 +2,16 @@ 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 [flags] ``` @@ -9,14 +19,19 @@ runware serverless apps delete [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 diff --git a/docs/runware_serverless_apps_resume.md b/docs/runware_serverless_apps_resume.md index 879ed75..517bd64 100644 --- a/docs/runware_serverless_apps_resume.md +++ b/docs/runware_serverless_apps_resume.md @@ -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 [flags] ``` diff --git a/docs/runware_serverless_apps_stop.md b/docs/runware_serverless_apps_stop.md index f5121d3..0c69820 100644 --- a/docs/runware_serverless_apps_stop.md +++ b/docs/runware_serverless_apps_stop.md @@ -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 [flags] ``` diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 7794f03..11f9aef 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -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 == "" { diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index 3a77883..79e1fa0 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -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" diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 2711101..a00ba93 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -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 } @@ -355,33 +355,3 @@ func newAppsUsageCmd() *cobra.Command { cobra.ExactArgs(1), ) } - -func newAppsStopCmd() *cobra.Command { - return stubLeaf( - "stop ", - "Stop a serverless application", - ` # stop a running application - runware serverless apps stop my-app`, - cobra.ExactArgs(1), - ) -} - -func newAppsResumeCmd() *cobra.Command { - return stubLeaf( - "resume ", - "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 ", - "Delete a serverless application", - ` # delete an application - runware serverless apps delete my-app`, - cobra.ExactArgs(1), - ) -} diff --git a/internal/cmd/serverless/apps_lifecycle.go b/internal/cmd/serverless/apps_lifecycle.go new file mode 100644 index 0000000..1e7de4a --- /dev/null +++ b/internal/cmd/serverless/apps_lifecycle.go @@ -0,0 +1,152 @@ +package serverless + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/api/transport" + "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" + "golang.org/x/term" +) + +var ( + errDeleteCancelled = errors.New("delete cancelled") + errDeleteNeedsConfirm = errors.New("delete requires confirmation; re-run with --yes or --force") +) + +func newAppsStopCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "stop ", + Short: "Stop a serverless application", + Long: `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.`, + Example: ` # stop a running application + runware serverless apps stop my-app`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runLifecycle(cmd, logger, args[0], "Stopping", (*serverlessapi.Client).StopDeployment) + }, + } +} + +func newAppsResumeCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "resume ", + Short: "Resume a stopped serverless application", + Long: `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.`, + Example: ` # resume a stopped application + runware serverless apps resume my-app`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runLifecycle(cmd, logger, args[0], "Resuming", (*serverlessapi.Client).ResumeDeployment) + }, + } +} + +func newAppsDeleteCmd(logger *log.Logger) *cobra.Command { + var ( + yes bool + force bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a serverless application", + Long: `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.`, + Example: ` # delete an application (prompts for confirmation) + runware serverless apps delete my-app + + # skip the confirmation prompt + runware serverless apps delete my-app --yes`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + if err := confirmDelete(id, yes || force, cmd.InOrStdin(), cmd.ErrOrStderr(), stdinIsTerminal(cmd.InOrStdin()), config.GetAPIKey()); err != nil { + return err + } + return runLifecycle(cmd, logger, id, "Deleting", (*serverlessapi.Client).DeleteDeployment) + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") + cmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt") + return cmd +} + +type lifecycleAction func(*serverlessapi.Client, context.Context, string) (*serverlessapi.Deployment, error) + +func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, action lifecycleAction) error { + spin := cmdutil.NewSpinner(fmt.Sprintf("%s application %s...", verb, id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + dep, err := action(client, cmd.Context(), id) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), deploymentResult(*dep)) +} + +// confirmDelete fails closed without an API key so a prompt cannot succeed +// and then fail with ErrNoAPIKey. skip (--yes/--force) bypasses the prompt. +func confirmDelete(appID string, skip bool, in io.Reader, out io.Writer, isTTY bool, apiKey string) error { + if apiKey == "" { + return transport.ErrNoAPIKey + } + if skip { + return nil + } + if !isTTY { + return errDeleteNeedsConfirm + } + + _, _ = fmt.Fprintf(out, "Delete application %s? [y/N] ", appID) + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return fmt.Errorf("read confirmation: %w", err) + } + return errDeleteCancelled + } + switch strings.ToLower(strings.TrimSpace(scanner.Text())) { + case "y", "yes": + return nil + default: + return errDeleteCancelled + } +} + +func stdinIsTerminal(in io.Reader) bool { + f, ok := in.(*os.File) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} diff --git a/internal/cmd/serverless/apps_lifecycle_test.go b/internal/cmd/serverless/apps_lifecycle_test.go new file mode 100644 index 0000000..d02120d --- /dev/null +++ b/internal/cmd/serverless/apps_lifecycle_test.go @@ -0,0 +1,115 @@ +package serverless + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/runware/runware-cli/internal/api/transport" +) + +const testDeleteKey = "k" + +func TestConfirmDelete_Skip(t *testing.T) { + var out bytes.Buffer + if err := confirmDelete(testAppID, true, strings.NewReader(""), &out, false, testDeleteKey); err != nil { + t.Fatalf("confirmDelete: %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected no prompt when skipped, got %q", out.String()) + } +} + +func TestConfirmDelete_NoAPIKeySkipsPrompt(t *testing.T) { + var out bytes.Buffer + err := confirmDelete(testAppID, true, strings.NewReader("y\n"), &out, true, "") + if !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected no prompt without API key, got %q", out.String()) + } +} + +func TestConfirmDelete_NonTTYRequiresYes(t *testing.T) { + err := confirmDelete(testAppID, false, strings.NewReader("y\n"), io.Discard, false, testDeleteKey) + if !errors.Is(err, errDeleteNeedsConfirm) { + t.Fatalf("expected errDeleteNeedsConfirm, got %v", err) + } +} + +func TestConfirmDelete_AcceptsYes(t *testing.T) { + cases := []string{"y\n", "Y\n", "yes\n", "Yes\n", " yes \n"} + for _, input := range cases { + var out bytes.Buffer + if err := confirmDelete(testAppID, false, strings.NewReader(input), &out, true, testDeleteKey); err != nil { + t.Fatalf("input %q: %v", input, err) + } + if !strings.Contains(out.String(), testAppID) { + t.Fatalf("input %q: expected prompt to mention app id, got %q", input, out.String()) + } + } +} + +func TestConfirmDelete_RejectsNo(t *testing.T) { + cases := []string{"n\n", "no\n", "\n", "maybe\n"} + for _, input := range cases { + err := confirmDelete(testAppID, false, strings.NewReader(input), io.Discard, true, testDeleteKey) + if !errors.Is(err, errDeleteCancelled) { + t.Fatalf("input %q: expected errDeleteCancelled, got %v", input, err) + } + } +} + +func TestConfirmDelete_EOFCancels(t *testing.T) { + err := confirmDelete(testAppID, false, strings.NewReader(""), io.Discard, true, testDeleteKey) + if !errors.Is(err, errDeleteCancelled) { + t.Fatalf("expected errDeleteCancelled, got %v", err) + } +} + +func TestStdinIsTerminal_NonFile(t *testing.T) { + if stdinIsTerminal(strings.NewReader("y\n")) { + t.Fatal("non-file reader should not be treated as a TTY") + } +} + +func TestDeleteCmd_SkipFlags(t *testing.T) { + cases := []struct { + args []string + yes bool + force bool + }{ + { + args: []string{"--yes"}, + yes: true, + }, + { + args: []string{"-y"}, + yes: true, + }, + { + args: []string{"--force"}, + force: true, + }, + } + for _, tc := range cases { + cmd := newAppsDeleteCmd(nil) + if err := cmd.ParseFlags(tc.args); err != nil { + t.Fatalf("%v: ParseFlags: %v", tc.args, err) + } + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + t.Fatalf("%v: yes: %v", tc.args, err) + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + t.Fatalf("%v: force: %v", tc.args, err) + } + if yes != tc.yes || force != tc.force { + t.Fatalf("%v: yes=%v force=%v, want yes=%v force=%v", tc.args, yes, force, tc.yes, tc.force) + } + } +}