From 8cedbfc4985295206b27118d18a8beb5e5520bb4 Mon Sep 17 00:00:00 2001 From: Oscar Ward Date: Mon, 12 Feb 2024 12:45:50 -0800 Subject: [PATCH] enhance: add the ability to require compute classes (#2476) --- .../01-command-line/acorn_install.md | 1 + integration/helper/config.go | 27 ++ integration/run/run_test.go | 242 ++++++++++++++++-- pkg/apis/api.acorn.io/v1/types.go | 1 + .../api.acorn.io/v1/zz_generated.deepcopy.go | 5 + pkg/config/config.go | 6 + pkg/controller/scheduling/scheduling.go | 10 + pkg/openapi/generated/openapi_generated.go | 8 +- pkg/profiles/default.go | 3 +- pkg/profiles/production.go | 1 + .../apigroups/acorn/apps/validator.go | 8 +- 11 files changed, 290 insertions(+), 22 deletions(-) diff --git a/docs/docs/100-reference/01-command-line/acorn_install.md b/docs/docs/100-reference/01-command-line/acorn_install.md index b7f9465fa..3a9a4ced4 100644 --- a/docs/docs/100-reference/01-command-line/acorn_install.md +++ b/docs/docs/100-reference/01-command-line/acorn_install.md @@ -69,6 +69,7 @@ acorn install --record-builds Keep a record of each acorn build that happens --registry-cpu string The CPU to allocate to the registry in the format of : (example 200m:1000m) --registry-memory string The memory to allocate to the registry in the format of : (example 256Mi:1Gi) + --require-compute-class Require applications to have a Compute Class set (default is false) --service-lb-annotation strings Annotation to add to the service of type LoadBalancer. Defaults to empty. (example key=value) --set-pod-security-enforce-profile Set the PodSecurity profile on created namespaces (default true) --skip-checks Bypass installation checks diff --git a/integration/helper/config.go b/integration/helper/config.go index 04a747087..b279be775 100644 --- a/integration/helper/config.go +++ b/integration/helper/config.go @@ -69,3 +69,30 @@ func SetIgnoreResourceRequirementsWithRestore(ctx context.Context, t *testing.T, t.Fatal(err) } } + +func SetRequireComputeClassWithRestore(ctx context.Context, t *testing.T, kclient kclient.WithWatch) { + t.Helper() + + cfg, err := config.Get(ctx, kclient) + if err != nil { + t.Fatal(err) + } + + state := z.Dereference(cfg.RequireComputeClass) + + cfg.RequireComputeClass = z.Pointer(true) + + t.Cleanup(func() { + cfg.RequireComputeClass = z.Pointer(state) + + err = config.Set(ctx, kclient, cfg) + if err != nil { + t.Fatal(err) + } + }) + + err = config.Set(ctx, kclient, cfg) + if err != nil { + t.Fatal(err) + } +} diff --git a/integration/run/run_test.go b/integration/run/run_test.go index c47ea4508..d16602c90 100644 --- a/integration/run/run_test.go +++ b/integration/run/run_test.go @@ -931,10 +931,198 @@ func TestDeployParam(t *testing.T) { assert.Equal(t, "5", appInstance.Status.AppSpec.Containers["foo"].Environment[0].Value) } +func TestRequireComputeClass(t *testing.T) { + ctx := helper.GetCTX(t) + + helper.StartController(t) + c, _ := helper.ClientAndProject(t) + kc := helper.MustReturn(kclient.Default) + + helper.SetRequireComputeClassWithRestore(ctx, t, kc) + + checks := []struct { + name string + noComputeClass bool + testDataDirectory string + computeClass adminv1.ProjectComputeClassInstance + expected map[string]v1.Scheduling + waitFor func(obj *v1.AppInstance) bool + fail bool + failMessage string + }{ + { + name: "no-computeclass", + noComputeClass: true, + testDataDirectory: "./testdata/simple", + fail: true, + failMessage: "compute class required but none configured", + }, + { + name: "valid", + testDataDirectory: "./testdata/computeclass", + computeClass: adminv1.ProjectComputeClassInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "acorn-test-custom", + Namespace: c.GetNamespace(), + }, + CPUScaler: 0.25, + Memory: adminv1.ComputeClassMemory{ + Min: "512Mi", + Max: "1Gi", + }, + Resources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + "mygpu/nvidia": resource.MustParse("1"), + }, Requests: corev1.ResourceList{ + "mygpu/nvidia": resource.MustParse("1"), + }}, + SupportedRegions: []string{apiv1.LocalRegion}, + }, + expected: map[string]v1.Scheduling{"simple": { + Requirements: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + "mygpu/nvidia": resource.MustParse("1"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + corev1.ResourceCPU: resource.MustParse("250m"), + "mygpu/nvidia": resource.MustParse("1"), + }, + }, + Tolerations: []corev1.Toleration{ + { + Key: tolerations.WorkloadTolerationKey, + Operator: corev1.TolerationOpExists, + }, + }}, + }, + waitFor: func(obj *v1.AppInstance) bool { + return obj.Status.Condition(v1.AppInstanceConditionParsed).Success && + obj.Status.Condition(v1.AppInstanceConditionScheduling).Success + }, + }, + { + name: "default", + testDataDirectory: "./testdata/simple", + computeClass: adminv1.ProjectComputeClassInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "acorn-test-custom", + Namespace: c.GetNamespace(), + }, + Default: true, + CPUScaler: 0.25, + Memory: adminv1.ComputeClassMemory{ + Default: "512Mi", + Max: "1Gi", + Min: "512Mi", + }, + SupportedRegions: []string{apiv1.LocalRegion}, + }, + expected: map[string]v1.Scheduling{"simple": { + Requirements: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("512Mi")}, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("512Mi"), + corev1.ResourceCPU: resource.MustParse("125m"), + }, + }, + Tolerations: []corev1.Toleration{ + { + Key: tolerations.WorkloadTolerationKey, + Operator: corev1.TolerationOpExists, + }, + }}, + }, + waitFor: func(obj *v1.AppInstance) bool { + return obj.Status.Condition(v1.AppInstanceConditionParsed).Success && + obj.Status.Condition(v1.AppInstanceConditionScheduling).Success + }, + }, + } + + for _, tt := range checks { + asClusterComputeClass := adminv1.ClusterComputeClassInstance(tt.computeClass) + // Perform the same test cases on both Project and Cluster ComputeClasses + for kind, computeClass := range map[string]crClient.Object{"projectcomputeclass": &tt.computeClass, "clustercomputeclass": &asClusterComputeClass} { + testcase := fmt.Sprintf("%v-%v", kind, tt.name) + t.Run(testcase, func(t *testing.T) { + if !tt.noComputeClass { + if err := kc.Create(ctx, computeClass); err != nil { + t.Fatal(err) + } + + // Clean-up and gurantee the computeclass doesn't exist after this test run + t.Cleanup(func() { + if err := kc.Delete(context.Background(), computeClass); err != nil && !apierrors.IsNotFound(err) { + t.Fatal(err) + } + err := helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { + lookingFor := computeClass + err := kc.Get(ctx, router.Key(computeClass.GetNamespace(), computeClass.GetName()), lookingFor) + return lookingFor, err + }) + if err != nil { + t.Fatal(err) + } + }) + } + + image, err := c.AcornImageBuild(ctx, tt.testDataDirectory+"/Acornfile", &client.AcornImageBuildOptions{ + Cwd: tt.testDataDirectory, + }) + if err != nil { + t.Fatal(err) + } + + // Assign a name for the test case so no collisions occur + app, err := c.AppRun(ctx, image.ID, &client.AppRunOptions{Name: testcase}) + if err == nil && tt.fail { + t.Fatal("expected error, got nil") + } else if err != nil { + if !tt.fail { + t.Fatal(err) + } + assert.Contains(t, err.Error(), tt.failMessage) + } + + // Clean-up and gurantee the app doesn't exist after this test run + if app != nil { + t.Cleanup(func() { + if err = kc.Delete(context.Background(), app); err != nil && !apierrors.IsNotFound(err) { + t.Fatal(err) + } + err := helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { + lookingFor := app + err := kc.Get(ctx, router.Key(app.GetName(), app.GetNamespace()), lookingFor) + return lookingFor, err + }) + if err != nil { + t.Fatal(err) + } + }) + } + + if tt.waitFor != nil { + appInstance := &v1.AppInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: app.Name, + Namespace: app.Namespace, + }, + } + appInstance = helper.WaitForObject(t, kc.Watch, new(v1.AppInstanceList), appInstance, tt.waitFor) + assert.EqualValues(t, appInstance.Status.Scheduling, tt.expected, "generated scheduling rules are incorrect") + } + }) + } + } +} + func TestUsingComputeClasses(t *testing.T) { helper.StartController(t) c, _ := helper.ClientAndProject(t) - kclient := helper.MustReturn(kclient.Default) + kc := helper.MustReturn(kclient.Default) ctx := helper.GetCTX(t) @@ -1149,6 +1337,24 @@ func TestUsingComputeClasses(t *testing.T) { }, fail: true, }, + { + name: "no-region", + testDataDirectory: "./testdata/computeclass", + computeClass: adminv1.ProjectComputeClassInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "acorn-test-custom", + Namespace: c.GetNamespace(), + }, + Default: true, + CPUScaler: 0.25, + Memory: adminv1.ComputeClassMemory{ + Default: "512Mi", + Max: "1Gi", + Min: "512Mi", + }, + }, + fail: true, + }, { name: "does-not-exist", noComputeClass: true, @@ -1164,18 +1370,18 @@ func TestUsingComputeClasses(t *testing.T) { testcase := fmt.Sprintf("%v-%v", kind, tt.name) t.Run(testcase, func(t *testing.T) { if !tt.noComputeClass { - if err := kclient.Create(ctx, computeClass); err != nil { + if err := kc.Create(ctx, computeClass); err != nil { t.Fatal(err) } // Clean-up and gurantee the computeclass doesn't exist after this test run t.Cleanup(func() { - if err := kclient.Delete(context.Background(), computeClass); err != nil && !apierrors.IsNotFound(err) { + if err := kc.Delete(context.Background(), computeClass); err != nil && !apierrors.IsNotFound(err) { t.Fatal(err) } err := helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { lookingFor := computeClass - err := kclient.Get(ctx, router.Key(computeClass.GetNamespace(), computeClass.GetName()), lookingFor) + err := kc.Get(ctx, router.Key(computeClass.GetNamespace(), computeClass.GetName()), lookingFor) return lookingFor, err }) if err != nil { @@ -1204,12 +1410,12 @@ func TestUsingComputeClasses(t *testing.T) { // Clean-up and gurantee the app doesn't exist after this test run if app != nil { t.Cleanup(func() { - if err = kclient.Delete(context.Background(), app); err != nil && !apierrors.IsNotFound(err) { + if err = kc.Delete(context.Background(), app); err != nil && !apierrors.IsNotFound(err) { t.Fatal(err) } err := helper.EnsureDoesNotExist(ctx, func() (crClient.Object, error) { lookingFor := app - err := kclient.Get(ctx, router.Key(app.GetName(), app.GetNamespace()), lookingFor) + err := kc.Get(ctx, router.Key(app.GetName(), app.GetNamespace()), lookingFor) return lookingFor, err }) if err != nil { @@ -1225,7 +1431,7 @@ func TestUsingComputeClasses(t *testing.T) { Namespace: app.Namespace, }, } - appInstance = helper.WaitForObject(t, kclient.Watch, new(v1.AppInstanceList), appInstance, tt.waitFor) + appInstance = helper.WaitForObject(t, kc.Watch, new(v1.AppInstanceList), appInstance, tt.waitFor) assert.EqualValues(t, appInstance.Status.Scheduling, tt.expected, "generated scheduling rules are incorrect") } }) @@ -1288,11 +1494,11 @@ func TestAppWithBadDefaultRegion(t *testing.T) { helper.StartController(t) ctx := helper.GetCTX(t) - kclient := helper.MustReturn(kclient.Default) + kc := helper.MustReturn(kclient.Default) c, project := helper.ClientAndProject(t) storageClasses := new(storagev1.StorageClassList) - err := kclient.List(ctx, storageClasses) + err := kc.List(ctx, storageClasses) if err != nil || len(storageClasses.Items) == 0 { t.Skip("No storage classes, so skipping TestAppWithBadDefaultRegion") return @@ -1307,11 +1513,11 @@ func TestAppWithBadDefaultRegion(t *testing.T) { Default: true, SupportedRegions: []string{"custom"}, } - if err = kclient.Create(ctx, &volumeClass); err != nil { + if err = kc.Create(ctx, &volumeClass); err != nil { t.Fatal(err) } defer func() { - if err = kclient.Delete(context.Background(), &volumeClass); err != nil && !apierrors.IsNotFound(err) { + if err = kc.Delete(context.Background(), &volumeClass); err != nil && !apierrors.IsNotFound(err) { t.Fatal(err) } }() @@ -1629,8 +1835,8 @@ func TestEnforcedQuota(t *testing.T) { t.Fatal("error while getting rest config:", err) } // Create a project. - kclient := helper.MustReturn(kclient.Default) - project := helper.TempProject(t, kclient) + kc := helper.MustReturn(kclient.Default) + project := helper.TempProject(t, kc) // Create a client for the project. c, err := client.New(restConfig, project.Name, project.Name) @@ -1644,7 +1850,7 @@ func TestEnforcedQuota(t *testing.T) { obj.Annotations = make(map[string]string) } obj.Annotations[labels.ProjectEnforcedQuotaAnnotation] = "true" - return kclient.Update(ctx, obj) == nil + return kc.Update(ctx, obj) == nil }) // Run a scaled app. @@ -1673,7 +1879,7 @@ func TestEnforcedQuota(t *testing.T) { // Grab the app's QuotaRequest and check that it has the appropriate values set. quotaRequest := &adminv1.QuotaRequestInstance{} - err = kclient.Get(ctx, router.Key(app.Namespace, app.Name), quotaRequest) + err = kc.Get(ctx, router.Key(app.Namespace, app.Name), quotaRequest) if err != nil { t.Fatal(err) } @@ -1690,7 +1896,7 @@ func TestEnforcedQuota(t *testing.T) { }}, AllocatedResources: quotaRequest.Spec.Resources, } - err = kclient.Status().Update(ctx, quotaRequest) + err = kc.Status().Update(ctx, quotaRequest) if err != nil { t.Fatal(err) } @@ -1709,8 +1915,8 @@ func TestAutoUpgradeImageValidation(t *testing.T) { if err != nil { t.Fatal("error while getting rest config:", err) } - kclient := helper.MustReturn(kclient.Default) - project := helper.TempProject(t, kclient) + kc := helper.MustReturn(kclient.Default) + project := helper.TempProject(t, kc) c, err := client.New(restConfig, project.Name, project.Name) if err != nil { diff --git a/pkg/apis/api.acorn.io/v1/types.go b/pkg/apis/api.acorn.io/v1/types.go index 758284976..a7708ce37 100644 --- a/pkg/apis/api.acorn.io/v1/types.go +++ b/pkg/apis/api.acorn.io/v1/types.go @@ -540,6 +540,7 @@ type Config struct { RegistryMemory *string `json:"registryMemory" name:"registry-memory" usage:"The memory to allocate to the registry in the format of : (example 256Mi:1Gi)"` RegistryCPU *string `json:"registryCPU" name:"registry-cpu" usage:"The CPU to allocate to the registry in the format of : (example 200m:1000m)"` IgnoreResourceRequirements *bool `json:"ignoreResourceRequirements" name:"ignore-resource-requirements" usage:"Ignore memory and CPU requests and limits, intended for local development (default is false)"` + RequireComputeClass *bool `json:"requireComputeClass" name:"require-compute-class" usage:"Require applications to have a Compute Class set (default is false)"` } type EncryptionKey struct { diff --git a/pkg/apis/api.acorn.io/v1/zz_generated.deepcopy.go b/pkg/apis/api.acorn.io/v1/zz_generated.deepcopy.go index 15ac99f47..fed62576e 100644 --- a/pkg/apis/api.acorn.io/v1/zz_generated.deepcopy.go +++ b/pkg/apis/api.acorn.io/v1/zz_generated.deepcopy.go @@ -624,6 +624,11 @@ func (in *Config) DeepCopyInto(out *Config) { *out = new(bool) **out = **in } + if in.RequireComputeClass != nil { + in, out := &in.RequireComputeClass, &out.RequireComputeClass + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Config. diff --git a/pkg/config/config.go b/pkg/config/config.go index fc401224d..7e69bb489 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -146,6 +146,9 @@ func complete(ctx context.Context, c *apiv1.Config, getter kclient.Reader, inclu if c.IgnoreResourceRequirements == nil { c.IgnoreResourceRequirements = profile.IgnoreResourceRequirements } + if c.RequireComputeClass == nil { + c.RequireComputeClass = profile.RequireComputeClass + } if c.Features == nil { c.Features = profile.Features } else { @@ -462,6 +465,9 @@ func merge(oldConfig, newConfig *apiv1.Config) *apiv1.Config { if newConfig.IgnoreResourceRequirements != nil { mergedConfig.IgnoreResourceRequirements = newConfig.IgnoreResourceRequirements } + if newConfig.RequireComputeClass != nil { + mergedConfig.RequireComputeClass = newConfig.RequireComputeClass + } if newConfig.AutoConfigureKarpenterDontEvictAnnotations != nil { mergedConfig.AutoConfigureKarpenterDontEvictAnnotations = newConfig.AutoConfigureKarpenterDontEvictAnnotations } diff --git a/pkg/controller/scheduling/scheduling.go b/pkg/controller/scheduling/scheduling.go index 216499e51..104276635 100644 --- a/pkg/controller/scheduling/scheduling.go +++ b/pkg/controller/scheduling/scheduling.go @@ -1,6 +1,8 @@ package scheduling import ( + "fmt" + "github.com/acorn-io/baaah/pkg/router" v1 "github.com/acorn-io/runtime/pkg/apis/internal.acorn.io/v1" adminv1 "github.com/acorn-io/runtime/pkg/apis/internal.admin.acorn.io/v1" @@ -69,10 +71,18 @@ func addScheduling(req router.Request, appInstance *v1.AppInstance, workloads ma tolerations []corev1.Toleration ) + cfg, err := config.Get(req.Ctx, req.Client) + if err != nil { + return err + } + computeClass, err := computeclasses.GetClassForWorkload(req.Ctx, req.Client, appInstance.Spec.ComputeClasses, container, name, appInstance.Namespace) if err != nil { return err } + if z.Dereference(cfg.RequireComputeClass) && computeClass == nil { + return fmt.Errorf("compute class required but none configured") + } requirements, err := ResourceRequirements(req, appInstance, name, container, computeClass) if err != nil { diff --git a/pkg/openapi/generated/openapi_generated.go b/pkg/openapi/generated/openapi_generated.go index a40cc2931..af3bfa039 100644 --- a/pkg/openapi/generated/openapi_generated.go +++ b/pkg/openapi/generated/openapi_generated.go @@ -2622,8 +2622,14 @@ func schema_pkg_apis_apiacornio_v1_Config(ref common.ReferenceCallback) common.O Format: "", }, }, + "requireComputeClass": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, }, - Required: []string{"ingressClassName", "clusterDomains", "letsEncrypt", "letsEncryptEmail", "letsEncryptTOSAgree", "setPodSecurityEnforceProfile", "podSecurityEnforceProfile", "httpEndpointPattern", "internalClusterDomain", "acornDNS", "acornDNSEndpoint", "autoUpgradeInterval", "recordBuilds", "publishBuilders", "builderPerProject", "internalRegistryPrefix", "ignoreUserLabelsAndAnnotations", "allowUserLabels", "allowUserAnnotations", "allowUserMetadataNamespaces", "workloadMemoryDefault", "workloadMemoryMaximum", "useCustomCABundle", "propagateProjectAnnotations", "propagateProjectLabels", "manageVolumeClasses", "volumeSizeDefault", "networkPolicies", "ingressControllerNamespace", "allowTrafficFromNamespace", "serviceLBAnnotations", "awsIdentityProviderArn", "eventTTL", "features", "certManagerIssuer", "profile", "autoConfigureKarpenterDontEvictAnnotations", "controllerMemory", "controllerCPU", "apiServerMemory", "apiServerCPU", "buildkitdMemory", "buildkitdCPU", "buildkitdServiceMemory", "buildkitdServiceCPU", "registryMemory", "registryCPU", "ignoreResourceRequirements"}, + Required: []string{"ingressClassName", "clusterDomains", "letsEncrypt", "letsEncryptEmail", "letsEncryptTOSAgree", "setPodSecurityEnforceProfile", "podSecurityEnforceProfile", "httpEndpointPattern", "internalClusterDomain", "acornDNS", "acornDNSEndpoint", "autoUpgradeInterval", "recordBuilds", "publishBuilders", "builderPerProject", "internalRegistryPrefix", "ignoreUserLabelsAndAnnotations", "allowUserLabels", "allowUserAnnotations", "allowUserMetadataNamespaces", "workloadMemoryDefault", "workloadMemoryMaximum", "useCustomCABundle", "propagateProjectAnnotations", "propagateProjectLabels", "manageVolumeClasses", "volumeSizeDefault", "networkPolicies", "ingressControllerNamespace", "allowTrafficFromNamespace", "serviceLBAnnotations", "awsIdentityProviderArn", "eventTTL", "features", "certManagerIssuer", "profile", "autoConfigureKarpenterDontEvictAnnotations", "controllerMemory", "controllerCPU", "apiServerMemory", "apiServerCPU", "buildkitdMemory", "buildkitdCPU", "buildkitdServiceMemory", "buildkitdServiceCPU", "registryMemory", "registryCPU", "ignoreResourceRequirements", "requireComputeClass"}, }, }, } diff --git a/pkg/profiles/default.go b/pkg/profiles/default.go index 6fb4b5e3b..b17c80d22 100644 --- a/pkg/profiles/default.go +++ b/pkg/profiles/default.go @@ -73,7 +73,8 @@ func defaultProfile() apiv1.Config { ControllerCPU: new(string), APIServerMemory: new(string), APIServerCPU: new(string), - IgnoreResourceRequirements: z.Pointer(false), + IgnoreResourceRequirements: new(bool), + RequireComputeClass: new(bool), AutoConfigureKarpenterDontEvictAnnotations: z.Pointer(true), } } diff --git a/pkg/profiles/production.go b/pkg/profiles/production.go index 01cd49964..501caa77f 100644 --- a/pkg/profiles/production.go +++ b/pkg/profiles/production.go @@ -34,6 +34,7 @@ func productionProfile() apiv1.Config { conf.APIServerMemory = z.Pointer("256Mi") conf.APIServerCPU = z.Pointer("100m") conf.IgnoreResourceRequirements = z.Pointer(false) + conf.RequireComputeClass = z.Pointer(true) conf.AutoConfigureKarpenterDontEvictAnnotations = z.Pointer(true) return conf diff --git a/pkg/server/registry/apigroups/acorn/apps/validator.go b/pkg/server/registry/apigroups/acorn/apps/validator.go index 480e3ca22..acd4905bd 100644 --- a/pkg/server/registry/apigroups/acorn/apps/validator.go +++ b/pkg/server/registry/apigroups/acorn/apps/validator.go @@ -209,7 +209,8 @@ func (s *Validator) Validate(ctx context.Context, obj runtime.Object) (result fi project, workloadsFromImage, apiv1cfg.WorkloadMemoryDefault, - apiv1cfg.WorkloadMemoryMaximum) + apiv1cfg.WorkloadMemoryMaximum, + apiv1cfg.RequireComputeClass) if len(errs) != 0 { result = append(result, errs...) return @@ -571,7 +572,7 @@ func (s *RBACValidator) CheckPermissionsForPrivilegeEscalation(ctx context.Conte } // checkScheduling must use apiv1.ComputeClass to validate the scheduling instead of the Instance counterparts. -func (s *Validator) checkScheduling(ctx context.Context, params *apiv1.App, project *v1.ProjectInstance, workloads map[string]v1.Container, specMemDefault, specMemMaximum *int64) []*field.Error { +func (s *Validator) checkScheduling(ctx context.Context, params *apiv1.App, project *v1.ProjectInstance, workloads map[string]v1.Container, specMemDefault, specMemMaximum *int64, requireCC *bool) []*field.Error { var ( memory = params.Spec.Memory computeClass = params.Spec.ComputeClasses @@ -630,6 +631,9 @@ func (s *Validator) checkScheduling(ctx context.Context, params *apiv1.App, proj // Need a ComputeClass to validate it if cc == nil { + if z.Dereference(requireCC) { + validationErrors = append(validationErrors, field.Invalid(field.NewPath("computeclass"), "", "compute class required but none configured")) + } continue }