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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go 1.26.3
- name: Setup Go 1.26.4
uses: actions/setup-go@v5
with:
go-version: 1.26.3
go-version: 1.26.4
# You can test your matrix by printing the current Go version
- name: Display Go version
run: go version
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.3"
go-version: "1.26.4"

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
Expand Down
12 changes: 6 additions & 6 deletions api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ func AddModelToRepo(input *AddModelToRepoInput) (*Model, error) {
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuscode %d: %s", res.StatusCode, string(rawData))
}

Expand Down Expand Up @@ -368,7 +368,7 @@ func GetModels(input *GetModelsInput) ([]*Model, error) {
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuscode %d: %s", res.StatusCode, string(rawData))
}

Expand Down Expand Up @@ -479,7 +479,7 @@ func GetModel(input *GetModelInput) (*Model, error) {
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuscode %d: %s", res.StatusCode, string(rawData))
}

Expand Down Expand Up @@ -567,7 +567,7 @@ func RemoveModel(input *RemoveModelInput) (*ModelRepoMutationResult, error) {
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuscode %d: %s", res.StatusCode, string(rawData))
}

Expand Down Expand Up @@ -703,7 +703,7 @@ func CreateModelRepoUpload(input *CreateModelRepoUploadInput) (*ModelRepoMutatio
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuscode %d: %s", res.StatusCode, string(rawData))
}

Expand Down Expand Up @@ -852,7 +852,7 @@ func UpdateModelVersionStatus(hash, status string) (*ModelVersion, error) {
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("statuscode %d: %s", res.StatusCode, string(rawData))
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/model/addModelToRepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ func completeModelUpload(upload *api.ModelRepoUpload, artifactPath string) error
err = fmt.Errorf("upload part %d missing ETag", part.PartNumber)
return
}
completed = append(completed, completedPart{PartNumber: part.PartNumber, ETag: fmt.Sprintf("\"%s\"", etag)})
completed = append(completed, completedPart{PartNumber: part.PartNumber, ETag: fmt.Sprintf("%q", etag)})
}()
if err != nil {
return err
Expand Down
12 changes: 11 additions & 1 deletion cmd/model/addModelToRepo_timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@ func TestSetModelGraphQLTimeoutWithoutInheritedFlag(t *testing.T) {
viper.Reset()

cmd := &cobra.Command{Use: "add"}
setModelGraphQLTimeout(cmd)
// CLAUDE.md: informational output must not corrupt stdout for JSON
// consumers — the "defaulting graphql timeout" notice must land on stderr.
stdout, stderr := captureStdStreams(t, func() {
setModelGraphQLTimeout(cmd)
})

if got := viper.GetDuration(api.GraphQLTimeoutKey); got != modelGraphQLTimeoutValue {
t.Fatalf("expected graphql timeout %s, got %s", modelGraphQLTimeoutValue, got)
}
if stdout != "" {
t.Fatalf("stdout must remain empty, got %q", stdout)
}
if stderr == "" {
t.Fatal("expected timeout-default notice on stderr, got empty")
}
}

func TestSetModelGraphQLTimeoutRespectsExistingConfiguredValue(t *testing.T) {
Expand Down
122 changes: 122 additions & 0 deletions cmd/model/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package model

import (
"errors"
"io"
"os"
"strings"
"sync"
"testing"

"github.com/runpod/runpodctl/api"
)

// captureStdStreams runs fn with os.Stdout and os.Stderr replaced by pipes and
// returns whatever each stream received. It exists to assert that informational
// and error output goes to the correct stream — a regression class CLAUDE.md
// explicitly calls out (legacy commands losing stderr ⇒ corrupts stdout for
// JSON-consuming agents).
func captureStdStreams(t *testing.T, fn func()) (stdout, stderr string) {
t.Helper()

origStdout, origStderr := os.Stdout, os.Stderr
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe stdout: %v", err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe stderr: %v", err)
}
os.Stdout, os.Stderr = stdoutW, stderrW

var (
wg sync.WaitGroup
stdoutBuf, stderrBuf strings.Builder
)
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(&stdoutBuf, stdoutR)
}()
go func() {
defer wg.Done()
_, _ = io.Copy(&stderrBuf, stderrR)
}()

