mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-11 10:23:17 +08:00
Populate default fields during both Create and Update RPC calls so that resources are stored with defaults instead of zero values. This is a much cleaner approach as it means that a) consumers don't need to be aware of the default value and b) default values can potentially change. Only ActorTemplate had defaults, but added default functions for all resources so that all RPC handlers are wired up.
408 lines
18 KiB
Go
408 lines
18 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/pkg/proto/ateapipb"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
"k8s.io/apimachinery/pkg/api/operation"
|
|
"k8s.io/apimachinery/pkg/api/validate"
|
|
"k8s.io/apimachinery/pkg/util/validation"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
)
|
|
|
|
// ListWorkerActorAssignments lists the Actors a Worker hosts. The assignments are a
|
|
// subresource rather than a field on Worker, so this is the only way to read
|
|
// them and neither GetWorker nor ListWorkers grows with occupancy.
|
|
func (s *RPCService) ListWorkerActorAssignments(ctx context.Context, req *ateapipb.ListWorkerActorAssignmentsRequest) (*ateapipb.ListWorkerActorAssignmentsResponse, error) {
|
|
if errs := validateListWorkerActorAssignmentsRequest(ctx, req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
// The Worker is read first so a listing against one that does not exist is
|
|
// NOT_FOUND rather than an empty page, which a caller cannot tell from a
|
|
// Worker hosting nothing.
|
|
if _, err := s.impl.GetWorker(ctx, name); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
}
|
|
return nil, fmt.Errorf("while fetching worker %s: %w", name, err)
|
|
}
|
|
|
|
page, err := s.impl.ListWorkerAssignments(ctx, name,
|
|
store.ListOptions{PageSize: effectivePageSize(req.GetPageSize()), PageToken: req.GetPageToken()})
|
|
if err != nil {
|
|
return nil, mapListError(fmt.Errorf("while listing the assignments of worker %s: %w", name, err))
|
|
}
|
|
return &ateapipb.ListWorkerActorAssignmentsResponse{
|
|
ActorAssignments: page.Items,
|
|
NextPageToken: page.NextPageToken,
|
|
}, nil
|
|
}
|
|
|
|
func validateListWorkerActorAssignmentsRequest(ctx context.Context, req *ateapipb.ListWorkerActorAssignmentsRequest) field.ErrorList {
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_ListWorkerActorAssignmentsRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
func (s *RPCService) ListWorkers(ctx context.Context, req *ateapipb.ListWorkersRequest) (*ateapipb.ListWorkersResponse, error) {
|
|
if errs := validateListWorkersRequest(ctx, 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) {
|
|
return s.store.ListWorkers(ctx, opts)
|
|
}
|
|
|
|
func validateListWorkersRequest(ctx context.Context, req *ateapipb.ListWorkersRequest) field.ErrorList {
|
|
// Call the generated validation.
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_ListWorkersRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
func (s *RPCService) GetWorker(ctx context.Context, req *ateapipb.GetWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateGetWorkerRequest(ctx, 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
|
|
}
|
|
|
|
// GetWorker returns the Worker with the Actors it hosts, read separately since
|
|
// the assignments are their own records. The one read that pays O(assignments).
|
|
// A failed read is an error, not a Worker reported as hosting nothing: the
|
|
// caller cannot tell those apart.
|
|
func (s *ServiceImpl) GetWorker(ctx context.Context, name string) (*ateapipb.Worker, error) {
|
|
return s.store.GetWorker(ctx, name)
|
|
}
|
|
|
|
func validateGetWorkerRequest(ctx context.Context, req *ateapipb.GetWorkerRequest) field.ErrorList {
|
|
// Call the generated validation.
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_GetWorkerRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
func (s *RPCService) CreateWorker(ctx context.Context, req *ateapipb.CreateWorkerRequest) (*ateapipb.Worker, error) {
|
|
// First scrub any fields that callers are not allowed to set. status is
|
|
// output-only, so whatever the request carried there is replaced rather
|
|
// than rejected.
|
|
inWorker := req.Worker
|
|
if inWorker != nil { // otherwise validation will flag it
|
|
scrubResourceMetadataForCreate(inWorker.Metadata)
|
|
inWorker.Status = nil
|
|
defaultWorker(inWorker)
|
|
}
|
|
|
|
// Validate the request, including the object within it.
|
|
if errs := validateCreateWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
|
|
// Handle the creation, including validation of the final stored object.
|
|
return s.impl.CreateWorker(ctx, inWorker)
|
|
}
|
|
|
|
func (s *ServiceImpl) CreateWorker(ctx context.Context, inWorker *ateapipb.Worker) (*ateapipb.Worker, error) {
|
|
// A Worker is registered only once its pod is Ready and has an IP, which
|
|
// makes ACTIVE the only state it can be born in.
|
|
outWorker := proto.CloneOf(inWorker)
|
|
outWorker.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE}
|
|
|
|
// Capacity is left unset: a Worker holds nothing until its own ateom says
|
|
// what it has, through WorkerService.SetWorkerCapacity. Nothing is placed
|
|
// on it in the meantime, which is the point -- the alternative is guessing
|
|
// on the Worker's behalf and placing against the guess.
|
|
|
|
// Verify that the result is properly valid before storing it.
|
|
if errs := validateWorkerUpdate(ctx, field.NewPath("worker"), outWorker, inWorker, true); len(errs) > 0 {
|
|
return nil, toGRPCInternalError(errs)
|
|
}
|
|
|
|
// Save the data in the storage layer.
|
|
created, err := s.store.CreateWorker(ctx, outWorker)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrAlreadyExists) {
|
|
return nil, status.Errorf(codes.AlreadyExists, "Worker %s already exists", inWorker.GetMetadata().GetName())
|
|
}
|
|
return nil, fmt.Errorf("while creating worker: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func validateCreateWorkerRequest(ctx context.Context, req *ateapipb.CreateWorkerRequest) field.ErrorList {
|
|
// Call the generated validation.
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_CreateWorkerRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
// UpdateWorker replaces the stored Worker with the one the request carries.
|
|
// Only 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 service layer enforces that with declarative validation against the
|
|
// stored worker inside the update transaction.
|
|
func (s *RPCService) UpdateWorker(ctx context.Context, req *ateapipb.UpdateWorkerRequest) (*ateapipb.Worker, error) {
|
|
// First scrub any fields that callers are not allowed to set.
|
|
inWorker := req.Worker
|
|
if inWorker != nil { // otherwise validation will flag it
|
|
scrubResourceMetadataForUpdate(inWorker.Metadata)
|
|
inWorker.Status = nil
|
|
}
|
|
|
|
// Validate the request.
|
|
if errs := validateUpdateWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
|
|
return s.mutateWorker(ctx, inWorker.GetMetadata().GetName(), store.PreconditionFrom(inWorker), 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, inWorker)
|
|
// Restore status and metadata from the server.
|
|
toUpdate.Status = status
|
|
toUpdate.Metadata = metadata
|
|
// Defaults are re-applied to the merged object, so a defaulted field
|
|
// the request left unset is defaulted again rather than cleared.
|
|
defaultWorker(toUpdate)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *ServiceImpl) UpdateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error) {
|
|
return s.store.UpdateWorker(ctx, name, precondition, func(toUpdate *ateapipb.Worker) error {
|
|
// Apply the mutation function to the stored value.
|
|
oldVal := proto.CloneOf(toUpdate)
|
|
if err := mutate(toUpdate); err != nil {
|
|
return err
|
|
}
|
|
newVal := toUpdate
|
|
|
|
// Validate the mutated value before doing any further work. This is
|
|
// what enforces the immutable fields, since only the stored worker
|
|
// gives declarative validation an old value to compare against.
|
|
if errs := validateWorkerUpdate(ctx, field.NewPath("worker"), newVal, oldVal, false); len(errs) > 0 {
|
|
return toGRPCStatusError(errs)
|
|
}
|
|
|
|
// Do any further work on the resource.
|
|
|
|
// Validate the final value before storing it.
|
|
if errs := validateWorkerUpdate(ctx, field.NewPath("worker"), newVal, oldVal, true); len(errs) > 0 {
|
|
return toGRPCInternalError(errs)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func validateUpdateWorkerRequest(ctx context.Context, req *ateapipb.UpdateWorkerRequest) field.ErrorList {
|
|
// Call the generated validation.
|
|
// We model this as a create rather than an update because updates assume
|
|
// the existence of a "current" value, which we do not have yet. This is
|
|
// validating the request itself. The result will be validated later, after
|
|
// we have a current value to compare against.
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_UpdateWorkerRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
// The assignment operations are pass-throughs: an assignment is its own record,
|
|
// so binding and releasing are single store calls rather than a read-modify-write
|
|
// of the Worker.
|
|
func (s *ServiceImpl) BindActorToWorker(ctx context.Context, workerName string, assignment *ateapipb.ActorAssignment, admit func(*ateapipb.Worker) error) error {
|
|
return s.store.BindActorToWorker(ctx, workerName, assignment, admit)
|
|
}
|
|
|
|
func (s *ServiceImpl) ReleaseActorFromWorker(ctx context.Context, workerName string, actorUID string) (*ateapipb.Worker, error) {
|
|
return s.store.ReleaseActorFromWorker(ctx, workerName, actorUID)
|
|
}
|
|
|
|
func (s *ServiceImpl) GetWorkerAssignment(ctx context.Context, workerName, actorUID string) (*ateapipb.ActorAssignment, error) {
|
|
return s.store.GetWorkerAssignment(ctx, workerName, actorUID)
|
|
}
|
|
|
|
func (s *ServiceImpl) ListWorkerAssignments(ctx context.Context, workerName string, opts store.ListOptions) (store.ListResponse[*ateapipb.ActorAssignment], error) {
|
|
return s.store.ListWorkerAssignments(ctx, workerName, opts)
|
|
}
|
|
|
|
func (s *ServiceImpl) FindWorkerHostingActor(ctx context.Context, actorUID string) (string, error) {
|
|
return s.store.FindWorkerHostingActor(ctx, actorUID)
|
|
}
|
|
|
|
func (s *RPCService) DeleteWorker(ctx context.Context, req *ateapipb.DeleteWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateDeleteWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, toGRPCStatusError(errs)
|
|
}
|
|
// The delete releases the Actor bound to this Worker before removing the
|
|
// record, so it is a workflow rather than a single store call.
|
|
return s.workerWorkflow.DeleteWorker(ctx, req.GetWorker().GetName(), store.DeletePreconditions{
|
|
UID: req.GetOptions().GetUid(),
|
|
Version: req.GetOptions().GetVersion(),
|
|
})
|
|
}
|
|
|
|
func (s *ServiceImpl) DeleteWorker(ctx context.Context, name string, pre store.DeletePreconditions) (*ateapipb.Worker, error) {
|
|
return s.store.DeleteWorker(ctx, name, pre)
|
|
}
|
|
|
|
func validateDeleteWorkerRequest(ctx context.Context, req *ateapipb.DeleteWorkerRequest) field.ErrorList {
|
|
// Call the generated validation. The preconditions in options are each
|
|
// optional: a zero value waives that guard, so only non-zero values are
|
|
// checked for shape.
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_DeleteWorkerRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
func (s *RPCService) DrainWorker(ctx context.Context, req *ateapipb.DrainWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := validateDrainWorkerRequest(ctx, 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
|
|
// The assignments are left alone: a draining Worker keeps its Actors
|
|
// until something releases them. Draining only stops new placements.
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func validateDrainWorkerRequest(ctx context.Context, req *ateapipb.DrainWorkerRequest) field.ErrorList {
|
|
// Call the generated validation.
|
|
op := operation.Operation{Type: operation.Create}
|
|
return Validate_DrainWorkerRequest(ctx, op, nil, req, nil)
|
|
}
|
|
|
|
// 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.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" }
|
|
|
|
// validateWorkerUpdate validates a Worker against the previous stored value.
|
|
// It is what enforces the immutable fields, which need an old value to compare
|
|
// against.
|
|
func validateWorkerUpdate(ctx context.Context, fldPath *field.Path, newVal, oldVal *ateapipb.Worker, requireStatus bool) field.ErrorList {
|
|
// Call the generated validation.
|
|
op := operation.Operation{Type: operation.Update}
|
|
errs := Validate_Worker(ctx, op, fldPath, newVal, oldVal)
|
|
if requireStatus {
|
|
// Status is optional in the schema, but is actually required to be set
|
|
// by the server. If it was specified, it was already validated above,
|
|
// but if it was not specified we need to flag that as an error.
|
|
errs = append(errs, validate.RequiredPointer(ctx, op, fldPath.Child("status"), newVal.GetStatus(), nil)...)
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func (s *ServiceImpl) WatchWorkers(ctx context.Context) (*store.WorkerWatch, error) {
|
|
return s.store.WatchWorkers(ctx)
|
|
}
|
|
|
|
// This is needed because DV doesn't have a standard format for IP addresses yet.
|
|
func ValidateCustom_Worker_Ip(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList {
|
|
return validation.IsValidIP(fldPath, *value)
|
|
}
|
|
|
|
// This exists only because nested subfield tags are not supported yet.
|
|
func ValidateCustom_UpdateWorkerRequest_Worker(ctx context.Context, op operation.Operation, fldPath *field.Path, worker, _ *ateapipb.Worker) field.ErrorList {
|
|
if worker == nil || worker.Metadata == nil {
|
|
return nil // handled by DV
|
|
}
|
|
|
|
// Updates are validated in 2 steps: first the update request and then the
|
|
// resource itself. DV for the request doesn't descend into the resource
|
|
// metadata. Once DV supports nested subfield tags, this can be changed to
|
|
// something like:
|
|
// +k8s:subfield(metadata)=+k8s:subfield(atespace)=+k8s:forbidden
|
|
// Workers are global-scoped, so metadata.atespace must be empty.
|
|
errs := Validate_ResourceMetadata(ctx, op, fldPath.Child("metadata"), worker.Metadata, nil)
|
|
errs = append(errs, validate.ForbiddenValue(ctx, op, fldPath.Child("metadata", "atespace"), &worker.Metadata.Atespace, nil)...)
|
|
return errs
|
|
}
|