Repository: kaito-project/kaito

Issue:
[Scale API] upgrade workspace CRD to support scale api
**Is your feature request related to a problem? Please describe.**
According to the scale API proposal: https://github.com/kaito-project/kaito/blob/main/website/docs/proposals/20250611-workspace-subresource-scale-api.md, this issue is used to track the work of upgrading `Workspace` CRD. and the related API change can be referenced    in the proposal: https://github.com/kaito-project/kaito/blob/main/website/docs/proposals/20250611-workspace-subresource-scale-api.md#workspace-crd-api-change

**Describe the solution you'd like**
upgrade `Workspace` CRD for supporting scale subresource API.

**Describe alternatives you've considered**

**Additional context**

Discussion:

Solution patch:
diff --git a/api/v1beta1/workspace_types.go b/api/v1beta1/workspace_types.go
index 01897fa..f116ead 100644
--- a/api/v1beta1/workspace_types.go
+++ b/api/v1beta1/workspace_types.go
@@ -24,6 +24,7 @@ import (
 // will provision new nodes before deploying the workload.
 // The final list of nodes used to run the workload is presented in workspace Status.
 type ResourceSpec struct {
+	// Deprecated: Count is deprecated in v1beta1 and will be removed in a future version.
 	// Count is the required number of GPU nodes.
 	// +optional
 	// +kubebuilder:default:=1
@@ -84,6 +85,8 @@ type PresetSpec struct {
 }
 
 type InferenceSpec struct {
+	// Number of desired inference workloads, Default value is 1.
+	Replicas int32 `json:"replicas,omitempty"`
 	// Preset describes the base model that will be deployed with preset configurations.
 	// +optional
 	Preset *PresetSpec `json:"preset,omitempty"`
@@ -104,6 +107,26 @@ type InferenceSpec struct {
 	Adapters []AdapterSpec `json:"adapters,omitempty"`
 }
 
+type InferenceStatus struct {
+	// Total number of running inference workloads of the workspace.
+	// This field is dynamic and changes based on scaling actions. It will set to Deployement.Status.Replicas
+	// when underlay workload is Deployment for Workspace.
+	Replicas int32 `json:"replicas,omitempty"`
+
+	// Selector is used to select the pods that provide metrics for making scaling action decisions.
+	// This field must be set when HPA and VPA is used for scaling.
+	Selector string `json:"selector,omitempty"`
+
+	// PerReplicaNodeCount is used for recording the number of gpu nodes for one replica of workspace workload.
+	// This field remains immutable after being set.
+	PerReplicaNodeCount int32 `json:"perReplicaNodeCount,omitempty"`
+
+	// TargetNodeCount is used for recording the desired number of gpu nodes that needed for all replicas of workspace.
+	// This field is dynamic and changes based on scaling actions. It has a specific calculation formula:
+	// TargetNodeCount = Resource.Replicas * InferenceStatus.PerReplicaNodeCount
+	TargetNodeCount int32 `json:"targetNodeCount,omitempty"`
+}
+
 type AdapterSpec struct {
 	// Source describes where to obtain the adapter data.
 	// +optional
@@ -184,6 +207,11 @@ type WorkspaceStatus struct {
 	// Conditions report the current conditions of the workspace.
 	// +optional
 	Conditions []metav1.Condition `json:"conditions,omitempty"`
+
+	// Inference provides the status of the inference service.
+	// It is only set when the InferenceSpec is specified in the Workspace.
+	// +optional
+	Inference *InferenceStatus `json:"inference,omitempty"`
 }
 
 // Workspace is the Schema for the workspaces API
@@ -197,6 +225,7 @@ type WorkspaceStatus struct {
 // +kubebuilder:printcolumn:name="JobStarted",type="string",JSONPath=".status.conditions[?(@.type==\"JobStarted\")].status",description=""
 // +kubebuilder:printcolumn:name="WorkspaceSucceeded",type="string",JSONPath=".status.conditions[?(@.type==\"WorkspaceSucceeded\")].status",description=""
 // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description=""
+// +kubebuilder:subresource:scale:specpath=.spec.inference.replicas,statuspath=.status.inference.replicas,selectorpath=.status.inference.selector
 type Workspace struct {
 	metav1.TypeMeta   `json:",inline"`
 	metav1.ObjectMeta `json:"metadata,omitempty"`
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index a280362..693e494 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -1,3 +1,5 @@
+//go:build !ignore_autogenerated
+
 // Copyright (c) KAITO authors.
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -166,6 +168,21 @@ func (in *InferenceSpec) DeepCopy() *InferenceSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InferenceStatus) DeepCopyInto(out *InferenceStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceStatus.
+func (in *InferenceStatus) DeepCopy() *InferenceStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(InferenceStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PresetMeta) DeepCopyInto(out *PresetMeta) {
 	*out = *in
@@ -419,6 +436,11 @@ func (in *WorkspaceStatus) DeepCopyInto(out *WorkspaceStatus) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Inference != nil {
+		in, out := &in.Inference, &out.Inference
+		*out = new(InferenceStatus)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceStatus.
diff --git a/charts/kaito/workspace/crds/kaito.sh_workspaces.yaml b/charts/kaito/workspace/crds/kaito.sh_workspaces.yaml
index 9a4dc38..6f7b5a3 100644
--- a/charts/kaito/workspace/crds/kaito.sh_workspaces.yaml
+++ b/charts/kaito/workspace/crds/kaito.sh_workspaces.yaml
@@ -523,6 +523,11 @@ spec:
                 required:
                 - name
                 type: object
+              replicas:
+                description: Number of desired inference workloads, Default value
+                  is 1.
+                format: int32
+                type: integer
               template:
                 description: |-
                   Template specifies the Pod template used to run the inference service. Users can specify custom Pod settings
@@ -550,7 +555,9 @@ spec:
             properties:
               count:
                 default: 1
-                description: Count is the required number of GPU nodes.
+                description: |-
+                  Deprecated: Count is deprecated in v1beta1 and will be removed in a future version.
+                  Count is the required number of GPU nodes.
                 type: integer
               instanceType:
                 default: Standard_NC24ads_A100_v4
@@ -689,6 +696,31 @@ spec:
                   - type
                   type: object
                 type: array
+              inference:
+                description: |-
+                  Inference provides the status of the inference service.
+                  It is only set when the InferenceSpec is specified in the Workspace.
+                properties:
+                  perReplicaNodeCount:
+                    description: PerReplicaNodeCount is used for recording the number
+                      of gpu nodes for one replica of workspace workload.
+                    format: int32
+                    type: integer
+                  replicas:
+                    description: Total number of running inference workloads of the
+                      workspace.
+                    format: int32
+                    type: integer
+                  selector:
+                    description: Selector is used to select the pods for the inference
+                      workload.
+                    type: string
+                  targetNodeCount:
+                    description: TargetNodeCount is used for recording the total number
+                      of gpu nodes for the workspace.
+                    format: int32
+                    type: integer
+                type: object
               workerNodes:
                 description: WorkerNodes is the list of nodes chosen to run the workload
                   based on the workspace resource requirement.
@@ -801,4 +833,8 @@ spec:
     served: true
     storage: true
     subresources:
+      scale:
+        labelSelectorPath: .status.inference.selector
+        specReplicasPath: .spec.inference.replicas
+        statusReplicasPath: .status.inference.replicas
       status: {}
diff --git a/config/crd/bases/kaito.sh_workspaces.yaml b/config/crd/bases/kaito.sh_workspaces.yaml
index 9a4dc38..ac4a1b1 100644
--- a/config/crd/bases/kaito.sh_workspaces.yaml
+++ b/config/crd/bases/kaito.sh_workspaces.yaml
@@ -523,6 +523,11 @@ spec:
                 required:
                 - name
                 type: object
+              replicas:
+                description: Number of desired inference workloads, Default value
+                  is 1.
+                format: int32
+                type: integer
               template:
                 description: |-
                   Template specifies the Pod template used to run the inference service. Users can specify custom Pod settings
@@ -550,7 +555,9 @@ spec:
             properties:
               count:
                 default: 1
-                description: Count is the required number of GPU nodes.
+                description: |-
+                  Deprecated: Count is deprecated in v1beta1 and will be removed in a future version.
+                  Count is the required number of GPU nodes.
                 type: integer
               instanceType:
                 default: Standard_NC24ads_A100_v4
@@ -689,6 +696,37 @@ spec:
                   - type
                   type: object
                 type: array
+              inference:
+                description: |-
+                  Inference provides the status of the inference service.
+                  It is only set when the InferenceSpec is specified in the Workspace.
+                properties:
+                  perReplicaNodeCount:
+                    description: |-
+                      PerReplicaNodeCount is used for recording the number of gpu nodes for one replica of workspace workload.
+                      This field remains immutable after being set.
+                    format: int32
+                    type: integer
+                  replicas:
+                    description: |-
+                      Total number of running inference workloads of the workspace.
+                      This field is dynamic and changes based on scaling actions. It will set to Deployement.Status.Replicas
+                      when underlay workload is Deployment for Workspace.
+                    format: int32
+                    type: integer
+                  selector:
+                    description: |-
+                      Selector is used to select the pods that provide metrics for making scaling action decisions.
+                      This field must be set when HPA and VPA is used for scaling.
+                    type: string
+                  targetNodeCount:
+                    description: |-
+                      TargetNodeCount is used for recording the desired number of gpu nodes that needed for all replicas of workspace.
+                      This field is dynamic and changes based on scaling actions. It has a specific calculation formula:
+                      TargetNodeCount = Resource.Replicas * InferenceStatus.PerReplicaNodeCount
+                    format: int32
+                    type: integer
+                type: object
               workerNodes:
                 description: WorkerNodes is the list of nodes chosen to run the workload
                   based on the workspace resource requirement.
@@ -801,4 +839,8 @@ spec:
     served: true
     storage: true
     subresources:
+      scale:
+        labelSelectorPath: .status.inference.selector
+        specReplicasPath: .spec.inference.replicas
+        statusReplicasPath: .status.inference.replicas
       status: {}
diff --git a/pkg/workspace/controllers/workspace_controller.go b/pkg/workspace/controllers/workspace_controller.go
index 4c9eea8..233f4d0 100644
--- a/pkg/workspace/controllers/workspace_controller.go
+++ b/pkg/workspace/controllers/workspace_controller.go
@@ -368,13 +368,16 @@ func (c *WorkspaceReconciler) applyWorkspaceResource(ctx context.Context, wObj *
 		return err
 	}
 
+	//nolint:staticcheck //SA1019: deprecate Resource.Count field
 	selectedNodes := utils.SelectNodes(validNodes, wObj.Resource.PreferredNodes, wObj.Status.WorkerNodes, lo.FromPtr(wObj.Resource.Count))
 
+	//nolint:staticcheck //SA1019: deprecate Resource.Count field
 	newNodesCount := lo.FromPtr(wObj.Resource.Count) - len(selectedNodes)
 
 	if newNodesCount > 0 {
 		// Check if node auto-provisioning is disabled
 		if featuregates.FeatureGates[consts.FeatureFlagDisableNodeAutoProvisioning] {
+			//nolint:staticcheck //SA1019: deprecate Resource.Count field
 			return fmt.Errorf("node auto-provisioning is disabled but insufficient nodes available: need %d nodes, have %d selected nodes", lo.FromPtr(wObj.Resource.Count), len(selectedNodes))
 		}
 
@@ -479,8 +482,9 @@ func (c *WorkspaceReconciler) getAllQualifiedNodes(ctx context.Context, wObj *ka
 				}
 			}
 		}
-
+		//nolint:staticcheck //SA1019: deprecate Resource.Count field
 		if len(qualifiedNodes) < *wObj.Resource.Count {
+			//nolint:staticcheck //SA1019: deprecate Resource.Count field
 			return nil, fmt.Errorf("when node auto-provisioning is disabled, at least %d preferred nodes must match the label selector and be ready and not deleting, only have %d", *wObj.Resource.Count, len(qualifiedNodes))
 		}
 	} else {
diff --git a/pkg/workspace/inference/preset_inferences.go b/pkg/workspace/inference/preset_inferences.go
index a0bdf65..1925645 100644
--- a/pkg/workspace/inference/preset_inferences.go
+++ b/pkg/workspace/inference/preset_inferences.go
@@ -138,6 +138,8 @@ func GeneratePresetInference(ctx context.Context, workspaceObj *v1beta1.Workspac
 
 	gpuConfig := getGPUConfig(gctx)
 	// initially respect the user setting by deploying the model on the same number of nodes as the user requested
+
+	//nolint:staticcheck //SA1019: deprecate Resource.Count field
 	numNodes := *workspaceObj.Resource.Count
 	// if gpu mem is known, we can setup the distributed correctly
 	if gpuConfig.GPUMemGB > 0 && gpuConfig.GPUCount > 0 {
diff --git a/pkg/workspace/manifests/manifests.go b/pkg/workspace/manifests/manifests.go
index c547c0c..dc082e2 100644
--- a/pkg/workspace/manifests/manifests.go
+++ b/pkg/workspace/manifests/manifests.go
@@ -340,6 +340,7 @@ func GenerateDeploymentManifestWithPodTemplate(workspaceObj *kaitov1beta1.Worksp
 			},
 		},
 		Spec: appsv1.DeploymentSpec{
+			//nolint:staticcheck //SA1019: deprecate Resource.Count field
 			Replicas: lo.ToPtr(int32(*workspaceObj.Resource.Count)),
 			Selector: labelselector,
 			Template: *templateCopy,



Test patch:
diff --git a/pkg/workspace/controllers/workspace_controller_test.go b/pkg/workspace/controllers/workspace_controller_test.go
index 7322fe9..35a173c 100644
--- a/pkg/workspace/controllers/workspace_controller_test.go
+++ b/pkg/workspace/controllers/workspace_controller_test.go
@@ -581,6 +581,7 @@ func TestGetAllQualifiedNodes(t *testing.T) {
 	}
 	mockWorkspaceWithPreferredNodes := test.MockWorkspaceWithPreferredNodes.DeepCopy()
 	mockWorkspaceWithPreferredNodes.Resource.PreferredNodes = []string{"node-p1", "node-p2"}
+	//nolint:staticcheck //SA1019: deprecate Resource.Count field
 	mockWorkspaceWithPreferredNodes.Resource.Count = ptr.To(2)
 
 	mockWorkspaceWithSinglePreferredNode := test.MockWorkspaceWithPreferredNodes.DeepCopy()
diff --git a/pkg/workspace/inference/preset_inferences_test.go b/pkg/workspace/inference/preset_inferences_test.go
index d85d685..1237623 100644
--- a/pkg/workspace/inference/preset_inferences_test.go
+++ b/pkg/workspace/inference/preset_inferences_test.go
@@ -268,6 +268,7 @@ func TestGeneratePresetInference(t *testing.T) {
 			tc.callMocks(mockClient)
 
 			workspace := tc.workspace
+			//nolint:staticcheck //SA1019: deprecate Resource.Count field
 			workspace.Resource.Count = &tc.nodeCount
 			expectedSecrets := []string{"fake-secret"}
 			if tc.hasAdapters {