Skip to content
Merged
6 changes: 6 additions & 0 deletions docs/runware_serverless_deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ A volume keeps it out of both.
Worker settings are supplied via flags on create. Endpoints are derived
server-side from the SDK (code) or from container.yaml (container).

A code app's endpoint path is its handler's method name with underscores turned
into hyphens, so renaming a method moves a public endpoint and 404s its callers.
Updating an existing application with --wait reports what the deploy did to the
endpoint set once the rollout lands. It is a report, not a gate: renaming an
endpoint on purpose is allowed.

```
runware serverless deploy [file] [flags]
```
Expand Down
7 changes: 7 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ type Build = gen.Build
// BuildStatus is a build lifecycle status.
type BuildStatus = gen.BuildStatus

const (
BuildStatusBuilding BuildStatus = gen.BuildStatusBuilding
BuildStatusFailed BuildStatus = gen.BuildStatusFailed
BuildStatusQueued BuildStatus = gen.BuildStatusQueued
BuildStatusReady BuildStatus = gen.BuildStatusReady
)

// ListWorkersParams are optional filters for ListWorkers.
type ListWorkersParams = gen.ListWorkersParams

Expand Down
35 changes: 34 additions & 1 deletion internal/cmd/serverless/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,13 @@ download is copied into every checkpoint and fetched again on every cold start.
A volume keeps it out of both.

Worker settings are supplied via flags on create. Endpoints are derived
server-side from the SDK (code) or from container.yaml (container).`,
server-side from the SDK (code) or from container.yaml (container).

A code app's endpoint path is its handler's method name with underscores turned
into hyphens, so renaming a method moves a public endpoint and 404s its callers.
Updating an existing application with --wait reports what the deploy did to the
endpoint set once the rollout lands. It is a report, not a gate: renaming an
endpoint on purpose is allowed.`,
Example: ` # deploy the current directory, with app.py as the entry point
runware serverless deploy ./app.py --id my-app --gpu-type h100

Expand Down Expand Up @@ -242,6 +248,19 @@ server-side from the SDK (code) or from container.yaml (container).`,
return err
}

// The set the app serves now, to compare against what the new version
// publishes. Only on an update we will wait for: a create has no
// previous set, and without --wait this returns before the build that
// decides the new one. A read failure costs the warning, not the deploy.
var (
endpointsBefore []string
versionBefore *uuid.UUID
canCompare bool
)
if update && wait {
endpointsBefore, versionBefore, canCompare = endpointComparisonBase(cmd.Context(), client, id)
}

