-
Notifications
You must be signed in to change notification settings - Fork 603
feat(controller): apply Task resource limits to the ActorTemplate #405
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ import ( | |
| "google.golang.org/grpc/codes" | ||
| "google.golang.org/grpc/credentials/insecure" | ||
| "google.golang.org/grpc/status" | ||
| "google.golang.org/protobuf/proto" | ||
| ) | ||
|
|
||
| type mockControlServer struct { | ||
|
|
@@ -40,7 +41,11 @@ type mockControlServer struct { | |
| suspendedActors []string | ||
| deletedActors []string | ||
| actorTemplates map[string]bool | ||
| createdTemplates []*ateapipb.ActorTemplate | ||
| deletedTemplates []string | ||
| // createTemplateErr, when set, is returned by CreateActorTemplate to stand in | ||
| // for Substrate rejecting a template (for example an invalid quantity). | ||
| createTemplateErr error | ||
| } | ||
|
|
||
| // noSecrets is a SecretResolver for tests: it never finds a key and never touches a cluster. | ||
|
|
@@ -57,11 +62,15 @@ func (m *mockControlServer) GetActorTemplate(_ context.Context, req *ateapipb.Ge | |
| } | ||
|
|
||
| func (m *mockControlServer) CreateActorTemplate(_ context.Context, req *ateapipb.CreateActorTemplateRequest) (*ateapipb.ActorTemplate, error) { | ||
| if m.createTemplateErr != nil { | ||
| return nil, m.createTemplateErr | ||
| } | ||
| if m.actorTemplates == nil { | ||
| m.actorTemplates = make(map[string]bool) | ||
| } | ||
| tmpl := req.GetActorTemplate() | ||
| m.actorTemplates[tmpl.GetMetadata().GetName()] = true | ||
| m.createdTemplates = append(m.createdTemplates, tmpl) | ||
| return tmpl, nil | ||
| } | ||
|
|
||
|
|
@@ -387,6 +396,196 @@ func TestTaskReconciler_WorkspaceReady(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| func TestTaskReconciler_ResourceLimits(t *testing.T) { | ||
| ctx := context.Background() | ||
|
|
||
| lis, err := net.Listen("tcp", "127.0.0.1:0") | ||
| if err != nil { | ||
| t.Fatalf("failed to listen: %v", err) | ||
| } | ||
| defer lis.Close() | ||
|
|
||
| mockSrv := &mockControlServer{} | ||
| grpcServer := grpc.NewServer() | ||
| ateapipb.RegisterControlServer(grpcServer, mockSrv) | ||
| go grpcServer.Serve(lis) | ||
| defer grpcServer.Stop() | ||
|
|
||
| client, err := substrate.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) | ||
| if err != nil { | ||
| t.Fatalf("failed to create substrate client: %v", err) | ||
| } | ||
| defer client.Close() | ||
|
|
||
| reconciler := controller.NewTaskReconciler(client, "test-template", "ax-system") | ||
| reconciler.SecretResolver = noSecrets | ||
| reconciler.WorkspaceReadyTimeout = 200 * time.Millisecond | ||
|
|
||
| task := &v1alpha1.Task{ | ||
| ApiVersion: v1alpha1.APIVersion, | ||
| Kind: v1alpha1.KindTask, | ||
| Metadata: &v1alpha1.ObjectMeta{ | ||
| Name: "sized-task", | ||
| Atespace: "default", | ||
| }, | ||
| Spec: &v1alpha1.TaskSpec{ | ||
| Image: "ghrc.io/my-org/my-image", | ||
| Resources: &v1alpha1.ResourceReqs{ | ||
| Limits: &v1alpha1.ResourceList{Cpu: "2", Memory: "4Gi"}, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| if _, err := reconciler.Reconcile(ctx, task); err != nil { | ||
| t.Fatalf("Reconcile failed: %v", err) | ||
| } | ||
| if len(mockSrv.createdTemplates) != 1 { | ||
| t.Fatalf("created %d templates, want 1", len(mockSrv.createdTemplates)) | ||
| } | ||
| want := &ateapipb.Resources{Limits: []*ateapipb.Limits{ | ||
| {Name: "cpu", Quantity: "2"}, | ||
| {Name: "memory", Quantity: "4Gi"}, | ||
| }} | ||
| if got := mockSrv.createdTemplates[0].GetResources(); !proto.Equal(got, want) { | ||
| t.Errorf("template resources = %v, want %v", got, want) | ||
| } | ||
|
|
||
| // Raising a limit is a launch configuration change and must provision a new | ||
| // template, under a new name, carrying the new value. | ||
| task.Spec.Resources.Limits.Memory = "8Gi" | ||
| if _, err := reconciler.Reconcile(ctx, task); err != nil { | ||
| t.Fatalf("Reconcile with changed limits failed: %v", err) | ||
| } | ||
| if len(mockSrv.createdTemplates) != 2 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we also assert that the two generated ActorTemplate names differ when the resource limits change? The implementation relies on
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 079ca2d: |
||
| t.Fatalf("limits change left %d templates, want 2", len(mockSrv.createdTemplates)) | ||
| } | ||
| first, second := mockSrv.createdTemplates[0].GetMetadata().GetName(), mockSrv.createdTemplates[1].GetMetadata().GetName() | ||
| if first == second { | ||
| t.Errorf("limits change reused template name %q, want a distinct name", first) | ||
| } | ||
| want.Limits[1].Quantity = "8Gi" | ||
| if got := mockSrv.createdTemplates[1].GetResources(); !proto.Equal(got, want) { | ||
| t.Errorf("template resources after change = %v, want %v", got, want) | ||
| } | ||
|
|
||
| // A task without limits inherits the worker defaults: no resources block. | ||
| plain := &v1alpha1.Task{ | ||
| ApiVersion: v1alpha1.APIVersion, | ||
| Kind: v1alpha1.KindTask, | ||
| Metadata: &v1alpha1.ObjectMeta{Name: "plain-task", Atespace: "default"}, | ||
| Spec: &v1alpha1.TaskSpec{Image: "ghrc.io/my-org/my-image"}, | ||
| } | ||
| if _, err := reconciler.Reconcile(ctx, plain); err != nil { | ||
| t.Fatalf("Reconcile of task without limits failed: %v", err) | ||
| } | ||
| if got := mockSrv.createdTemplates[len(mockSrv.createdTemplates)-1].GetResources(); got != nil { | ||
| t.Errorf("template for task without limits has resources %v, want none", got) | ||
| } | ||
| } | ||
|
|
||
| // A task that asks for limits must never run without them: invalid limits and a | ||
| // Substrate rejection of the template both fail the reconcile instead of falling | ||
| // back to the default template. Without limits the fallback contract is unchanged. | ||
| func TestTaskReconciler_ResourceLimitsFailurePaths(t *testing.T) { | ||
| ctx := context.Background() | ||
|
|
||
| newTask := func(name string, resources *v1alpha1.ResourceReqs) *v1alpha1.Task { | ||
| return &v1alpha1.Task{ | ||
| ApiVersion: v1alpha1.APIVersion, | ||
| Kind: v1alpha1.KindTask, | ||
| Metadata: &v1alpha1.ObjectMeta{Name: name, Atespace: "default"}, | ||
| Spec: &v1alpha1.TaskSpec{Image: "ghrc.io/my-org/my-image", Resources: resources}, | ||
| } | ||
| } | ||
| setup := func(t *testing.T, mockSrv *mockControlServer) *controller.TaskReconciler { | ||
| lis, err := net.Listen("tcp", "127.0.0.1:0") | ||
| if err != nil { | ||
| t.Fatalf("failed to listen: %v", err) | ||
| } | ||
| grpcServer := grpc.NewServer() | ||
| ateapipb.RegisterControlServer(grpcServer, mockSrv) | ||
| go grpcServer.Serve(lis) | ||
| client, err := substrate.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) | ||
| if err != nil { | ||
| t.Fatalf("failed to create substrate client: %v", err) | ||
| } | ||
| t.Cleanup(func() { | ||
| client.Close() | ||
| grpcServer.Stop() | ||
| lis.Close() | ||
| }) | ||
| reconciler := controller.NewTaskReconciler(client, "test-template", "ax-system") | ||
| reconciler.SecretResolver = noSecrets | ||
| reconciler.WorkspaceReadyTimeout = 200 * time.Millisecond | ||
| return reconciler | ||
| } | ||
|
|
||
| t.Run("invalid limits fail before provisioning", func(t *testing.T) { | ||
| mockSrv := &mockControlServer{} | ||
| reconciler := setup(t, mockSrv) | ||
|
|
||
| for _, reqs := range []*v1alpha1.ResourceReqs{ | ||
| {Limits: &v1alpha1.ResourceList{Cpu: "two"}}, | ||
| {Limits: &v1alpha1.ResourceList{Cpu: "0"}}, | ||
| {Limits: &v1alpha1.ResourceList{Cpu: "1000"}}, | ||
| {Limits: &v1alpha1.ResourceList{Memory: "-4Gi"}}, | ||
| {Requests: &v1alpha1.ResourceList{Cpu: "500m"}, Limits: &v1alpha1.ResourceList{Cpu: "2"}}, | ||
| } { | ||
| reconciled, err := reconciler.Reconcile(ctx, newTask("bad-limits", reqs)) | ||
| if err == nil { | ||
| t.Errorf("Reconcile(%v) succeeded, want error", reqs) | ||
| } | ||
| if reconciled.Status.Phase != "Failed" { | ||
| t.Errorf("Reconcile(%v) phase = %q, want Failed", reqs, reconciled.Status.Phase) | ||
| } | ||
| assertCondition(t, reconciled, "Ready", "False", "InvalidResources") | ||
| } | ||
| if len(mockSrv.createdTemplates) != 0 || len(mockSrv.createdActors) != 0 || len(mockSrv.resumedActors) != 0 { | ||
| t.Errorf("invalid limits reached Substrate: templates=%d actors=%v resumed=%v", | ||
| len(mockSrv.createdTemplates), mockSrv.createdActors, mockSrv.resumedActors) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("rejected template with limits does not fall back", func(t *testing.T) { | ||
| mockSrv := &mockControlServer{ | ||
| createTemplateErr: status.Error(codes.InvalidArgument, "actor_template.resources.limits[0].quantity: Invalid value"), | ||
| } | ||
| reconciler := setup(t, mockSrv) | ||
|
|
||
| reconciled, err := reconciler.Reconcile(ctx, newTask("rejected-limits", &v1alpha1.ResourceReqs{ | ||
| Limits: &v1alpha1.ResourceList{Cpu: "2", Memory: "4Gi"}, | ||
| })) | ||
| if err == nil { | ||
| t.Fatal("Reconcile succeeded although the template with limits was rejected") | ||
| } | ||
| if reconciled.Status.Phase != "Failed" { | ||
| t.Errorf("phase = %q, want Failed", reconciled.Status.Phase) | ||
| } | ||
| assertCondition(t, reconciled, "Ready", "False", "TemplateCreationFailed") | ||
| if len(mockSrv.createdActors) != 0 || len(mockSrv.resumedActors) != 0 { | ||
| t.Errorf("task ran on a fallback template without its limits: actors=%v resumed=%v", mockSrv.createdActors, mockSrv.resumedActors) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("rejected template without limits still falls back", func(t *testing.T) { | ||
| mockSrv := &mockControlServer{ | ||
| createTemplateErr: status.Error(codes.InvalidArgument, "image must be pinned by digest"), | ||
| } | ||
| reconciler := setup(t, mockSrv) | ||
|
|
||
| reconciled, err := reconciler.Reconcile(ctx, newTask("no-limits", nil)) | ||
| if err != nil { | ||
| t.Fatalf("Reconcile without limits failed: %v", err) | ||
| } | ||
| if reconciled.Status.Phase != "Running" { | ||
| t.Errorf("phase = %q, want Running", reconciled.Status.Phase) | ||
| } | ||
| if len(mockSrv.createdActors) != 1 { | ||
| t.Errorf("fallback did not create the actor: %v", mockSrv.createdActors) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| // assertCondition fails the test unless the task has a condition of the given type with | ||
| // the expected status and reason. | ||
| func assertCondition(t *testing.T, task *v1alpha1.Task, condType, wantStatus, wantReason string) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we add a failure-path test for invalid resource limits?
The current test verifies valid CPU/memory limits and the no-limits case, but not what happens when the requested limits cannot be accepted by Substrate.
Given that the reconciler currently falls back to the default ActorTemplate when custom template creation fails, a regression here could cause the Task to run without the requested limits while the reconciliation still succeeds.
A test covering an invalid/rejected resource configuration would make this behavior explicit and protect the resource-isolation guarantee.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
TestTaskReconciler_ResourceLimitsFailurePathsin 079ca2d with three cases:two,0,1000,-4Gi, and arequestsblock) fail before provisioning: phaseFailed,InvalidResources, and nothing reaches Substrate;CreateActorTemplatefor a task with valid limits: phaseFailed,TemplateCreationFailed, and no actor is created or resumed on a fallback template;TestValidateResourcescovers the parser edge cases (suffixes, exponents, bounds, bad strings).