diff --git a/cli/kthena/cmd/common_test.go b/cli/kthena/cmd/common_test.go new file mode 100644 index 000000000..4f2211bae --- /dev/null +++ b/cli/kthena/cmd/common_test.go @@ -0,0 +1,76 @@ +/* +Copyright The Volcano Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "embed" + "io" + "os" + "testing" + + "github.com/spf13/cobra" +) + +//go:embed testdata/helm/templates/*/*.yaml +var testTemplatesFS embed.FS + +func TestMain(m *testing.M) { + // Initialize templates from embedded test data + InitTemplates(testTemplatesFS) + templatesBasePath = "testdata/helm/templates" + + code := m.Run() + os.Exit(code) +} + +// Helper function to execute command and capture output +func executeCommand(root *cobra.Command, args ...string) (output string, err error) { + // Create buffers for stdout/stderr + oldStdout := os.Stdout + oldStderr := os.Stderr + + r, w, _ := os.Pipe() + os.Stdout = w + os.Stderr = w + + // Also set cobra command output + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs(args) + + // Execute in goroutine and capture output + errChan := make(chan error, 1) + go func() { + errChan <- root.Execute() + w.Close() + }() + + // Read captured output + outBytes, _ := io.ReadAll(r) + err = <-errChan + + // Restore stdout/stderr + os.Stdout = oldStdout + os.Stderr = oldStderr + + // Combine all output + output = string(outBytes) + buf.String() + + return output, err +} diff --git a/cli/kthena/cmd/create_test.go b/cli/kthena/cmd/create_test.go new file mode 100644 index 000000000..95aa4040e --- /dev/null +++ b/cli/kthena/cmd/create_test.go @@ -0,0 +1,260 @@ +/* +Copyright The Volcano Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "os" + "strings" + "testing" +) + +func TestCreateManifestCommand(t *testing.T) { + tests := []struct { + name string + args []string + expectError bool + errorMsg string + }{ + { + name: "missing template flag", + args: []string{"create", "manifest"}, + expectError: true, + errorMsg: "required flag", + }, + { + name: "template not found", + args: []string{"create", "manifest", "--template", "nonexistent-template"}, + expectError: true, + errorMsg: "not found", + }, + { + name: "dry run with valid template", + args: []string{"create", "manifest", "--template", "Qwen3-8B", "--dry-run"}, + expectError: false, + }, + { + name: "dry run with name flag", + args: []string{"create", "manifest", "--template", "Qwen3-8B", "--name", "test-model", "--dry-run"}, + expectError: false, + }, + { + name: "dry run with set values", + args: []string{"create", "manifest", "--template", "Qwen/Qwen3-8B", "--set", "name=my-model,owner=test-user", "--dry-run"}, + expectError: false, + }, + { + name: "dry run with namespace", + args: []string{"create", "manifest", "--template", "Qwen/Qwen3-8B", "--namespace", "test-namespace", "--dry-run"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset flags before each test + templateName = "" + valuesFile = "" + dryRun = false + namespace = "default" + name = "" + manifestFlags = make(map[string]string) + + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none. Output: %s", output) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) && !strings.Contains(output, tt.errorMsg) { + t.Errorf("Expected error containing '%s', got: %v, output: %s", tt.errorMsg, err, output) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v, output: %s", err, output) + } + } + }) + } +} + +func TestLoadTemplateValues(t *testing.T) { + tests := []struct { + name string + setupFlags func() + expectError bool + expectedKeys []string + }{ + { + name: "load from manifest flags", + setupFlags: func() { + manifestFlags = map[string]string{ + "key1": "value1", + "key2": "value2", + } + name = "" + namespace = "default" + }, + expectError: false, + expectedKeys: []string{"key1", "key2", "namespace"}, + }, + { + name: "load with name flag", + setupFlags: func() { + manifestFlags = make(map[string]string) + name = "test-model" + namespace = "test-ns" + }, + expectError: false, + expectedKeys: []string{"name", "namespace"}, + }, + { + name: "default namespace when not specified", + setupFlags: func() { + manifestFlags = make(map[string]string) + name = "" + namespace = "default" + }, + expectError: false, + expectedKeys: []string{"namespace"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupFlags() + + values, err := loadTemplateValues() + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + for _, key := range tt.expectedKeys { + if _, exists := values[key]; !exists { + t.Errorf("Expected key '%s' not found in values", key) + } + } + } + }) + } +} + +func TestRenderTemplate(t *testing.T) { + tests := []struct { + name string + templateName string + values map[string]interface{} + expectError bool + checkOutput func(string) bool + }{ + { + name: "render valid template", + templateName: "Qwen/Qwen3-8B", + values: map[string]interface{}{ + "name": "test-model", + "namespace": "default", + }, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "ModelBooster") && + strings.Contains(output, "test-model") + }, + }, + { + name: "template not found", + templateName: "nonexistent-template", + values: map[string]interface{}{}, + expectError: true, + }, + { + name: "render with custom values", + templateName: "Qwen/Qwen3-8B", + values: map[string]interface{}{ + "name": "custom-model", + "namespace": "prod", + "owner": "test-owner", + "workerReplicas": 2, + }, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "custom-model") && + strings.Contains(output, "prod") && + strings.Contains(output, "test-owner") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := renderTemplate(tt.templateName, tt.values) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + }) + } +} + +func TestAskForConfirmation(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"yes", "y\n", true}, + {"yes full", "yes\n", true}, + {"Yes capital", "Yes\n", true}, + {"no", "n\n", false}, + {"no full", "no\n", false}, + {"empty", "\n", false}, + {"random", "maybe\n", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + + go func() { + defer w.Close() + w.Write([]byte(tt.input)) + }() + + result := askForConfirmation("Test question") + os.Stdin = oldStdin + + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/cli/kthena/cmd/describe_test.go b/cli/kthena/cmd/describe_test.go new file mode 100644 index 000000000..facf061d4 --- /dev/null +++ b/cli/kthena/cmd/describe_test.go @@ -0,0 +1,317 @@ +/* +Copyright The Volcano Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "strings" + "testing" + "time" + + "github.com/volcano-sh/kthena/client-go/clientset/versioned" + "github.com/volcano-sh/kthena/client-go/clientset/versioned/fake" + workloadv1alpha1 "github.com/volcano-sh/kthena/pkg/apis/workload/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDescribeTemplateCommand(t *testing.T) { + tests := []struct { + name string + args []string + expectError bool + errorMsg string + checkOutput func(string) bool + }{ + { + name: "describe existing template", + args: []string{"describe", "template", "Qwen/Qwen3-8B"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "Template Content") && + strings.Contains(output, "ModelBooster") + }, + }, + { + name: "describe nonexistent template", + args: []string{"describe", "template", "nonexistent-template"}, + expectError: true, + errorMsg: "not found", + }, + { + name: "describe template without name", + args: []string{"describe", "template"}, + expectError: true, + }, + { + name: "describe template with backward compatibility", + args: []string{"describe", "template", "Qwen3-8B"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "Template Content") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none. Output: %s", output) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) && !strings.Contains(output, tt.errorMsg) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v, output: %s", err, output) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + + // Reset flags + getNamespace = "" + }) + } +} + +func TestDescribeModelBoosterCommand(t *testing.T) { + fakeClient := fake.NewClientset( + &workloadv1alpha1.ModelBooster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-model", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-1 * time.Hour)}, + }, + Spec: workloadv1alpha1.ModelBoosterSpec{ + Name: "test-model", + }, + }, + ) + + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + expectError bool + errorMsg string + checkOutput func(string) bool + }{ + { + name: "describe existing model booster", + args: []string{"describe", "model-booster", "test-model"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "Model:") && + strings.Contains(output, "test-model") && + strings.Contains(output, "Namespace:") && + strings.Contains(output, "default") + }, + }, + { + name: "describe nonexistent model booster", + args: []string{"describe", "model-booster", "nonexistent"}, + expectError: true, + errorMsg: "failed to get Model", + }, + { + name: "describe model booster in specific namespace", + args: []string{"describe", "model-booster", "test-model", "-n", "default"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "test-model") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = "" + + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none. Output: %s", output) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) && !strings.Contains(output, tt.errorMsg) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v, output: %s", err, output) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + }) + } +} + +func TestDescribeModelServingCommand(t *testing.T) { + fakeClient := fake.NewClientset( + &workloadv1alpha1.ModelServing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-serving", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-30 * time.Minute)}, + }, + }, + ) + + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + expectError bool + checkOutput func(string) bool + }{ + { + name: "describe existing model serving", + args: []string{"describe", "model-serving", "test-serving"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "ModelServing:") && + strings.Contains(output, "test-serving") + }, + }, + { + name: "describe with alias ms", + args: []string{"describe", "ms", "test-serving"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "test-serving") + }, + }, + { + name: "describe nonexistent model serving", + args: []string{"describe", "model-serving", "nonexistent"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = "" + + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + }) + } +} + +func TestDescribeAutoscalingPolicyCommand(t *testing.T) { + fakeClient := fake.NewClientset( + &workloadv1alpha1.AutoscalingPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-1 * time.Hour)}, + }, + }, + ) + + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + expectError bool + checkOutput func(string) bool + }{ + { + name: "describe existing autoscaling policy", + args: []string{"describe", "autoscaling-policy", "test-policy"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "AutoscalingPolicy:") && + strings.Contains(output, "test-policy") + }, + }, + { + name: "describe with alias asp", + args: []string{"describe", "asp", "test-policy"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "test-policy") + }, + }, + { + name: "describe nonexistent policy", + args: []string{"describe", "autoscaling-policy", "nonexistent"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = "" + + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + }) + } +} diff --git a/cli/kthena/cmd/get.go b/cli/kthena/cmd/get.go index dd034db82..b72a01889 100644 --- a/cli/kthena/cmd/get.go +++ b/cli/kthena/cmd/get.go @@ -217,7 +217,14 @@ func runGetTemplate(cmd *cobra.Command, args []string) error { return w.Flush() } -func getKthenaClient() (*versioned.Clientset, error) { +// getKthenaClientFunc is a variable that can be mocked in tests +var getKthenaClientFunc = getKthenaClientImpl + +func getKthenaClient() (versioned.Interface, error) { + return getKthenaClientFunc() +} + +func getKthenaClientImpl() (versioned.Interface, error) { config, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile) if err != nil { return nil, fmt.Errorf("failed to load kubeconfig: %v", err) diff --git a/cli/kthena/cmd/get_test.go b/cli/kthena/cmd/get_test.go new file mode 100644 index 000000000..4a54ddc8d --- /dev/null +++ b/cli/kthena/cmd/get_test.go @@ -0,0 +1,470 @@ +/* +Copyright The Volcano Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "strings" + "testing" + "time" + + "github.com/volcano-sh/kthena/client-go/clientset/versioned" + "github.com/volcano-sh/kthena/client-go/clientset/versioned/fake" + workloadv1alpha1 "github.com/volcano-sh/kthena/pkg/apis/workload/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetTemplatesCommand(t *testing.T) { + tests := []struct { + name string + args []string + expectError bool + checkOutput func(string) bool + }{ + { + name: "list all templates", + args: []string{"get", "templates"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") && + strings.Contains(output, "DESCRIPTION") + }, + }, + { + name: "list templates with yaml output", + args: []string{"get", "templates", "-o", "yaml"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "Name:") || + strings.Contains(output, "Description:") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v, output: %s", err, output) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + + // Reset flags + outputFormat = "" + }) + } +} + +func TestGetTemplateCommand(t *testing.T) { + tests := []struct { + name string + args []string + expectError bool + errorMsg string + checkOutput func(string) bool + }{ + { + name: "get existing template", + args: []string{"get", "template", "Qwen/Qwen3-8B"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "ModelBooster") || + strings.Contains(output, "apiVersion") + }, + }, + { + name: "get template with yaml output", + args: []string{"get", "template", "Qwen/Qwen3-8B", "-o", "yaml"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "apiVersion") && + strings.Contains(output, "ModelBooster") + }, + }, + { + name: "get nonexistent template", + args: []string{"get", "template", "nonexistent-template"}, + expectError: true, + errorMsg: "not found", + }, + { + name: "get template without name", + args: []string{"get", "template"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none. Output: %s", output) + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) && !strings.Contains(output, tt.errorMsg) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v, output: %s", err, output) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + + // Reset flags + outputFormat = "" + }) + } +} + +func TestGetModelBoostersCommand(t *testing.T) { + // Create fake client with test data + fakeClient := fake.NewClientset( + &workloadv1alpha1.ModelBooster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-model-1", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-1 * time.Hour)}, + }, + }, + &workloadv1alpha1.ModelBooster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-model-2", + Namespace: "production", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-2 * time.Hour)}, + }, + }, + ) + + // Mock the client function + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + expectError bool + checkOutput func(string) bool + }{ + { + name: "list model-boosters in default namespace", + args: []string{"get", "model-boosters"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") && + strings.Contains(output, "test-model-1") + }, + }, + { + name: "list model-boosters in specific namespace", + args: []string{"get", "model-boosters", "-n", "production"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "test-model-2") + }, + }, + { + name: "list model-boosters across all namespaces", + args: []string{"get", "model-boosters", "--all-namespaces"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAMESPACE") && + strings.Contains(output, "test-model-1") && + strings.Contains(output, "test-model-2") + }, + }, + { + name: "list model-boosters with name filter", + args: []string{"get", "model-boosters", "test-model-1"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "test-model-1") && + !strings.Contains(output, "test-model-2") + }, + }, + { + name: "alias model-booster works", + args: []string{"get", "model-booster"}, + expectError: false, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset flags + getNamespace = "" + getAllNamespaces = false + + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none. Output: %s", output) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v, output: %s", err, output) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + } + }) + } +} + +func TestGetModelServingsCommand(t *testing.T) { + fakeClient := fake.NewClientset( + &workloadv1alpha1.ModelServing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-serving-1", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-30 * time.Minute)}, + }, + }, + ) + + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + checkOutput func(string) bool + }{ + { + name: "list model-servings", + args: []string{"get", "model-servings"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") && + strings.Contains(output, "test-serving-1") + }, + }, + { + name: "alias ms works", + args: []string{"get", "ms"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") + }, + }, + { + name: "alias model-serving works", + args: []string{"get", "model-serving"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = "" + getAllNamespaces = false + + output, err := executeCommand(rootCmd, tt.args...) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + }) + } +} + +func TestGetAutoscalingPoliciesCommand(t *testing.T) { + fakeClient := fake.NewClientset( + &workloadv1alpha1.AutoscalingPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-1 * time.Hour)}, + }, + }, + ) + + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + checkOutput func(string) bool + }{ + { + name: "list autoscaling-policies", + args: []string{"get", "autoscaling-policies"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") && + strings.Contains(output, "test-policy") + }, + }, + { + name: "alias asp works", + args: []string{"get", "asp"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = "" + getAllNamespaces = false + + output, err := executeCommand(rootCmd, tt.args...) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + }) + } +} + +func TestGetAutoscalingPolicyBindingsCommand(t *testing.T) { + fakeClient := fake.NewClientset( + &workloadv1alpha1.AutoscalingPolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-45 * time.Minute)}, + }, + }, + ) + + originalGetClientFunc := getKthenaClientFunc + getKthenaClientFunc = func() (versioned.Interface, error) { + return fakeClient, nil + } + defer func() { + getKthenaClientFunc = originalGetClientFunc + }() + + tests := []struct { + name string + args []string + checkOutput func(string) bool + }{ + { + name: "list autoscaling-policy-bindings", + args: []string{"get", "autoscaling-policy-bindings"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") && + strings.Contains(output, "test-binding") + }, + }, + { + name: "alias aspb works", + args: []string{"get", "aspb"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "NAME") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = "" + getAllNamespaces = false + + output, err := executeCommand(rootCmd, tt.args...) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + }) + } +} + +func TestResolveGetNamespace(t *testing.T) { + tests := []struct { + name string + getNamespaceVal string + allNamespacesVal bool + expected string + }{ + { + name: "all namespaces flag set", + getNamespaceVal: "", + allNamespacesVal: true, + expected: "", + }, + { + name: "specific namespace set", + getNamespaceVal: "production", + allNamespacesVal: false, + expected: "production", + }, + { + name: "default namespace", + getNamespaceVal: "", + allNamespacesVal: false, + expected: "default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getNamespace = tt.getNamespaceVal + getAllNamespaces = tt.allNamespacesVal + + result := resolveGetNamespace() + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} diff --git a/cli/kthena/cmd/root.go b/cli/kthena/cmd/root.go index 2172452d0..66f6c40da 100644 --- a/cli/kthena/cmd/root.go +++ b/cli/kthena/cmd/root.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "fmt" "os" "github.com/spf13/cobra" @@ -47,8 +48,8 @@ Examples: // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - err := rootCmd.Execute() - if err != nil { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) os.Exit(1) } } diff --git a/cli/kthena/cmd/root_test.go b/cli/kthena/cmd/root_test.go new file mode 100644 index 000000000..b651f98e3 --- /dev/null +++ b/cli/kthena/cmd/root_test.go @@ -0,0 +1,196 @@ +/* +Copyright The Volcano Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "strings" + "testing" +) + +func TestRootCommand(t *testing.T) { + tests := []struct { + name string + args []string + checkOutput func(string) bool + }{ + { + name: "root command help", + args: []string{"--help"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "kthena") && + strings.Contains(output, "CLI") && + strings.Contains(output, "inference workloads") + }, + }, + { + name: "root command version info", + args: []string{}, + checkOutput: func(output string) bool { + // Root command with no args should show help or usage + return strings.Contains(output, "kthena") || + strings.Contains(output, "Usage") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := executeCommand(rootCmd, tt.args...) + + // Help command doesn't return error + if err != nil && !strings.Contains(tt.args[0], "--help") { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + }) + } +} + +func TestGetRootCmd(t *testing.T) { + cmd := GetRootCmd() + + if cmd == nil { + t.Fatal("GetRootCmd() returned nil") + } + + if cmd.Use != "kthena" { + t.Errorf("Expected command use to be 'kthena', got '%s'", cmd.Use) + } + + if !strings.Contains(cmd.Short, "Kthena CLI") { + t.Errorf("Expected short description to contain 'Kthena CLI', got '%s'", cmd.Short) + } + + // Check that subcommands are registered + expectedCommands := []string{"create", "get", "describe"} + for _, expectedCmd := range expectedCommands { + found := false + for _, subCmd := range cmd.Commands() { + if subCmd.Use == expectedCmd { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' to be registered", expectedCmd) + } + } +} + +func TestSubcommands(t *testing.T) { + tests := []struct { + name string + args []string + expectError bool + checkOutput func(string) bool + }{ + { + name: "create help", + args: []string{"create", "--help"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "create") && + strings.Contains(output, "manifest") + }, + }, + { + name: "get help", + args: []string{"get", "--help"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "get") && + strings.Contains(output, "resources") + }, + }, + { + name: "describe help", + args: []string{"describe", "--help"}, + checkOutput: func(output string) bool { + return strings.Contains(output, "describe") && + strings.Contains(output, "detailed information") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := executeCommand(rootCmd, tt.args...) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + + if tt.checkOutput != nil && !tt.checkOutput(output) { + t.Errorf("Output validation failed. Output: %s", output) + } + }) + } +} + +func TestCommandAliases(t *testing.T) { + tests := []struct { + name string + commands [][]string // Different ways to invoke the same command + }{ + { + name: "model-booster aliases", + commands: [][]string{ + {"get", "model-boosters", "--help"}, + {"get", "model-booster", "--help"}, + }, + }, + { + name: "model-serving aliases", + commands: [][]string{ + {"get", "model-servings", "--help"}, + {"get", "model-serving", "--help"}, + {"get", "ms", "--help"}, + }, + }, + { + name: "autoscaling-policy aliases", + commands: [][]string{ + {"get", "autoscaling-policies", "--help"}, + {"get", "autoscaling-policy", "--help"}, + {"get", "asp", "--help"}, + }, + }, + { + name: "autoscaling-policy-binding aliases", + commands: [][]string{ + {"get", "autoscaling-policy-bindings", "--help"}, + {"get", "autoscaling-policy-binding", "--help"}, + {"get", "aspb", "--help"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, args := range tt.commands { + output, err := executeCommand(rootCmd, args...) + if err != nil { + t.Errorf("Command %v failed: %v", args, err) + } + if output == "" { + t.Errorf("Command %v produced no output", args) + } + } + }) + } +} diff --git a/cli/kthena/cmd/templates.go b/cli/kthena/cmd/templates.go index badfe5fcc..2f1c2e443 100644 --- a/cli/kthena/cmd/templates.go +++ b/cli/kthena/cmd/templates.go @@ -24,6 +24,7 @@ import ( ) var templatesFS embed.FS +var templatesBasePath = "helm/templates" // Can be overridden in tests type ManifestInfo struct { Name string @@ -40,7 +41,7 @@ func InitTemplates(fs embed.FS) { func findTemplatePath(templateName string) (string, error) { // If templateName contains a slash, it's in vendor/model format, use it directly if strings.Contains(templateName, "/") { - templatePath := fmt.Sprintf("helm/templates/%s.yaml", templateName) + templatePath := fmt.Sprintf("%s/%s.yaml", templatesBasePath, templateName) _, err := templatesFS.Open(templatePath) if err == nil { return templatePath, nil @@ -48,14 +49,14 @@ func findTemplatePath(templateName string) (string, error) { } // Fallback: search through all vendor directories (for backward compatibility) - vendors, err := templatesFS.ReadDir("helm/templates") + vendors, err := templatesFS.ReadDir(templatesBasePath) if err != nil { return "", fmt.Errorf("failed to read templates directory: %v", err) } for _, vendor := range vendors { if vendor.IsDir() { - vendorPath := fmt.Sprintf("helm/templates/%s/%s.yaml", vendor.Name(), templateName) + vendorPath := fmt.Sprintf("%s/%s/%s.yaml", templatesBasePath, vendor.Name(), templateName) _, err := templatesFS.Open(vendorPath) if err == nil { return vendorPath, nil @@ -83,7 +84,7 @@ func GetTemplateContent(templateName string) (string, error) { // ListTemplates returns a list of all available template names in vendor/model format func ListTemplates() ([]string, error) { - vendors, err := templatesFS.ReadDir("helm/templates") + vendors, err := templatesFS.ReadDir(templatesBasePath) if err != nil { return nil, fmt.Errorf("failed to read templates directory: %v", err) } @@ -91,7 +92,7 @@ func ListTemplates() ([]string, error) { var templates []string for _, vendor := range vendors { if vendor.IsDir() { - vendorPath := fmt.Sprintf("helm/templates/%s", vendor.Name()) + vendorPath := fmt.Sprintf("%s/%s", templatesBasePath, vendor.Name()) models, err := templatesFS.ReadDir(vendorPath) if err != nil { fmt.Fprintf(os.Stderr, "warning: could not read vendor directory %s: %v\n", vendorPath, err) diff --git a/cli/kthena/cmd/templates_test.go b/cli/kthena/cmd/templates_test.go new file mode 100644 index 000000000..d1b25cdd0 --- /dev/null +++ b/cli/kthena/cmd/templates_test.go @@ -0,0 +1,282 @@ +/* +Copyright The Volcano Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "strings" + "testing" +) + +func TestListTemplates(t *testing.T) { + templates, err := ListTemplates() + if err != nil { + t.Fatalf("Failed to list templates: %v", err) + } + + if len(templates) == 0 { + t.Error("Expected at least one template, got none") + } + + // Check that templates are in vendor/model format + for _, template := range templates { + if !strings.Contains(template, "/") { + t.Errorf("Template '%s' is not in vendor/model format", template) + } + } + + // Check for known templates (at least Qwen should exist) + foundQwen := false + for _, template := range templates { + if strings.Contains(template, "Qwen") { + foundQwen = true + break + } + } + + if !foundQwen { + t.Error("Expected to find at least one Qwen template") + } +} + +func TestTemplateExists(t *testing.T) { + tests := []struct { + name string + templateName string + expected bool + }{ + { + name: "existing template with vendor prefix", + templateName: "Qwen/Qwen3-8B", + expected: true, + }, + { + name: "existing template without vendor prefix (backward compatibility)", + templateName: "Qwen3-8B", + expected: true, + }, + { + name: "nonexistent template", + templateName: "nonexistent-template", + expected: false, + }, + { + name: "empty template name", + templateName: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TemplateExists(tt.templateName) + if result != tt.expected { + t.Errorf("TemplateExists(%s) = %v, expected %v", tt.templateName, result, tt.expected) + } + }) + } +} + +func TestGetTemplateContent(t *testing.T) { + tests := []struct { + name string + templateName string + expectError bool + checkContent func(string) bool + }{ + { + name: "get existing template", + templateName: "Qwen/Qwen3-8B", + expectError: false, + checkContent: func(content string) bool { + return strings.Contains(content, "ModelBooster") && + strings.Contains(content, "apiVersion") && + strings.Contains(content, "workload.serving.volcano.sh") + }, + }, + { + name: "get template without vendor prefix", + templateName: "Qwen3-8B", + expectError: false, + checkContent: func(content string) bool { + return strings.Contains(content, "ModelBooster") + }, + }, + { + name: "nonexistent template", + templateName: "nonexistent-template", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := GetTemplateContent(tt.templateName) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkContent != nil && !tt.checkContent(content) { + t.Errorf("Content validation failed for template %s", tt.templateName) + } + } + }) + } +} + +func TestGetTemplateInfo(t *testing.T) { + tests := []struct { + name string + templateName string + expectError bool + checkInfo func(ManifestInfo) bool + }{ + { + name: "get info for existing template", + templateName: "Qwen/Qwen3-8B", + expectError: false, + checkInfo: func(info ManifestInfo) bool { + return info.Name == "Qwen/Qwen3-8B" && + info.Description != "" && + info.FilePath != "" + }, + }, + { + name: "nonexistent template", + templateName: "nonexistent-template", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := GetTemplateInfo(tt.templateName) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.checkInfo != nil && !tt.checkInfo(info) { + t.Errorf("Info validation failed. Got: %+v", info) + } + } + }) + } +} + +func TestExtractManifestDescriptionFromContent(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + { + name: "description with 'Description:' prefix", + content: `# Description: This is a test template +apiVersion: v1 +kind: Test`, + expected: "This is a test template", + }, + { + name: "description without prefix", + content: `# This template description contains the word description +apiVersion: v1 +kind: Test`, + expected: "This template description contains the word description", + }, + { + name: "no description", + content: `apiVersion: v1 +kind: Test`, + expected: "No description available", + }, + { + name: "description after non-comment lines", + content: `apiVersion: v1 +# Description: This should be ignored +kind: Test`, + expected: "No description available", + }, + { + name: "empty content", + content: "", + expected: "No description available", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractManifestDescriptionFromContent(tt.content) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestFindTemplatePath(t *testing.T) { + tests := []struct { + name string + templateName string + expectError bool + }{ + { + name: "find with vendor prefix", + templateName: "Qwen/Qwen3-8B", + expectError: false, + }, + { + name: "find without vendor prefix (fallback)", + templateName: "Qwen3-8B", + expectError: false, + }, + { + name: "template not found", + templateName: "nonexistent/template", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := findTemplatePath(tt.templateName) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none, path: %s", path) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if path == "" { + t.Errorf("Expected non-empty path") + } + } + }) + } +} diff --git a/cli/kthena/cmd/testdata/helm/templates/Qwen/Qwen3-32B.yaml b/cli/kthena/cmd/testdata/helm/templates/Qwen/Qwen3-32B.yaml new file mode 100644 index 000000000..e2c6654e3 --- /dev/null +++ b/cli/kthena/cmd/testdata/helm/templates/Qwen/Qwen3-32B.yaml @@ -0,0 +1,36 @@ +# Description: Template for Qwen3 32B model deployment with vLLM backend +apiVersion: workload.serving.volcano.sh/v1alpha1 +kind: ModelBooster +metadata: + annotations: + api.kubernetes.io/name: {{ .Values.annotationName | default "example" | quote }} + name: {{ .Values.name | default "qwen3-32b" | quote }} + {{- if .Values.namespace }} + namespace: {{ .Values.namespace | quote }} + {{- end }} +spec: + name: {{ .Values.modelName | default .Values.name | default "qwen3-32b" | quote }} + owner: {{ .Values.owner | default "example" | quote }} + backend: + name: {{ .Values.backendName | default "qwen3-32b-vllm" | quote }} + type: {{ .Values.backendType | default "vLLM" | quote }} + modelURI: {{ .Values.modelURI | default "hf://Qwen/Qwen3-32B" | quote }} + cacheURI: {{ .Values.cacheURI | default "hostpath://mnt/cache" | quote }} + minReplicas: {{ .Values.minReplicas | default 1 }} + maxReplicas: {{ .Values.maxReplicas | default 3 }} + workers: + - type: {{ .Values.workerType | default "server" | quote }} + image: {{ .Values.workerImage | default "vllm/vllm-openai:latest" | quote }} + replicas: {{ .Values.workerReplicas | default 1 }} + pods: {{ .Values.workerPods | default 1 }} + config: + served-model-name: {{ .Values.modelName | default .Values.name | default "qwen3-32b" | quote }} + tensor-parallel-size: {{ .Values.tensorParallelSize | default 4 }} + enforce-eager: "" # Enable PyTorch eager mode if GPU compute capability is below 8.0 + gpu-memory-utilization: {{ .Values.gpuMemoryUtilization | default "0.85" }} # Set GPU memory utilization to 85% + max-model-len: {{ .Values.maxModelLen | default "2048" }} + resources: + limits: + nvidia.com/gpu: {{ .Values.gpuLimit | default "4" | quote }} + requests: + nvidia.com/gpu: {{ .Values.gpuRequest | default "4" | quote }} diff --git a/cli/kthena/cmd/testdata/helm/templates/Qwen/Qwen3-8B.yaml b/cli/kthena/cmd/testdata/helm/templates/Qwen/Qwen3-8B.yaml new file mode 100644 index 000000000..7839ed563 --- /dev/null +++ b/cli/kthena/cmd/testdata/helm/templates/Qwen/Qwen3-8B.yaml @@ -0,0 +1,34 @@ +# Description: Template for Qwen3 8b model deployment with vLLM backend +apiVersion: workload.serving.volcano.sh/v1alpha1 +kind: ModelBooster +metadata: + annotations: + api.kubernetes.io/name: {{ .Values.annotationName | default "example" | quote }} + name: {{ .Values.name | default "qwen3-8b" | quote }} + {{- if .Values.namespace }} + namespace: {{ .Values.namespace | quote }} + {{- end }} +spec: + name: {{ .Values.modelName | default .Values.name | default "qwen3-8b" | quote }} + owner: {{ .Values.owner | default "example" | quote }} + backend: + name: {{ .Values.backendName | default "qwen3-8b-vllm" | quote }} + type: {{ .Values.backendType | default "vLLM" | quote }} + modelURI: {{ .Values.modelURI | default "hf://Qwen/Qwen3-8B" | quote }} + cacheURI: {{ .Values.cacheURI | default "hostpath://mnt/cache" | quote }} + minReplicas: {{ .Values.minReplicas | default 1 }} + maxReplicas: {{ .Values.maxReplicas | default 3 }} + workers: + - type: {{ .Values.workerType | default "server" | quote }} + image: {{ .Values.workerImage | default "vllm/vllm-openai:latest" | quote }} + replicas: {{ .Values.workerReplicas | default 1 }} + pods: {{ .Values.workerPods | default 1 }} + config: + served-model-name: {{ .Values.modelName | default .Values.name | default "qwen3-8b" | quote }} + tensor-parallel-size: {{ .Values.tensorParallelSize | default 2 }} + enforce-eager: "" # Enable PyTorch eager mode if GPU compute capability is below 8.0 + resources: + limits: + nvidia.com/gpu: {{ .Values.gpuLimit | default "2" | quote }} + requests: + nvidia.com/gpu: {{ .Values.gpuRequest | default "2" | quote }} diff --git a/cli/kthena/cmd/testdata/helm/templates/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B.yaml b/cli/kthena/cmd/testdata/helm/templates/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B.yaml new file mode 100644 index 000000000..1f9d6fedb --- /dev/null +++ b/cli/kthena/cmd/testdata/helm/templates/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B.yaml @@ -0,0 +1,36 @@ +# Description: Template for DeepSeek R1 Distill Qwen 32B model deployment with vLLM backend +apiVersion: workload.serving.volcano.sh/v1alpha1 +kind: ModelBooster +metadata: + annotations: + api.kubernetes.io/name: {{ .Values.annotationName | default "example" | quote }} + name: {{ .Values.name | default "deepseek-r1-distill-qwen-32b" | quote }} + {{- if .Values.namespace }} + namespace: {{ .Values.namespace | quote }} + {{- end }} +spec: + name: {{ .Values.modelName | default .Values.name | default "deepseek-r1-distill-qwen-32b" | quote }} + owner: {{ .Values.owner | default "example" | quote }} + backend: + name: {{ .Values.backendName | default "deepseek-r1-distill-qwen-32b-vllm" | quote }} + type: {{ .Values.backendType | default "vLLM" | quote }} + modelURI: {{ .Values.modelURI | default "hf://deepseek-ai/DeepSeek-R1-Distill-Qwen-32B" | quote }} + cacheURI: {{ .Values.cacheURI | default "hostpath://mnt/cache" | quote }} + minReplicas: {{ .Values.minReplicas | default 1 }} + maxReplicas: {{ .Values.maxReplicas | default 3 }} + workers: + - type: {{ .Values.workerType | default "server" | quote }} + image: {{ .Values.workerImage | default "vllm/vllm-openai:latest" | quote }} + replicas: {{ .Values.workerReplicas | default 1 }} + pods: {{ .Values.workerPods | default 1 }} + config: + served-model-name: {{ .Values.modelName | default .Values.name | default "deepseek-r1-distill-qwen-32b" | quote }} + tensor-parallel-size: {{ .Values.tensorParallelSize | default 4 }} + enforce-eager: "" # Enable PyTorch eager mode if GPU compute capability is below 8.0 + gpu-memory-utilization: {{ .Values.gpuMemoryUtilization | default "0.85" }} # Set GPU memory utilization to 85% + max-model-len: {{ .Values.maxModelLen | default "2048" }} + resources: + limits: + nvidia.com/gpu: {{ .Values.gpuLimit | default "4" | quote }} + requests: + nvidia.com/gpu: {{ .Values.gpuRequest | default "4" | quote }} diff --git a/cli/kthena/cmd/testdata/helm/templates/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B.yaml b/cli/kthena/cmd/testdata/helm/templates/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B.yaml new file mode 100644 index 000000000..be3bd0476 --- /dev/null +++ b/cli/kthena/cmd/testdata/helm/templates/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B.yaml @@ -0,0 +1,34 @@ +# Description: Template for DeepSeek R1 Distill Qwen 7B model deployment with vLLM backend +apiVersion: workload.serving.volcano.sh/v1alpha1 +kind: ModelBooster +metadata: + annotations: + api.kubernetes.io/name: {{ .Values.annotationName | default "example" | quote }} + name: {{ .Values.name | default "deepseek-r1-distill-qwen-7b" | quote }} + {{- if .Values.namespace }} + namespace: {{ .Values.namespace | quote }} + {{- end }} +spec: + name: {{ .Values.modelName | default .Values.name | default "deepseek-r1-distill-qwen-7b" | quote }} + owner: {{ .Values.owner | default "example" | quote }} + backend: + name: {{ .Values.backendName | default "deepseek-r1-distill-qwen-7b-vllm" | quote }} + type: {{ .Values.backendType | default "vLLM" | quote }} + modelURI: {{ .Values.modelURI | default "hf://deepseek-ai/DeepSeek-R1-Distill-Qwen-7B" | quote }} + cacheURI: {{ .Values.cacheURI | default "hostpath://mnt/cache" | quote }} + minReplicas: {{ .Values.minReplicas | default 1 }} + maxReplicas: {{ .Values.maxReplicas | default 3 }} + workers: + - type: {{ .Values.workerType | default "server" | quote }} + image: {{ .Values.workerImage | default "vllm/vllm-openai:latest" | quote }} + replicas: {{ .Values.workerReplicas | default 1 }} + pods: {{ .Values.workerPods | default 1 }} + config: + served-model-name: {{ .Values.modelName | default .Values.name | default "deepseek-r1-distill-qwen-7b" | quote }} + tensor-parallel-size: {{ .Values.tensorParallelSize | default 2 }} + enforce-eager: "" # Enable PyTorch eager mode if GPU compute capability is below 8.0 + resources: + limits: + nvidia.com/gpu: {{ .Values.gpuLimit | default "2" | quote }} + requests: + nvidia.com/gpu: {{ .Values.gpuRequest | default "2" | quote }} diff --git a/cli/kthena/main.go b/cli/kthena/main.go index 751f88362..077fedef9 100644 --- a/cli/kthena/main.go +++ b/cli/kthena/main.go @@ -14,9 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* -Copyright © 2025 NAME HERE -*/ package main import (