var (
appVolumes *[]serverlessapi.AppVolume
appEnv *map[string]string
Expand Down Expand Up @@ -316,6 +335,20 @@ server-side from the SDK (code) or from container.yaml (container).`,
}
spin.Stop()

// The endpoint rows are the outgoing version's until the submitted one
// activates, and a source update on an already-active app answers
// `active` throughout its build — so the wait above returns at once and
// says nothing about whether this deploy landed. The pin is what moves
// when it does.
if canCompare {
settled, err := waitForSubmittedVersion(cmd.Context(), client, app.AppId, versionBefore, pollInterval)
if err == nil && activationMoved(versionBefore, settled.ActiveVersionId) {
if paths, err := deployEndpointPaths(cmd.Context(), client, app.AppId); err == nil {
reportEndpointSetChange(cmd.ErrOrStderr(), compareEndpointSets(endpointsBefore, paths))
}
}
}

if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil {
return err
}
Expand Down
216 changes: 216 additions & 0 deletions internal/cmd/serverless/deploy_endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package serverless

import (
"context"
"fmt"
"io"
"slices"
"time"

"github.com/google/uuid"
serverlessapi "github.com/runware/runware-cli/internal/api/serverless"
)

// endpointPageLimit is the per-page size deployEndpointPaths asks for: the
// contract's maximum, so the common app takes one round trip.
const endpointPageLimit = 100

// endpointComparisonBase reads what a comparison after the deploy needs: the set
// the app serves now, and the version it serves it from.
//
// Both or neither. A nil pin from a failed read is indistinguishable from an app
// that has never activated a version, and activationMoved counts the second as a
// move — so a half-captured base would make waitForSubmittedVersion return on its
// first poll, against the outgoing endpoint set, which is the silent miss this
// whole path exists to avoid.
func endpointComparisonBase(
ctx context.Context,
client *serverlessapi.Client,
appID string,
) (paths []string, pin *uuid.UUID, ok bool) {
paths, err := deployEndpointPaths(ctx, client, appID)
if err != nil {
return nil, nil, false
}
app, err := client.GetApp(ctx, appID)
if err != nil {
return nil, nil, false
}
return paths, app.ActiveVersionId, true
}

// waitForSubmittedVersion polls until the app pins a version other than previous,
// and returns the app it saw last.
//
// The app's status cannot answer this on its own. A source update on an app that
// is already active leaves it active while the new build runs, so `--wait` sees a
// terminal status immediately and the endpoint rows it would read are still the
// outgoing version's. The pin is what moves when the submitted version activates.
//
// It gives up when no activation can still arrive: the app left the states a roll
// can land in, or the roll failed, which on a live app leaves the status active
// and the pin where it was — the case that would otherwise poll forever.
func waitForSubmittedVersion(
ctx context.Context,
client *serverlessapi.Client,
appID string,
previous *uuid.UUID,
interval time.Duration,
) (*serverlessapi.App, error) {
if interval <= 0 {
interval = 2 * time.Second
}
for {
app, err := client.GetApp(ctx, appID)
if err != nil {
return nil, err
}
if activationMoved(previous, app.ActiveVersionId) {
return app, nil
}
switch app.Status {
case serverlessapi.AppStatusActive, serverlessapi.AppStatusInitializing:
if buildFailed(ctx, client, appID) {
return app, nil
}
default:
return app, nil
}

timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}

// activationMoved reports that the app pins a different version than it did.
// A first deploy moves from no pin at all, which counts.
func activationMoved(previous, current *uuid.UUID) bool {
if current == nil {
return false
}
return previous == nil || *previous != *current
}

// buildFailed reports that the app's newest build gave up, so no activation is
// coming. Newest first, as listBuilds returns them; an unreadable list is not a
// failure, and the poll simply continues.
func buildFailed(ctx context.Context, client *serverlessapi.Client, appID string) bool {
page, err := client.ListBuilds(ctx, appID, nil)
if err != nil || len(page.Data) == 0 {
return false
}
return page.Data[0].Status == serverlessapi.BuildStatusFailed
}

// endpointSetChange is what a deploy did to the app's public endpoint set: the
// paths it published and the ones it retired, each sorted.
type endpointSetChange struct {
added []string
removed []string
}

func (c endpointSetChange) empty() bool {
return len(c.added) == 0 && len(c.removed) == 0
}

// deployEndpointPaths reads every live endpoint path on the app, sorted.
//
// It follows the cursor rather than trusting one page. The limit defaults to 20
// and an app may declare 20 endpoints, so a full app already sits exactly on the
// page boundary; a short read would report the endpoints it did not see as
// removed, and tell the customer their callers are about to 404 on paths that
// never moved.
func deployEndpointPaths(ctx context.Context, client *serverlessapi.Client, appID string) ([]string, error) {
var (
paths []string
cursor string
)
for {
params := &serverlessapi.ListEndpointsParams{}
params.Limit, params.Cursor = listPageParams(endpointPageLimit, cursor)
page, err := client.ListEndpoints(ctx, appID, params)
if err != nil {
return nil, err
}
for i := range page.Data {
paths = append(paths, page.Data[i].Path)
}
if page.NextCursor == nil || *page.NextCursor == "" {
break
}
cursor = *page.NextCursor
}
slices.Sort(paths)
return paths, nil
}

func compareEndpointSets(before, after []string) endpointSetChange {
beforeSet := make(map[string]struct{}, len(before))
for _, path := range before {
beforeSet[path] = struct{}{}
}
afterSet := make(map[string]struct{}, len(after))
for _, path := range after {
afterSet[path] = struct{}{}
}

var change endpointSetChange
for _, path := range after {
if _, ok := beforeSet[path]; !ok {
change.added = append(change.added, path)
}
}
for _, path := range before {
if _, ok := afterSet[path]; !ok {
change.removed = append(change.removed, path)
}
}
slices.Sort(change.added)
slices.Sort(change.removed)
return change
}

// reportEndpointSetChange writes the change, and nothing when the deploy left the
// set alone. w must be stderr: stdout carries the app record --format json
// promises, and a warning there would corrupt it.
func reportEndpointSetChange(w io.Writer, change endpointSetChange) {
if change.empty() {
return
}
var clauses []string
if len(change.removed) > 0 {
clauses = append(clauses, "removes "+quotedPaths(change.removed))
}
if len(change.added) > 0 {
clauses = append(clauses, "adds "+quotedPaths(change.added))
}

message := "This deploy " + clauses[0]
for _, clause := range clauses[1:] {
message += " and " + clause
}
_, _ = fmt.Fprintf(w, "Warning: %s.\n", message)
if len(change.removed) > 0 {
// Source-neutral: a code app's paths come from its handler names and a
// container app's from container.yaml, and this report covers both.
_, _ = fmt.Fprintf(w,
"Callers of %s will receive 404s. Restore those paths in the source if this was not intended.\n",
quotedPaths(change.removed))
}
}

func quotedPaths(paths []string) string {
out := ""
for i, path := range paths {
if i > 0 {
out += ", "
}
out += "'" + path + "'"
}
return out
}
Loading
Loading