mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-21 06:40:12 +08:00
This commit changes everything except HTTP(s) links. github.com/agent-substrate/substrate -> go.ate.dev/substrate I chose "go.ate.dev" for brevity, but "pkg.ate.dev" would be OK, too. This is implemented as "CloudFlare Pages" on my personal account. If we like it, we need to make it more "community operated". This does not yet convert other repos. This breaks a TON of open PRs, so we should consider if/when we want to merge it. /cc @BenTheElder @rakyll @EItanya
898 lines
36 KiB
Go
898 lines
36 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package controlapi
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"go.ate.dev/substrate/cmd/ateapi/internal/store"
|
|
"go.ate.dev/substrate/cmd/ateapi/internal/store/storetest"
|
|
"go.ate.dev/substrate/internal/apierror"
|
|
"go.ate.dev/substrate/pkg/proto/ateapipb"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/testing/protocmp"
|
|
)
|
|
|
|
// Worker names are pod UIDs, which are opaque to everything above the syncer.
|
|
const (
|
|
apiWorkerName = "5f2c1a90-7b34-4e6d-8a11-0c3e9d5b7f42"
|
|
apiOtherWorkerName = "1a7e4c83-6d20-4f95-b3c8-9e0a2f6d4b17"
|
|
)
|
|
|
|
// validWorker returns a Worker in the shape CreateWorker accepts: named, with
|
|
// its pod coordinates filled in and no status — status is output-only.
|
|
func validWorker(name string, mods ...func(*ateapipb.Worker)) *ateapipb.Worker {
|
|
w := &ateapipb.Worker{
|
|
Metadata: &ateapipb.ResourceMetadata{Name: name},
|
|
WorkerNamespace: "ate-system",
|
|
WorkerPool: "pool-1",
|
|
WorkerPod: "worker-pod-1",
|
|
WorkerPodUid: name,
|
|
NodeName: "node-1",
|
|
Ips: []string{"10.1.2.3"},
|
|
SandboxClass: "gvisor",
|
|
}
|
|
for _, m := range mods {
|
|
m(w)
|
|
}
|
|
return w
|
|
}
|
|
|
|
func newAPIAssignment(actorUID string) *ateapipb.ActorAssignment {
|
|
return &ateapipb.ActorAssignment{
|
|
Actor: &ateapipb.ObjectRef{Atespace: "team-a", Name: "actor-1"},
|
|
ActorUid: actorUID,
|
|
}
|
|
}
|
|
|
|
// newWorkerAPIService returns a service backed by a real store, which is what
|
|
// makes the compare-and-set assertions below meaningful — a fake would decide
|
|
// the outcome the test is trying to observe.
|
|
// impl is a real ServiceImpl rather than the store itself, as main wires it:
|
|
// the service layer is where a read composes the Worker with records kept
|
|
// outside it, so a test that hands RPCService the bare store silently skips
|
|
// that and reports whatever the store row happens to hold.
|
|
func newWorkerAPIService(t *testing.T) (*RPCService, store.Interface) {
|
|
t.Helper()
|
|
persistence, cleanup := storetest.SetupTestStore(t)
|
|
t.Cleanup(cleanup)
|
|
impl := newServiceImpl(persistence, nil)
|
|
return &RPCService{impl: impl, workerWorkflow: NewWorkerWorkflow(persistence)}, persistence
|
|
}
|
|
|
|
// seedAPIWorker registers a worker directly through the store and returns it as
|
|
// stored, so tests start from a known uid and version.
|
|
func seedAPIWorker(t *testing.T, ctx context.Context, persistence store.Interface, worker *ateapipb.Worker) *ateapipb.Worker {
|
|
t.Helper()
|
|
worker = proto.Clone(worker).(*ateapipb.Worker)
|
|
if worker.GetStatus() == nil {
|
|
worker.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE}
|
|
}
|
|
created, err := persistence.CreateWorker(ctx, worker)
|
|
if err != nil {
|
|
t.Fatalf("seeding worker %s: %v", worker.GetMetadata().GetName(), err)
|
|
}
|
|
return created
|
|
}
|
|
|
|
// assignAPIWorker binds an Actor to a worker the way the resume workflow does:
|
|
// in-process, through the store. There is no AssignWorker RPC to go through.
|
|
func assignAPIWorker(t *testing.T, ctx context.Context, persistence store.Interface, name, actorUID string) *ateapipb.Worker {
|
|
t.Helper()
|
|
if err := persistence.BindActorToWorker(ctx, name, newAPIAssignment(actorUID), nil); err != nil {
|
|
t.Fatalf("assigning worker %s: %v", name, err)
|
|
}
|
|
assigned, err := persistence.GetWorker(ctx, name)
|
|
if err != nil {
|
|
t.Fatalf("re-reading worker %s after assigning: %v", name, err)
|
|
}
|
|
return assigned
|
|
}
|
|
|
|
func workerRef(name string) *ateapipb.ObjectRef {
|
|
return &ateapipb.ObjectRef{Name: name}
|
|
}
|
|
|
|
// updateFrom builds the body of an UpdateWorker request the way a client does:
|
|
// read the worker, change what it means to change, send the whole thing back.
|
|
// The metadata comes along as the uid and version guards every update requires,
|
|
// and so does everything else — an update replaces the stored worker, so an
|
|
// immutable field the request drops reads as a request to clear it.
|
|
func updateFrom(observed *ateapipb.Worker, mutate func(*ateapipb.Worker)) *ateapipb.Worker {
|
|
worker := proto.Clone(observed).(*ateapipb.Worker)
|
|
if mutate != nil {
|
|
mutate(worker)
|
|
}
|
|
return worker
|
|
}
|
|
|
|
// The assignments are a subresource, so this RPC is the only way to read them.
|
|
// It also pins the identity the store gives each one.
|
|
func TestListWorkerAssignments(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
assignAPIWorker(t, ctx, persistence, apiWorkerName, "actor-uid-1")
|
|
assignAPIWorker(t, ctx, persistence, apiWorkerName, "actor-uid-2")
|
|
|
|
page, err := svc.ListWorkerActorAssignments(ctx, &ateapipb.ListWorkerActorAssignmentsRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("ListWorkerActorAssignments() failed: %v", err)
|
|
}
|
|
var uids []string
|
|
for _, a := range page.GetActorAssignments() {
|
|
uids = append(uids, a.GetActorUid())
|
|
if got := a.GetMetadata().GetName(); got != a.GetActorUid() {
|
|
t.Errorf("assignment name = %q, want the Actor uid %q", got, a.GetActorUid())
|
|
}
|
|
if got := a.GetMetadata().GetAtespace(); got != "" {
|
|
t.Errorf("assignment atespace = %q, want empty: Workers are global-scoped", got)
|
|
}
|
|
if a.GetMetadata().GetUid() == "" {
|
|
t.Error("assignment uid is unset, want the one the store generated")
|
|
}
|
|
}
|
|
sort.Strings(uids)
|
|
if diff := cmp.Diff([]string{"actor-uid-1", "actor-uid-2"}, uids); diff != "" {
|
|
t.Errorf("assignments mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
// An absent Worker is NOT_FOUND rather than an empty page, which a caller
|
|
// cannot tell from a Worker hosting nothing.
|
|
func TestListWorkerAssignments_AbsentWorker(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
_, err := svc.ListWorkerActorAssignments(ctx, &ateapipb.ListWorkerActorAssignmentsRequest{
|
|
Worker: workerRef("3b9f1e77-2c4d-4a80-91be-6d5c8f0a7e21"),
|
|
})
|
|
if got := apierror.Code(err); got != codes.NotFound {
|
|
t.Fatalf("code = %v (err %v), want %v", got, err, codes.NotFound)
|
|
}
|
|
}
|
|
|
|
// Capacity is reported by the Worker, so it lives in status and a client
|
|
// cannot move it. An update that tries is ignored rather than refused, as it is
|
|
// for anything else a request carries in status.
|
|
func TestUpdateWorker_CannotChangeCapacity(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seeded := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
before := seeded.GetStatus().GetCapacity()
|
|
|
|
got, err := svc.UpdateWorker(ctx, &ateapipb.UpdateWorkerRequest{
|
|
Worker: updateFrom(seeded, func(w *ateapipb.Worker) {
|
|
w.Status.Capacity = &ateapipb.WorkerResources{Actors: 4094}
|
|
}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateWorker() failed: %v", err)
|
|
}
|
|
if diff := cmp.Diff(before, got.GetStatus().GetCapacity(), protocmp.Transform()); diff != "" {
|
|
t.Errorf("a client update moved capacity (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestGetWorker_Errors(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *ateapipb.GetWorkerRequest
|
|
want codes.Code
|
|
}{
|
|
{"absent", &ateapipb.GetWorkerRequest{Worker: workerRef(apiOtherWorkerName)}, codes.NotFound},
|
|
{"no ref", &ateapipb.GetWorkerRequest{}, codes.InvalidArgument},
|
|
{"no name", &ateapipb.GetWorkerRequest{Worker: &ateapipb.ObjectRef{}}, codes.InvalidArgument},
|
|
// Workers are global-scoped, so naming an atespace is a client bug
|
|
// rather than a lookup that happens to miss.
|
|
{"atespace set", &ateapipb.GetWorkerRequest{Worker: &ateapipb.ObjectRef{Atespace: "team-a", Name: apiWorkerName}}, codes.InvalidArgument},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := svc.GetWorker(ctx, tc.req)
|
|
if got := apierror.Code(err); got != tc.want {
|
|
t.Errorf("GetWorker() code = %v (err %v), want %v", got, err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateWorker(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
|
|
got, err := svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: validWorker(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorker() failed: %v", err)
|
|
}
|
|
if got.GetMetadata().GetVersion() != 1 {
|
|
t.Errorf("created worker version = %d, want 1", got.GetMetadata().GetVersion())
|
|
}
|
|
if got.GetMetadata().GetUid() == "" {
|
|
t.Error("created worker has no uid; the store is meant to assign one")
|
|
}
|
|
// A Worker takes no Actors until it is marked available.
|
|
if got.GetStatus().GetState() != ateapipb.WorkerState_WORKER_STATE_UNAVAILABLE {
|
|
t.Errorf("created worker state = %v, want %v", got.GetStatus().GetState(), ateapipb.WorkerState_WORKER_STATE_UNAVAILABLE)
|
|
}
|
|
|
|
stored, err := persistence.GetWorker(ctx, apiWorkerName)
|
|
if err != nil {
|
|
t.Fatalf("GetWorker() failed: %v", err)
|
|
}
|
|
if diff := cmp.Diff(stored, got, protocmp.Transform()); diff != "" {
|
|
t.Errorf("CreateWorker() returned something other than what it stored (-stored +returned):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
// status is output-only, so a request that carries one has it replaced rather
|
|
// than rejected.
|
|
func TestCreateWorker_IgnoresRequestStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
in := validWorker(apiWorkerName)
|
|
in.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_DRAINING, Allocated: &ateapipb.WorkerResources{Actors: 9}}
|
|
|
|
got, err := svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: in})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorker() failed: %v", err)
|
|
}
|
|
want := &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_UNAVAILABLE}
|
|
if diff := cmp.Diff(want, got.GetStatus(), protocmp.Transform()); diff != "" {
|
|
t.Errorf("created worker status mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorker_AlreadyExists(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
_, err := svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: validWorker(apiWorkerName)})
|
|
if got := apierror.Code(err); got != codes.AlreadyExists {
|
|
t.Errorf("CreateWorker() code = %v (err %v), want %v", got, err, codes.AlreadyExists)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorker_InvalidArgument(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*ateapipb.Worker) // nil sends no worker at all
|
|
}{
|
|
{name: "no worker"},
|
|
{name: "no name", mutate: func(w *ateapipb.Worker) { w.Metadata = &ateapipb.ResourceMetadata{} }},
|
|
{name: "atespace set", mutate: func(w *ateapipb.Worker) { w.Metadata.Atespace = "team-a" }},
|
|
{name: "no ips", mutate: func(w *ateapipb.Worker) { w.Ips = nil }},
|
|
{name: "bad ip", mutate: func(w *ateapipb.Worker) { w.Ips = []string{"not-an-ip"} }},
|
|
{name: "two ipv4 ips", mutate: func(w *ateapipb.Worker) { w.Ips = []string{"10.1.2.3", "10.1.2.4"} }},
|
|
{name: "three ips", mutate: func(w *ateapipb.Worker) { w.Ips = []string{"10.1.2.3", "fd00::1", "fd00::2"} }},
|
|
{name: "no node", mutate: func(w *ateapipb.Worker) { w.NodeName = "" }},
|
|
{name: "no pool", mutate: func(w *ateapipb.Worker) { w.WorkerPool = "" }},
|
|
{name: "no pod", mutate: func(w *ateapipb.Worker) { w.WorkerPod = "" }},
|
|
{name: "pod uid not a uuid", mutate: func(w *ateapipb.Worker) { w.WorkerPodUid = "not-a-uuid" }},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := &ateapipb.CreateWorkerRequest{}
|
|
if tc.mutate != nil {
|
|
worker := validWorker(apiWorkerName)
|
|
tc.mutate(worker)
|
|
req.Worker = worker
|
|
}
|
|
_, err := svc.CreateWorker(ctx, req)
|
|
if got := apierror.Code(err); got != codes.InvalidArgument {
|
|
t.Errorf("CreateWorker() code = %v (err %v), want %v", got, err, codes.InvalidArgument)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateWorker(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seeded := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
got, err := svc.UpdateWorker(ctx, &ateapipb.UpdateWorkerRequest{
|
|
Worker: updateFrom(seeded, func(w *ateapipb.Worker) {
|
|
w.Labels = map[string]string{"tier": "batch"}
|
|
}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateWorker() failed: %v", err)
|
|
}
|
|
|
|
want := proto.Clone(seeded).(*ateapipb.Worker)
|
|
want.Labels = map[string]string{"tier": "batch"}
|
|
want.Metadata = got.GetMetadata()
|
|
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
|
|
t.Errorf("UpdateWorker() mismatch (-want +got):\n%s", diff)
|
|
}
|
|
if got.GetMetadata().GetVersion() != 2 {
|
|
t.Errorf("updated worker version = %d, want 2", got.GetMetadata().GetVersion())
|
|
}
|
|
}
|
|
|
|
// Update replaces rather than patches, so a mutable field the request leaves
|
|
// unset is cleared. Immutable fields are the exception: dropping one of those
|
|
// is an error rather than a clear, which TestUpdateWorker_Errors covers.
|
|
func TestUpdateWorker_OmittedMutableFieldIsCleared(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
labelled := validWorker(apiWorkerName)
|
|
labelled.Labels = map[string]string{"tier": "batch"}
|
|
seeded := seedAPIWorker(t, ctx, persistence, labelled)
|
|
|
|
got, err := svc.UpdateWorker(ctx, &ateapipb.UpdateWorkerRequest{
|
|
Worker: updateFrom(seeded, func(w *ateapipb.Worker) {
|
|
w.Labels = nil
|
|
}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateWorker() failed: %v", err)
|
|
}
|
|
if len(got.GetLabels()) != 0 {
|
|
t.Errorf("labels = %v, want them cleared: the request carried none", got.GetLabels())
|
|
}
|
|
}
|
|
|
|
// status is output-only, so the server keeps its own no matter what the request
|
|
// carries. That is what protects the in-process Actor binding, which lives
|
|
// under status and is written by the actor workflows rather than over the API.
|
|
func TestUpdateWorker_LeavesStatusAlone(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
assigned := assignAPIWorker(t, ctx, persistence, apiWorkerName, "actor-uid-1")
|
|
|
|
got, err := svc.UpdateWorker(ctx, &ateapipb.UpdateWorkerRequest{
|
|
Worker: updateFrom(assigned, func(w *ateapipb.Worker) {
|
|
w.Labels = map[string]string{"tier": "batch"}
|
|
// A forged status: drained, and with the Actor released out from
|
|
// under the workflow that bound it. Neither may land.
|
|
w.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_DRAINING}
|
|
}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateWorker() failed: %v", err)
|
|
}
|
|
if diff := cmp.Diff(assigned.GetStatus(), got.GetStatus(), protocmp.Transform()); diff != "" {
|
|
t.Errorf("UpdateWorker() disturbed status (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestUpdateWorker_Preconditions(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seeded := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
update := func(bend func(*ateapipb.ResourceMetadata)) error {
|
|
_, err := svc.UpdateWorker(ctx, &ateapipb.UpdateWorkerRequest{
|
|
Worker: updateFrom(seeded, func(w *ateapipb.Worker) {
|
|
w.Labels = map[string]string{"tier": "batch"}
|
|
bend(w.Metadata)
|
|
}),
|
|
})
|
|
return err
|
|
}
|
|
|
|
t.Run("stale version", func(t *testing.T) {
|
|
err := update(func(md *ateapipb.ResourceMetadata) { md.Version += 7 })
|
|
if got := apierror.Code(err); got != codes.Aborted {
|
|
t.Errorf("UpdateWorker() code = %v, want %v", got, codes.Aborted)
|
|
}
|
|
})
|
|
|
|
t.Run("foreign uid", func(t *testing.T) {
|
|
err := update(func(md *ateapipb.ResourceMetadata) { md.Uid = apiOtherWorkerName })
|
|
if got := apierror.Code(err); got != codes.Aborted {
|
|
t.Errorf("UpdateWorker() code = %v, want %v", got, codes.Aborted)
|
|
}
|
|
})
|
|
|
|
// Both guards are required: an update that pins neither is a blind write,
|
|
// which is rejected before it reaches the store.
|
|
t.Run("missing uid", func(t *testing.T) {
|
|
err := update(func(md *ateapipb.ResourceMetadata) { md.Uid = "" })
|
|
if got := apierror.Code(err); got != codes.InvalidArgument {
|
|
t.Errorf("UpdateWorker() code = %v, want %v", got, codes.InvalidArgument)
|
|
}
|
|
})
|
|
|
|
t.Run("missing version", func(t *testing.T) {
|
|
err := update(func(md *ateapipb.ResourceMetadata) { md.Version = 0 })
|
|
if got := apierror.Code(err); got != codes.InvalidArgument {
|
|
t.Errorf("UpdateWorker() code = %v, want %v", got, codes.InvalidArgument)
|
|
}
|
|
})
|
|
|
|
t.Run("matching", func(t *testing.T) {
|
|
if err := update(func(*ateapipb.ResourceMetadata) {}); err != nil {
|
|
t.Errorf("UpdateWorker() with matching preconditions failed: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUpdateWorker_Errors(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seeded := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
// Every case below carries the guards an update requires and the worker as
|
|
// stored, so the rule it is named for is the one that rejects it.
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*ateapipb.Worker) // nil sends no worker at all
|
|
want codes.Code
|
|
}{
|
|
{"no worker", nil, codes.InvalidArgument},
|
|
{"atespace set", func(w *ateapipb.Worker) { w.Metadata.Atespace = "team-a" }, codes.InvalidArgument},
|
|
{"absent", func(w *ateapipb.Worker) {
|
|
w.Metadata.Name = "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63"
|
|
}, codes.NotFound},
|
|
// Immutable fields, changed. A replacement update carries the whole
|
|
// worker, so these are the cases where it carries a different one.
|
|
{"ips changed", func(w *ateapipb.Worker) { w.Ips = []string{"10.9.9.9"} }, codes.InvalidArgument},
|
|
{"ips family added", func(w *ateapipb.Worker) { w.Ips = append(w.Ips, "fd00::1") }, codes.InvalidArgument},
|
|
{"worker_pod changed", func(w *ateapipb.Worker) { w.WorkerPod = "worker-pod-2" }, codes.InvalidArgument},
|
|
{"node_name changed", func(w *ateapipb.Worker) { w.NodeName = "node-2" }, codes.InvalidArgument},
|
|
{"sandbox_class changed", func(w *ateapipb.Worker) { w.SandboxClass = "microvm" }, codes.InvalidArgument},
|
|
// capacity is deliberately absent here: it may change (the pool's actor
|
|
// ceiling moves, a pod can be resized, a Worker may report its own).
|
|
// TestUpdateWorker_CapacityChanges covers that.
|
|
//
|
|
// And immutable fields dropped, which a replacement update reads as a
|
|
// request to clear them. Rejected rather than silently applied.
|
|
{"ips omitted", func(w *ateapipb.Worker) { w.Ips = nil }, codes.InvalidArgument},
|
|
{"sandbox_class omitted", func(w *ateapipb.Worker) { w.SandboxClass = "" }, codes.InvalidArgument},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := &ateapipb.UpdateWorkerRequest{}
|
|
if tc.mutate != nil {
|
|
req.Worker = updateFrom(seeded, tc.mutate)
|
|
}
|
|
_, err := svc.UpdateWorker(ctx, req)
|
|
if got := apierror.Code(err); got != tc.want {
|
|
t.Errorf("UpdateWorker() code = %v (err %v), want %v", got, err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A draining worker can still have its labels updated; only its status is
|
|
// frozen.
|
|
func TestUpdateWorker_DrainingWorkerKeepsLabelsMutable(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
drained, err := svc.DrainWorker(ctx, &ateapipb.DrainWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("DrainWorker() failed: %v", err)
|
|
}
|
|
|
|
got, err := svc.UpdateWorker(ctx, &ateapipb.UpdateWorkerRequest{
|
|
Worker: updateFrom(drained.GetWorker(), func(w *ateapipb.Worker) { w.Labels = map[string]string{"tier": "batch"} }),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateWorker() failed: %v", err)
|
|
}
|
|
if got.GetLabels()["tier"] != "batch" {
|
|
t.Errorf("labels = %v, want tier=batch", got.GetLabels())
|
|
}
|
|
if got.GetStatus().GetState() != ateapipb.WorkerState_WORKER_STATE_DRAINING {
|
|
t.Errorf("state = %v, want it still %v", got.GetStatus().GetState(), ateapipb.WorkerState_WORKER_STATE_DRAINING)
|
|
}
|
|
}
|
|
|
|
func TestDeleteWorker(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seeded := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
got, err := svc.DeleteWorker(ctx, &ateapipb.DeleteWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("DeleteWorker() failed: %v", err)
|
|
}
|
|
// Delete drains before it sweeps, so it removes one revision past what was
|
|
// seeded.
|
|
want := proto.Clone(seeded).(*ateapipb.Worker)
|
|
want.Metadata.Version = seeded.GetMetadata().GetVersion() + 1
|
|
want.Metadata.UpdateTime = got.GetMetadata().GetUpdateTime()
|
|
want.Status.State = ateapipb.WorkerState_WORKER_STATE_DRAINING
|
|
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
|
|
t.Errorf("DeleteWorker() returned something other than the worker it removed (-want +got):\n%s", diff)
|
|
}
|
|
if _, err := persistence.GetWorker(ctx, apiWorkerName); err == nil {
|
|
t.Error("worker still readable after DeleteWorker")
|
|
}
|
|
}
|
|
|
|
// Delete reports absence rather than succeeding silently. Callers that want
|
|
// idempotence, like the worker-pod syncer, opt into it by treating NOT_FOUND as
|
|
// success.
|
|
func TestDeleteWorker_Absent(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
_, err := svc.DeleteWorker(ctx, &ateapipb.DeleteWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if got := apierror.Code(err); got != codes.NotFound {
|
|
t.Errorf("DeleteWorker() code = %v (err %v), want %v", got, err, codes.NotFound)
|
|
}
|
|
}
|
|
|
|
// An assigned worker deletes like any other: the delete does not cascade, and
|
|
// an Actor pointing at a Worker that is gone is an expected steady state.
|
|
func TestDeleteWorker_AssignedWorkerDeletesAnyway(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
assignAPIWorker(t, ctx, persistence, apiWorkerName, "actor-uid-1")
|
|
|
|
got, err := svc.DeleteWorker(ctx, &ateapipb.DeleteWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("DeleteWorker() failed: %v", err)
|
|
}
|
|
// The record carries the total, not the assignments themselves.
|
|
if n := got.GetStatus().GetAllocated().GetActors(); n != 1 {
|
|
t.Errorf("deleted worker hosted %d actors, want the 1 it was holding", n)
|
|
}
|
|
}
|
|
|
|
func TestDeleteWorker_Preconditions(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seeded := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
t.Run("stale version", func(t *testing.T) {
|
|
_, err := svc.DeleteWorker(ctx, &ateapipb.DeleteWorkerRequest{
|
|
Worker: workerRef(apiWorkerName),
|
|
Options: &ateapipb.DeleteOptions{Version: seeded.GetMetadata().GetVersion() + 7},
|
|
})
|
|
if got := apierror.Code(err); got != codes.Aborted {
|
|
t.Errorf("DeleteWorker() code = %v (err %v), want %v", got, err, codes.Aborted)
|
|
}
|
|
})
|
|
|
|
t.Run("foreign uid", func(t *testing.T) {
|
|
_, err := svc.DeleteWorker(ctx, &ateapipb.DeleteWorkerRequest{
|
|
Worker: workerRef(apiWorkerName),
|
|
Options: &ateapipb.DeleteOptions{Uid: apiOtherWorkerName},
|
|
})
|
|
if got := apierror.Code(err); got != codes.Aborted {
|
|
t.Errorf("DeleteWorker() code = %v (err %v), want %v", got, err, codes.Aborted)
|
|
}
|
|
})
|
|
|
|
// A refused delete must leave the worker where it was.
|
|
if _, err := persistence.GetWorker(ctx, apiWorkerName); err != nil {
|
|
t.Fatalf("worker gone after two refused deletes: %v", err)
|
|
}
|
|
|
|
t.Run("matching", func(t *testing.T) {
|
|
if _, err := svc.DeleteWorker(ctx, &ateapipb.DeleteWorkerRequest{
|
|
Worker: workerRef(apiWorkerName),
|
|
Options: &ateapipb.DeleteOptions{
|
|
Uid: seeded.GetMetadata().GetUid(),
|
|
Version: seeded.GetMetadata().GetVersion(),
|
|
},
|
|
}); err != nil {
|
|
t.Errorf("DeleteWorker() with matching preconditions failed: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDrainWorker(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
got, err := svc.DrainWorker(ctx, &ateapipb.DrainWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("DrainWorker() failed: %v", err)
|
|
}
|
|
if got.GetWorker().GetStatus().GetState() != ateapipb.WorkerState_WORKER_STATE_DRAINING {
|
|
t.Errorf("state = %v, want %v", got.GetWorker().GetStatus().GetState(), ateapipb.WorkerState_WORKER_STATE_DRAINING)
|
|
}
|
|
if got.GetWorker().GetMetadata().GetVersion() != 2 {
|
|
t.Errorf("version = %d, want 2", got.GetWorker().GetMetadata().GetVersion())
|
|
}
|
|
|
|
// Draining again is a no-op, and specifically must not bump the version:
|
|
// callers re-drive drain on every pod event.
|
|
again, err := svc.DrainWorker(ctx, &ateapipb.DrainWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("second DrainWorker() failed: %v", err)
|
|
}
|
|
if diff := cmp.Diff(got, again, protocmp.Transform()); diff != "" {
|
|
t.Errorf("second DrainWorker() changed the worker (-first +second):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
// Drain deliberately leaves the bound Actor alone: it stops the scheduler
|
|
// routing new Actors here, it does not evict the one already running.
|
|
func TestDrainWorker_KeepsAssignment(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
assignAPIWorker(t, ctx, persistence, apiWorkerName, "actor-uid-1")
|
|
|
|
got, err := svc.DrainWorker(ctx, &ateapipb.DrainWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("DrainWorker() failed: %v", err)
|
|
}
|
|
if n := got.GetWorker().GetStatus().GetAllocated().GetActors(); n != 1 {
|
|
t.Errorf("drained worker hosts %d actors, want the 1 left in place", n)
|
|
}
|
|
}
|
|
|
|
func TestDrainWorker_Errors(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *ateapipb.DrainWorkerRequest
|
|
want codes.Code
|
|
}{
|
|
{"absent", &ateapipb.DrainWorkerRequest{Worker: workerRef(apiWorkerName)}, codes.NotFound},
|
|
{"no ref", &ateapipb.DrainWorkerRequest{}, codes.InvalidArgument},
|
|
{"no name", &ateapipb.DrainWorkerRequest{Worker: &ateapipb.ObjectRef{}}, codes.InvalidArgument},
|
|
{"atespace set", &ateapipb.DrainWorkerRequest{Worker: &ateapipb.ObjectRef{Atespace: "team-a", Name: apiWorkerName}}, codes.InvalidArgument},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := svc.DrainWorker(ctx, tc.req)
|
|
if got := apierror.Code(err); got != tc.want {
|
|
t.Errorf("DrainWorker() code = %v (err %v), want %v", got, err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A created Worker is marked available, back unavailable, and available again.
|
|
// A repeated mark is a no-op and must not bump the version: the syncer re-drives
|
|
// it on every pod event.
|
|
func TestMarkWorkerAvailability(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
created, err := svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: validWorker(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorker() failed: %v", err)
|
|
}
|
|
|
|
markAvailable := func() (*ateapipb.Worker, error) {
|
|
resp, err := svc.MarkWorkerAvailable(ctx, &ateapipb.MarkWorkerAvailableRequest{Worker: workerRef(apiWorkerName)})
|
|
return resp.GetWorker(), err
|
|
}
|
|
markUnavailable := func() (*ateapipb.Worker, error) {
|
|
resp, err := svc.MarkWorkerUnavailable(ctx, &ateapipb.MarkWorkerUnavailableRequest{Worker: workerRef(apiWorkerName)})
|
|
return resp.GetWorker(), err
|
|
}
|
|
version := created.GetMetadata().GetVersion()
|
|
for i, step := range []struct {
|
|
name string
|
|
mark func() (*ateapipb.Worker, error)
|
|
want ateapipb.WorkerState
|
|
changed bool
|
|
}{
|
|
{"available", markAvailable, ateapipb.WorkerState_WORKER_STATE_ACTIVE, true},
|
|
{"available again", markAvailable, ateapipb.WorkerState_WORKER_STATE_ACTIVE, false},
|
|
{"unavailable", markUnavailable, ateapipb.WorkerState_WORKER_STATE_UNAVAILABLE, true},
|
|
{"unavailable again", markUnavailable, ateapipb.WorkerState_WORKER_STATE_UNAVAILABLE, false},
|
|
{"available once more", markAvailable, ateapipb.WorkerState_WORKER_STATE_ACTIVE, true},
|
|
} {
|
|
got, err := step.mark()
|
|
if err != nil {
|
|
t.Fatalf("step %d (%s): failed: %v", i, step.name, err)
|
|
}
|
|
if state := got.GetStatus().GetState(); state != step.want {
|
|
t.Errorf("step %d (%s): state = %v, want %v", i, step.name, state, step.want)
|
|
}
|
|
if step.changed {
|
|
version++
|
|
}
|
|
if v := got.GetMetadata().GetVersion(); v != version {
|
|
t.Errorf("step %d (%s): version = %d, want %d", i, step.name, v, version)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Marking leaves the bound Actor alone, as draining does.
|
|
func TestMarkWorkerUnavailable_KeepsAssignment(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
assignAPIWorker(t, ctx, persistence, apiWorkerName, "actor-uid-1")
|
|
|
|
got, err := svc.MarkWorkerUnavailable(ctx, &ateapipb.MarkWorkerUnavailableRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("MarkWorkerUnavailable() failed: %v", err)
|
|
}
|
|
if n := got.GetWorker().GetStatus().GetAllocated().GetActors(); n != 1 {
|
|
t.Errorf("unavailable worker hosts %d actors, want the 1 left in place", n)
|
|
}
|
|
}
|
|
|
|
// DRAINING is final: marking a draining Worker either way is refused and
|
|
// leaves it as it is.
|
|
func TestMarkWorker_Draining(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, persistence := newWorkerAPIService(t)
|
|
seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
drained, err := svc.DrainWorker(ctx, &ateapipb.DrainWorkerRequest{Worker: workerRef(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("DrainWorker() failed: %v", err)
|
|
}
|
|
|
|
_, err = svc.MarkWorkerUnavailable(ctx, &ateapipb.MarkWorkerUnavailableRequest{Worker: workerRef(apiWorkerName)})
|
|
if got := apierror.Code(err); got != codes.FailedPrecondition {
|
|
t.Errorf("MarkWorkerUnavailable() on a draining worker code = %v (err %v), want %v", got, err, codes.FailedPrecondition)
|
|
}
|
|
_, err = svc.MarkWorkerAvailable(ctx, &ateapipb.MarkWorkerAvailableRequest{Worker: workerRef(apiWorkerName)})
|
|
if got := apierror.Code(err); got != codes.FailedPrecondition {
|
|
t.Errorf("MarkWorkerAvailable() on a draining worker code = %v (err %v), want %v", got, err, codes.FailedPrecondition)
|
|
}
|
|
|
|
stored, err := persistence.GetWorker(ctx, apiWorkerName)
|
|
if err != nil {
|
|
t.Fatalf("GetWorker() failed: %v", err)
|
|
}
|
|
if diff := cmp.Diff(drained.GetWorker(), stored, protocmp.Transform()); diff != "" {
|
|
t.Errorf("refused marks changed a draining worker (-drained +stored):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestMarkWorker_Errors(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
refs := []struct {
|
|
name string
|
|
ref *ateapipb.ObjectRef
|
|
want codes.Code
|
|
}{
|
|
{"absent", workerRef(apiWorkerName), codes.NotFound},
|
|
{"no ref", nil, codes.InvalidArgument},
|
|
{"no name", &ateapipb.ObjectRef{}, codes.InvalidArgument},
|
|
{"atespace set", &ateapipb.ObjectRef{Atespace: "team-a", Name: apiWorkerName}, codes.InvalidArgument},
|
|
}
|
|
for _, tc := range refs {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := svc.MarkWorkerAvailable(ctx, &ateapipb.MarkWorkerAvailableRequest{Worker: tc.ref})
|
|
if got := apierror.Code(err); got != tc.want {
|
|
t.Errorf("MarkWorkerAvailable() code = %v (err %v), want %v", got, err, tc.want)
|
|
}
|
|
_, err = svc.MarkWorkerUnavailable(ctx, &ateapipb.MarkWorkerUnavailableRequest{Worker: tc.ref})
|
|
if got := apierror.Code(err); got != tc.want {
|
|
t.Errorf("MarkWorkerUnavailable() code = %v (err %v), want %v", got, err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServiceImplUpdateWorker_ImmutableFields pins the immutable-field rule at
|
|
// the layer that now owns it: declarative validation in ServiceImpl, which
|
|
// every write path shares. It moved up from the store contract when the store
|
|
// stopped enforcing immutability itself.
|
|
func TestServiceImplUpdateWorker_ImmutableFields(t *testing.T) {
|
|
ctx := context.Background()
|
|
persistence, cleanup := storetest.SetupTestStore(t)
|
|
defer cleanup()
|
|
impl := newServiceImpl(persistence, nil)
|
|
|
|
// Every case below is rejected, so nothing writes and this stays the
|
|
// current incarnation for all of them.
|
|
created := seedAPIWorker(t, ctx, persistence, validWorker(apiWorkerName))
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
field string
|
|
mutate func(*ateapipb.Worker)
|
|
}{
|
|
{"worker_namespace", "worker_namespace", func(w *ateapipb.Worker) { w.WorkerNamespace = "other-ns" }},
|
|
{"worker_pool", "worker_pool", func(w *ateapipb.Worker) { w.WorkerPool = "other-pool" }},
|
|
{"worker_pod", "worker_pod", func(w *ateapipb.Worker) { w.WorkerPod = "other-pod" }},
|
|
{"worker_pod_uid", "worker_pod_uid", func(w *ateapipb.Worker) { w.WorkerPodUid = apiOtherWorkerName }},
|
|
{"node_name", "node_name", func(w *ateapipb.Worker) { w.NodeName = "other-node" }},
|
|
{"ips", "ips", func(w *ateapipb.Worker) { w.Ips = []string{"10.0.0.9"} }},
|
|
// capacity is absent: it is reported into status, which a client
|
|
// cannot write. See TestUpdateWorker_CannotChangeCapacity.
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := impl.UpdateWorker(ctx, apiWorkerName, store.PreconditionFrom(created), func(toUpdate *ateapipb.Worker) error {
|
|
tc.mutate(toUpdate)
|
|
return nil
|
|
})
|
|
if got := apierror.Code(err); got != codes.InvalidArgument {
|
|
t.Fatalf("changing %s returned %v (err %v), want %v", tc.field, got, err, codes.InvalidArgument)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.field) {
|
|
t.Errorf("error %v does not name the offending field %s", err, tc.field)
|
|
}
|
|
got, err := persistence.GetWorker(ctx, apiWorkerName)
|
|
if err != nil {
|
|
t.Fatalf("GetWorker failed: %v", err)
|
|
}
|
|
if got.GetMetadata().GetVersion() != 1 {
|
|
t.Errorf("rejected mutation bumped the version to %d, want 1", got.GetMetadata().GetVersion())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Server-assigned metadata carried on a create request is scrubbed rather than
|
|
// rejected: the fields are documented as ignored on input, so even garbage in
|
|
// them must not fail validation.
|
|
func TestCreateWorker_IgnoresRequestMetadataServerFields(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
in := validWorker(apiWorkerName)
|
|
in.Metadata.Uid = "not-a-uuid"
|
|
in.Metadata.Version = -5
|
|
|
|
got, err := svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: in})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorker() failed: %v", err)
|
|
}
|
|
if got.GetMetadata().GetUid() == "" || got.GetMetadata().GetUid() == "not-a-uuid" {
|
|
t.Errorf("created worker uid = %q, want a server-assigned uid", got.GetMetadata().GetUid())
|
|
}
|
|
if got.GetMetadata().GetVersion() != 1 {
|
|
t.Errorf("created worker version = %d, want 1", got.GetMetadata().GetVersion())
|
|
}
|
|
}
|
|
|
|
// Every stored Worker carries an actor ceiling, so no reader has to know a
|
|
// default. A Worker that reports its own keeps it; one that does not is worth
|
|
// one Actor, which is what a Worker was before it could report.
|
|
func TestCreateWorker_HoldsNoCapacityUntilReported(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newWorkerAPIService(t)
|
|
|
|
got, err := svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: validWorker(apiWorkerName)})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorker() failed: %v", err)
|
|
}
|
|
if capacity := got.GetStatus().GetCapacity(); capacity != nil {
|
|
t.Errorf("created worker capacity = %v, want none until its worker reports", capacity)
|
|
}
|
|
|
|
// Capacity is status, so a request cannot bring its own: a Worker only
|
|
// gets one by reporting it.
|
|
carried := validWorker("11111111-2222-3333-4444-555555555555")
|
|
carried.Status = &ateapipb.WorkerStatus{Capacity: &ateapipb.WorkerResources{Actors: 4094}}
|
|
got, err = svc.CreateWorker(ctx, &ateapipb.CreateWorkerRequest{Worker: carried})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorker() carrying a capacity failed: %v", err)
|
|
}
|
|
if capacity := got.GetStatus().GetCapacity(); capacity != nil {
|
|
t.Errorf("a request carrying a ceiling set capacity to %v, want none", capacity)
|
|
}
|
|
}
|