defer func() {
os.Stdout, os.Stderr = origStdout, origStderr
}()

fn()

_ = stdoutW.Close()
_ = stderrW.Close()
wg.Wait()
_ = stdoutR.Close()
_ = stderrR.Close()

return stdoutBuf.String(), stderrBuf.String()
}

func TestHandleModelRepoError(t *testing.T) {
tests := []struct {
name string
err error
wantHandled bool
wantStderrSub string // empty = stderr must be empty
wantStdoutSub string // empty = stdout must be empty
}{
{
name: "nil error is a no-op",
err: nil,
wantHandled: false,
},
{
name: "ErrModelRepoNotImplemented routes to stderr",
err: api.ErrModelRepoNotImplemented,
wantHandled: true,
wantStderrSub: api.ErrModelRepoNotImplemented.Error(),
},
{
name: "feature-not-enabled message routes to stderr",
err: errors.New("Model Repo feature is not enabled for this user"),
wantHandled: true,
wantStderrSub: "Model Repo feature is not enabled for this user",
},
{
name: "unrelated error is not handled",
err: errors.New("some other failure"),
wantHandled: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var handled bool
stdout, stderr := captureStdStreams(t, func() {
handled = handleModelRepoError(tt.err)
})

if handled != tt.wantHandled {
t.Fatalf("handled = %v, want %v", handled, tt.wantHandled)
}

// CLAUDE.md: deprecation warnings / handled errors must go to stderr
// only; stdout must stay clean for JSON-consuming agents.
if stdout != "" {
t.Fatalf("stdout must remain empty, got %q", stdout)
}

if tt.wantStderrSub == "" {
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
}
return
}
if !strings.Contains(stderr, tt.wantStderrSub) {
t.Fatalf("stderr = %q, want substring %q", stderr, tt.wantStderrSub)
}
})
}
}
17 changes: 17 additions & 0 deletions cmd/model/getModels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ func TestModelVersionHash(t *testing.T) {
}},
want: "hash-1",
},
{
name: "skips nil version entries",
model: &api.Model{Versions: []*api.ModelVersion{
nil,
{Hash: "hash-after-nil"},
}},
want: "hash-after-nil",
},
{
name: "all hashes blank or whitespace",
model: &api.Model{Versions: []*api.ModelVersion{
{Hash: ""},
{Hash: " "},
{Hash: "\t\n"},
}},
want: "",
},
{
name: "no versions",
model: &api.Model{},
Expand Down
53 changes: 48 additions & 5 deletions cmd/serverless/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package serverless
import (
"encoding/json"
"fmt"
"math/rand"
"math/rand/v2"
"strings"

"github.com/runpod/runpodctl/internal/api"
Expand All @@ -24,6 +24,9 @@ examples:
# create from a template
runpodctl serverless create --template-id <id> --gpu-id "NVIDIA GeForce RTX 4090"

# create from a template and attach a model
Comment thread
brosenpod marked this conversation as resolved.
runpodctl serverless create --template-id <id> --gpu-id ADA_24 --model-reference https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct:main

# create from a hub repo
runpodctl hub search vllm # find the hub id
runpodctl serverless create --hub-id <id> --gpu-id "NVIDIA GeForce RTX 4090"
Expand Down Expand Up @@ -53,6 +56,7 @@ var (
createFlashBoot bool
createExecutionTimeout int
createNetworkVolumeIDs string
createModelReferences []string
)

func init() {
Expand All @@ -74,6 +78,7 @@ func init() {
createCmd.Flags().BoolVar(&createFlashBoot, "flash-boot", true, "enable flash boot")
createCmd.Flags().IntVar(&createExecutionTimeout, "execution-timeout", -1, "max seconds per request")
createCmd.Flags().StringVar(&createNetworkVolumeIDs, "network-volume-ids", "", "comma-separated network volume ids for multi-region")
createCmd.Flags().StringArrayVar(&createModelReferences, "model-reference", nil, "model reference to attach to the endpoint (repeatable)")
}

func runCreate(cmd *cobra.Command, args []string) error {
Expand All @@ -83,6 +88,13 @@ func runCreate(cmd *cobra.Command, args []string) error {
if createTemplateID != "" && createHubID != "" {
return fmt.Errorf("--template-id and --hub-id are mutually exclusive; use one or the other")
}
if createHubID != "" && len(createModelReferences) > 0 {
return fmt.Errorf("--model-reference is only supported with --template-id")
}
computeType := strings.ToUpper(strings.TrimSpace(createComputeType))
if len(createModelReferences) > 0 && computeType != "" && computeType != "GPU" {
return fmt.Errorf("--model-reference is only supported with --compute-type GPU")
}

client, err := api.NewClient()
if err != nil {
Expand All @@ -93,7 +105,7 @@ func runCreate(cmd *cobra.Command, args []string) error {
req := &api.EndpointCreateRequest{
Name: createName,
TemplateID: createTemplateID,
ComputeType: strings.ToUpper(strings.TrimSpace(createComputeType)),
ComputeType: computeType,
GpuCount: createGpuCount,
WorkersMin: createWorkersMin,
WorkersMax: createWorkersMax,
Expand Down Expand Up @@ -171,7 +183,6 @@ func runCreate(cmd *cobra.Command, args []string) error {
endpointName = listing.Title
}

//nolint:gosec
templateName := fmt.Sprintf("%s__template__%s", endpointName, randomString(7))

gqlReq := &api.EndpointCreateGQLInput{
Expand Down Expand Up @@ -259,6 +270,18 @@ func runCreate(cmd *cobra.Command, args []string) error {
req.NetworkVolumeIDs = strings.Split(createNetworkVolumeIDs, ",")
}

if len(createModelReferences) > 0 {
gqlReq := buildTemplateEndpointGQLInput(req, gpuTypeID, createDataCenterIDs, createModelReferences)
gqlEndpoint, gqlErr := client.CreateEndpointGQL(gqlReq)
if gqlErr != nil {
output.Error(gqlErr)
return fmt.Errorf("failed to create endpoint: %w", gqlErr)
}

format := output.ParseFormat(cmd.Flag("output").Value.String())
return output.Print(gqlEndpoint, &output.Config{Format: format})
}

endpoint, err := client.CreateEndpoint(req)
Comment thread
brosenpod marked this conversation as resolved.
if err != nil {
output.Error(err)
Expand All @@ -280,11 +303,31 @@ func runCreate(cmd *cobra.Command, args []string) error {
return output.Print(endpoint, &output.Config{Format: format})
}

func buildTemplateEndpointGQLInput(req *api.EndpointCreateRequest, gpuTypeID, locations string, modelReferences []string) *api.EndpointCreateGQLInput {
// saveEndpoint derives GPU by default when instanceIds is omitted; computeType is not part of EndpointInput.
return &api.EndpointCreateGQLInput{
Name: req.Name,
TemplateID: req.TemplateID,
GpuIDs: gpuTypeID,
GpuCount: req.GpuCount,
WorkersMin: req.WorkersMin,
WorkersMax: req.WorkersMax,
Locations: locations,
NetworkVolumeID: req.NetworkVolumeID,
ModelReferences: modelReferences,
}
}

// randomString returns an n-character lowercase-alphanumeric suffix.
// The suffix is used only to disambiguate generated template names; it is not
// a token, secret, or anything an attacker benefits from predicting. gosec
// G404 flags math/rand/v2 generically, so the directive below is a deliberate
// suppression rather than a missed crypto/rand call.
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))] //nolint:gosec
for i := range n {
b[i] = letters[rand.IntN(len(letters))] //nolint:gosec // non-crypto: template-name uniqueness suffix, not a token
}
return string(b)
}
Loading