Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/infra/k8s/ate/v1/workerpool_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ type WorkerPoolSpec struct {
// +kubebuilder:validation:Minimum=0
Replicas int32 `json:"replicas"`

// maxActorsPerWorker is the maximum number of actors each worker hosts at
// once. When omitted, the worker uses its default limit. Zero prevents
// workers from accepting actors. Changing this value rolls the workers.
//
// +optional
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=2147483647
MaxActorsPerWorker *int32 `json:"maxActorsPerWorker,omitempty"`

// workerImage is the worker container image to deploy as workers.
// +kubebuilder:validation:MinLength=1
// +required
Expand Down
23 changes: 23 additions & 0 deletions api/infra/k8s/ate/v1/workerpool_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
)

const gpuResourceName = corev1.ResourceName("nvidia.com/gpu")
Expand Down Expand Up @@ -77,6 +78,28 @@ func TestWorkerPoolValidation(t *testing.T) {
},
wantErr: true,
errMsg: "spec.replicas: Invalid value: -1: spec.replicas in body should be greater than or equal to 0",
}, {
name: "actor limit of three",
mutate: func(wp *WorkerPool) {
wp.Spec.MaxActorsPerWorker = ptr.To(int32(3))
},
}, {
name: "zero actor limit",
mutate: func(wp *WorkerPool) {
wp.Spec.MaxActorsPerWorker = ptr.To(int32(0))
},
}, {
name: "maximum actor limit",
mutate: func(wp *WorkerPool) {
wp.Spec.MaxActorsPerWorker = ptr.To(int32(2147483647))
},
}, {
name: "negative actor limit",
mutate: func(wp *WorkerPool) {
wp.Spec.MaxActorsPerWorker = ptr.To(int32(-1))
},
wantErr: true,
errMsg: "spec.maxActorsPerWorker in body should be greater than or equal to 0",
}, {
name: "missing workerImage",
mutate: func(wp *WorkerPool) {
Expand Down
5 changes: 5 additions & 0 deletions api/infra/k8s/ate/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions charts/substrate-crds/templates/ate.dev_workerpools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ spec:
spec:
description: spec defines the desired state of WorkerPool
properties:
maxActorsPerWorker:
description: |-
maxActorsPerWorker is the maximum number of actors each worker hosts at
once. When omitted, the worker uses its default limit. Zero prevents
workers from accepting actors. Changing this value rolls the workers.
format: int32
maximum: 2147483647
minimum: 0
type: integer
replicas:
description: replicas is the number of worker pods to run.
format: int32
Expand Down
4 changes: 4 additions & 0 deletions cmd/atecontroller/internal/controllers/workerpool_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package controllers

import (
"slices"
"strconv"
"strings"

appsv1 "k8s.io/api/apps/v1"
Expand Down Expand Up @@ -167,6 +168,9 @@ func buildDeploymentApplyConfig(wp *atev1.WorkerPool, otel workerOTelSettings, s
}
previewArg := "--preview=" + strings.Join(enabledGates(), ",")
args = append(args, previewArg)
if wp.Spec.MaxActorsPerWorker != nil {
args = append(args, "--max-actors="+strconv.FormatInt(int64(*wp.Spec.MaxActorsPerWorker), 10))
}

containerAC := corev1ac.Container().
WithName("worker").
Expand Down
32 changes: 32 additions & 0 deletions cmd/atecontroller/internal/controllers/workerpool_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,38 @@ func TestBuildDeploymentApplyConfig(t *testing.T) {
}
}

func TestBuildDeploymentApplyConfigMaxActorsPerWorker(t *testing.T) {
for _, class := range []atev1.SandboxClass{atev1.SandboxClassGvisor, atev1.SandboxClassMicroVM} {
t.Run(string(class), func(t *testing.T) {
for _, tt := range []struct {
name string
limit *int32
want string
}{
{name: "omitted"},
{name: "zero", limit: ptr.To(int32(0)), want: "--max-actors=0"},
{name: "three", limit: ptr.To(int32(3)), want: "--max-actors=3"},
{name: "maximum", limit: ptr.To(int32(2147483647)), want: "--max-actors=2147483647"},
} {
t.Run(tt.name, func(t *testing.T) {
wp := testWorkerPoolApplyConfig(nil)
wp.Spec.SandboxClasses = []atev1.WorkerPoolSandboxClass{{Name: class}}
baseline := buildDeploymentApplyConfig(wp, workerOTelSettings{}, installdefaults.SystemNamespace, installdefaults.AteletServiceAccount, installdefaults.RouterServiceAccount, egresspolicy.DNSEgressPolicyEnforceInAtunnel)
want := append([]string(nil), baseline.Spec.Template.Spec.Containers[0].Args...)
if tt.want != "" {
want = append(want, tt.want)
}
wp.Spec.MaxActorsPerWorker = tt.limit
got := buildDeploymentApplyConfig(wp, workerOTelSettings{}, installdefaults.SystemNamespace, installdefaults.AteletServiceAccount, installdefaults.RouterServiceAccount, egresspolicy.DNSEgressPolicyEnforceInAtunnel)
if diff := cmp.Diff(want, got.Spec.Template.Spec.Containers[0].Args); diff != "" {
t.Fatalf("worker arguments mismatch (-want +got):\n%s", diff)
}
})
}
})
}
}

func TestBuildDeploymentApplyConfigMetadata(t *testing.T) {
wp := testWorkerPoolApplyConfig(&atev1.WorkerPoolPodTemplate{
Labels: map[string]atev1.WorkerPoolLabelValue{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"fmt"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -195,6 +197,49 @@ func TestWorkerPoolReplicasUpdate(t *testing.T) {
})
}

func TestWorkerPoolMaxActorsPerWorkerUpdate(t *testing.T) {
t.Parallel()
ctx := t.Context()
wp := makeWorkerPool("test-max-actors", "default", 1, "worker:v1")
wp.Spec.MaxActorsPerWorker = ptr.To(int32(3))
if err := k8sClient.Create(ctx, wp); err != nil {
t.Fatalf("create WorkerPool: %v", err)
}
deleteOnCleanup(t, wp)

for _, tt := range []struct {
name string
limit *int32
want string
}{
{name: "create", limit: ptr.To(int32(3)), want: "--max-actors=3"},
{name: "update", limit: ptr.To(int32(6)), want: "--max-actors=6"},
{name: "clear"},
} {
t.Run(tt.name, func(t *testing.T) {
if tt.name != "create" {
updateWorkerPoolSpec(t, ctx, wp, "update WorkerPool actor limit", func(current *atev1.WorkerPool) {
current.Spec.MaxActorsPerWorker = tt.limit
})
}
eventually(t, func(ctx context.Context) (bool, error) {
dep, err := getDeployment(ctx, wp)
if err != nil || len(dep.Spec.Template.Spec.Containers) == 0 {
return false, nil
}
args := dep.Spec.Template.Spec.Containers[0].Args
limits := slices.DeleteFunc(append([]string(nil), args...), func(arg string) bool {
return !strings.HasPrefix(arg, "--max-actors=")
})
if tt.want == "" {
return len(limits) == 0, nil
}
return len(limits) == 1 && limits[0] == tt.want, nil
})
})
}
}

// TestWorkerPoolImageUpdate verifies that changing spec.workerImage on a
// WorkerPool propagates to the managed Deployment.
func TestWorkerPoolImageUpdate(t *testing.T) {
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The `WorkerPool` defines the pool of physical "warm" compute capacity. It manage
| `replicas` | `int32` | **Required.** Number of physical standby pods to maintain in the cluster. |
| `workerImage` | `string` | **Required.** The container image for the `ateworker` process (e.g. `ko://go.ate.dev/substrate/cmd/ateworker-gvisor`). |
| `sandboxClasses` | `[]WorkerPoolSandboxClass` | **Required.** The sandbox runtime families the pool runs; exactly one entry today. Each entry has `name` (**required**, `gvisor` or `microvm`, immutable), which drives the worker pod shape (e.g. KVM device mounts, node placement), and `configRef.name` (optional), which names a cluster-scoped [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) of the same class. Without `configRef`, the pool uses the class default (see [Class default](#class-default)). The controller records the result in `status.sandboxClasses`. The sandbox binaries an actor boots with come from the `SandboxConfig` the pool of its assigned worker resolved. |
| `maxActorsPerWorker` | `int32` | **Optional.** Maximum actors each worker hosts at once. Zero accepts no actors. Omission keeps the worker default (1000). |
| `template` | `WorkerPoolPodTemplate` | **Optional.** Metadata, scheduling, and resource settings for worker workloads. |

#### `WorkerPoolPodTemplate` (`spec.template`)
Expand Down Expand Up @@ -53,7 +54,8 @@ metadata; they do not affect actor scheduling. Actor selectors match
Setting `resources.limits` (CPU and Memory) on a `WorkerPool` establishes each worker pod's **capacity** — the envelope its actor sandboxes share, taken from the `ateworker` container's limits. The scheduler only places an actor on a worker whose remaining capacity is `>=` the actor's declared resource limits (see [Sandbox Right-Sizing](#sandbox-right-sizing-resources) on the `ActorTemplate`).

- Worker capacity is a shared budget: each actor placed on a worker subtracts its declared limits from what is left. Size a pool's `limits` for the actors it should host together.
- A worker also has an actor limit, set by the ateworker's `--max-actors` flag (default 1000). Placement stops at whichever runs out first.
- `spec.maxActorsPerWorker` sets each worker's `--max-actors` flag. Omission keeps the worker default (1000). Placement stops when either the actor count or resource capacity runs out.
- Changing or removing `maxActorsPerWorker` rolls the workers through the normal drain process. The limit applies to each worker, not to the whole pool.
- A worker keeps some of its memory limit for itself and reports the rest as capacity. Each actor can also use memory on its worker outside its own limits, e.g. for the microVM's VMM. The worker reports this overhead with each runtime, placement counts it against capacity, and it shows in the worker's allocated resources.
- Capacity is advisory for placement only: a worker that declares no CPU/memory limit reports zero capacity for that dimension, which the scheduler treats as **unconstrained** (placement is never blocked by missing data). The actual sandbox size still comes from the `ActorTemplate`.

Expand All @@ -69,6 +71,7 @@ metadata:
workload: secret-agent
spec:
replicas: 10
maxActorsPerWorker: 3
workerImage: ko://go.ate.dev/substrate/cmd/ateworker-gvisor
sandboxClasses:
- name: gvisor
Expand Down
9 changes: 9 additions & 0 deletions manifests/ate-install/generated/ate.dev_workerpools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ spec:
spec:
description: spec defines the desired state of WorkerPool
properties:
maxActorsPerWorker:
description: |-
maxActorsPerWorker is the maximum number of actors each worker hosts at
once. When omitted, the worker uses its default limit. Zero prevents
workers from accepting actors. Changing this value rolls the workers.
format: int32
maximum: 2147483647
minimum: 0
type: integer
replicas:
description: replicas is the number of worker pods to run.
format: int32
Expand Down
Loading