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
265 changes: 183 additions & 82 deletions api/serverless/openapi.yaml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ func TestCreateApp(t *testing.T) {
source, err := NewCodeAppSource(CodeSourceUpsert{
BaseImage: "python:3.11-slim",
Codebase: CodebaseSource{
UploadId: uuid.MustParse("019c7654-8b21-7abc-9123-abcdef123456"),
SourceId: uuid.MustParse("019c7654-8b21-7abc-9123-abcdef123456"),
ModelFile: "model.py",
},
})
if err != nil {
Expand Down
3,636 changes: 1,975 additions & 1,661 deletions internal/api/serverless/gen/client.gen.go

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions internal/api/serverless/sourceuploads.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ const SourceUploadStateReady = gen.SourceUploadStateReady

// CreateSourceUpload opens an upload session for appId, which need not exist
// yet, and returns it with a short-lived instruction for staging the archive.
func (c *Client) CreateSourceUpload(ctx context.Context, appID string, body SourceUploadCreate) (*SourceUploadCreation, error) {
func (c *Client) CreateSourceUpload(ctx context.Context, body SourceUploadCreate) (*SourceUploadCreation, error) {
if c.apiKey == "" {
return nil, transport.ErrNoAPIKey
}

resp, err := c.inner.CreateSourceUploadWithResponse(ctx, appID, body)
resp, err := c.inner.CreateSourceUploadWithResponse(ctx, body)
if err != nil {
return nil, fmt.Errorf("create source upload: %w", err)
}
Expand Down Expand Up @@ -110,12 +110,12 @@ func (c *Client) StageSourceArchive(ctx context.Context, transfer SourceUploadTr

// CompleteSourceUpload asks the API to verify the staged archive against the
// session declaration and returns the session in its settled state.
func (c *Client) CompleteSourceUpload(ctx context.Context, appID string, uploadID SourceUploadID) (*SourceUpload, error) {
func (c *Client) CompleteSourceUpload(ctx context.Context, uploadID SourceUploadID) (*SourceUpload, error) {
if c.apiKey == "" {
return nil, transport.ErrNoAPIKey
}

resp, err := c.inner.CompleteSourceUploadWithResponse(ctx, appID, uploadID)
resp, err := c.inner.CompleteSourceUploadWithResponse(ctx, uploadID)
if err != nil {
return nil, fmt.Errorf("complete source upload: %w", err)
}
Expand Down Expand Up @@ -147,12 +147,12 @@ func (c *Client) CompleteSourceUpload(ctx context.Context, appID string, uploadI

// DeleteSourceUpload aborts an unconsumed session and removes its staging
// object. Repeating a successful abort is idempotent.
func (c *Client) DeleteSourceUpload(ctx context.Context, appID string, uploadID SourceUploadID) error {
func (c *Client) DeleteSourceUpload(ctx context.Context, uploadID SourceUploadID) error {
if c.apiKey == "" {
return transport.ErrNoAPIKey
}

resp, err := c.inner.DeleteSourceUploadWithResponse(ctx, appID, uploadID)
resp, err := c.inner.DeleteSourceUploadWithResponse(ctx, uploadID)
if err != nil {
return fmt.Errorf("delete source upload: %w", err)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/cmd/serverless/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`,

spin := cmdutil.NewSpinner(fmt.Sprintf("Uploading source for %s...", id))
spin.Start()
uploadID, err := uploadSource(cmd.Context(), client, id, archive, modelFile)
sourceID, err := uploadSource(cmd.Context(), client, archive)
spin.Stop()
if err != nil {
return err
Expand All @@ -117,7 +117,8 @@ serverless init' is planned). Endpoints are derived server-side from the SDK.`,
source, err := serverlessapi.NewCodeAppSource(serverlessapi.CodeSourceUpsert{
BaseImage: baseImage,
Codebase: serverlessapi.CodebaseSource{
UploadId: uploadID,
SourceId: sourceID,
ModelFile: modelFile,
},
Requirements: optionalStringSlice(requirements),
})
Expand Down
22 changes: 12 additions & 10 deletions internal/cmd/serverless/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@ import (
serverlessapi "github.com/runware/runware-cli/internal/api/serverless"
)

// uploadSource stages an app's source archive and returns the id of the ready
// upload for the create request to consume.
// uploadSource stages a source archive and returns the id of the source the
// ready upload published, for the create request to consume.
//
// Three steps, because the archive never travels through the API itself: the
// session declares what is coming, the bytes go straight to the staging object
// the API names, and completion is what opens the archive and verifies it
// against the declaration. Only then does the upload id mean anything to a
// create.
func uploadSource(ctx context.Context, client *serverlessapi.Client, appID string, archive []byte, modelFile string) (uuid.UUID, error) {
// against the declaration. Only then does the published source mean anything to
// a create.
func uploadSource(ctx context.Context, client *serverlessapi.Client, archive []byte) (uuid.UUID, error) {
digest := sha256.Sum256(archive)

created, err := client.CreateSourceUpload(ctx, appID, serverlessapi.SourceUploadCreate{
created, err := client.CreateSourceUpload(ctx, serverlessapi.SourceUploadCreate{
DeclaredByteLength: int64(len(archive)),
// Fresh per invocation, and deliberately not the archive's digest: a
// session replays only while it is still pending, and answers 409 once
Expand All @@ -30,7 +30,6 @@ func uploadSource(ctx context.Context, client *serverlessapi.Client, appID strin
IdempotencyKey: uuid.NewString(),
Sha256: hex.EncodeToString(digest[:]),
SourceType: serverlessapi.AppSourceTypeCode,
ModelFile: &modelFile,
})
if err != nil {
return uuid.Nil, err
Expand All @@ -40,18 +39,21 @@ func uploadSource(ctx context.Context, client *serverlessapi.Client, appID strin
// The session holds a staging object that will now never be completed,
// so give it back rather than leaving it to expire. A failure here is
// not the one worth reporting.
_ = client.DeleteSourceUpload(ctx, appID, created.Upload.Id)
_ = client.DeleteSourceUpload(ctx, created.Upload.Id)
return uuid.Nil, err
}

upload, err := client.CompleteSourceUpload(ctx, appID, created.Upload.Id)
upload, err := client.CompleteSourceUpload(ctx, created.Upload.Id)
if err != nil {
return uuid.Nil, err
}
if upload.State != serverlessapi.SourceUploadStateReady {
return uuid.Nil, fmt.Errorf("source upload %s: %s", upload.State, rejectionReason(upload))
}
return upload.Id, nil
if upload.SourceId == nil {
return uuid.Nil, fmt.Errorf("source upload %s: ready without a source id", upload.Id)
}
return *upload.SourceId, nil
}

// rejectionReason reports why completion refused an archive, for the states
Expand Down
26 changes: 8 additions & 18 deletions internal/cmd/serverless/upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import (
serverlessapi "github.com/runware/runware-cli/internal/api/serverless"
)

const uploadTestAppID = "my-app"

// TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload walks the three steps
// a deploy now takes before it can create an app, and pins what each one sends:
// the declaration has to describe the archive the transfer then carries, or
Expand Down Expand Up @@ -54,7 +52,7 @@ func TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload(t *testing.T) {

api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/apps/"+uploadTestAppID+"/source-uploads":
case r.Method == http.MethodPost && r.URL.Path == "/v1/source-uploads":
if err := json.NewDecoder(r.Body).Decode(&declaration); err != nil {
t.Fatalf("decode declaration: %v", err)
}
Expand All @@ -63,7 +61,6 @@ func TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload(t *testing.T) {
_, _ = w.Write([]byte(`{
"upload": {
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 18,
"sha256": "` + wantSHA + `",
"sourceType": "code",
Expand All @@ -86,10 +83,10 @@ func TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload(t *testing.T) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 18,
"sha256": "` + wantSHA + `",
"sourceType": "code",
"sourceId": "019c7654-8b21-7abc-9123-abcdef123456",
"state": "ready",
"expiresAt": "2026-09-02T12:00:00Z",
"createdAt": "2026-09-02T11:00:00Z",
Expand All @@ -103,13 +100,13 @@ func TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload(t *testing.T) {
defer api.Close()

client := serverlessapi.NewClient("test-key", api.URL, slog.Default())
id, err := uploadSource(context.Background(), client, uploadTestAppID, archive, "model.py")
id, err := uploadSource(context.Background(), client, archive)
if err != nil {
t.Fatalf("uploadSource: %v", err)
}

if id.String() != "019c7654-8b21-7abc-9123-abcdef123456" {
t.Errorf("upload id = %s, want the id completion settled", id)
t.Errorf("source id = %s, want the source completion published", id)
}
if !completed {
t.Error("the upload was never completed, so no create could consume it")
Expand All @@ -128,9 +125,6 @@ func TestUploadSource_StagesTheArchiveAndReturnsAReadyUpload(t *testing.T) {
if declaration.DeclaredByteLength != int64(len(archive)) {
t.Errorf("declared length = %d, want %d", declaration.DeclaredByteLength, len(archive))
}
if declaration.ModelFile == nil || *declaration.ModelFile != "model.py" {
t.Errorf("declared modelFile = %v, want model.py", declaration.ModelFile)
}
if declaration.SourceType != serverlessapi.AppSourceTypeCode {
t.Errorf("declared sourceType = %q, want code", declaration.SourceType)
}
Expand All @@ -153,7 +147,6 @@ func TestUploadSource_AbortsTheSessionWhenStagingFails(t *testing.T) {
_, _ = w.Write([]byte(`{
"upload": {
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 3,
"sha256": "` + strings.Repeat("a", 64) + `",
"sourceType": "code",
Expand All @@ -180,7 +173,7 @@ func TestUploadSource_AbortsTheSessionWhenStagingFails(t *testing.T) {
defer api.Close()

client := serverlessapi.NewClient("test-key", api.URL, slog.Default())
if _, err := uploadSource(context.Background(), client, uploadTestAppID, []byte("zip"), "model.py"); err == nil {
if _, err := uploadSource(context.Background(), client, []byte("zip")); err == nil {
t.Fatal("uploadSource succeeded despite a refused transfer")
}
if !aborted {
Expand All @@ -202,7 +195,6 @@ func TestUploadSource_ReportsARejectedArchive(t *testing.T) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 3,
"sha256": "` + strings.Repeat("a", 64) + `",
"sourceType": "code",
Expand All @@ -218,7 +210,6 @@ func TestUploadSource_ReportsARejectedArchive(t *testing.T) {
_, _ = w.Write([]byte(`{
"upload": {
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 3,
"sha256": "` + strings.Repeat("a", 64) + `",
"sourceType": "code",
Expand All @@ -239,7 +230,7 @@ func TestUploadSource_ReportsARejectedArchive(t *testing.T) {
defer api.Close()

client := serverlessapi.NewClient("test-key", api.URL, slog.Default())
_, err := uploadSource(context.Background(), client, uploadTestAppID, []byte("zip"), "model.py")
_, err := uploadSource(context.Background(), client, []byte("zip"))
if err == nil {
t.Fatal("uploadSource accepted a rejected archive")
}
Expand All @@ -266,10 +257,10 @@ func TestUploadSource_UsesAFreshKeyPerInvocation(t *testing.T) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 18,
"sha256": "` + strings.Repeat("a", 64) + `",
"sourceType": "code",
"sourceId": "019c7654-8b21-7abc-9123-abcdef123456",
"state": "ready",
"expiresAt": "2026-09-02T12:00:00Z",
"createdAt": "2026-09-02T11:00:00Z",
Expand All @@ -286,7 +277,6 @@ func TestUploadSource_UsesAFreshKeyPerInvocation(t *testing.T) {
_, _ = w.Write([]byte(`{
"upload": {
"id": "019c7654-8b21-7abc-9123-abcdef123456",
"appId": "` + uploadTestAppID + `",
"declaredByteLength": 18,
"sha256": "` + strings.Repeat("a", 64) + `",
"sourceType": "code",
Expand All @@ -308,7 +298,7 @@ func TestUploadSource_UsesAFreshKeyPerInvocation(t *testing.T) {

client := serverlessapi.NewClient("test-key", api.URL, slog.Default())
for range 2 {
if _, err := uploadSource(context.Background(), client, uploadTestAppID, archive, "model.py"); err != nil {
if _, err := uploadSource(context.Background(), client, archive); err != nil {
t.Fatalf("uploadSource: %v", err)
}
}
Expand Down