diff --git a/.tangled/workflows/workflow-amd64.yaml b/.tangled/workflows/workflow-amd64.yaml index a3fdf30..427461e 100644 --- a/.tangled/workflows/workflow-amd64.yaml +++ b/.tangled/workflows/workflow-amd64.yaml @@ -1,3 +1,8 @@ +when: + - event: ["push"] + tag: ["v*"] + branch : ["*"] + engine: kubernetes image: golang:1.25-trixie architecture: amd64 diff --git a/.tangled/workflows/workflow-arm64.yaml b/.tangled/workflows/workflow-arm64.yaml index 082e87d..ff02570 100644 --- a/.tangled/workflows/workflow-arm64.yaml +++ b/.tangled/workflows/workflow-arm64.yaml @@ -1,6 +1,7 @@ when: - - event: ["manual"] + - event: ["push"] tag: ["v*"] + branch : ["*"] engine: kubernetes image: golang:1.25-trixie diff --git a/Dockerfile b/Dockerfile index 4127abf..be4a3c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ RUN go mod download COPY loom/cmd/controller/main.go cmd/controller/main.go COPY loom/api/ api/ COPY loom/internal/ internal/ -COPY loom/pkg/ pkg/ # Build # CGO is required for go-sqlite3 diff --git a/api/v1alpha1/spindleset_types.go b/api/v1alpha1/spindleset_types.go index 40adfce..ab302b3 100644 --- a/api/v1alpha1/spindleset_types.go +++ b/api/v1alpha1/spindleset_types.go @@ -137,22 +137,35 @@ type WorkflowDependencies struct { Nixpkgs []string `json:"nixpkgs,omitempty"` } +// ResourceProfile defines a resource configuration for spindle jobs based on node labels. +// Profiles are matched against workflow architecture and applied to job pods. +type ResourceProfile struct { + // NodeSelector defines labels that must match for this profile to be used. + // Must include kubernetes.io/arch to match workflow architecture. + // Additional labels allow differentiation between node types (e.g., node-tier, instance-type). + // +kubebuilder:validation:Required + NodeSelector map[string]string `json:"nodeSelector"` + + // Resources defines the compute resource requirements for jobs using this profile. + // +kubebuilder:validation:Required + Resources corev1.ResourceRequirements `json:"resources"` +} + // SpindleTemplate defines the pod template configuration for spindle jobs. // This is configured via ConfigMap and used internally by the engine. type SpindleTemplate struct { - // Resources defines the compute resource requirements for spindle jobs. - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // NodeSelector is a selector which must be true for the pod to fit on a node. - // For MVP, this is not exposed via ConfigMap. - NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // ResourceProfiles is an ordered list of resource configurations based on node labels. + // When creating a job, the first profile matching the workflow's architecture is selected. + // The profile's nodeSelector and resources are applied to the job pod. + // +optional + ResourceProfiles []ResourceProfile `json:"resourceProfiles,omitempty"` // Tolerations allows pods to schedule onto nodes with matching taints. - // For MVP, this is not exposed via ConfigMap. + // +optional Tolerations []corev1.Toleration `json:"tolerations,omitempty"` // Affinity defines scheduling constraints for spindle job pods. - // For MVP, this is not exposed via ConfigMap. + // +optional Affinity *corev1.Affinity `json:"affinity,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index df69145..f9c412e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -53,6 +53,29 @@ func (in *PipelineRunSpec) DeepCopy() *PipelineRunSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceProfile) DeepCopyInto(out *ResourceProfile) { + *out = *in + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceProfile. +func (in *ResourceProfile) DeepCopy() *ResourceProfile { + if in == nil { + return nil + } + out := new(ResourceProfile) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SpindleSet) DeepCopyInto(out *SpindleSet) { *out = *in @@ -165,12 +188,11 @@ func (in *SpindleSetStatus) DeepCopy() *SpindleSetStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SpindleTemplate) DeepCopyInto(out *SpindleTemplate) { *out = *in - in.Resources.DeepCopyInto(&out.Resources) - if in.NodeSelector != nil { - in, out := &in.NodeSelector, &out.NodeSelector - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val + if in.ResourceProfiles != nil { + in, out := &in.ResourceProfiles, &out.ResourceProfiles + *out = make([]ResourceProfile, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.Tolerations != nil { diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 273f6df..da84e52 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -60,7 +60,13 @@ type LoomConfig struct { // LoomTemplateConfig holds job template configuration type LoomTemplateConfig struct { - Resources ResourceConfig `yaml:"resources"` + ResourceProfiles []ResourceProfileConfig `yaml:"resourceProfiles"` +} + +// ResourceProfileConfig holds a resource profile from ConfigMap +type ResourceProfileConfig struct { + NodeSelector map[string]string `yaml:"nodeSelector"` + Resources ResourceConfig `yaml:"resources"` } // ResourceConfig holds resource requirements as strings for parsing @@ -146,6 +152,25 @@ func convertToResourceRequirements(cfg ResourceConfig) (corev1.ResourceRequireme return reqs, nil } +// convertToResourceProfiles converts ConfigMap profiles to API ResourceProfiles +func convertToResourceProfiles(profiles []ResourceProfileConfig) ([]loomv1alpha1.ResourceProfile, error) { + result := make([]loomv1alpha1.ResourceProfile, 0, len(profiles)) + + for i, p := range profiles { + resources, err := convertToResourceRequirements(p.Resources) + if err != nil { + return nil, fmt.Errorf("failed to convert profile %d resources: %w", i, err) + } + + result = append(result, loomv1alpha1.ResourceProfile{ + NodeSelector: p.NodeSelector, + Resources: resources, + }) + } + + return result, nil +} + // initializeSpindle creates a spindle server with KubernetesEngine func initializeSpindle(ctx context.Context, cfg *config.Config, mgr ctrl.Manager, loomCfg *LoomConfig) (*spindle.Spindle, error) { // Initialize Kubernetes engine @@ -155,15 +180,15 @@ func initializeSpindle(ctx context.Context, cfg *config.Config, mgr ctrl.Manager namespace = "default" } - // Convert resource config to Kubernetes types - resources, err := convertToResourceRequirements(loomCfg.Template.Resources) + // Convert resource profiles to Kubernetes types + profiles, err := convertToResourceProfiles(loomCfg.Template.ResourceProfiles) if err != nil { - return nil, fmt.Errorf("failed to convert resource requirements: %w", err) + return nil, fmt.Errorf("failed to convert resource profiles: %w", err) } // Create template from loom config template := loomv1alpha1.SpindleTemplate{ - Resources: resources, + ResourceProfiles: profiles, } kubeEngine := engine.NewKubernetesEngine(mgr.GetClient(), mgr.GetConfig(), namespace, template) @@ -317,8 +342,7 @@ func main() { } setupLog.Info("Loom configuration loaded", "maxConcurrentJobs", loomCfg.MaxConcurrentJobs, - "cpuRequest", loomCfg.Template.Resources.Requests.CPU, - "memoryRequest", loomCfg.Template.Resources.Requests.Memory) + "resourceProfiles", len(loomCfg.Template.ResourceProfiles)) // Load spindle configuration from environment spindleCfg, err := config.Load(ctx) diff --git a/config/crd/bases/loom.j5t.io_spindlesets.yaml b/config/crd/bases/loom.j5t.io_spindlesets.yaml index 4a0dd7e..56bc13e 100644 --- a/config/crd/bases/loom.j5t.io_spindlesets.yaml +++ b/config/crd/bases/loom.j5t.io_spindlesets.yaml @@ -198,9 +198,8 @@ spec: Set internally by the engine from ConfigMap configuration. properties: affinity: - description: |- - Affinity defines scheduling constraints for spindle job pods. - For MVP, this is not exposed via ConfigMap. + description: Affinity defines scheduling constraints for spindle + job pods. properties: nodeAffinity: description: Describes node affinity scheduling rules for @@ -1118,77 +1117,93 @@ spec: x-kubernetes-list-type: atomic type: object type: object - nodeSelector: - additionalProperties: - type: string + resourceProfiles: description: |- - NodeSelector is a selector which must be true for the pod to fit on a node. - For MVP, this is not exposed via ConfigMap. - type: object - resources: - description: Resources defines the compute resource requirements - for spindle jobs. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. + ResourceProfiles is an ordered list of resource configurations based on node labels. + When creating a job, the first profile matching the workflow's architecture is selected. + The profile's nodeSelector and resources are applied to the job pod. + items: + description: |- + ResourceProfile defines a resource configuration for spindle jobs based on node labels. + Profiles are matched against workflow architecture and applied to job pods. + properties: + nodeSelector: + additionalProperties: + type: string + description: |- + NodeSelector defines labels that must match for this profile to be used. + Must include kubernetes.io/arch to match workflow architecture. + Additional labels allow differentiation between node types (e.g., node-tier, instance-type). + type: object + resources: + description: Resources defines the compute resource requirements + for jobs using this profile. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in + PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object + required: + - nodeSelector + - resources + type: object + type: array tolerations: - description: |- - Tolerations allows pods to schedule onto nodes with matching taints. - For MVP, this is not exposed via ConfigMap. + description: Tolerations allows pods to schedule onto nodes with + matching taints. items: description: |- The pod this Toleration is attached to tolerates any taint that matches diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 0e281ac..150f228 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -8,4 +8,4 @@ kind: Kustomization images: - name: controller newName: atcr.io/evan.jarrett.net/loom - newTag: debug + newTag: latest diff --git a/config/manager/loom-config.yaml b/config/manager/loom-config.yaml index 8ca4f9a..a820ba5 100644 --- a/config/manager/loom-config.yaml +++ b/config/manager/loom-config.yaml @@ -8,12 +8,42 @@ data: # Maximum number of concurrent spindle jobs maxConcurrentJobs: 5 - # Default template for spindle job pods + # Template for spindle job pods template: - resources: - requests: - cpu: "1" - memory: "1Gi" - limits: - cpu: "3" - memory: "4Gi" + # Resource profiles are matched against workflow architecture and node labels. + # The first profile matching the workflow's architecture is selected. + # Profile's nodeSelector and resources are applied to the job pod. + resourceProfiles: + # ARM large nodes (8-core, 16GB) - labeled with node-tier: large + - nodeSelector: + kubernetes.io/arch: arm64 + node-tier: large + resources: + requests: + cpu: "2" + memory: "4Gi" + limits: + cpu: "6" + memory: "12Gi" + + # ARM small nodes (4-core, 8GB) - fallback for arm64 + - nodeSelector: + kubernetes.io/arch: arm64 + resources: + requests: + cpu: "1" + memory: "2Gi" + limits: + cpu: "3" + memory: "6Gi" + + # AMD64 nodes + - nodeSelector: + kubernetes.io/arch: amd64 + resources: + requests: + cpu: "4" + memory: "4Gi" + limits: + cpu: "8" + memory: "8Gi" diff --git a/internal/jobbuilder/job_template.go b/internal/jobbuilder/job_template.go index 8182f1b..011dd67 100644 --- a/internal/jobbuilder/job_template.go +++ b/internal/jobbuilder/job_template.go @@ -69,6 +69,30 @@ type WorkflowConfig struct { Namespace string } +// selectResourceProfile selects the first resource profile matching the workflow architecture. +// Returns the profile's resources and nodeSelector, or default values if no match is found. +func selectResourceProfile(profiles []loomv1alpha1.ResourceProfile, architecture string) (corev1.ResourceRequirements, map[string]string) { + // Iterate through profiles to find first match + for _, profile := range profiles { + // Check if profile's nodeSelector has the matching architecture + if arch, ok := profile.NodeSelector["kubernetes.io/arch"]; ok && arch == architecture { + return profile.Resources, profile.NodeSelector + } + } + + // No profile matched - return defaults + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("4Gi"), + }, + }, nil +} + // BuildJob creates a Kubernetes Job specification for running a spindle workflow. func BuildJob(config WorkflowConfig) (*batchv1.Job, error) { if config.WorkflowName == "" { @@ -90,26 +114,17 @@ func BuildJob(config WorkflowConfig) (*batchv1.Job, error) { return nil, fmt.Errorf("failed to marshal workflow spec: %w", err) } + // Select resource profile based on workflow architecture + resources, profileNodeSelector := selectResourceProfile(config.Template.ResourceProfiles, config.Architecture) + // Build architecture-based node affinity archAffinity := BuildArchitectureAffinity(config.Architecture) // Merge with user-provided affinity from template finalAffinity := MergeAffinity(archAffinity, config.Template.Affinity) - // Default resources if not specified - resources := config.Template.Resources - if resources.Requests == nil && resources.Limits == nil { - resources = corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("500m"), - corev1.ResourceMemory: resource.MustParse("1Gi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("2"), - corev1.ResourceMemory: resource.MustParse("4Gi"), - }, - } - } + // Use profile's nodeSelector (includes architecture and additional labels from profile) + finalNodeSelector := profileNodeSelector // Job name: spindle-{pipelineID}-{workflowName} (truncated if needed) // Sanitize workflow name: remove file extensions and replace dots with hyphens @@ -238,7 +253,7 @@ func BuildJob(config WorkflowConfig) (*batchv1.Job, error) { }, // Node targeting - NodeSelector: config.Template.NodeSelector, + NodeSelector: finalNodeSelector, Tolerations: config.Template.Tolerations, Affinity: finalAffinity, diff --git a/internal/jobbuilder/job_template_test.go b/internal/jobbuilder/job_template_test.go new file mode 100644 index 0000000..9558fc2 --- /dev/null +++ b/internal/jobbuilder/job_template_test.go @@ -0,0 +1,355 @@ +package jobbuilder + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + loomv1alpha1 "tangled.org/evan.jarrett.net/loom/api/v1alpha1" +) + +func TestSelectResourceProfile(t *testing.T) { + tests := []struct { + name string + profiles []loomv1alpha1.ResourceProfile + architecture string + wantCPU string + wantMemory string + wantLabels map[string]string + }{ + { + name: "select arm64 profile", + profiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("4Gi"), + }, + }, + }, + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "amd64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("16"), + corev1.ResourceMemory: resource.MustParse("32Gi"), + }, + }, + }, + }, + architecture: "arm64", + wantCPU: "1", + wantMemory: "2Gi", + wantLabels: map[string]string{ + "kubernetes.io/arch": "arm64", + }, + }, + { + name: "select amd64 profile", + profiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + }, + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "amd64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + }, + }, + }, + }, + architecture: "amd64", + wantCPU: "4", + wantMemory: "8Gi", + wantLabels: map[string]string{ + "kubernetes.io/arch": "amd64", + }, + }, + { + name: "select first matching profile with additional labels", + profiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + "node-tier": "large", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + }, + }, + }, + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + }, + }, + architecture: "arm64", + wantCPU: "4", + wantMemory: "8Gi", + wantLabels: map[string]string{ + "kubernetes.io/arch": "arm64", + "node-tier": "large", + }, + }, + { + name: "fallback to defaults when no profile matches", + profiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "amd64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + }, + }, + }, + }, + architecture: "arm64", + wantCPU: "500m", + wantMemory: "1Gi", + wantLabels: nil, + }, + { + name: "fallback to defaults when no profiles configured", + profiles: []loomv1alpha1.ResourceProfile{}, + architecture: "amd64", + wantCPU: "500m", + wantMemory: "1Gi", + wantLabels: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotResources, gotLabels := selectResourceProfile(tt.profiles, tt.architecture) + + // Check CPU request + gotCPU := gotResources.Requests[corev1.ResourceCPU] + wantCPU := resource.MustParse(tt.wantCPU) + if !gotCPU.Equal(wantCPU) { + t.Errorf("selectResourceProfile() CPU = %v, want %v", gotCPU.String(), wantCPU.String()) + } + + // Check memory request + gotMemory := gotResources.Requests[corev1.ResourceMemory] + wantMemory := resource.MustParse(tt.wantMemory) + if !gotMemory.Equal(wantMemory) { + t.Errorf("selectResourceProfile() Memory = %v, want %v", gotMemory.String(), wantMemory.String()) + } + + // Check labels + if len(gotLabels) != len(tt.wantLabels) { + t.Errorf("selectResourceProfile() labels count = %d, want %d", len(gotLabels), len(tt.wantLabels)) + } + for k, wantV := range tt.wantLabels { + if gotV, ok := gotLabels[k]; !ok || gotV != wantV { + t.Errorf("selectResourceProfile() label[%s] = %v, want %v", k, gotV, wantV) + } + } + }) + } +} + +func TestBuildJob(t *testing.T) { + tests := []struct { + name string + config WorkflowConfig + wantCPU string + wantMemory string + wantNodeSelector map[string]string + wantErr bool + }{ + { + name: "use arm64 profile with additional labels", + config: WorkflowConfig{ + WorkflowName: "test-workflow", + PipelineID: "test-pipeline", + SpindleSetName: "test-spindleset", + Image: "test:latest", + Architecture: "arm64", + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, + Namespace: "default", + Template: loomv1alpha1.SpindleTemplate{ + ResourceProfiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + "node-tier": "large", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("4Gi"), + }, + }, + }, + }, + }, + }, + wantCPU: "2", + wantMemory: "4Gi", + wantNodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + "node-tier": "large", + }, + wantErr: false, + }, + { + name: "use amd64 profile", + config: WorkflowConfig{ + WorkflowName: "test-workflow", + PipelineID: "test-pipeline", + SpindleSetName: "test-spindleset", + Image: "test:latest", + Architecture: "amd64", + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, + Namespace: "default", + Template: loomv1alpha1.SpindleTemplate{ + ResourceProfiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + }, + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "amd64", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + }, + }, + }, + }, + }, + }, + wantCPU: "4", + wantMemory: "8Gi", + wantNodeSelector: map[string]string{ + "kubernetes.io/arch": "amd64", + }, + wantErr: false, + }, + { + name: "profile with multiple labels", + config: WorkflowConfig{ + WorkflowName: "test-workflow", + PipelineID: "test-pipeline", + SpindleSetName: "test-spindleset", + Image: "test:latest", + Architecture: "arm64", + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, + Namespace: "default", + Template: loomv1alpha1.SpindleTemplate{ + ResourceProfiles: []loomv1alpha1.ResourceProfile{ + { + NodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + "node-tier": "large", + "custom-label": "custom-value", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + }, + }, + }, + }, + wantCPU: "1", + wantMemory: "2Gi", + wantNodeSelector: map[string]string{ + "kubernetes.io/arch": "arm64", + "node-tier": "large", + "custom-label": "custom-value", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + job, err := BuildJob(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("BuildJob() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + + // Check resources + container := job.Spec.Template.Spec.Containers[0] + gotCPU := container.Resources.Requests[corev1.ResourceCPU] + wantCPU := resource.MustParse(tt.wantCPU) + if !gotCPU.Equal(wantCPU) { + t.Errorf("BuildJob() CPU = %v, want %v", gotCPU.String(), wantCPU.String()) + } + + gotMemory := container.Resources.Requests[corev1.ResourceMemory] + wantMemory := resource.MustParse(tt.wantMemory) + if !gotMemory.Equal(wantMemory) { + t.Errorf("BuildJob() Memory = %v, want %v", gotMemory.String(), wantMemory.String()) + } + + // Check nodeSelector + gotNodeSelector := job.Spec.Template.Spec.NodeSelector + if len(gotNodeSelector) != len(tt.wantNodeSelector) { + t.Errorf("BuildJob() nodeSelector count = %d, want %d", len(gotNodeSelector), len(tt.wantNodeSelector)) + } + for k, wantV := range tt.wantNodeSelector { + if gotV, ok := gotNodeSelector[k]; !ok || gotV != wantV { + t.Errorf("BuildJob() nodeSelector[%s] = %v, want %v", k, gotV, wantV) + } + } + }) + } +}