mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
This mostly eliminates the need to hand-write validation code. This PR is a long series of commits which add DV for most of Actor and all of Atespace. Here is a map to the commits: * The first few take a dep on a new Kubernetes tag, import the code into third_party, and apply a single patch. Because we have different Go modules for tools, I had to do it twice. When that patch lands, we can revert these commits, but that won't be until the 1.38 cycle in a few months. * The next commits slowly add DV support, so a human can review each of them in a reasonable amount of time. The emphasis is on great test cases. * I made a bad choice early on as to where to generate the code into, so I moved it. Rebasing on that was exceedingly hard, so I left it as a move. * This required changing update/go-generate -> update/codegen -- we need to get the ordering of tools right, which `go generate` does not guarantee. * Then I added a "middle" layer called "ServiceImpl" between the RPC and storage layers. This allows things like workflow to call the same business logic as the RPC layer, including validation. Lots of test fixes. * Then I finished the Create() and Update() paths for Actor. Those represent the "right" (or closest to) way to implement resources, and tests for validation. I strongly encourage reviewers to read it commit-by-commit. Rebasing this is VERY tedious, so the sooner it lands or dies completely, the better. Then we can start converting the rest. `./hack/run-tool.sh validation-gen --docs` will produce some docs on the tool and the available tags. @laoj2 @juli4n @EItanya @HavenXia @lalitc375 @yongruilin @jpbetz FYI
361 lines
14 KiB
Go
361 lines
14 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"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
|
|
"github.com/agent-substrate/substrate/internal/resources"
|
|
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
"k8s.io/apimachinery/pkg/api/validate/content"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
)
|
|
|
|
func (s *RPCService) ListWorkers(ctx context.Context, req *ateapipb.ListWorkersRequest) (*ateapipb.ListWorkersResponse, error) {
|
|
if errs := validateListWorkersRequest(req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
|
|
page, err := s.impl.ListWorkers(ctx, store.ListOptions{PageSize: effectivePageSize(req.GetPageSize()), PageToken: req.GetPageToken()})
|
|
if err != nil {
|
|
return nil, mapListError(fmt.Errorf("while listing workers in db: %w", err))
|
|
}
|
|
return &ateapipb.ListWorkersResponse{
|
|
Workers: page.Items,
|
|
NextPageToken: page.NextPageToken,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ServiceImpl) ListWorkers(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapipb.Worker], error) {
|
|
// TODO: implement this
|
|
return s.store.ListWorkers(ctx, opts)
|
|
}
|
|
|
|
func validateListWorkersRequest(req *ateapipb.ListWorkersRequest) field.ErrorList {
|
|
var fldPath *field.Path
|
|
var errs field.ErrorList
|
|
|
|
if val, fldPath := req.PageSize, fldPath.Child("page_size"); val < 0 {
|
|
errs = append(errs, field.Invalid(fldPath, val, "must be greater than or equal to 0"))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (s *RPCService) GetWorker(ctx context.Context, req *ateapipb.GetWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateGetWorkerRequest(req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
worker, err := s.impl.GetWorker(ctx, name)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("while getting worker: %w", err)
|
|
}
|
|
return worker, nil
|
|
}
|
|
|
|
func (s *ServiceImpl) GetWorker(ctx context.Context, name string) (*ateapipb.Worker, error) {
|
|
// TODO: implement this
|
|
return s.store.GetWorker(ctx, name)
|
|
}
|
|
|
|
func validateGetWorkerRequest(req *ateapipb.GetWorkerRequest) field.ErrorList {
|
|
var fldPath *field.Path
|
|
return resources.ValidateGlobalObjectRef(req.GetWorker(), fldPath.Child("worker"))
|
|
}
|
|
|
|
func (s *RPCService) CreateWorker(ctx context.Context, req *ateapipb.CreateWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateCreateWorkerRequest(req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetMetadata().GetName()
|
|
|
|
// status is output-only, so whatever the request carried there is replaced
|
|
// rather than rejected.
|
|
worker := proto.Clone(req.GetWorker()).(*ateapipb.Worker)
|
|
worker.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE}
|
|
|
|
created, err := s.impl.CreateWorker(ctx, worker)
|
|
if errors.Is(err, store.ErrAlreadyExists) {
|
|
return nil, status.Errorf(codes.AlreadyExists, "Worker %s already exists", name)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("while creating worker: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *ServiceImpl) CreateWorker(ctx context.Context, worker *ateapipb.Worker) (*ateapipb.Worker, error) {
|
|
// TODO: implement this
|
|
return s.store.CreateWorker(ctx, worker)
|
|
}
|
|
|
|
func validateCreateWorkerRequest(req *ateapipb.CreateWorkerRequest) field.ErrorList {
|
|
var fldPath *field.Path
|
|
|
|
worker, workerPath := req.GetWorker(), fldPath.Child("worker")
|
|
if worker == nil {
|
|
return field.ErrorList{field.Required(workerPath, "")}
|
|
}
|
|
return validateWorker(worker, workerPath)
|
|
}
|
|
|
|
// UpdateWorker replaces the stored Worker with the one the request carries.
|
|
// Only sandbox_class and labels are the caller's to change; a request that
|
|
// alters an immutable field — including by leaving it unset, which would clear
|
|
// it — is rejected. The store enforces that, since only it holds the stored
|
|
// worker to compare against.
|
|
func (s *RPCService) UpdateWorker(ctx context.Context, req *ateapipb.UpdateWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateUpdateWorkerRequest(req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
in := req.GetWorker()
|
|
|
|
return s.mutateWorker(ctx, in.GetMetadata().GetName(), store.PreconditionFrom(in), func(toUpdate *ateapipb.Worker) error {
|
|
// Status and metadata are server-owned fields.
|
|
status, metadata := toUpdate.GetStatus(), toUpdate.GetMetadata()
|
|
// Reset + merge from the input worker.
|
|
proto.Reset(toUpdate)
|
|
proto.Merge(toUpdate, in)
|
|
// Restore status and metadata from the server.
|
|
toUpdate.Status = status
|
|
toUpdate.Metadata = metadata
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *ServiceImpl) UpdateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error) {
|
|
// TODO: implement this
|
|
return s.store.UpdateWorker(ctx, name, precondition, mutate)
|
|
}
|
|
|
|
func validateUpdateWorkerRequest(req *ateapipb.UpdateWorkerRequest) field.ErrorList {
|
|
var fldPath *field.Path
|
|
|
|
worker, workerPath := req.GetWorker(), fldPath.Child("worker")
|
|
if worker == nil {
|
|
return field.ErrorList{field.Required(workerPath, "")}
|
|
}
|
|
|
|
// Only the metadata guards are checked here. The rest of the worker is
|
|
// pinned to what create stored — validateWorker already passed on it, and
|
|
// an update that changed any of it does not get written.
|
|
return resources.ValidateGlobalUpdateMetadataRef(worker.GetMetadata(), workerPath.Child("metadata"))
|
|
}
|
|
|
|
func (s *RPCService) DeleteWorker(ctx context.Context, req *ateapipb.DeleteWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateDeleteWorkerRequest(req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
worker, err := s.impl.DeleteWorker(ctx, name, store.DeletePreconditions{
|
|
UID: req.GetOptions().GetUid(),
|
|
Version: req.GetOptions().GetVersion(),
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
case errors.Is(err, store.ErrUIDConflict):
|
|
return nil, status.Errorf(codes.Aborted, "Worker %s does not have uid %s", name, req.GetOptions().GetUid())
|
|
case errors.Is(err, store.ErrVersionConflict):
|
|
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
|
|
}
|
|
return nil, fmt.Errorf("while deleting worker: %w", err)
|
|
}
|
|
return worker, nil
|
|
}
|
|
|
|
func (s *ServiceImpl) DeleteWorker(ctx context.Context, name string, pre store.DeletePreconditions) (*ateapipb.Worker, error) {
|
|
// TODO: implement this
|
|
return s.store.DeleteWorker(ctx, name, pre)
|
|
}
|
|
|
|
func validateDeleteWorkerRequest(req *ateapipb.DeleteWorkerRequest) field.ErrorList {
|
|
var fldPath *field.Path
|
|
|
|
errs := resources.ValidateGlobalObjectRef(req.GetWorker(), fldPath.Child("worker"))
|
|
|
|
// Delete carries its preconditions in options, and each is optional: a zero
|
|
// value waives that guard. Absent options waive both, so nil needs no
|
|
// special case.
|
|
opts, optsPath := req.GetOptions(), fldPath.Child("options")
|
|
if val, p := opts.GetUid(), optsPath.Child("uid"); val != "" {
|
|
errs = append(errs, resources.ValidateUUID(val, p)...)
|
|
}
|
|
if val, p := opts.GetVersion(), optsPath.Child("version"); val < 0 {
|
|
errs = append(errs, field.Invalid(p, val, "must not be negative"))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (s *RPCService) DrainWorker(ctx context.Context, req *ateapipb.DrainWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateDrainWorkerRequest(req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
// A DrainWorkerRequest names a worker and carries no guards, so the ones
|
|
// the store requires come from a read here rather than from the client. A
|
|
// write that lands in between is reported as a conflict for the caller to
|
|
// retry, the same as any other guarded update.
|
|
observed, err := s.impl.GetWorker(ctx, name)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("while getting worker to drain: %w", err)
|
|
}
|
|
|
|
return s.mutateWorker(ctx, name, store.PreconditionFrom(observed), func(toUpdate *ateapipb.Worker) error {
|
|
if toUpdate.GetStatus().GetState() == ateapipb.WorkerState_WORKER_STATE_DRAINING {
|
|
// already draining, do nothing
|
|
return &workerUnchanged{worker: proto.Clone(toUpdate).(*ateapipb.Worker)}
|
|
}
|
|
toUpdate.Status.State = ateapipb.WorkerState_WORKER_STATE_DRAINING
|
|
// status.assignment is deliberately left alone: a draining Worker keeps
|
|
// hosting the Actor bound to it until something releases it. Draining
|
|
// only stops the scheduler routing new Actors here.
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func validateDrainWorkerRequest(req *ateapipb.DrainWorkerRequest) field.ErrorList {
|
|
var fldPath *field.Path
|
|
return resources.ValidateGlobalObjectRef(req.GetWorker(), fldPath.Child("worker"))
|
|
}
|
|
|
|
// mutateWorker runs mutate against the named Worker and translates what comes
|
|
// back into the RPC's result. A mutation that found nothing to do reports the
|
|
// Worker it saw; anything else is a store error.
|
|
func (s *RPCService) mutateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error) {
|
|
worker, err := s.impl.UpdateWorker(ctx, name, precondition, mutate)
|
|
if err == nil {
|
|
return worker, nil
|
|
}
|
|
|
|
var unchanged *workerUnchanged
|
|
if errors.As(err, &unchanged) {
|
|
return unchanged.worker, nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
case errors.Is(err, store.ErrUIDConflict):
|
|
return nil, status.Errorf(codes.Aborted, "Worker %s is not the one the request describes", name)
|
|
case errors.Is(err, store.ErrVersionConflict):
|
|
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
|
|
case errors.Is(err, store.ErrImmutableField):
|
|
return nil, status.Errorf(codes.InvalidArgument, "while updating worker %s: %v", name, err)
|
|
case errors.Is(err, store.ErrPreconditionRequired):
|
|
return nil, status.Errorf(codes.InvalidArgument, "while updating worker %s: %v", name, err)
|
|
}
|
|
return nil, fmt.Errorf("while updating worker: %w", err)
|
|
}
|
|
|
|
// workerUnchanged ends an UpdateWorker mutation that found its work already
|
|
// done. The store hands a mutation's error straight back and leaves the Worker —
|
|
// and its version — untouched, which is what lets DrainWorker be idempotent: a
|
|
// call with nothing left to do costs no version bump. worker is a copy, because
|
|
// the store is free to reuse or discard the message once mutate returns.
|
|
type workerUnchanged struct {
|
|
worker *ateapipb.Worker
|
|
}
|
|
|
|
func (u *workerUnchanged) Error() string { return "worker is already in the requested state" }
|
|
|
|
// validateWorker checks that the caller-controlled fields of a Worker are
|
|
// well-formed. It is the create-time check: every field it covers is immutable
|
|
// afterwards, so no update path re-runs it.
|
|
func validateWorker(worker *ateapipb.Worker, fldPath *field.Path) field.ErrorList {
|
|
var errs field.ErrorList
|
|
|
|
// Worker is global-scoped: metadata.atespace must be empty, name required +
|
|
// valid. uid and version are server-assigned, so a create ignores whatever
|
|
// the request carried in them.
|
|
metaPath := fldPath.Child("metadata")
|
|
if val, p := worker.GetMetadata().GetAtespace(), metaPath.Child("atespace"); val != "" {
|
|
errs = append(errs, field.Invalid(p, val, "must be empty for a global-scoped resource"))
|
|
}
|
|
if val, p := worker.GetMetadata().GetName(), metaPath.Child("name"); val == "" {
|
|
errs = append(errs, field.Required(p, ""))
|
|
} else {
|
|
errs = append(errs, resources.ValidateResourceName(val, p)...)
|
|
}
|
|
|
|
if val, fldPath := worker.GetWorkerNamespace(), fldPath.Child("worker_namespace"); val == "" {
|
|
errs = append(errs, field.Required(fldPath, ""))
|
|
} else {
|
|
for _, msg := range content.IsDNS1123Label(val) {
|
|
errs = append(errs, field.Invalid(fldPath, val, msg))
|
|
}
|
|
}
|
|
|
|
if val, fldPath := worker.GetWorkerPool(), fldPath.Child("worker_pool"); val == "" {
|
|
errs = append(errs, field.Required(fldPath, ""))
|
|
} else {
|
|
for _, msg := range content.IsDNS1123Subdomain(val) {
|
|
errs = append(errs, field.Invalid(fldPath, val, msg))
|
|
}
|
|
}
|
|
|
|
if val, fldPath := worker.GetWorkerPod(), fldPath.Child("worker_pod"); val == "" {
|
|
errs = append(errs, field.Required(fldPath, ""))
|
|
} else {
|
|
for _, msg := range content.IsDNS1123Subdomain(val) {
|
|
errs = append(errs, field.Invalid(fldPath, val, msg))
|
|
}
|
|
}
|
|
|
|
if val, fldPath := worker.GetIp(), fldPath.Child("ip"); val == "" {
|
|
errs = append(errs, field.Required(fldPath, ""))
|
|
} else {
|
|
errs = append(errs, resources.ValidateIP(val, fldPath)...)
|
|
}
|
|
|
|
if val, fldPath := worker.GetWorkerPodUid(), fldPath.Child("worker_pod_uid"); val == "" {
|
|
errs = append(errs, field.Required(fldPath, ""))
|
|
} else {
|
|
errs = append(errs, resources.ValidateUUID(val, fldPath)...)
|
|
}
|
|
|
|
if val, fldPath := worker.GetNodeName(), fldPath.Child("node_name"); val == "" {
|
|
errs = append(errs, field.Required(fldPath, ""))
|
|
} else {
|
|
for _, msg := range content.IsDNS1123Subdomain(val) {
|
|
errs = append(errs, field.Invalid(fldPath, val, msg))
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (s *ServiceImpl) WatchWorkers(ctx context.Context) (*store.WorkerWatch, error) {
|
|
// TODO: implement this
|
|
return s.store.WatchWorkers(ctx)
|
|
}
|