Resolve SandboxConfig from the ActorTemplate instead of the WorkerPool (#1446)

This PR moves sandbox config selection from the WorkerPool to the
ActorTemplate.

The existing behavior is preserved while we are designing the upgrade:
sandbox config still cannot be updated once set (ActorTemplates are
create-only and `sandbox_config` is immutable).

For now the ActorTemplate still *requires* `sandbox_config.config_name`
— there is no resolution of the cluster default (`spec.default`). This
is temporary while we figure out the defaulting design.

- [ ] Tests pass
- [x] Appropriate changes to documentation are included in the PR
This commit is contained in:
Zoe Zhao
2026-09-03 16:13:07 -07:00
committed by GitHub
parent f11d3b2bb8
commit 6d3afdd63b
42 changed files with 369 additions and 361 deletions
+2 -2
View File
@@ -96,7 +96,7 @@ linters:
- path: 'pkg/api/v1alpha1/sandboxconfig_types\.go' - path: 'pkg/api/v1alpha1/sandboxconfig_types\.go'
text: '^requiredfields: .*\bAssetFile\.(SHA256|URL)\b' text: '^requiredfields: .*\bAssetFile\.(SHA256|URL)\b'
- path: 'pkg/api/v1alpha1/sandboxconfig_types\.go' - path: 'pkg/api/v1alpha1/sandboxconfig_types\.go'
text: '^(nomaps|optionalfields|requiredfields): .*\bSandboxConfigSpec\.(Assets|Default|PauseImage|SandboxClass)\b' text: '^(nomaps|optionalfields|requiredfields): .*\bSandboxConfigSpec\.(Assets|PauseImage|SandboxClass)\b'
- path: 'pkg/api/v1alpha1/sandboxconfig_types\.go' - path: 'pkg/api/v1alpha1/sandboxconfig_types\.go'
text: '^(nonpointerstructs|requiredfields): .*\bSandboxConfig\.Spec\b' text: '^(nonpointerstructs|requiredfields): .*\bSandboxConfig\.Spec\b'
- path: 'pkg/api/v1alpha1/csidriverconfig_types\.go' - path: 'pkg/api/v1alpha1/csidriverconfig_types\.go'
@@ -112,7 +112,7 @@ linters:
- path: 'pkg/api/v1alpha1/workerpool_types\.go' - path: 'pkg/api/v1alpha1/workerpool_types\.go'
text: '^optionalfields: .*\bWorkerPoolPodTemplate\.PriorityClassName\b' text: '^optionalfields: .*\bWorkerPoolPodTemplate\.PriorityClassName\b'
- path: 'pkg/api/v1alpha1/workerpool_types\.go' - path: 'pkg/api/v1alpha1/workerpool_types\.go'
text: '^(optionalfields|requiredfields): .*\bWorkerPoolSpec\.(Replicas|SandboxClass|SandboxConfigName|WorkerImage)\b' text: '^(optionalfields|requiredfields): .*\bWorkerPoolSpec\.(Replicas|SandboxClass|WorkerImage)\b'
- path: 'pkg/api/v1alpha1/workerpool_types\.go' - path: 'pkg/api/v1alpha1/workerpool_types\.go'
text: '^optionalfields: .*\bWorkerPoolStatus\.(ReadyReplicas|Replicas|Selector)\b' text: '^optionalfields: .*\bWorkerPoolStatus\.(ReadyReplicas|Replicas|Selector)\b'
- path: 'pkg/api/v1alpha1/workerpool_types\.go' - path: 'pkg/api/v1alpha1/workerpool_types\.go'
+2 -2
View File
@@ -315,8 +315,8 @@ def teardown_substrate() -> None:
def install_microvm_deps() -> None: def install_microvm_deps() -> None:
"""Stage kata/cloud-hypervisor assets and apply the cluster-wide """Stage kata/cloud-hypervisor assets and apply the cluster-wide
microvm SandboxConfig. Required before a microvm WorkerPool can microvm SandboxConfig. Required before a microvm ActorTemplate can
schedule; must run after deploy_substrate() (which installs the CRDs).""" boot; must run after deploy_substrate() (which installs the CRDs)."""
run(["hack/install-microvm-deps.sh", "--install"]) run(["hack/install-microvm-deps.sh", "--install"])
+5 -5
View File
@@ -115,11 +115,11 @@ run_kubectl_ate() {
} }
substitute() { substitute() {
# SandboxConfig names are pinned per class (rather than defaulted) so a stale # SandboxConfig names are pinned per class in the ActorTemplates (rather
# config from a dirty teardown fails loudly instead of silently binding this # than defaulted) so a stale config from a dirty teardown fails loudly
# pool. gvisor-default is applied by hack/install-ate.sh; microvm is applied # instead of silently binding these workloads. gvisor-default is applied by
# by hack/install-microvm-deps.sh. The protojson templates take the sandbox # hack/install-ate.sh; microvm is applied by hack/install-microvm-deps.sh.
# class as its proto enum spelling. # The protojson templates take the sandbox class as its proto enum spelling.
local manifest="$1" local manifest="$1"
local sandbox_config_name sandbox_class_enum local sandbox_config_name sandbox_class_enum
case "${SANDBOX_CLASS}" in case "${SANDBOX_CLASS}" in
@@ -34,5 +34,4 @@ metadata:
spec: spec:
replicas: ${WORKER_COUNT} replicas: ${WORKER_COUNT}
sandboxClass: ${SANDBOX_CLASS} sandboxClass: ${SANDBOX_CLASS}
sandboxConfigName: ${SANDBOX_CONFIG_NAME}
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-${SANDBOX_CLASS} workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-${SANDBOX_CLASS}
+5 -5
View File
@@ -101,15 +101,15 @@ func (e *Env) DeployAteSystem(ctx context.Context, opts DeployOptions) error {
} }
// Enforce per-class SandboxConfig asset requirements. This is applied // Enforce per-class SandboxConfig asset requirements. This is applied
// before any SandboxConfig so the default below is validated too. // before any SandboxConfig so the config below is validated too.
if err := e.Kube.ApplyPath(ctx, e.Cfg.Manifest("sandboxconfig-validation.yaml")); err != nil { if err := e.Kube.ApplyPath(ctx, e.Cfg.Manifest("sandboxconfig-validation.yaml")); err != nil {
return err return err
} }
// Install the cluster-wide default sandbox config. Sandbox binaries live // Install the cluster-wide sandbox config. Sandbox binaries live on
// on cluster-scoped SandboxConfigs resolved via each WorkerPool's // cluster-scoped SandboxConfigs each ActorTemplate names via
// SandboxClass, decoupled from ActorTemplate; gVisor pools resolve to this // sandboxConfig.configName; gVisor templates name this one unless they
// default unless they name their own SandboxConfig. // create their own SandboxConfig.
if err := e.Kube.ApplyPath(ctx, e.Cfg.Manifest("sandboxconfig-gvisor.yaml")); err != nil { if err := e.Kube.ApplyPath(ctx, e.Cfg.Manifest("sandboxconfig-gvisor.yaml")); err != nil {
return err return err
} }
+16 -21
View File
@@ -281,8 +281,8 @@ func (s *ServiceImpl) UpdateActor(ctx context.Context, actorRef resources.ActorR
// Update actor template is only allowed while the actor is suspended. // Update actor template is only allowed while the actor is suspended.
// The repointed ref must also resolve, mirroring CreateActor's // The repointed ref must also resolve, mirroring CreateActor's
// check (same non-atomicity caveat; resume re-resolves and fails // check (same non-atomicity caveat; resume re-resolves and fails
// cleanly), and the replacement's sandbox class, volumes, and volume // cleanly), and the replacement's sandbox config, volumes, and
// mounts must match the old template's. // volume mounts must match the old template's.
if !proto.Equal(oldVal.GetActorTemplate(), newVal.GetActorTemplate()) { if !proto.Equal(oldVal.GetActorTemplate(), newVal.GetActorTemplate()) {
if state := oldVal.GetStatus().GetState(); state != ateapipb.ActorState_ACTOR_STATE_SUSPENDED { if state := oldVal.GetStatus().GetState(); state != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
return status.Errorf(codes.FailedPrecondition, return status.Errorf(codes.FailedPrecondition,
@@ -293,15 +293,24 @@ func (s *ServiceImpl) UpdateActor(ctx context.Context, actorRef resources.ActorR
return err return err
} }
oldTemplate, err := resolveActorTemplate(ctx, s.store, oldVal) oldTemplate, err := resolveActorTemplate(ctx, s.store, oldVal)
if err == nil { switch {
if err := validateTemplateSandboxClassUnchanged(oldTemplate, newTemplate); err != nil { case err == nil:
return err // Snapshots are not portable across sandbox runtime
// families, so the replacement template must name the same
// SandboxConfig.
if !proto.Equal(oldTemplate.GetSandboxConfig(), newTemplate.GetSandboxConfig()) {
oldSC, newSC := oldTemplate.GetSandboxConfig(), newTemplate.GetSandboxConfig()
return status.Errorf(codes.FailedPrecondition,
"the current actor template names SandboxConfig %q (class %s) but the new one names %q (class %s); the sandbox config must be identical to repoint an actor",
oldSC.GetConfigName(), oldSC.GetSandboxClass(), newSC.GetConfigName(), newSC.GetSandboxClass())
} }
if err := validateTemplateVolumesUnchanged(oldTemplate, newTemplate); err != nil { if err := validateTemplateVolumesUnchanged(oldTemplate, newTemplate); err != nil {
return err return err
} }
} else if !errors.Is(err, errActorTemplateNotFound) { case errors.Is(err, errActorTemplateNotFound):
// Skip the validation if old template is not found // The old template is gone, so there is nothing left to
// compare the sandbox config or volume layout against.
default:
return err return err
} }
} }
@@ -331,20 +340,6 @@ func (s *ServiceImpl) UpdateActor(ctx context.Context, actorRef resources.ActorR
return storedActor, nil return storedActor, nil
} }
// validateTemplateSandboxClassUnchanged rejects a template repoint that
// changes the sandbox class: snapshots are not portable across sandbox
// runtime families, so the actor's saved state could not be restored under
// the new template.
func validateTemplateSandboxClassUnchanged(oldTemplate, newTemplate *ateapipb.ActorTemplate) error {
oldClass := oldTemplate.GetSandboxConfig().GetSandboxClass()
newClass := newTemplate.GetSandboxConfig().GetSandboxClass()
if oldClass != newClass {
return status.Errorf(codes.FailedPrecondition,
"sandbox class differs between the current (%s) and the new (%s) actor template; the sandbox class must be identical to repoint an actor", oldClass, newClass)
}
return nil
}
// validateTemplateVolumesUnchanged rejects a template repoint that changes // validateTemplateVolumesUnchanged rejects a template repoint that changes
// the template's volumes or any container's volume mounts: an actor's // the template's volumes or any container's volume mounts: an actor's
// snapshot data is laid out per the volumes and mount paths it was captured // snapshot data is laid out per the volumes and mount paths it was captured
@@ -45,6 +45,12 @@ func (s *RPCService) CreateActorTemplate(ctx context.Context, req *ateapipb.Crea
return nil, toGRPCStatusError(errs) return nil, toGRPCStatusError(errs)
} }
// config_name is required; the declarative validation has already
// rejected an empty one.
if _, err := resolveTemplateSandboxConfig(s.sandboxConfigLister, in.GetSandboxConfig()); err != nil {
return nil, err
}
templateRef := resources.ActorTemplateRefFromActorTemplate(in) templateRef := resources.ActorTemplateRefFromActorTemplate(in)
stored, err := s.impl.CreateActorTemplate(ctx, in) stored, err := s.impl.CreateActorTemplate(ctx, in)
@@ -63,7 +69,6 @@ func (s *RPCService) CreateActorTemplate(ctx context.Context, req *ateapipb.Crea
func (s *ServiceImpl) CreateActorTemplate(ctx context.Context, inTemplate *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error) { func (s *ServiceImpl) CreateActorTemplate(ctx context.Context, inTemplate *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error) {
// Build the stored object: status is server-owned and starts empty. // Build the stored object: status is server-owned and starts empty.
// TODO: check that sandbox_config.config_name matches sandbox_class.
outTemplate := proto.Clone(inTemplate).(*ateapipb.ActorTemplate) outTemplate := proto.Clone(inTemplate).(*ateapipb.ActorTemplate)
outTemplate.Status = &ateapipb.ActorTemplateStatus{} outTemplate.Status = &ateapipb.ActorTemplateStatus{}
@@ -23,12 +23,15 @@ import (
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/resources"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/testing/protocmp"
"k8s.io/apimachinery/pkg/api/operation" "k8s.io/apimachinery/pkg/api/operation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
) )
@@ -174,11 +177,72 @@ func TestValidateCreateActorTemplateRequest(t *testing.T) {
} }
} }
// gvisorDefaultLister returns a SandboxConfig lister seeded with the
// "gvisor-default" config that validActorTemplate names.
func gvisorDefaultLister(t *testing.T) listersv1alpha1.SandboxConfigLister {
t.Helper()
return sandboxConfigListerFor(t, []*atev1alpha1.SandboxConfig{{
ObjectMeta: metav1.ObjectMeta{Name: "gvisor-default"},
Spec: atev1alpha1.SandboxConfigSpec{
SandboxClass: atev1alpha1.SandboxClassGvisor,
PauseImage: "registry.k8s.io/pause@sha256:x",
Assets: testAssets(),
},
}})
}
// TestCreateActorTemplate_SandboxConfigChecks pins the create-time checks on
// the template's named SandboxConfig: it must exist and match the template's
// class, both FailedPrecondition — they depend on cluster state, and the
// lister may briefly lag a just-created config, so the error is retryable.
func TestCreateActorTemplate_SandboxConfigChecks(t *testing.T) {
persistence := newTestPersistence(t)
s := &RPCService{impl: newServiceImpl(persistence, nil), sandboxConfigLister: gvisorDefaultLister(t)}
ctx := context.Background()
if _, err := persistence.CreateAtespace(ctx, &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "ns1"}}); err != nil {
t.Fatalf("CreateAtespace failed: %v", err)
}
tests := []struct {
name string
sandbox *ateapipb.SandboxConfig
wantCode codes.Code
}{{
name: "named config exists and matches",
sandbox: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"},
wantCode: codes.OK,
}, {
name: "empty config_name is rejected",
sandbox: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM},
wantCode: codes.InvalidArgument,
}, {
name: "named config missing",
sandbox: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "does-not-exist"},
wantCode: codes.FailedPrecondition,
}, {
name: "named config class mismatch",
sandbox: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM, ConfigName: "gvisor-default"},
wantCode: codes.FailedPrecondition,
}}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Metadata = &ateapipb.ResourceMetadata{Atespace: "ns1", Name: fmt.Sprintf("tmpl-%d", i)}
tmpl.SandboxConfig = tt.sandbox
})}
_, err := s.CreateActorTemplate(ctx, req)
if status.Code(err) != tt.wantCode {
t.Errorf("CreateActorTemplate error = %v, want code %v", err, tt.wantCode)
}
})
}
}
// TestCreateActorTemplate covers the atespace precondition: creation fails // TestCreateActorTemplate covers the atespace precondition: creation fails
// while the atespace is missing, and succeeds once the atespace exists. // while the atespace is missing, and succeeds once the atespace exists.
func TestCreateActorTemplate(t *testing.T) { func TestCreateActorTemplate(t *testing.T) {
persistence := newTestPersistence(t) persistence := newTestPersistence(t)
s := &RPCService{impl: newServiceImpl(persistence, nil)} s := &RPCService{impl: newServiceImpl(persistence, nil), sandboxConfigLister: gvisorDefaultLister(t)}
ctx := context.Background() ctx := context.Background()
req := func(atespace, name string) *ateapipb.CreateActorTemplateRequest { req := func(atespace, name string) *ateapipb.CreateActorTemplateRequest {
return &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { return &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
@@ -208,7 +272,7 @@ func TestCreateActorTemplate(t *testing.T) {
// the only guard. // the only guard.
func TestCreateActorTemplateIgnoresServerOwnedFields(t *testing.T) { func TestCreateActorTemplateIgnoresServerOwnedFields(t *testing.T) {
persistence := newTestPersistence(t) persistence := newTestPersistence(t)
s := &RPCService{impl: newServiceImpl(persistence, nil)} s := &RPCService{impl: newServiceImpl(persistence, nil), sandboxConfigLister: gvisorDefaultLister(t)}
ctx := context.Background() ctx := context.Background()
if _, err := persistence.CreateAtespace(ctx, &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "ns1"}}); err != nil { if _, err := persistence.CreateAtespace(ctx, &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "ns1"}}); err != nil {
+23 -52
View File
@@ -870,8 +870,8 @@ func TestUpdateActor(t *testing.T) {
// TestUpdateActor_RepointTemplate covers the mutable actor_template ref: an // TestUpdateActor_RepointTemplate covers the mutable actor_template ref: an
// update may point a suspended actor at a different template (it takes effect // update may point a suspended actor at a different template (it takes effect
// on the next ResumeActor), but the actor must be suspended, the new ref must // on the next ResumeActor), but the actor must be suspended, the new ref must
// resolve, and the replacement's volumes and volume mounts must match the old // resolve, and the replacement's sandbox config, volumes, and volume mounts
// template's. // must match the old template's.
func TestUpdateActor_RepointTemplate(t *testing.T) { func TestUpdateActor_RepointTemplate(t *testing.T) {
ctx := context.Background() ctx := context.Background()
persistence, cleanup := storetest.SetupTestStore(t) persistence, cleanup := storetest.SetupTestStore(t)
@@ -946,13 +946,13 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
t.Fatalf("UpdateActor to a template with different volumes = %v, want FailedPrecondition (err: %v)", got, err) t.Fatalf("UpdateActor to a template with different volumes = %v, want FailedPrecondition (err: %v)", got, err)
} }
// Repointing at a template with a different sandbox class is rejected. // Repointing at a template naming a different SandboxConfig is rejected.
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{ _, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
Metadata: created.GetMetadata(), Metadata: created.GetMetadata(),
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-e"}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-e"},
}}) }})
if got := status.Code(err); got != codes.FailedPrecondition { if got := status.Code(err); got != codes.FailedPrecondition {
t.Fatalf("UpdateActor to a template with a different sandbox class = %v, want FailedPrecondition (err: %v)", got, err) t.Fatalf("UpdateActor to a template with a different sandbox config = %v, want FailedPrecondition (err: %v)", got, err)
} }
// Repointing at an existing template with identical volumes and mounts // Repointing at an existing template with identical volumes and mounts
@@ -968,6 +968,25 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
t.Errorf("updated actor_template.name = %q, want %q", got, want) t.Errorf("updated actor_template.name = %q, want %q", got, want)
} }
// When the old template no longer exists there is nothing left to
// compare the sandbox config or volume layout against, so the repoint
// only requires the new ref to resolve.
orphan := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "orphan-actor"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-gone"},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
})
repointed, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
Metadata: orphan.GetMetadata(),
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-e"},
}})
if err != nil {
t.Fatalf("UpdateActor from a deleted template failed: %v", err)
}
if got, want := repointed.GetActorTemplate().GetName(), "tmpl-e"; got != want {
t.Errorf("updated actor_template.name = %q, want %q", got, want)
}
// Repointing an actor that is not suspended is rejected, even at a // Repointing an actor that is not suspended is rejected, even at a
// compatible template. // compatible template.
running := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{ running := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
@@ -998,54 +1017,6 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
} }
} }
// TestValidateTemplateSandboxClassUnchanged exercises the sandbox class
// comparison applied when an actor is repointed at a replacement template.
func TestValidateTemplateSandboxClassUnchanged(t *testing.T) {
template := func(config *ateapipb.SandboxConfig) *ateapipb.ActorTemplate {
return &ateapipb.ActorTemplate{SandboxConfig: config}
}
gvisorDefault := &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"}
gvisorNightly := &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-nightly"}
microvm := &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM, ConfigName: "microvm"}
tests := []struct {
name string
oldTmpl, newTmpl *ateapipb.ActorTemplate
wantErr bool
}{{
name: "same sandbox class",
oldTmpl: template(gvisorDefault),
newTmpl: template(gvisorDefault),
}, {
name: "same class with a different config name",
oldTmpl: template(gvisorDefault),
newTmpl: template(gvisorNightly),
}, {
name: "class changed",
oldTmpl: template(gvisorDefault),
newTmpl: template(microvm),
wantErr: true,
}, {
name: "class set on the new template only",
oldTmpl: template(nil),
newTmpl: template(microvm),
wantErr: true,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTemplateSandboxClassUnchanged(tt.oldTmpl, tt.newTmpl)
if gotErr := err != nil; gotErr != tt.wantErr {
t.Fatalf("validateTemplateSandboxClassUnchanged() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil {
if got := status.Code(err); got != codes.FailedPrecondition {
t.Errorf("status code = %v, want FailedPrecondition", got)
}
}
})
}
}
// TestValidateTemplateVolumesUnchanged exercises the volumes and // TestValidateTemplateVolumesUnchanged exercises the volumes and
// per-container mount comparison applied when an actor is repointed at a // per-container mount comparison applied when an actor is repointed at a
// replacement template. // replacement template.
@@ -29,6 +29,7 @@ func TestActorTemplateCRUD(t *testing.T) {
tc := setupTest(t, ns) tc := setupTest(t, ns)
defer tc.cleanup() defer tc.cleanup()
ctx := context.Background() ctx := context.Background()
ensureDefaultGvisorSandboxConfig(t, tc)
created, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{ created, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{ ActorTemplate: &ateapipb.ActorTemplate{
@@ -120,4 +121,15 @@ func TestActorTemplateCRUD(t *testing.T) {
assertGrpcError(t, err, codes.NotFound, "ActorTemplate "+testAtespace+"/tmpl-a not found") assertGrpcError(t, err, codes.NotFound, "ActorTemplate "+testAtespace+"/tmpl-a not found")
_, err = tc.client.DeleteActorTemplate(ctx, &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-a"}}) _, err = tc.client.DeleteActorTemplate(ctx, &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-a"}})
assertGrpcError(t, err, codes.NotFound, "ActorTemplate "+testAtespace+"/tmpl-a not found") assertGrpcError(t, err, codes.NotFound, "ActorTemplate "+testAtespace+"/tmpl-a not found")
// config_name is required: a template must name its SandboxConfig.
_, err = tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl-unnamed-config"},
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1"}},
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR},
},
})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, `sandbox_config\.config_name`)
} }
@@ -180,6 +180,7 @@ func TestCreateActor_SubstrateTemplateRef(t *testing.T) {
defer tc.cleanup() defer tc.cleanup()
ctx := context.Background() ctx := context.Background()
ensureDefaultGvisorSandboxConfig(t, tc)
if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{ if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{ ActorTemplate: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "sub-tmpl"}, Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "sub-tmpl"},
@@ -313,6 +314,7 @@ func TestCreateActor_RejectsSnapshotWithExternalVolumes(t *testing.T) {
ns := namespaceForTest("ns-snapshot-external-volume") ns := namespaceForTest("ns-snapshot-external-volume")
tc := setupTest(t, ns) tc := setupTest(t, ns)
defer tc.cleanup() defer tc.cleanup()
ensureDefaultGvisorSandboxConfig(t, tc)
template, err := tc.client.CreateActorTemplate(context.Background(), &ateapipb.CreateActorTemplateRequest{ template, err := tc.client.CreateActorTemplate(context.Background(), &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{ ActorTemplate: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl1"}, Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl1"},
@@ -667,8 +669,8 @@ func TestUpdateActor_Success(t *testing.T) {
// TestUpdateActor_RepointTemplate verifies UpdateActor can point an actor at // TestUpdateActor_RepointTemplate verifies UpdateActor can point an actor at
// a different substrate ActorTemplate (effective on the next ResumeActor), // a different substrate ActorTemplate (effective on the next ResumeActor),
// and that a ref to an absent template, or to one with different volumes or // and that a ref to an absent template, or to one with a different sandbox
// volume mounts, is rejected. // config, volumes, or volume mounts, is rejected.
func TestUpdateActor_RepointTemplate(t *testing.T) { func TestUpdateActor_RepointTemplate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -678,6 +680,7 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
{name: "absent-template", template: "absent", wantCode: codes.FailedPrecondition}, {name: "absent-template", template: "absent", wantCode: codes.FailedPrecondition},
{name: "different-mounts", template: "tmpl-c", wantCode: codes.FailedPrecondition}, {name: "different-mounts", template: "tmpl-c", wantCode: codes.FailedPrecondition},
{name: "different-volumes", template: "tmpl-d", wantCode: codes.FailedPrecondition}, {name: "different-volumes", template: "tmpl-d", wantCode: codes.FailedPrecondition},
{name: "different-sandbox-config", template: "tmpl-e", wantCode: codes.FailedPrecondition},
{name: "same-volumes", template: "tmpl-b", wantCode: codes.OK}, {name: "same-volumes", template: "tmpl-b", wantCode: codes.OK},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -687,18 +690,23 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
defer tc.cleanup() defer tc.cleanup()
ctx := context.Background() ctx := context.Background()
ensureDefaultGvisorSandboxConfig(t, tc)
ensureGvisorSandboxConfig(t, tc, "gvisor-nightly")
// tmpl-a and tmpl-b are volume-compatible; tmpl-c mounts the data // tmpl-a and tmpl-b are volume-compatible; tmpl-c mounts the data
// volume elsewhere and tmpl-d declares an extra volume. // volume elsewhere, tmpl-d declares an extra volume, and tmpl-e
// names a different SandboxConfig.
dataVolume := &ateapipb.Volume{Name: "data", DurableDir: &ateapipb.DurableDirVolumeSource{}} dataVolume := &ateapipb.Volume{Name: "data", DurableDir: &ateapipb.DurableDirVolumeSource{}}
scratchVolume := &ateapipb.Volume{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}} scratchVolume := &ateapipb.Volume{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}
templates := map[string]struct { templates := map[string]struct {
mountPath string mountPath string
volumes []*ateapipb.Volume volumes []*ateapipb.Volume
configName string
}{ }{
"tmpl-a": {"/data", []*ateapipb.Volume{dataVolume}}, "tmpl-a": {"/data", []*ateapipb.Volume{dataVolume}, "gvisor-default"},
"tmpl-b": {"/data", []*ateapipb.Volume{dataVolume}}, "tmpl-b": {"/data", []*ateapipb.Volume{dataVolume}, "gvisor-default"},
"tmpl-c": {"/mnt/data", []*ateapipb.Volume{dataVolume}}, "tmpl-c": {"/mnt/data", []*ateapipb.Volume{dataVolume}, "gvisor-default"},
"tmpl-d": {"/data", []*ateapipb.Volume{dataVolume, scratchVolume}}, "tmpl-d": {"/data", []*ateapipb.Volume{dataVolume, scratchVolume}, "gvisor-default"},
"tmpl-e": {"/data", []*ateapipb.Volume{dataVolume}, "gvisor-nightly"},
} }
for name, tmpl := range templates { for name, tmpl := range templates {
if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{ if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
@@ -711,7 +719,7 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
}}, }},
Volumes: tmpl.volumes, Volumes: tmpl.volumes,
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"}, SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"}, SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: tmpl.configName},
}, },
}); err != nil { }); err != nil {
t.Fatalf("CreateActorTemplate %s failed: %v", name, err) t.Fatalf("CreateActorTemplate %s failed: %v", name, err)
@@ -176,7 +176,7 @@ func setupTestWithVolumePlugins(t *testing.T, ns string, plugins map[string]volu
mockDriverName: mockPlugin, mockDriverName: mockPlugin,
} }
} }
service := controlapi.NewRPCService(persistence, wc, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, scLister, dialer, instruments, "", volPlugins) service := controlapi.NewRPCService(persistence, wc, sandboxConfigLister, csiDriverConfigLister, scLister, dialer, instruments, "", volPlugins)
// 5. Start REAL gRPC Server for ATE API // 5. Start REAL gRPC Server for ATE API
grpcServer := grpc.NewServer(grpc.ChainUnaryInterceptor( grpcServer := grpc.NewServer(grpc.ChainUnaryInterceptor(
@@ -305,8 +305,8 @@ func createTemplateWithContainersAndVolumes(t *testing.T, tc *testContext, ns st
t.Helper() t.Helper()
// Sandbox binaries live on a (cluster-scoped) SandboxConfig the template // Sandbox binaries live on a (cluster-scoped) SandboxConfig the template
// names. Create a default gvisor SandboxConfig so a boot-from-spec Run can // names. Create the gvisor-default SandboxConfig so a boot-from-spec Run
// resolve its assets. // can resolve its assets.
ensureDefaultGvisorSandboxConfig(t, tc) ensureDefaultGvisorSandboxConfig(t, tc)
createWorkerPool(t, tc, ns, "pool1", map[string]string{poolLabelKey: ns}) createWorkerPool(t, tc, ns, "pool1", map[string]string{poolLabelKey: ns})
@@ -367,16 +367,21 @@ func createTemplateWithContainersAndVolumes(t *testing.T, tc *testContext, ns st
// it is what a resolved WorkloadSpec's sandbox assets should name. // it is what a resolved WorkloadSpec's sandbox assets should name.
const testPauseImage = "pause@sha256:abc" const testPauseImage = "pause@sha256:abc"
// ensureDefaultGvisorSandboxConfig creates the cluster-scoped default gvisor // ensureDefaultGvisorSandboxConfig creates the cluster-scoped "gvisor-default"
// SandboxConfig (idempotently) and waits for it to appear in the lister. // SandboxConfig (idempotently) and waits for it to appear in the lister.
func ensureDefaultGvisorSandboxConfig(t *testing.T, tc *testContext) { func ensureDefaultGvisorSandboxConfig(t *testing.T, tc *testContext) {
t.Helper() t.Helper()
const name = "gvisor-default" ensureGvisorSandboxConfig(t, tc, "gvisor-default")
}
// ensureGvisorSandboxConfig creates a cluster-scoped gvisor SandboxConfig
// (idempotently) and waits for it to appear in the lister.
func ensureGvisorSandboxConfig(t *testing.T, tc *testContext, name string) {
t.Helper()
sc := &atev1alpha1.SandboxConfig{ sc := &atev1alpha1.SandboxConfig{
ObjectMeta: metav1.ObjectMeta{Name: name}, ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: atev1alpha1.SandboxConfigSpec{ Spec: atev1alpha1.SandboxConfigSpec{
SandboxClass: atev1alpha1.SandboxClassGvisor, SandboxClass: atev1alpha1.SandboxClassGvisor,
Default: true,
PauseImage: testPauseImage, PauseImage: testPauseImage,
Assets: map[string]map[string]atev1alpha1.AssetFile{ Assets: map[string]map[string]atev1alpha1.AssetFile{
"amd64": {"runsc": { "amd64": {"runsc": {
@@ -391,13 +396,13 @@ func ensureDefaultGvisorSandboxConfig(t *testing.T, tc *testContext) {
}, },
} }
if _, err := tc.substrateClient.ApiV1alpha1().SandboxConfigs().Create(context.Background(), sc, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { if _, err := tc.substrateClient.ApiV1alpha1().SandboxConfigs().Create(context.Background(), sc, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) {
t.Fatalf("failed to create default SandboxConfig: %v", err) t.Fatalf("failed to create SandboxConfig %s: %v", name, err)
} }
if err := wait.PollUntilContextTimeout(context.Background(), 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) { if err := wait.PollUntilContextTimeout(context.Background(), 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) {
_, err := tc.sandboxConfigLister.Get(name) _, err := tc.sandboxConfigLister.Get(name)
return err == nil, nil return err == nil, nil
}); err != nil { }); err != nil {
t.Fatalf("default SandboxConfig not synced into lister: %v", err) t.Fatalf("SandboxConfig %s not synced into lister: %v", name, err)
} }
} }
@@ -20,76 +20,62 @@ import (
"github.com/agent-substrate/substrate/internal/proto/ateletpb" "github.com/agent-substrate/substrate/internal/proto/ateletpb"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1" listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
"k8s.io/apimachinery/pkg/labels" "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
) )
// resolveTemplateSandboxConfig resolves the SandboxConfig the ActorTemplate
// names via sandbox_config.config_name and checks that its class matches the
// template's sandbox_class.
func resolveTemplateSandboxConfig(
sandboxConfigLister listersv1alpha1.SandboxConfigLister,
templateSandbox *ateapipb.SandboxConfig,
) (*atev1alpha1.SandboxConfig, error) {
name := templateSandbox.GetConfigName()
sc, err := sandboxConfigLister.Get(name)
if k8serrors.IsNotFound(err) {
return nil, status.Errorf(codes.FailedPrecondition, "SandboxConfig %q not found", name)
}
if err != nil {
return nil, fmt.Errorf("while getting SandboxConfig %q: %w", name, err)
}
if class := sandboxClassString(templateSandbox.GetSandboxClass()); string(sc.Spec.SandboxClass) != class {
return nil, status.Errorf(codes.FailedPrecondition,
"SandboxConfig %q has class %q but sandbox_config.sandbox_class is %q", name, sc.Spec.SandboxClass, class)
}
return sc, nil
}
// resolveSandboxAssets determines the sandbox binaries and pause image an actor // resolveSandboxAssets determines the sandbox binaries and pause image an actor
// should boot with and projects them onto the ateletpb.SandboxAssets atelet // should boot with and projects them onto the ateletpb.SandboxAssets atelet
// fetches. It takes the SandboxClass (default gvisor) of a given worker pool, // fetches: the SandboxConfig the ActorTemplate names via
// then picks the SandboxConfig named by the pool — or, if none is named, the // sandbox_config.config_name (required; enforced by CreateActorTemplate),
// cluster default SandboxConfig for that class. // checked against the template's sandbox_class.
func resolveSandboxAssets( func resolveSandboxAssets(
workerPoolLister listersv1alpha1.WorkerPoolLister,
sandboxConfigLister listersv1alpha1.SandboxConfigLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister,
poolNamespace, poolName string, templateSandbox *ateapipb.SandboxConfig,
) (*ateletpb.SandboxAssets, error) { ) (*ateletpb.SandboxAssets, error) {
wp, err := workerPoolLister.WorkerPools(poolNamespace).Get(poolName) if sandboxClassString(templateSandbox.GetSandboxClass()) == "" {
return nil, fmt.Errorf("ActorTemplate names unrecognized sandbox_class %v", templateSandbox.GetSandboxClass())
}
if templateSandbox.GetConfigName() == "" {
return nil, fmt.Errorf("ActorTemplate names no sandbox_config.config_name")
}
sc, err := resolveTemplateSandboxConfig(sandboxConfigLister, templateSandbox)
if err != nil { if err != nil {
return nil, fmt.Errorf("while getting WorkerPool %s/%s: %w", poolNamespace, poolName, err) return nil, err
} }
return sandboxAssetsProto(sc), nil
class := wp.Spec.SandboxClass
if class == "" {
class = atev1alpha1.SandboxClassGvisor
}
var sc *atev1alpha1.SandboxConfig
if name := wp.Spec.SandboxConfigName; name != "" {
sc, err = sandboxConfigLister.Get(name)
if err != nil {
return nil, fmt.Errorf("while getting SandboxConfig %q: %w", name, err)
}
if sc.Spec.SandboxClass != class {
return nil, fmt.Errorf("SandboxConfig %q has class %q but WorkerPool %s/%s is class %q",
name, sc.Spec.SandboxClass, poolNamespace, poolName, class)
}
} else {
sc, err = defaultSandboxConfig(sandboxConfigLister, class)
if err != nil {
return nil, err
}
}
return sandboxAssetsProto(class, sc), nil
}
// defaultSandboxConfig returns the single SandboxConfig marked Default for the
// given class, erroring if there are zero or more than one.
func defaultSandboxConfig(lister listersv1alpha1.SandboxConfigLister, class atev1alpha1.SandboxClass) (*atev1alpha1.SandboxConfig, error) {
all, err := lister.List(labels.Everything())
if err != nil {
return nil, fmt.Errorf("while listing SandboxConfigs: %w", err)
}
var match *atev1alpha1.SandboxConfig
for _, sc := range all {
if sc.Spec.SandboxClass == class && sc.Spec.Default {
if match != nil {
return nil, fmt.Errorf("multiple default SandboxConfigs for class %q (%q and %q)", class, match.Name, sc.Name)
}
match = sc
}
}
if match == nil {
return nil, fmt.Errorf("no default SandboxConfig for class %q; set one with spec.default=true or name one via WorkerPool.spec.sandboxConfigName", class)
}
return match, nil
} }
// sandboxAssetsProto converts a resolved SandboxConfig into the proto atelet // sandboxAssetsProto converts a resolved SandboxConfig into the proto atelet
// consumes. // consumes.
func sandboxAssetsProto(class atev1alpha1.SandboxClass, sc *atev1alpha1.SandboxConfig) *ateletpb.SandboxAssets { func sandboxAssetsProto(sc *atev1alpha1.SandboxConfig) *ateletpb.SandboxAssets {
out := &ateletpb.SandboxAssets{ out := &ateletpb.SandboxAssets{
SandboxClass: string(class), SandboxClass: string(sc.Spec.SandboxClass),
PauseImage: sc.Spec.PauseImage, PauseImage: sc.Spec.PauseImage,
Assets: make(map[string]*ateletpb.ArchAssets, len(sc.Spec.Assets)), Assets: make(map[string]*ateletpb.ArchAssets, len(sc.Spec.Assets)),
} }
@@ -15,31 +15,27 @@
package controlapi package controlapi
import ( import (
"strings"
"testing" "testing"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1" listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/cache"
) )
// listerFor builds listers over the given objects, using the same key // sandboxConfigListerFor builds a lister over the given SandboxConfigs, using
// functions the informers use. // the same key function the informers use.
func listersFor(t *testing.T, pools []*atev1alpha1.WorkerPool, configs []*atev1alpha1.SandboxConfig) (listersv1alpha1.WorkerPoolLister, listersv1alpha1.SandboxConfigLister) { func sandboxConfigListerFor(t *testing.T, configs []*atev1alpha1.SandboxConfig) listersv1alpha1.SandboxConfigLister {
t.Helper() t.Helper()
poolIdx := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc})
for _, p := range pools {
if err := poolIdx.Add(p); err != nil {
t.Fatalf("adding WorkerPool: %v", err)
}
}
configIdx := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) configIdx := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
for _, c := range configs { for _, c := range configs {
if err := configIdx.Add(c); err != nil { if err := configIdx.Add(c); err != nil {
t.Fatalf("adding SandboxConfig: %v", err) t.Fatalf("adding SandboxConfig: %v", err)
} }
} }
return listersv1alpha1.NewWorkerPoolLister(poolIdx), listersv1alpha1.NewSandboxConfigLister(configIdx) return listersv1alpha1.NewSandboxConfigLister(configIdx)
} }
func testAssets() map[string]map[string]atev1alpha1.AssetFile { func testAssets() map[string]map[string]atev1alpha1.AssetFile {
@@ -48,23 +44,12 @@ func testAssets() map[string]map[string]atev1alpha1.AssetFile {
} }
} }
// TestResolveSandboxAssetsCarriesPauseImage pins that the pause image travels // TestResolveSandboxAssets pins the template-side resolution: the config the
// with the sandbox binaries — it is resolved from the pool's SandboxConfig, not // template names is resolved (with its class checked), an empty name or an
// from the ActorTemplate — for both the named and the class-default config. // unrecognized class is an error, and the pause image travels with the
func TestResolveSandboxAssetsCarriesPauseImage(t *testing.T) { // sandbox binaries.
const ( func TestResolveSandboxAssets(t *testing.T) {
defaultPause = "registry.k8s.io/pause@sha256:default" const namedPause = "gcr.io/gke-release/pause@sha256:named"
namedPause = "gcr.io/gke-release/pause@sha256:named"
)
defaultConfig := &atev1alpha1.SandboxConfig{
ObjectMeta: metav1.ObjectMeta{Name: "gvisor-default"},
Spec: atev1alpha1.SandboxConfigSpec{
SandboxClass: atev1alpha1.SandboxClassGvisor,
Default: true,
PauseImage: defaultPause,
Assets: testAssets(),
},
}
namedConfig := &atev1alpha1.SandboxConfig{ namedConfig := &atev1alpha1.SandboxConfig{
ObjectMeta: metav1.ObjectMeta{Name: "gvisor-custom"}, ObjectMeta: metav1.ObjectMeta{Name: "gvisor-custom"},
Spec: atev1alpha1.SandboxConfigSpec{ Spec: atev1alpha1.SandboxConfigSpec{
@@ -76,22 +61,53 @@ func TestResolveSandboxAssetsCarriesPauseImage(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
configName string sandbox *ateapipb.SandboxConfig
wantPauseImage string wantPauseImage string
}{ wantErr string
{name: "class default", wantPauseImage: defaultPause}, }{{
{name: "named config", configName: "gvisor-custom", wantPauseImage: namedPause}, name: "named config",
} sandbox: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
ConfigName: "gvisor-custom",
},
wantPauseImage: namedPause,
}, {
name: "named config class mismatch",
sandbox: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM,
ConfigName: "gvisor-custom",
},
wantErr: `has class "gvisor"`,
}, {
name: "missing named config",
sandbox: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
ConfigName: "does-not-exist",
},
wantErr: `SandboxConfig "does-not-exist" not found`,
}, {
name: "unrecognized sandbox class",
sandbox: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_UNSPECIFIED,
ConfigName: "gvisor-custom",
},
wantErr: "unrecognized sandbox_class",
}, {
name: "empty config name",
sandbox: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR},
wantErr: "names no sandbox_config.config_name",
}}
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
pool := &atev1alpha1.WorkerPool{ configLister := sandboxConfigListerFor(t, []*atev1alpha1.SandboxConfig{namedConfig})
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "worker-ns"},
Spec: atev1alpha1.WorkerPoolSpec{SandboxConfigName: tt.configName},
}
poolLister, configLister := listersFor(t, []*atev1alpha1.WorkerPool{pool},
[]*atev1alpha1.SandboxConfig{defaultConfig, namedConfig})
got, err := resolveSandboxAssets(poolLister, configLister, "worker-ns", "pool1") got, err := resolveSandboxAssets(configLister, tt.sandbox)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("resolveSandboxAssets() error = %v, want it to contain %q", err, tt.wantErr)
}
return
}
if err != nil { if err != nil {
t.Fatalf("resolveSandboxAssets() error: %v", err) t.Fatalf("resolveSandboxAssets() error: %v", err)
} }
+3 -4
View File
@@ -39,7 +39,7 @@ type RPCService struct {
persistence serviceStore persistence serviceStore
workerCache *workercache.Cache workerCache *workercache.Cache
dialer *AteletDialer dialer *AteletDialer
workerPoolLister listersv1alpha1.WorkerPoolLister sandboxConfigLister listersv1alpha1.SandboxConfigLister
csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister
actorWorkflow *ActorWorkflow actorWorkflow *ActorWorkflow
workerWorkflow *WorkerWorkflow workerWorkflow *WorkerWorkflow
@@ -62,7 +62,6 @@ type VolumePluginRegistry interface {
func NewRPCService( func NewRPCService(
persistence store.Interface, persistence store.Interface,
workerCache *workercache.Cache, workerCache *workercache.Cache,
workerPoolLister listersv1alpha1.WorkerPoolLister,
sandboxConfigLister listersv1alpha1.SandboxConfigLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister,
csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister, csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister,
storageClassLister storagev1listers.StorageClassLister, storageClassLister storagev1listers.StorageClassLister,
@@ -76,13 +75,13 @@ func NewRPCService(
impl: impl, impl: impl,
persistence: persistence, persistence: persistence,
workerCache: workerCache, workerCache: workerCache,
workerPoolLister: workerPoolLister, sandboxConfigLister: sandboxConfigLister,
csiDriverConfigLister: csiDriverConfigLister, csiDriverConfigLister: csiDriverConfigLister,
dialer: dialer, dialer: dialer,
instruments: instruments, instruments: instruments,
volumePlugins: volumePlugins, volumePlugins: volumePlugins,
} }
s.actorWorkflow = NewActorWorkflow(impl, workerCache, dialer, workerPoolLister, sandboxConfigLister, storageClassLister, instruments, egressGatewayAddress, s) s.actorWorkflow = NewActorWorkflow(impl, workerCache, dialer, sandboxConfigLister, storageClassLister, instruments, egressGatewayAddress, s)
s.workerWorkflow = NewWorkerWorkflow(impl) s.workerWorkflow = NewWorkerWorkflow(impl)
return s return s
} }
@@ -23,7 +23,6 @@ import (
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/resources"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@@ -74,17 +73,15 @@ type goldenActorControl interface {
// ActorTemplateReconciler drives stored ActorTemplates through the golden // ActorTemplateReconciler drives stored ActorTemplates through the golden
// actor state machine. // actor state machine.
type ActorTemplateReconciler struct { type ActorTemplateReconciler struct {
persistence templateReconcilerStore persistence templateReconcilerStore
control goldenActorControl control goldenActorControl
sandboxConfigs listersv1alpha1.SandboxConfigLister queue workqueue.TypedRateLimitingInterface[resources.ActorTemplateRef]
queue workqueue.TypedRateLimitingInterface[resources.ActorTemplateRef]
} }
func NewActorTemplateReconciler(persistence templateReconcilerStore, control goldenActorControl, sandboxConfigs listersv1alpha1.SandboxConfigLister) *ActorTemplateReconciler { func NewActorTemplateReconciler(persistence templateReconcilerStore, control goldenActorControl) *ActorTemplateReconciler {
return &ActorTemplateReconciler{ return &ActorTemplateReconciler{
persistence: persistence, persistence: persistence,
control: control, control: control,
sandboxConfigs: sandboxConfigs,
// Create rate-limiting queue with exponential backoff // Create rate-limiting queue with exponential backoff
queue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[resources.ActorTemplateRef]()), queue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[resources.ActorTemplateRef]()),
} }
@@ -206,7 +203,6 @@ func (r *ActorTemplateReconciler) reconcileOne(ctx context.Context, ref resource
// The golden snapshot exists already. // The golden snapshot exists already.
return 0, nil return 0, nil
} }
// TODO: Freeze sandbox assets before creating the golden actor.
actor, err := r.ensureActorExists(ctx, tmpl, goldenActorRef) actor, err := r.ensureActorExists(ctx, tmpl, goldenActorRef)
if err != nil { if err != nil {
@@ -299,8 +299,7 @@ func withFailed(reason string) func(*ateapipb.ActorTemplate) {
} }
func newTestTemplateReconciler(persistence templateReconcilerStore, control goldenActorControl) *ActorTemplateReconciler { func newTestTemplateReconciler(persistence templateReconcilerStore, control goldenActorControl) *ActorTemplateReconciler {
// The SandboxConfig lister is not used by reconcileOne or resync. return NewActorTemplateReconciler(persistence, control)
return NewActorTemplateReconciler(persistence, control, nil)
} }
func TestGoldenSnapshotWarmupFor(t *testing.T) { func TestGoldenSnapshotWarmupFor(t *testing.T) {
@@ -72,7 +72,6 @@ type ActorWorkflow struct {
workerCache *workercache.Cache workerCache *workercache.Cache
scheduler scheduling.Scheduler scheduler scheduling.Scheduler
dialer *AteletDialer dialer *AteletDialer
workerPoolLister listersv1alpha1.WorkerPoolLister
sandboxConfigLister listersv1alpha1.SandboxConfigLister sandboxConfigLister listersv1alpha1.SandboxConfigLister
storageClassLister storagev1listers.StorageClassLister storageClassLister storagev1listers.StorageClassLister
instruments *Instruments instruments *Instruments
@@ -85,7 +84,6 @@ func NewActorWorkflow(
store actorWorkflowStore, store actorWorkflowStore,
workerCache *workercache.Cache, workerCache *workercache.Cache,
dialer *AteletDialer, dialer *AteletDialer,
workerPoolLister listersv1alpha1.WorkerPoolLister,
sandboxConfigLister listersv1alpha1.SandboxConfigLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister,
storageClassLister storagev1listers.StorageClassLister, storageClassLister storagev1listers.StorageClassLister,
instruments *Instruments, instruments *Instruments,
@@ -97,7 +95,6 @@ func NewActorWorkflow(
workerCache: workerCache, workerCache: workerCache,
scheduler: scheduling.New(workerCache, scheduling.WithMeter(otel.Meter("ateapi"))), scheduler: scheduling.New(workerCache, scheduling.WithMeter(otel.Meter("ateapi"))),
dialer: dialer, dialer: dialer,
workerPoolLister: workerPoolLister,
sandboxConfigLister: sandboxConfigLister, sandboxConfigLister: sandboxConfigLister,
storageClassLister: storageClassLister, storageClassLister: storageClassLister,
instruments: instruments, instruments: instruments,
@@ -762,10 +762,11 @@ func (w *ActorWorkflow) ensureAteletRestored(ctx context.Context, actorRef resou
slog.InfoContext(ctx, "Actor has no snapshot; ActorTemplate has no golden snapshot; Booting from ActorTemplate spec") slog.InfoContext(ctx, "Actor has no snapshot; ActorTemplate has no golden snapshot; Booting from ActorTemplate spec")
tele.SnapshotKind = ateattr.SnapshotKindBoot tele.SnapshotKind = ateattr.SnapshotKindBoot
// Booting from scratch: resolve the sandbox binaries from the pool's // Booting from scratch: resolve the sandbox binaries from the
// SandboxConfig and send them so atelet can fetch and record them. // template's SandboxConfig and send them so atelet can fetch and
// (Restores above are self-describing via the snapshot manifest.) // record them. (Restores above are self-describing via the snapshot
sandboxAssets, err := resolveSandboxAssets(w.workerPoolLister, w.sandboxConfigLister, assignment.GetWorkerNamespace(), assignment.GetWorkerPool()) // manifest.)
sandboxAssets, err := resolveSandboxAssets(w.sandboxConfigLister, actorTemplate.GetSandboxConfig())
if err != nil { if err != nil {
return tele, fmt.Errorf("while resolving sandbox assets: %w", err) return tele, fmt.Errorf("while resolving sandbox assets: %w", err)
} }
@@ -54,7 +54,7 @@ func newTestActorWorkflow(t *testing.T, st store.Interface, tmplAtespace, tmplNa
}); err != nil && !errors.Is(err, store.ErrAlreadyExists) { }); err != nil && !errors.Is(err, store.ErrAlreadyExists) {
t.Fatalf("create test ActorTemplate: %v", err) t.Fatalf("create test ActorTemplate: %v", err)
} }
return NewActorWorkflow(st, nil, nil, nil, nil, nil, nil, "", nil) return NewActorWorkflow(st, nil, nil, nil, nil, nil, "", nil)
} }
// seedWorkflowActor stores an actor with the given state, bound to the given // seedWorkflowActor stores an actor with the given state, bound to the given
+2 -2
View File
@@ -188,10 +188,10 @@ func main() {
volPlugins := make(map[string]volume.VolumePluginControlPlane) volPlugins := make(map[string]volume.VolumePluginControlPlane)
ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts)
controlSrv := controlapi.NewRPCService(persistence, workerCache, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, storageClassLister, ateletDialer, instruments, *egressGatewayAddress, volPlugins) controlSrv := controlapi.NewRPCService(persistence, workerCache, sandboxConfigLister, csiDriverConfigLister, storageClassLister, ateletDialer, instruments, *egressGatewayAddress, volPlugins)
// Drive stored ActorTemplates through the golden actor flow. // Drive stored ActorTemplates through the golden actor flow.
templateReconciler := controlapi.NewActorTemplateReconciler(persistence, controlSrv, sandboxConfigLister) templateReconciler := controlapi.NewActorTemplateReconciler(persistence, controlSrv)
templateReconciler.Start(shutdownCtx) templateReconciler.Start(shutdownCtx)
actorIDCAPool, err := localca.NewRefreshingPool(*actorIDCAPoolFile) actorIDCAPool, err := localca.NewRefreshingPool(*actorIDCAPoolFile)
@@ -33,5 +33,4 @@ metadata:
spec: spec:
replicas: 1 replicas: 1
sandboxClass: microvm sandboxClass: microvm
sandboxConfigName: microvm
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm
-1
View File
@@ -40,7 +40,6 @@ spec:
# template reconciler runs while building the golden snapshot. # template reconciler runs while building the golden snapshot.
replicas: 2 replicas: 2
sandboxClass: microvm sandboxClass: microvm
sandboxConfigName: microvm
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm
template: template:
nodeSelector: nodeSelector:
@@ -76,8 +76,8 @@ resources:
quantity: 512Mi quantity: 512Mi
sandboxConfig: sandboxConfig:
sandboxClass: SANDBOX_CLASS_MICROVM sandboxClass: SANDBOX_CLASS_MICROVM
# Deliberately not the class default; installed cluster-wide by # Installed cluster-wide by hack/install-microvm-deps.sh; naming it
# hack/install-microvm-deps.sh, so a missing or stale install fails loudly. # explicitly makes a missing or stale install fail loudly.
configName: microvm configName: microvm
snapshotsConfig: snapshotsConfig:
onPause: SNAPSHOT_CONTENT_SCOPE_FULL onPause: SNAPSHOT_CONTENT_SCOPE_FULL
@@ -36,7 +36,6 @@ metadata:
spec: spec:
replicas: 2 replicas: 2
sandboxClass: microvm sandboxClass: microvm
sandboxConfigName: microvm
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm
# No template.resources, unlike the counter demo's micro-VM variant: an # No template.resources, unlike the counter demo's micro-VM variant: an
# unlimited ateom container reports no capacity, which the scheduler reads as # unlimited ateom container reports no capacity, which the scheduler reads as
@@ -49,8 +49,8 @@ resources:
quantity: 512Mi quantity: 512Mi
sandboxConfig: sandboxConfig:
sandboxClass: SANDBOX_CLASS_MICROVM sandboxClass: SANDBOX_CLASS_MICROVM
# Deliberately not the class default; installed cluster-wide by # Installed cluster-wide by hack/install-microvm-deps.sh; naming it
# hack/install-microvm-deps.sh, so a missing or stale install fails loudly. # explicitly makes a missing or stale install fail loudly.
configName: microvm configName: microvm
snapshotsConfig: snapshotsConfig:
onPause: SNAPSHOT_CONTENT_SCOPE_FULL onPause: SNAPSHOT_CONTENT_SCOPE_FULL
-1
View File
@@ -37,7 +37,6 @@ metadata:
spec: spec:
replicas: 2 replicas: 2
sandboxClass: microvm sandboxClass: microvm
sandboxConfigName: microvm
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm
# No template.resources, unlike the counter demo's micro-VM variant: an # No template.resources, unlike the counter demo's micro-VM variant: an
# unlimited ateom container reports no capacity, which the scheduler reads as # unlimited ateom container reports no capacity, which the scheduler reads as
+14 -17
View File
@@ -12,8 +12,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. | | `replicas` | `int32` | **Required.** Number of physical standby pods to maintain in the cluster. |
| `workerImage` | `string` | **Required.** The container image for the `ateom` herder process (e.g. `ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor`). | | `workerImage` | `string` | **Required.** The container image for the `ateom` herder process (e.g. `ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor`). |
| `sandboxClass` | `string` | Optional. The sandbox runtime family for the pool: `gvisor` (default) or `microvm`. Drives the worker pod shape (e.g. KVM device mounts, node placement) and which `SandboxConfig`s are eligible. | | `sandboxClass` | `string` | Optional. The sandbox runtime family for the pool: `gvisor` (default) or `microvm`. Drives the worker pod shape (e.g. KVM device mounts, node placement). The sandbox binaries themselves come from the [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) each `ActorTemplate` selects. |
| `sandboxConfigName` | `string` | Optional. Name of a cluster-scoped [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) providing the sandbox binaries and pause image. If empty, the cluster default `SandboxConfig` for the pool's `sandboxClass` is used. |
| `template` | `WorkerPoolPodTemplate` | **Optional.** Metadata, scheduling, and resource settings for worker workloads. | | `template` | `WorkerPoolPodTemplate` | **Optional.** Metadata, scheduling, and resource settings for worker workloads. |
#### `WorkerPoolPodTemplate` (`spec.template`) #### `WorkerPoolPodTemplate` (`spec.template`)
@@ -85,8 +84,8 @@ spec:
project: agent-platform project: agent-platform
annotations: annotations:
policy.example.com/exemption: sandbox-host policy.example.com/exemption: sandbox-host
# sandboxClass defaults to gvisor; the pool resolves to the cluster's default # sandboxClass defaults to gvisor. The sandbox binaries come from the
# gvisor SandboxConfig unless sandboxConfigName is set. # SandboxConfig each ActorTemplate selects, not from the pool.
``` ```
### Devices (GPUs) — temporarily unsupported ### Devices (GPUs) — temporarily unsupported
@@ -118,15 +117,15 @@ The `ActorTemplate` defines the code, environment, and state-management policies
| Field | Type | Description | | Field | Type | Description |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `containers` | `[]Container` | **Required.** The workload definition — see [Container Fields](#container-fields) below. Each container may also declare an optional `readyz` HTTP probe — see [Container Readiness Probe](#container-readiness-probe-readyz). | | `containers` | `[]Container` | **Required.** The workload definition — see [Container Fields](#container-fields) below. Each container may also declare an optional `readyz` HTTP probe — see [Container Readiness Probe](#container-readiness-probe-readyz). |
| `sandboxConfig` | `SandboxConfig` | **Required.** The sandbox runtime selection: `sandboxClass` (**required**, `SANDBOX_CLASS_GVISOR` or `SANDBOX_CLASS_MICROVM`) picks the runtime family this template's actors require — only `WorkerPool`s whose `sandboxClass` matches are eligible — and `configName` (**required**) names the cluster-scoped [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) object supplying the sandbox binaries. | | `sandboxConfig` | `SandboxConfig` | **Required.** The sandbox runtime selection: `sandboxClass` (**required**, `SANDBOX_CLASS_GVISOR` or `SANDBOX_CLASS_MICROVM`) picks the runtime family this template's actors require — only `WorkerPool`s whose `sandboxClass` matches are eligible — and `configName` (**required**) names the cluster-scoped [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) object supplying the sandbox binaries. It must reference an existing config of the matching class; `CreateActorTemplate` rejects the template otherwise. |
| `workerSelector` | `*LabelSelector` | Optional. Gates which `WorkerPool`s actors from this template may use, by matching against each pool's labels. If unset, all pools are eligible (subject to the actor's own `worker_selector`). | | `workerSelector` | `*LabelSelector` | Optional. Gates which `WorkerPool`s actors from this template may use, by matching against each pool's labels. If unset, all pools are eligible (subject to the actor's own `worker_selector`). |
| `snapshotsConfig` | `SnapshotsConfig` | **Required.** The base object-storage location snapshots are written under, plus the pause/commit/resume scopes. See [Snapshot Storage Layout](#snapshot-storage-layout). | | `snapshotsConfig` | `SnapshotsConfig` | **Required.** The base object-storage location snapshots are written under, plus the pause/commit/resume scopes. See [Snapshot Storage Layout](#snapshot-storage-layout). |
| `volumes` | `[]Volume` | Optional. Volumes the containers may mount, each a `durableDir`, an `externalVolumeTemplate` (see [CSI Volumes Guide](csi-volumes.md)), or a `systemInfo` volume (see [SystemInfo Volumes](#systeminfo-volumes)). Every declared volume must be mounted by at least one container. A `microvm` template may declare several `durableDir` volumes; a `gvisor` template is limited to one. | | `volumes` | `[]Volume` | Optional. Volumes the containers may mount, each a `durableDir`, an `externalVolumeTemplate` (see [CSI Volumes Guide](csi-volumes.md)), or a `systemInfo` volume (see [SystemInfo Volumes](#systeminfo-volumes)). Every declared volume must be mounted by at least one container. A `microvm` template may declare several `durableDir` volumes; a `gvisor` template is limited to one. |
| `resources` | `*ResourceRequirements` | Optional. Declares each actor's compute size via `limits` — see [Sandbox Right-Sizing](#sandbox-right-sizing-specresources). Immutable, like the rest of the spec. | | `resources` | `*ResourceRequirements` | Optional. Declares each actor's compute size via `limits` — see [Sandbox Right-Sizing](#sandbox-right-sizing-specresources). Immutable, like the rest of the spec. |
The sandbox itself — the binaries (e.g. the gVisor `runsc` binary) and the `pauseImage` holding the sandbox's namespaces — comes from the cluster-scoped [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) object named by `sandboxConfig.configName`. At runtime the workers resolve it from the `WorkerPool` side — by name (`workerPool.spec.sandboxConfigName`) or, by default, the cluster default `SandboxConfig` for the pool's `sandboxClass`. The sandbox itself — the binaries (e.g. the gVisor `runsc` binary) and the `pauseImage` holding the sandbox's namespaces — comes from the cluster-scoped [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) object the template names via `sandboxConfig.configName`. An actor always resolves the config from its current template — repointing the actor at another template requires the same config.
Because a snapshot is not restorable across sandbox runtimes, `sandboxClass` is a **hard scheduling gate**: an actor is only ever placed on a `WorkerPool` of the matching class. It is AND'd with `workerSelector` (and the actor's `worker_selector`), which can only narrow the eligible pools further. It has no default — `sandboxConfig` is required — and, like the rest of the spec, is immutable, so each template's class is fixed at creation. Because a snapshot is not restorable across sandbox runtimes, `sandboxClass` is a **hard scheduling gate**: an actor is only ever placed on a `WorkerPool` of the matching class. It is AND'd with `workerSelector` (and the actor's `worker_selector`), which can only narrow the eligible pools further. It has no default — `sandboxConfig` is required and its `sandboxClass` must be set — and, like the rest of the spec, is immutable, so each template's class is fixed at creation.
### Sandbox Right-Sizing (`spec.resources`) ### Sandbox Right-Sizing (`spec.resources`)
@@ -266,7 +265,7 @@ A container that exceeds its memory limit is OOM-killed on its own; the actor's
Per-container limits are micro-VM only today. gVisor applies cgroup limits at the sandbox level: one sentry backs every container in the actor, so a per-container cgroup is created and then stays empty ([google/gvisor#190](https://github.com/google/gvisor/issues/190)). A template that sets `resources` with `sandboxClass: gvisor` is rejected. Per-container limits are micro-VM only today. gVisor applies cgroup limits at the sandbox level: one sentry backs every container in the actor, so a per-container cgroup is created and then stays empty ([google/gvisor#190](https://github.com/google/gvisor/issues/190)). A template that sets `resources` with `sandboxClass: gvisor` is rejected.
These limits subdivide the sandbox that [`spec.resources`](#sandbox-right-sizing-specresources) already sized; a container that declares none is bounded by the guest as a whole, not by a copy of the actor's total. A micro-VM guest is sized from `spec.resources.limits.memory` minus the VMM reserve, or from the pool's [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) when the template declares no actor-level limit. The CPU ceiling is the guest's vCPU count, which falls back to the pool's `default_vcpus` (1 unless the `SandboxConfig` raises it), so a template that declares no `spec.resources.limits.cpu` caps each container, and their sum, at `1000m`. A limit above either ceiling can never bind, so the actor fails to start with an error naming both the limit and the ceiling. These limits subdivide the sandbox that [`spec.resources`](#sandbox-right-sizing-specresources) already sized; a container that declares none is bounded by the guest as a whole, not by a copy of the actor's total. A micro-VM guest is sized from `spec.resources.limits.memory` minus the VMM reserve, or from the template's [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) when the template declares no actor-level limit. The CPU ceiling is the guest's vCPU count, which falls back to the config's `default_vcpus` (1 unless the `SandboxConfig` raises it), so a template that declares no `spec.resources.limits.cpu` caps each container, and their sum, at `1000m`. A limit above either ceiling can never bind, so the actor fails to start with an error naming both the limit and the ceiling.
Each limit is validated on its own at apply, but the sum across the actor's containers is only checked when the actor first runs, against the real guest size. A template whose limits do not fit is accepted by the API server and fails on its first actor. Each limit is validated on its own at apply, but the sum across the actor's containers is only checked when the actor first runs, against the real guest size. A template whose limits do not fit is accepted by the API server and fails on its first actor.
@@ -311,10 +310,10 @@ containers:
workerSelector: workerSelector:
matchLabels: matchLabels:
workload: secret-agent workload: secret-agent
# Both fields are required: sandboxClass picks the runtime family (set # sandboxClass (required) picks the runtime family (set SANDBOX_CLASS_MICROVM
# SANDBOX_CLASS_MICROVM to require micro-VM pools) and configName names the # to require micro-VM pools); configName (required) names the cluster-scoped
# cluster-scoped SandboxConfig supplying the sandbox binaries (see section 3); # SandboxConfig supplying the sandbox binaries (see section 3).
# gvisor-default is the cluster-wide default that manifests/ate-install ships. # gvisor-default is the SandboxConfig that manifests/ate-install ships.
sandboxConfig: sandboxConfig:
sandboxClass: SANDBOX_CLASS_GVISOR sandboxClass: SANDBOX_CLASS_GVISOR
configName: gvisor-default configName: gvisor-default
@@ -356,7 +355,7 @@ Two consequences worth planning for:
## 3. SandboxConfig: The Sandbox Itself ## 3. SandboxConfig: The Sandbox Itself
`SandboxConfig` is a **cluster-scoped** resource that decouples the sandbox — its binaries (the gVisor `runsc` binary, or a micro-VM kernel/firmware/config) and the `pauseImage` that holds the sandbox's namespaces — from the `ActorTemplate`. A `WorkerPool` resolves its sandbox from a `SandboxConfig` — either the one named by `spec.sandboxConfigName`, or the cluster default for the pool's `sandboxClass`. `SandboxConfig` is a **cluster-scoped** resource that decouples the sandbox — its binaries (the gVisor `runsc` binary, or a micro-VM kernel/firmware/config) and the `pauseImage` that holds the sandbox's namespaces — from the workload definition in the `ActorTemplate`. An actor's cold boot resolves the sandbox binaries from the config its `ActorTemplate` names via `sandboxConfig.configName`.
This means a single, cluster-managed config pins the sandbox runtime version for many templates: snapshots stay restorable because the version is recorded in each snapshot's manifest, and operators upgrade the runtime in one place. This means a single, cluster-managed config pins the sandbox runtime version for many templates: snapshots stay restorable because the version is recorded in each snapshot's manifest, and operators upgrade the runtime in one place.
@@ -364,12 +363,11 @@ This means a single, cluster-managed config pins the sandbox runtime version for
| Field | Type | Description | | Field | Type | Description |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `sandboxClass` | `string` | **Required.** Runtime family this config applies to: `gvisor` (default) or `microvm`. A `WorkerPool` only uses `SandboxConfig`s whose `sandboxClass` matches its own. | | `sandboxClass` | `string` | **Required.** Runtime family this config applies to: `gvisor` (default) or `microvm`. An `ActorTemplate` only uses `SandboxConfig`s whose `sandboxClass` matches its own. |
| `pauseImage` | `string` | **Required.** The image for the sandbox's root container (e.g. `registry.k8s.io/pause`, or `gcr.io/gke-release/pause` on GKE). Must be pinned by digest (`...@sha256:...`) — it is recorded in each snapshot's manifest so a restore rebuilds the sandbox from the same image. | | `pauseImage` | `string` | **Required.** The image for the sandbox's root container (e.g. `registry.k8s.io/pause`, or `gcr.io/gke-release/pause` on GKE). Must be pinned by digest (`...@sha256:...`) — it is recorded in each snapshot's manifest so a restore rebuilds the sandbox from the same image. |
| `default` | `bool` | Optional. Marks this as the cluster default for its `sandboxClass`. A `WorkerPool` with no `sandboxConfigName` resolves to the default for its class. At most one default per class. |
| `assets` | `map[arch]map[name]AssetFile` | Optional. Content-addressed files atelet fetches, keyed by architecture (`amd64`, `arm64`) then asset name. gVisor expects a `gvisor` asset (the release's `gvisor.tar.zstd`), which atelet auto-extracts. A micro-VM backend expects several. Each `AssetFile` is a `{ url, sha256 }` pair. | | `assets` | `map[arch]map[name]AssetFile` | Optional. Content-addressed files atelet fetches, keyed by architecture (`amd64`, `arm64`) then asset name. gVisor expects a `gvisor` asset (the release's `gvisor.tar.zstd`), which atelet auto-extracts. A micro-VM backend expects several. Each `AssetFile` is a `{ url, sha256 }` pair. |
A default cluster-wide gVisor `SandboxConfig` (`gvisor-default`) is installed with the platform, so gVisor pools work out of the box. A cluster-wide gVisor `SandboxConfig` (`gvisor-default`) is installed with the platform, so gVisor templates can name it via `sandboxConfig.configName` without any extra setup.
### Example ### Example
@@ -380,7 +378,6 @@ metadata:
name: gvisor-default name: gvisor-default
spec: spec:
sandboxClass: gvisor sandboxClass: gvisor
default: true
pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
assets: assets:
amd64: amd64:
+1 -1
View File
@@ -324,7 +324,7 @@ The node-level subsystem manages the physical execution of sandboxes and the mov
### Sandbox Classes ### Sandbox Classes
A `WorkerPool` selects a **sandbox class** (`spec.sandboxClass`), and each class has a matching `ateom` herder image. The sandbox binaries themselves are not baked into the worker image — they, and the pause image holding the sandbox's namespaces, come at runtime from a cluster-scoped [`SandboxConfig`](api-guide.md#3-sandboxconfig-the-sandbox-itself) and are pinned into each snapshot's manifest so restores stay reproducible across runtime upgrades. A `WorkerPool` selects a **sandbox class** (`spec.sandboxClass`), and each class has a matching `ateom` herder image. The sandbox binaries themselves are not baked into the worker image — they, and the pause image holding the sandbox's namespaces, come at runtime from a cluster-scoped [`SandboxConfig`](api-guide.md#3-sandboxconfig-the-sandbox-itself) the `ActorTemplate` names in its sandbox config (naming one is currently required; per-class cluster defaults are planned) and are pinned into each snapshot's manifest so restores stay reproducible across runtime upgrades.
* **gVisor** (`ateom-gvisor`, the default): Runs the workload under `runsc` for kernel-level sandboxing. Suspend and resume leverage gVisor's native checkpoint/restore of the sandboxed process tree. * **gVisor** (`ateom-gvisor`, the default): Runs the workload under `runsc` for kernel-level sandboxing. Suspend and resume leverage gVisor's native checkpoint/restore of the sandboxed process tree.
+4 -3
View File
@@ -20,9 +20,10 @@ For how the pieces fit together, see the [Architecture](architecture.md) and
- **SandboxConfig**: a cluster-scoped resource holding the sandbox binaries for - **SandboxConfig**: a cluster-scoped resource holding the sandbox binaries for
one runtime family (the gVisor `runsc` binary, or a micro-VM one runtime family (the gVisor `runsc` binary, or a micro-VM
kernel/firmware/config), plus the pause image for the sandbox's root kernel/firmware/config), plus the pause image for the sandbox's root
container. A `WorkerPool` resolves its sandbox from the config it names, or container. An actor resolves its sandbox at first cold boot from the config its
from the cluster default for its class, so one config pins the runtime version `ActorTemplate` names (naming one is currently required; per-class cluster
for many templates. defaults are planned), so one config pins the runtime version for many
templates.
## Records (dynamic state, in the control-plane store) ## Records (dynamic state, in the control-plane store)
+5 -5
View File
@@ -887,13 +887,13 @@ deploy_ate_system() {
deploy_crds deploy_crds
# Enforce per-class SandboxConfig asset requirements (applied before any # Enforce per-class SandboxConfig asset requirements (applied before any
# SandboxConfig so the defaults below are validated too). # SandboxConfig so the configs below are validated too).
run_kubectl apply -f manifests/ate-install/sandboxconfig-validation.yaml run_kubectl apply -f manifests/ate-install/sandboxconfig-validation.yaml
# Install the cluster-wide default sandbox config(s). Sandbox binaries live on # Install the cluster-wide sandbox config(s). Sandbox binaries live on
# cluster-scoped SandboxConfigs resolved via each WorkerPool's SandboxClass # cluster-scoped SandboxConfigs each ActorTemplate names via
# (decoupled from ActorTemplate). gVisor pools resolve to this default unless # sandboxConfig.configName; gVisor templates name this one unless they
# they name their own SandboxConfig. # create their own SandboxConfig.
run_kubectl apply -f manifests/ate-install/sandboxconfig-gvisor.yaml run_kubectl apply -f manifests/ate-install/sandboxconfig-gvisor.yaml
# Ahead of the bundle below, for the same reason as the namespace: every # Ahead of the bundle below, for the same reason as the namespace: every
+4 -4
View File
@@ -29,9 +29,9 @@
# arm64 the v1.14.0 binary is built from source, so its bytes vary per # arm64 the v1.14.0 binary is built from source, so its bytes vary per
# toolchain and cannot be pinned in the manifest). # toolchain and cannot be pinned in the manifest).
# #
# WorkerPools must reference the SandboxConfig explicitly via # ActorTemplates must reference the SandboxConfig explicitly via
# sandboxConfigName: microvm. This avoids a dirty teardown silently binding # sandboxConfig.configName: microvm. This avoids a dirty teardown silently
# new pools to a stale config. # binding new templates to a stale config.
# #
# On --delete: removes the SandboxConfig from the cluster. Bucket contents # On --delete: removes the SandboxConfig from the cluster. Bucket contents
# are left alone (they're inert until a SandboxConfig points at them, and # are left alone (they're inert until a SandboxConfig points at them, and
@@ -195,4 +195,4 @@ sed -e "s|\${BUCKET_NAME}|${BUCKET_NAME}|g" \
"${MANIFEST_TEMPLATE}" \ "${MANIFEST_TEMPLATE}" \
| run_kubectl apply -f - | run_kubectl apply -f -
log "Done. WorkerPools must reference this SandboxConfig by name (sandboxConfigName: microvm)." log "Done. ActorTemplates must reference this SandboxConfig by name (sandboxConfig.configName: microvm)."
+4 -6
View File
@@ -38,10 +38,9 @@ func substrateTemplateSubstitutions(bucket, name string, trustBundle bool) (inli
"${FIXTURE_SUFFIX}": "-" + name, "${FIXTURE_SUFFIX}": "-" + name,
} }
blocks = map[string]string{ blocks = map[string]string{
// gvisor-default is the cluster-wide default SandboxConfig // gvisor-default is the cluster-wide SandboxConfig
// manifests/ate-install ships; config_name is required, so the // manifests/ate-install ships; config_name is required, so the
// templates name it explicitly even though the gVisor WorkerPools // fixtures name it explicitly.
// leave sandboxConfigName empty and resolve to the same object.
"${TEMPLATE_SANDBOX_CONFIG}": "sandboxConfig:\n sandboxClass: SANDBOX_CLASS_GVISOR\n configName: gvisor-default", "${TEMPLATE_SANDBOX_CONFIG}": "sandboxConfig:\n sandboxClass: SANDBOX_CLASS_GVISOR\n configName: gvisor-default",
"${TEMPLATE_RESOURCES}": "", "${TEMPLATE_RESOURCES}": "",
// Off unless the caller opts in; see WithTrustBundle. // Off unless the caller opts in; see WithTrustBundle.
@@ -57,9 +56,8 @@ func substrateTemplateSubstitutions(bucket, name string, trustBundle bool) (inli
} }
inline["${FIXTURE_SUFFIX}"] = "-" + SandboxClassMicroVM + "-" + name inline["${FIXTURE_SUFFIX}"] = "-" + SandboxClassMicroVM + "-" + name
// The cluster-wide SandboxConfig hack/install-microvm-deps.sh installs. // The cluster-wide SandboxConfig hack/install-microvm-deps.sh installs;
// It is deliberately not the class default, so a missing or stale one // a missing or stale one fails loudly at template creation.
// fails loudly.
blocks["${TEMPLATE_SANDBOX_CONFIG}"] = "sandboxConfig:\n sandboxClass: SANDBOX_CLASS_MICROVM\n configName: microvm" blocks["${TEMPLATE_SANDBOX_CONFIG}"] = "sandboxConfig:\n sandboxClass: SANDBOX_CLASS_MICROVM\n configName: microvm"
// Only for fixtures that declare no limits of their own. Without them the // Only for fixtures that declare no limits of their own. Without them the
// guest boots at the kata config's default (2GiB), and several of those // guest boots at the kata config's default (2GiB), and several of those
+4 -4
View File
@@ -195,10 +195,10 @@ func fixtureSubstitutions(bucket, name string) (inline, blocks map[string]string
inline["${ATEOM_IMAGE}"] = "ko://github.com/agent-substrate/substrate/cmd/ateom-microvm" inline["${ATEOM_IMAGE}"] = "ko://github.com/agent-substrate/substrate/cmd/ateom-microvm"
inline["${FIXTURE_SUFFIX}"] = "-" + SandboxClassMicroVM + "-" + name inline["${FIXTURE_SUFFIX}"] = "-" + SandboxClassMicroVM + "-" + name
// The cluster-wide SandboxConfig hack/install-microvm-deps.sh installs. A // The micro-VM ActorTemplates name the cluster-wide SandboxConfig
// micro-VM WorkerPool has to name it: it is deliberately not the class // hack/install-microvm-deps.sh installs (configName: microvm), so a
// default, so a missing or stale one fails loudly. // missing or stale one fails loudly. The pool only selects the class.
blocks["${WORKERPOOL_RUNTIME}"] = " sandboxClass: microvm\n sandboxConfigName: microvm" blocks["${WORKERPOOL_RUNTIME}"] = " sandboxClass: microvm"
// Must match the WorkerPool's: a snapshot is not portable across sandbox // Must match the WorkerPool's: a snapshot is not portable across sandbox
// classes, so only same-class pools are eligible to run these actors. // classes, so only same-class pools are eligible to run these actors.
blocks["${TEMPLATE_SANDBOX_CLASS}"] = " sandboxClass: microvm" blocks["${TEMPLATE_SANDBOX_CLASS}"] = " sandboxClass: microvm"
+12 -14
View File
@@ -119,7 +119,7 @@ func memoryLimit(tmpl *ateapipb.ActorTemplate) string {
// TestRenderSubstrateFixtures_GVisor pins the default rendering: every // TestRenderSubstrateFixtures_GVisor pins the default rendering: every
// micro-VM block is gone, no placeholder survives, and the templates name the // micro-VM block is gone, no placeholder survives, and the templates name the
// cluster-wide default SandboxConfig. // cluster-wide gvisor-default SandboxConfig.
func TestRenderSubstrateFixtures_GVisor(t *testing.T) { func TestRenderSubstrateFixtures_GVisor(t *testing.T) {
t.Setenv(sandboxClassEnv, "") t.Setenv(sandboxClassEnv, "")
for _, fixture := range substrateFixtures { for _, fixture := range substrateFixtures {
@@ -128,9 +128,8 @@ func TestRenderSubstrateFixtures_GVisor(t *testing.T) {
if !strings.HasSuffix(pool.Spec.WorkerImage, "/cmd/ateom-gvisor") { if !strings.HasSuffix(pool.Spec.WorkerImage, "/cmd/ateom-gvisor") {
t.Errorf("WorkerPool workerImage = %q, want the gVisor ateom", pool.Spec.WorkerImage) t.Errorf("WorkerPool workerImage = %q, want the gVisor ateom", pool.Spec.WorkerImage)
} }
if pool.Spec.SandboxClass != "" || pool.Spec.SandboxConfigName != "" { if pool.Spec.SandboxClass != "" {
t.Errorf("WorkerPool carries micro-VM runtime fields: class=%q config=%q", t.Errorf("WorkerPool carries micro-VM runtime fields: class=%q", pool.Spec.SandboxClass)
pool.Spec.SandboxClass, pool.Spec.SandboxConfigName)
} }
templates := renderTemplates(t, fixture.manifests.Template) templates := renderTemplates(t, fixture.manifests.Template)
@@ -142,8 +141,8 @@ func TestRenderSubstrateFixtures_GVisor(t *testing.T) {
if got := tmpl.GetSandboxConfig().GetSandboxClass(); got != ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR { if got := tmpl.GetSandboxConfig().GetSandboxClass(); got != ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR {
t.Errorf("template %s sandboxClass = %v, want GVISOR", name, got) t.Errorf("template %s sandboxClass = %v, want GVISOR", name, got)
} }
// The templates name the cluster-wide default SandboxConfig // The templates name the cluster-wide gvisor-default
// explicitly: config_name is required. // SandboxConfig explicitly: config_name is required.
if got := tmpl.GetSandboxConfig().GetConfigName(); got != "gvisor-default" { if got := tmpl.GetSandboxConfig().GetConfigName(); got != "gvisor-default" {
t.Errorf("template %s configName = %q, want gvisor-default", name, got) t.Errorf("template %s configName = %q, want gvisor-default", name, got)
} }
@@ -167,9 +166,9 @@ func TestRenderSubstrateFixtures_GVisor(t *testing.T) {
} }
} }
// TestRenderSubstrateFixtures_MicroVM pins the micro-VM rendering: the pool // TestRenderSubstrateFixtures_MicroVM pins the micro-VM rendering: the
// names the cluster-wide SandboxConfig, the templates match its class and // templates name the cluster-wide SandboxConfig and match the pool's class
// carry limits, and the snapshots land under their own prefix. // and carry limits, and the snapshots land under their own prefix.
func TestRenderSubstrateFixtures_MicroVM(t *testing.T) { func TestRenderSubstrateFixtures_MicroVM(t *testing.T) {
t.Setenv(sandboxClassEnv, SandboxClassMicroVM) t.Setenv(sandboxClassEnv, SandboxClassMicroVM)
for _, fixture := range substrateFixtures { for _, fixture := range substrateFixtures {
@@ -178,9 +177,8 @@ func TestRenderSubstrateFixtures_MicroVM(t *testing.T) {
if !strings.HasSuffix(pool.Spec.WorkerImage, "/cmd/ateom-microvm") { if !strings.HasSuffix(pool.Spec.WorkerImage, "/cmd/ateom-microvm") {
t.Errorf("WorkerPool workerImage = %q, want the micro-VM ateom", pool.Spec.WorkerImage) t.Errorf("WorkerPool workerImage = %q, want the micro-VM ateom", pool.Spec.WorkerImage)
} }
if pool.Spec.SandboxClass != SandboxClassMicroVM || pool.Spec.SandboxConfigName != "microvm" { if pool.Spec.SandboxClass != SandboxClassMicroVM {
t.Errorf("WorkerPool runtime = class %q / config %q, want microvm / microvm", t.Errorf("WorkerPool runtime = class %q, want microvm", pool.Spec.SandboxClass)
pool.Spec.SandboxClass, pool.Spec.SandboxConfigName)
} }
templates := renderTemplates(t, fixture.manifests.Template) templates := renderTemplates(t, fixture.manifests.Template)
@@ -192,8 +190,8 @@ func TestRenderSubstrateFixtures_MicroVM(t *testing.T) {
if got := tmpl.GetSandboxConfig().GetSandboxClass(); got != ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM { if got := tmpl.GetSandboxConfig().GetSandboxClass(); got != ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM {
t.Errorf("template %s sandboxClass = %v, want MICROVM — it must match the pool's or no worker is eligible", name, got) t.Errorf("template %s sandboxClass = %v, want MICROVM — it must match the pool's or no worker is eligible", name, got)
} }
// Deliberately not the class default (see fixture.go), so a // Named explicitly (see fixture.go), so a missing or stale
// missing or stale microvm install fails loudly. // microvm install fails loudly.
if got := tmpl.GetSandboxConfig().GetConfigName(); got != "microvm" { if got := tmpl.GetSandboxConfig().GetConfigName(); got != "microvm" {
t.Errorf("template %s configName = %q, want microvm", name, got) t.Errorf("template %s configName = %q, want microvm", name, got)
} }
+5 -6
View File
@@ -85,10 +85,9 @@ func CreateSubstrateTemplateFrom(ctx context.Context, t *testing.T, clients *Cli
Labels: opts.Labels, Labels: opts.Labels,
}, },
Spec: v1alpha1.WorkerPoolSpec{ Spec: v1alpha1.WorkerPoolSpec{
Replicas: opts.PoolReplicas, Replicas: opts.PoolReplicas,
WorkerImage: existingWp.Spec.WorkerImage, WorkerImage: existingWp.Spec.WorkerImage,
SandboxClass: existingWp.Spec.SandboxClass, SandboxClass: existingWp.Spec.SandboxClass,
SandboxConfigName: existingWp.Spec.SandboxConfigName,
}, },
} }
if _, err := clients.SubstrateK8s.ApiV1alpha1().WorkerPools(namespace).Create(ctx, wp, metav1.CreateOptions{}); err != nil { if _, err := clients.SubstrateK8s.ApiV1alpha1().WorkerPools(namespace).Create(ctx, wp, metav1.CreateOptions{}); err != nil {
@@ -112,8 +111,8 @@ func CreateSubstrateTemplateFrom(ctx context.Context, t *testing.T, clients *Cli
// micro-VM, where an ActorTemplate that declares none boots the guest // micro-VM, where an ActorTemplate that declares none boots the guest
// at the kata config default (2GiB) instead of the demo's 512Mi. // at the kata config default (2GiB) instead of the demo's 512Mi.
Resources: srcTmpl.GetResources(), Resources: srcTmpl.GetResources(),
// Both sandbox_class and config_name are required; the source carries // The source carries the sandbox_class/config_name pair for the
// the pair for the class under test. // class under test.
SandboxConfig: srcTmpl.GetSandboxConfig(), SandboxConfig: srcTmpl.GetSandboxConfig(),
SnapshotsConfig: snapshots, SnapshotsConfig: snapshots,
Volumes: srcTmpl.GetVolumes(), Volumes: srcTmpl.GetVolumes(),
@@ -34,9 +34,6 @@ spec:
- jsonPath: .spec.sandboxClass - jsonPath: .spec.sandboxClass
name: Class name: Class
type: string type: string
- jsonPath: .spec.default
name: Default
type: boolean
- jsonPath: .metadata.creationTimestamp - jsonPath: .metadata.creationTimestamp
name: Age name: Age
type: date type: date
@@ -45,8 +42,9 @@ spec:
openAPIV3Schema: openAPIV3Schema:
description: |- description: |-
SandboxConfig is cluster-scoped configuration describing the sandbox binaries SandboxConfig is cluster-scoped configuration describing the sandbox binaries
for a sandbox runtime family. It is referenced (or defaulted) by WorkerPools for a sandbox runtime family. It is referenced by an ActorTemplate's
and decouples sandbox binary selection from ActorTemplate. sandbox_config.config_name (required) and decouples
sandbox binary selection from the workload definition.
properties: properties:
apiVersion: apiVersion:
description: |- description: |-
@@ -104,13 +102,6 @@ spec:
intentionally generic; per-class requirements are enforced by a intentionally generic; per-class requirements are enforced by a
ValidatingAdmissionPolicy. ValidatingAdmissionPolicy.
type: object type: object
default:
description: |-
Default marks this SandboxConfig as the cluster-wide default for its
SandboxClass. A WorkerPool with no explicit SandboxConfigName resolves to
the default config for its SandboxClass. At most one default is expected
per SandboxClass.
type: boolean
pauseImage: pauseImage:
description: |- description: |-
PauseImage is the container image used as the root sandbox container. PauseImage is the container image used as the root sandbox container.
@@ -132,8 +123,9 @@ spec:
sandboxClass: sandboxClass:
default: gvisor default: gvisor
description: |- description: |-
SandboxClass is the sandbox runtime family this config applies to. A SandboxClass is the sandbox runtime family this config applies to. An
WorkerPool only uses SandboxConfigs whose SandboxClass matches its own. ActorTemplate only uses SandboxConfigs whose SandboxClass matches its
sandbox_config.sandbox_class.
enum: enum:
- gvisor - gvisor
- microvm - microvm
@@ -77,23 +77,16 @@ spec:
default: gvisor default: gvisor
description: |- description: |-
SandboxClass selects the sandbox runtime family for this pool, which drives SandboxClass selects the sandbox runtime family for this pool, which drives
the worker pod shape (KVM/vhost device mounts and node placement) and which the worker pod shape (KVM/vhost device mounts and node placement). The
SandboxConfigs are eligible. The concrete binary is still selected by concrete binary is still selected by WorkerImage. Defaults to gvisor.
WorkerImage. Defaults to gvisor. The sandbox binaries themselves come from the SandboxConfig each
ActorTemplate names (required).
See Also: TODOs in ActorTemplate SandboxClass See Also: TODOs in ActorTemplate SandboxClass
enum: enum:
- gvisor - gvisor
- microvm - microvm
type: string type: string
sandboxConfigName:
description: |-
SandboxConfigName names a cluster-scoped SandboxConfig to use for fetching
sandbox binaries. It overrides the cluster-wide default SandboxConfig for
this pool's SandboxClass. The referenced config's SandboxClass must match
this pool's SandboxClass. If empty, the default SandboxConfig for the
SandboxClass is used.
type: string
template: template:
description: Template holds optional metadata, scheduling, and resource description: Template holds optional metadata, scheduling, and resource
settings for worker workloads. settings for worker workloads.
@@ -12,20 +12,19 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# Cluster-wide default SandboxConfig for the gVisor (runsc) sandbox class. A # Cluster-wide SandboxConfig for the gVisor (runsc) sandbox class, shipped with
# WorkerPool with sandboxClass gvisor (the default) and no explicit # the platform so gVisor ActorTemplates have a config to name via
# sandboxConfigName resolves to this. atelet fetches the gVisor release tarball # sandboxConfig.configName. atelet fetches the gVisor release tarball
# (gvisor.tar.zstd: runsc plus the gvisor-bin/ helpers runsc requires next to # (gvisor.tar.zstd: runsc plus the gvisor-bin/ helpers runsc requires next to
# it) matching the worker node's architecture and extracts it locally. To pin a # it) matching the worker node's architecture and extracts it locally. To pin a
# different release, edit the assets below or create another SandboxConfig and # different release, edit the assets below or create another SandboxConfig and
# name it from the WorkerPool. # name it from the ActorTemplate.
apiVersion: ate.dev/v1alpha1 apiVersion: ate.dev/v1alpha1
kind: SandboxConfig kind: SandboxConfig
metadata: metadata:
name: gvisor-default name: gvisor-default
spec: spec:
sandboxClass: gvisor sandboxClass: gvisor
default: true
# The root sandbox container's image. On GCP, prefer the in-project mirror # The root sandbox container's image. On GCP, prefer the in-project mirror
# gcr.io/gke-release/pause@sha256:bcbd57ba5653580ec647b16d8163cdd1112df3609129b01f912a8032e48265da. # gcr.io/gke-release/pause@sha256:bcbd57ba5653580ec647b16d8163cdd1112df3609129b01f912a8032e48265da.
pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
@@ -14,15 +14,11 @@
# Cluster-wide SandboxConfig for the micro-VM (kata + cloud-hypervisor) sandbox # Cluster-wide SandboxConfig for the micro-VM (kata + cloud-hypervisor) sandbox
# class. Unlike sandboxconfig-gvisor.yaml (applied unconditionally by # class. Unlike sandboxconfig-gvisor.yaml (applied unconditionally by
# hack/install-ate.sh --deploy-ate-system and marked default:true), this is # hack/install-ate.sh --deploy-ate-system), this is opt-in: apply via
# opt-in: apply via hack/install-microvm-deps.sh --install after staging the # hack/install-microvm-deps.sh --install after staging the asset set
# asset set (assemble.sh + stage-to-gcs.sh / stage-to-rustfs.sh). # (assemble.sh + stage-to-gcs.sh / stage-to-rustfs.sh). Every microvm
# # ActorTemplate names it explicitly (configName: microvm), so a missing/stale
# It is deliberately NOT marked default:true. A dirty teardown could leave this # config fails loudly at template creation.
# CR behind, and if it were the class default a subsequent WorkerPool that
# omitted sandboxConfigName would silently resolve to the stale config. Making
# every microvm WorkerPool name it explicitly (sandboxConfigName: microvm) makes
# a missing/stale config fail loudly instead.
# #
# The sandbox binaries (cloud-hypervisor, virtiofsd, guest kernel, guest rootfs, # The sandbox binaries (cloud-hypervisor, virtiofsd, guest kernel, guest rootfs,
# base configuration.toml) are FETCHED at runtime from the cluster object store # base configuration.toml) are FETCHED at runtime from the cluster object store
+6 -13
View File
@@ -51,22 +51,15 @@ type AssetFile struct {
// SandboxConfigSpec is the desired state of a SandboxConfig. // SandboxConfigSpec is the desired state of a SandboxConfig.
type SandboxConfigSpec struct { type SandboxConfigSpec struct {
// SandboxClass is the sandbox runtime family this config applies to. A // SandboxClass is the sandbox runtime family this config applies to. An
// WorkerPool only uses SandboxConfigs whose SandboxClass matches its own. // ActorTemplate only uses SandboxConfigs whose SandboxClass matches its
// sandbox_config.sandbox_class.
// //
// +required // +required
// +kubebuilder:validation:Enum=gvisor;microvm // +kubebuilder:validation:Enum=gvisor;microvm
// +kubebuilder:default=gvisor // +kubebuilder:default=gvisor
SandboxClass SandboxClass `json:"sandboxClass"` SandboxClass SandboxClass `json:"sandboxClass"`
// Default marks this SandboxConfig as the cluster-wide default for its
// SandboxClass. A WorkerPool with no explicit SandboxConfigName resolves to
// the default config for its SandboxClass. At most one default is expected
// per SandboxClass.
//
// +optional
Default bool `json:"default,omitempty"`
// PauseImage is the container image used as the root sandbox container. // PauseImage is the container image used as the root sandbox container.
// It holds the sandbox's namespaces and runs no workload code, so it is an // It holds the sandbox's namespaces and runs no workload code, so it is an
// implementation detail of the sandbox rather than something actor authors // implementation detail of the sandbox rather than something actor authors
@@ -98,8 +91,9 @@ type SandboxConfigSpec struct {
} }
// SandboxConfig is cluster-scoped configuration describing the sandbox binaries // SandboxConfig is cluster-scoped configuration describing the sandbox binaries
// for a sandbox runtime family. It is referenced (or defaulted) by WorkerPools // for a sandbox runtime family. It is referenced by an ActorTemplate's
// and decouples sandbox binary selection from ActorTemplate. // sandbox_config.config_name (required) and decouples
// sandbox binary selection from the workload definition.
// //
// +genclient // +genclient
// +genclient:nonNamespaced // +genclient:nonNamespaced
@@ -107,7 +101,6 @@ type SandboxConfigSpec struct {
// +kubebuilder:object:root=true // +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster,shortName=sandboxconfig // +kubebuilder:resource:scope=Cluster,shortName=sandboxconfig
// +kubebuilder:printcolumn:name="Class",type=string,JSONPath=`.spec.sandboxClass` // +kubebuilder:printcolumn:name="Class",type=string,JSONPath=`.spec.sandboxClass`
// +kubebuilder:printcolumn:name="Default",type=boolean,JSONPath=`.spec.default`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type SandboxConfig struct { type SandboxConfig struct {
metav1.TypeMeta `json:",inline"` metav1.TypeMeta `json:",inline"`
+4 -11
View File
@@ -94,9 +94,10 @@ type WorkerPoolSpec struct {
Template *WorkerPoolPodTemplate `json:"template,omitempty"` Template *WorkerPoolPodTemplate `json:"template,omitempty"`
// SandboxClass selects the sandbox runtime family for this pool, which drives // SandboxClass selects the sandbox runtime family for this pool, which drives
// the worker pod shape (KVM/vhost device mounts and node placement) and which // the worker pod shape (KVM/vhost device mounts and node placement). The
// SandboxConfigs are eligible. The concrete binary is still selected by // concrete binary is still selected by WorkerImage. Defaults to gvisor.
// WorkerImage. Defaults to gvisor. // The sandbox binaries themselves come from the SandboxConfig each
// ActorTemplate names (required).
// //
// See Also: TODOs in ActorTemplate SandboxClass // See Also: TODOs in ActorTemplate SandboxClass
// //
@@ -104,14 +105,6 @@ type WorkerPoolSpec struct {
// +kubebuilder:validation:Enum=gvisor;microvm // +kubebuilder:validation:Enum=gvisor;microvm
// +kubebuilder:default=gvisor // +kubebuilder:default=gvisor
SandboxClass SandboxClass `json:"sandboxClass,omitempty"` SandboxClass SandboxClass `json:"sandboxClass,omitempty"`
// SandboxConfigName names a cluster-scoped SandboxConfig to use for fetching
// sandbox binaries. It overrides the cluster-wide default SandboxConfig for
// this pool's SandboxClass. The referenced config's SandboxClass must match
// this pool's SandboxClass. If empty, the default SandboxConfig for the
// SandboxClass is used.
// +optional
SandboxConfigName string `json:"sandboxConfigName,omitempty"`
} }
type WorkerPoolStatus struct { type WorkerPoolStatus struct {