diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 7304c43d1..17526d61f 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -28,8 +28,8 @@ import ( "time" "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/controlapi" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache" "github.com/agent-substrate/substrate/internal/localca" "github.com/agent-substrate/substrate/internal/localjwtauthority" @@ -41,8 +41,6 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" - "k8s.io/apimachinery/pkg/api/operation" - "k8s.io/apimachinery/pkg/util/validation/field" ) // Server implements ateapipb.ActorIdentityServer @@ -96,7 +94,7 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor JWTs") } - if errs := validateMintJWTRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateMintJWTRequest(ctx, req); len(errs) > 0 { return nil, status.Error(codes.InvalidArgument, errs.ToAggregate().Error()) } @@ -152,7 +150,7 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* if err != nil { return nil, err } - if errs := validateMintCertRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateMintCertRequest(ctx, req); len(errs) > 0 { return nil, status.Error(codes.InvalidArgument, errs.ToAggregate().Error()) } // Validation bounds purpose to the enum's range; which purposes this @@ -286,18 +284,6 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { return &ateletCaller{podName: identity.PodName, nodeName: identity.NodeName}, nil } -func validateMintJWTRequest(ctx context.Context, req *ateapipb.MintJWTRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return controlapi.Validate_MintJWTRequest(ctx, op, nil, req, nil) -} - -func validateMintCertRequest(ctx context.Context, req *ateapipb.MintCertRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return controlapi.Validate_MintCertRequest(ctx, op, nil, req, nil) -} - // authorizeActor resolves the actor from the authenticated worker and verifies // that the worker and actor still point at one another. Actor identity supplied // by the requester never participates in this authorization decision. diff --git a/cmd/ateapi/internal/actoridentity/actoridentity_test.go b/cmd/ateapi/internal/actoridentity/actoridentity_test.go index d1282de67..6c765c803 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity_test.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity_test.go @@ -21,11 +21,9 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" - "fmt" "math/big" "net/url" "path" - "strings" "testing" "time" @@ -41,14 +39,8 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" - "k8s.io/apimachinery/pkg/util/validation/field" ) -func assertValidateErr(t *testing.T, got field.ErrorList, want field.ErrorList) { - t.Helper() - field.ErrorMatcher{}.ByType().ByField().ByOrigin().Test(t, want, got) -} - const ( testAtespace = "team-alpha" testActorName = "counter-1" @@ -931,154 +923,3 @@ func TestMintCertAuthorizesBeforeSigning(t *testing.T) { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, codes.PermissionDenied) } } - -func TestValidateMintJWTRequest(t *testing.T) { - // This test verifies validation of user input for minting a JWT. - validReq := func(mods ...func(req *ateapipb.MintJWTRequest)) *ateapipb.MintJWTRequest { - req := &ateapipb.MintJWTRequest{ - Audience: []string{"aud1"}, - Atespace: "as1", - ActorName: "actor1", - ActorUid: "01234567-89ab-cdef-0123-456789abcdef", - } - for _, m := range mods { - m(req) - } - return req - } - - tests := []struct { - name string - req *ateapipb.MintJWTRequest - want field.ErrorList - }{{ - "valid", - validReq(), - nil, - }, { - "missing audience", - validReq(func(r *ateapipb.MintJWTRequest) { r.Audience = nil }), - field.ErrorList{field.Required(field.NewPath("audience"), "")}, - }, { - "too many audiences", - validReq(func(r *ateapipb.MintJWTRequest) { - r.Audience = make([]string, 17) - for i := range r.Audience { - r.Audience[i] = fmt.Sprintf("https://svc-%d.example.com", i) - } - }), - field.ErrorList{field.TooMany(field.NewPath("audience"), 17, 16).WithOrigin("maxItems")}, - }, { - "duplicate audience entry", - validReq(func(r *ateapipb.MintJWTRequest) { - r.Audience = []string{"https://a.example.com", "https://a.example.com"} - }), - field.ErrorList{field.Duplicate(field.NewPath("audience").Index(1), nil)}, - }, { - "audience entry too long", - validReq(func(r *ateapipb.MintJWTRequest) { r.Audience = []string{strings.Repeat("a", 513)} }), - field.ErrorList{field.TooLong(field.NewPath("audience").Index(0), nil, 512).WithOrigin("maxLength")}, - }, { - "missing atespace", - validReq(func(r *ateapipb.MintJWTRequest) { r.Atespace = "" }), - field.ErrorList{field.Required(field.NewPath("atespace"), "")}, - }, { - "invalid atespace", - validReq(func(r *ateapipb.MintJWTRequest) { r.Atespace = "AS1" }), - field.ErrorList{field.Invalid(field.NewPath("atespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor_name", - validReq(func(r *ateapipb.MintJWTRequest) { r.ActorName = "" }), - field.ErrorList{field.Required(field.NewPath("actor_name"), "")}, - }, { - "invalid actor_name", - validReq(func(r *ateapipb.MintJWTRequest) { r.ActorName = "invalid value" }), - field.ErrorList{field.Invalid(field.NewPath("actor_name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "unspecified actor_uid", - validReq(func(r *ateapipb.MintJWTRequest) { r.ActorUid = "" }), - nil, - }, { - "invalid actor_uid", - validReq(func(r *ateapipb.MintJWTRequest) { r.ActorUid = "not a uid" }), - field.ErrorList{field.Invalid(field.NewPath("actor_uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateMintJWTRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateMintCertRequest(t *testing.T) { - // This test verifies validation of user input for minting a certificate. - validReq := func(mods ...func(req *ateapipb.MintCertRequest)) *ateapipb.MintCertRequest { - req := &ateapipb.MintCertRequest{ - Worker: &ateapipb.ObjectRef{Name: "worker1"}, - CertificateSigningRequest: []byte{0x01}, - ExpectedActorUid: "01234567-89ab-cdef-0123-456789abcdef", - Purpose: ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_ATUNNEL, - } - for _, m := range mods { - m(req) - } - return req - } - - tests := []struct { - name string - req *ateapipb.MintCertRequest - want field.ErrorList - }{{ - "valid", - validReq(), - nil, - }, { - "oversized certificate_signing_request", - validReq(func(r *ateapipb.MintCertRequest) { r.CertificateSigningRequest = make([]byte, 16385) }), - field.ErrorList{field.TooLong(field.NewPath("certificate_signing_request"), nil, 16384)}, - }, { - "missing worker", - validReq(func(r *ateapipb.MintCertRequest) { r.Worker = nil }), - field.ErrorList{field.Required(field.NewPath("worker"), "")}, - }, { - "worker.atespace must be empty", - validReq(func(r *ateapipb.MintCertRequest) { r.Worker.Atespace = "as1" }), - field.ErrorList{field.Forbidden(field.NewPath("worker", "atespace"), "")}, - }, { - "missing worker.name", - validReq(func(r *ateapipb.MintCertRequest) { r.Worker.Name = "" }), - field.ErrorList{field.Required(field.NewPath("worker", "name"), "")}, - }, { - "invalid worker.name", - validReq(func(r *ateapipb.MintCertRequest) { r.Worker.Name = "invalid value" }), - field.ErrorList{field.Invalid(field.NewPath("worker", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing certificate_signing_request", - validReq(func(r *ateapipb.MintCertRequest) { r.CertificateSigningRequest = nil }), - field.ErrorList{field.Required(field.NewPath("certificate_signing_request"), "")}, - }, { - "missing expected_actor_uid", - validReq(func(r *ateapipb.MintCertRequest) { r.ExpectedActorUid = "" }), - field.ErrorList{field.Required(field.NewPath("expected_actor_uid"), "")}, - }, { - "invalid expected_actor_uid", - validReq(func(r *ateapipb.MintCertRequest) { r.ExpectedActorUid = "not a uid" }), - field.ErrorList{field.Invalid(field.NewPath("expected_actor_uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }, { - "unspecified purpose", - validReq(func(r *ateapipb.MintCertRequest) { - r.Purpose = ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_UNSPECIFIED - }), - field.ErrorList{field.Required(field.NewPath("purpose"), "")}, - }, { - "out-of-range purpose", - validReq(func(r *ateapipb.MintCertRequest) { r.Purpose = ateapipb.ActorCertificatePurpose(99) }), - field.ErrorList{field.Invalid(field.NewPath("purpose"), nil, "").WithOrigin("maximum")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateMintCertRequest(context.Background(), tt.req), tt.want) - }) - } -} diff --git a/cmd/ateapi/internal/controlapi/actor.go b/cmd/ateapi/internal/controlapi/actor.go index ec12686c2..812a006fc 100644 --- a/cmd/ateapi/internal/controlapi/actor.go +++ b/cmd/ateapi/internal/controlapi/actor.go @@ -21,6 +21,7 @@ import ( "time" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "github.com/agent-substrate/substrate/internal/ateattr" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -28,8 +29,6 @@ import ( "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/field" ) @@ -42,7 +41,7 @@ func (s *RPCService) CreateActor(ctx context.Context, req *ateapipb.CreateActorR } // Validate the request, including the object within it. - if errs := validateCreateActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateCreateActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -103,7 +102,7 @@ func (s *ServiceImpl) CreateActor(ctx context.Context, inActor *ateapipb.Actor) LatestSnapshot: sourceSnapshotStatus.GetSnapshot(), SourceSnapshot: sourceSnapshotStatus, } - if errs := validateActorUpdate(ctx, field.NewPath("actor"), outActor, inActor, true); len(errs) > 0 { + if errs := validation.ValidateActorUpdate(ctx, field.NewPath("actor"), outActor, inActor, true); len(errs) > 0 { return nil, toGRPCInternalError(errs) } @@ -168,14 +167,8 @@ func (s *ServiceImpl) resolveSnapshotSource(ctx context.Context, actorAtespace s }, nil } -func validateCreateActorRequest(ctx context.Context, req *ateapipb.CreateActorRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_CreateActorRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) GetActor(ctx context.Context, req *ateapipb.GetActorRequest) (*ateapipb.Actor, error) { - if errs := validateGetActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateGetActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } actorRef := resources.ActorRefFromObjectRef(req.GetActor()) @@ -192,14 +185,8 @@ func (s *ServiceImpl) GetActor(ctx context.Context, actorRef resources.ActorRef) return s.store.GetActor(ctx, actorRef) } -func validateGetActorRequest(ctx context.Context, req *ateapipb.GetActorRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_GetActorRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) ListActors(ctx context.Context, req *ateapipb.ListActorsRequest) (*ateapipb.ListActorsResponse, error) { - if errs := validateListActorsRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateListActorsRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -217,12 +204,6 @@ func (s *ServiceImpl) ListActors(ctx context.Context, atespace string, opts stor return s.store.ListActors(ctx, atespace, opts) } -func validateListActorsRequest(ctx context.Context, req *ateapipb.ListActorsRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_ListActorsRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) UpdateActor(ctx context.Context, req *ateapipb.UpdateActorRequest) (*ateapipb.Actor, error) { // First scrub any fields that users are not allowed to set. inActor := req.Actor @@ -232,7 +213,7 @@ func (s *RPCService) UpdateActor(ctx context.Context, req *ateapipb.UpdateActorR } // Validate the request. - if errs := validateUpdateActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateUpdateActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -271,14 +252,14 @@ func (s *ServiceImpl) UpdateActor(ctx context.Context, actorRef resources.ActorR newVal := toUpdate // Validate the user's input before doing any further work. - if errs := validateActorUpdate(ctx, field.NewPath("actor"), newVal, oldVal, false); len(errs) > 0 { + if errs := validation.ValidateActorUpdate(ctx, field.NewPath("actor"), 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 := validateActorUpdate(ctx, field.NewPath("actor"), newVal, oldVal, true); len(errs) > 0 { + if errs := validation.ValidateActorUpdate(ctx, field.NewPath("actor"), newVal, oldVal, true); len(errs) > 0 { return toGRPCInternalError(errs) } @@ -302,18 +283,8 @@ func (s *ServiceImpl) UpdateActor(ctx context.Context, actorRef resources.ActorR return storedActor, nil } -func validateUpdateActorRequest(ctx context.Context, req *ateapipb.UpdateActorRequest) 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_UpdateActorRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) DeleteActor(ctx context.Context, req *ateapipb.DeleteActorRequest) (deleted *ateapipb.Actor, err error) { - if errs := validateDeleteActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateDeleteActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } start := time.Now() @@ -345,14 +316,8 @@ func (s *ServiceImpl) DeleteActor(ctx context.Context, actorRef resources.ActorR return s.store.DeleteActor(ctx, actorRef) } -func validateDeleteActorRequest(ctx context.Context, req *ateapipb.DeleteActorRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_DeleteActorRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) PauseActor(ctx context.Context, req *ateapipb.PauseActorRequest) (*ateapipb.PauseActorResponse, error) { - if errs := validatePauseActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidatePauseActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } actorRef := resources.ActorRefFromObjectRef(req.GetActor()) @@ -373,14 +338,8 @@ func (s *RPCService) PauseActor(ctx context.Context, req *ateapipb.PauseActorReq return &ateapipb.PauseActorResponse{Actor: actor}, nil } -func validatePauseActorRequest(ctx context.Context, req *ateapipb.PauseActorRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_PauseActorRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) ResumeActor(ctx context.Context, req *ateapipb.ResumeActorRequest) (*ateapipb.ResumeActorResponse, error) { - if errs := validateResumeActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateResumeActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } actorRef := resources.ActorRefFromObjectRef(req.GetActor()) @@ -401,14 +360,8 @@ func (s *RPCService) ResumeActor(ctx context.Context, req *ateapipb.ResumeActorR return &ateapipb.ResumeActorResponse{Actor: actor, Resumed: resumed}, nil } -func validateResumeActorRequest(ctx context.Context, req *ateapipb.ResumeActorRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_ResumeActorRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) SuspendActor(ctx context.Context, req *ateapipb.SuspendActorRequest) (*ateapipb.SuspendActorResponse, error) { - if errs := validateSuspendActorRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateSuspendActorRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } actorRef := resources.ActorRefFromObjectRef(req.GetActor()) @@ -427,38 +380,3 @@ func (s *RPCService) SuspendActor(ctx context.Context, req *ateapipb.SuspendActo setSpanActorAttributes(ctx, actor) return &ateapipb.SuspendActorResponse{Actor: actor}, nil } - -func validateSuspendActorRequest(ctx context.Context, req *ateapipb.SuspendActorRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_SuspendActorRequest(ctx, op, nil, req, nil) -} - -func validateActorUpdate(ctx context.Context, fldPath *field.Path, newVal, oldVal *ateapipb.Actor, requireStatus bool) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Update} - errs := Validate_Actor(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 -} - -// This exists only because nested subfield tags are not supported yet. -func ValidateCustom_UpdateActorRequest_Actor(ctx context.Context, op operation.Operation, fldPath *field.Path, actor, _ *ateapipb.Actor) field.ErrorList { - if actor == nil || actor.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:required - errs := Validate_ResourceMetadata(ctx, op, fldPath.Child("metadata"), actor.Metadata, nil) - errs = append(errs, validate.RequiredValue(ctx, op, fldPath.Child("metadata", "atespace"), &actor.Metadata.Atespace, nil)...) - return errs -} diff --git a/cmd/ateapi/internal/controlapi/actor_snapshot.go b/cmd/ateapi/internal/controlapi/actor_snapshot.go index 326bf22e0..20f093a83 100644 --- a/cmd/ateapi/internal/controlapi/actor_snapshot.go +++ b/cmd/ateapi/internal/controlapi/actor_snapshot.go @@ -18,42 +18,23 @@ import ( "context" "errors" "fmt" - "slices" - "strings" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "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/util/validation/field" ) -// actorSnapshotTagScopes lists the scopes a client may set on an ActorSnapshotTag. -// ACTOR_SNAPSHOT_TAG_SCOPE_UNSPECIFIED is deliberately absent: scope is required -// on the wire, not defaulted. See validateActorSnapshotTagScope. -var actorSnapshotTagScopes = []ateapipb.ActorSnapshotTagScope{ - ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE, - ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, -} - -// actorSnapshotTagScopeNames names actorSnapshotTagScopes for error messages. -var actorSnapshotTagScopeNames = func() []string { - names := make([]string, len(actorSnapshotTagScopes)) - for i, scope := range actorSnapshotTagScopes { - names[i] = scope.String() - } - return names -}() - func (s *ServiceImpl) CreateActorSnapshot(ctx context.Context, snapshot *ateapipb.ActorSnapshot) (*ateapipb.ActorSnapshot, error) { // TODO: implement this return s.store.CreateActorSnapshot(ctx, snapshot) } func (s *RPCService) GetActorSnapshot(ctx context.Context, req *ateapipb.GetActorSnapshotRequest) (*ateapipb.ActorSnapshot, error) { - if errs := validateGetActorSnapshotRequest(req); len(errs) > 0 { + if errs := validation.ValidateGetActorSnapshotRequest(req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } snapshot, err := s.impl.GetActorSnapshot(ctx, resources.ActorSnapshotRefFromObjectRef(req.GetActorSnapshot())) @@ -71,21 +52,8 @@ func (s *ServiceImpl) GetActorSnapshot(ctx context.Context, snapshotRef resource return s.store.GetActorSnapshot(ctx, snapshotRef) } -func validateGetActorSnapshotRequest(req *ateapipb.GetActorSnapshotRequest) field.ErrorList { - var fldPath *field.Path - var errs field.ErrorList - - if val, fldPath := req.ActorSnapshot, fldPath.Child("actor_snapshot"); val == nil { - errs = append(errs, field.Required(fldPath, "")) - } else { - errs = append(errs, resources.ValidateObjectRef(val, fldPath)...) - } - - return errs -} - func (s *RPCService) GetActorSnapshotTag(ctx context.Context, req *ateapipb.GetActorSnapshotTagRequest) (*ateapipb.ActorSnapshotTag, error) { - if errs := validateGetActorSnapshotTagRequest(req); len(errs) > 0 { + if errs := validation.ValidateGetActorSnapshotTagRequest(req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } tag, err := s.impl.GetActorSnapshotTag(ctx, resources.ActorSnapshotTagRefFromObjectRef(req.GetActorSnapshotTag())) @@ -103,21 +71,8 @@ func (s *ServiceImpl) GetActorSnapshotTag(ctx context.Context, tagRef resources. return s.store.GetActorSnapshotTag(ctx, tagRef) } -func validateGetActorSnapshotTagRequest(req *ateapipb.GetActorSnapshotTagRequest) field.ErrorList { - var fldPath *field.Path - var errs field.ErrorList - - if val, fldPath := req.ActorSnapshotTag, fldPath.Child("actor_snapshot_tag"); val == nil { - errs = append(errs, field.Required(fldPath, "")) - } else { - errs = append(errs, resources.ValidateObjectRef(val, fldPath)...) - } - - return errs -} - func (s *RPCService) ListActorSnapshots(ctx context.Context, req *ateapipb.ListActorSnapshotsRequest) (*ateapipb.ListActorSnapshotsResponse, error) { - if errs := validateListActorSnapshotsRequest(req); len(errs) > 0 { + if errs := validation.ValidateListActorSnapshotsRequest(req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } page, err := s.impl.ListActorSnapshots(ctx, req.GetAtespace(), store.ListOptions{PageSize: effectivePageSize(req.GetPageSize()), PageToken: req.GetPageToken()}) @@ -132,24 +87,8 @@ func (s *ServiceImpl) ListActorSnapshots(ctx context.Context, atespace string, o return s.store.ListActorSnapshots(ctx, atespace, opts) } -func validateListActorSnapshotsRequest(req *ateapipb.ListActorSnapshotsRequest) field.ErrorList { - var fldPath *field.Path - var errs field.ErrorList - - // An empty atespace is allowed here and means "all atespaces". - if val, fldPath := req.Atespace, fldPath.Child("atespace"); val != "" { - errs = append(errs, resources.ValidateResourceName(val, fldPath)...) - } - - 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) CreateActorSnapshotTag(ctx context.Context, req *ateapipb.CreateActorSnapshotTagRequest) (*ateapipb.ActorSnapshotTag, error) { - if errs := validateCreateActorSnapshotTagRequest(req); len(errs) > 0 { + if errs := validation.ValidateCreateActorSnapshotTagRequest(req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } ref := req.GetActorSnapshotTag().GetSnapshot() @@ -177,32 +116,8 @@ func (s *ServiceImpl) CreateActorSnapshotTag(ctx context.Context, snapshotRef re return s.store.CreateActorSnapshotTag(ctx, snapshotRef, tag) } -func validateCreateActorSnapshotTagRequest(req *ateapipb.CreateActorSnapshotTagRequest) field.ErrorList { - var fldPath *field.Path - var errs field.ErrorList - - tag := req.ActorSnapshotTag - tagPath := fldPath.Child("actor_snapshot_tag") - if tag == nil { - errs = append(errs, field.Required(tagPath, "")) - return errs - } - - errs = append(errs, resources.ValidateObjectRef(&ateapipb.ObjectRef{Atespace: tag.GetMetadata().GetAtespace(), Name: tag.GetMetadata().GetName()}, tagPath.Child("metadata"))...) - - if val, p := tag.Snapshot, tagPath.Child("snapshot"); val == nil { - errs = append(errs, field.Required(p, "")) - } else { - errs = append(errs, resources.ValidateObjectRef(val, p)...) - } - - errs = append(errs, validateActorSnapshotTagScope(tag.GetScope(), tagPath.Child("scope"))...) - - return errs -} - func (s *RPCService) UpdateActorSnapshotTag(ctx context.Context, req *ateapipb.UpdateActorSnapshotTagRequest) (*ateapipb.ActorSnapshotTag, error) { - if errs := validateUpdateActorSnapshotTagRequest(req); len(errs) > 0 { + if errs := validation.ValidateUpdateActorSnapshotTagRequest(req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } in := req.GetActorSnapshotTag() @@ -247,25 +162,8 @@ func (s *ServiceImpl) UpdateActorSnapshotTag(ctx context.Context, tagRef resourc return s.store.UpdateActorSnapshotTag(ctx, tagRef, precondition, mutate) } -func validateUpdateActorSnapshotTagRequest(req *ateapipb.UpdateActorSnapshotTagRequest) field.ErrorList { - var fldPath *field.Path - var errs field.ErrorList - - tag := req.GetActorSnapshotTag() - tagPath := fldPath.Child("actor_snapshot_tag") - if tag == nil { - return field.ErrorList{field.Required(tagPath, "")} - } - - errs = append(errs, resources.ValidateUpdateMetadataRef(tag.GetMetadata(), tagPath.Child("metadata"))...) - - errs = append(errs, validateActorSnapshotTagScope(tag.GetScope(), tagPath.Child("scope"))...) - - return errs -} - func (s *RPCService) DeleteActorSnapshotTag(ctx context.Context, req *ateapipb.DeleteActorSnapshotTagRequest) (*ateapipb.ActorSnapshotTag, error) { - if errs := validateDeleteActorSnapshotTagRequest(req); len(errs) > 0 { + if errs := validation.ValidateDeleteActorSnapshotTagRequest(req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } tag, err := s.impl.DeleteActorSnapshotTag(ctx, resources.ActorSnapshotTagRefFromObjectRef(req.GetActorSnapshotTag())) @@ -282,27 +180,3 @@ func (s *ServiceImpl) DeleteActorSnapshotTag(ctx context.Context, tagRef resourc // TODO: implement this return s.store.DeleteActorSnapshotTag(ctx, tagRef) } - -func validateDeleteActorSnapshotTagRequest(req *ateapipb.DeleteActorSnapshotTagRequest) field.ErrorList { - var fldPath *field.Path - var errs field.ErrorList - - if val, fldPath := req.ActorSnapshotTag, fldPath.Child("actor_snapshot_tag"); val == nil { - errs = append(errs, field.Required(fldPath, "")) - } else { - errs = append(errs, resources.ValidateObjectRef(val, fldPath)...) - } - - return errs -} - -// validateActorSnapshotTagScope checks that scope is one a client may set. -func validateActorSnapshotTagScope(scope ateapipb.ActorSnapshotTagScope, p *field.Path) field.ErrorList { - switch { - case scope == ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_UNSPECIFIED: - return field.ErrorList{field.Required(p, "must be one of: "+strings.Join(actorSnapshotTagScopeNames, ", "))} - case !slices.Contains(actorSnapshotTagScopes, scope): - return field.ErrorList{field.NotSupported(p, scope.String(), actorSnapshotTagScopeNames)} - } - return nil -} diff --git a/cmd/ateapi/internal/controlapi/actor_snapshot_test.go b/cmd/ateapi/internal/controlapi/actor_snapshot_test.go index 65281694e..a4975c7d7 100644 --- a/cmd/ateapi/internal/controlapi/actor_snapshot_test.go +++ b/cmd/ateapi/internal/controlapi/actor_snapshot_test.go @@ -18,188 +18,16 @@ import ( "context" "testing" - "github.com/google/go-cmp/cmp" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/testing/protocmp" - "k8s.io/apimachinery/pkg/util/validation/field" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/google/go-cmp/cmp" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/testing/protocmp" ) -func TestValidateUpdateActorSnapshotTagRequest(t *testing.T) { - // validUID is a well-formed uid to pass validation. - const validUID = "2a5f8c1e-9b3d-4f7a-8e6c-1d0b4a7f2e93" - scopes := []string{ - ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE.String(), - ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED.String(), - } - // Every case carries a uid and version guard, because an update that carries - // neither is rejected as a blind write before anything else is checked. - tests := []struct { - name string - req *ateapipb.UpdateActorSnapshotTagRequest - wantError field.ErrorList - }{ - { - name: "valid", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: nil, - }, - { - name: "missing tag", - req: &ateapipb.UpdateActorSnapshotTagRequest{}, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag"), "")}, - }, - { - name: "missing tag.metadata.atespace", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Name: "tag1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "atespace"), "")}, - }, - { - name: "invalid tag.metadata.atespace", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "NS1", Name: "tag1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "atespace"), "NS1", "")}, - }, - { - name: "missing tag.metadata.name", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "name"), "")}, - }, - { - name: "invalid tag.metadata.name", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "TAG1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "name"), "TAG1", "")}, - }, - { - name: "missing tag.metadata.uid precondition", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "uid"), "")}, - }, - { - name: "invalid tag.metadata.uid precondition", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: "not-a-uuid", Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "uid"), "not-a-uuid", "")}, - }, - { - name: "missing tag.metadata.version precondition", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "version"), "")}, - }, - { - name: "negative tag.metadata.version precondition", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: -1}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "version"), int64(-1), "")}, - }, - { - // A blind write: the caller never read the tag it is updating. - name: "guards on neither uid nor version", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, - }, - }, - wantError: field.ErrorList{ - field.Required(field.NewPath("actor_snapshot_tag", "metadata", "uid"), ""), - field.Required(field.NewPath("actor_snapshot_tag", "metadata", "version"), ""), - }, - }, - { - name: "unset tag.scope", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, - }, - }, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "scope"), "")}, - }, - { - name: "explicit tag.scope UNSPECIFIED", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_UNSPECIFIED, - }, - }, - wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "scope"), "")}, - }, - { - name: "tag.scope ATESPACE explicitly unpublishes", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE, - }, - }, - wantError: nil, - }, - { - name: "tag.scope outside the enum", - req: &ateapipb.UpdateActorSnapshotTagRequest{ - ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, - Scope: ateapipb.ActorSnapshotTagScope(7), - }, - }, - wantError: field.ErrorList{field.NotSupported(field.NewPath("actor_snapshot_tag", "scope"), "7", scopes)}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateUpdateActorSnapshotTagRequest(tt.req), tt.wantError) - }) - } -} - func TestCreateActorSnapshotTag_MissingSnapshotIsNotFound(t *testing.T) { persistence, cleanup := storetest.SetupTestStore(t) t.Cleanup(cleanup) diff --git a/cmd/ateapi/internal/controlapi/actor_template.go b/cmd/ateapi/internal/controlapi/actor_template.go index 8c72f8d29..ca1376e48 100644 --- a/cmd/ateapi/internal/controlapi/actor_template.go +++ b/cmd/ateapi/internal/controlapi/actor_template.go @@ -18,17 +18,14 @@ import ( "context" "errors" "fmt" - "regexp" - "strings" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "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/operation" - "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/util/validation/field" ) @@ -41,7 +38,7 @@ func (s *RPCService) CreateActorTemplate(ctx context.Context, req *ateapipb.Crea } // Validate the request, including the object within it. - if errs := validateCreateActorTemplateRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateCreateActorTemplateRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -68,27 +65,15 @@ func (s *ServiceImpl) CreateActorTemplate(ctx context.Context, inTemplate *ateap outTemplate.Status = &ateapipb.ActorTemplateStatus{} // Validate the final value before storing it. - if errs := validateActorTemplateUpdate(ctx, field.NewPath("actor_template"), outTemplate, inTemplate); len(errs) > 0 { + if errs := validation.ValidateActorTemplateUpdate(ctx, field.NewPath("actor_template"), outTemplate, inTemplate); len(errs) > 0 { return nil, toGRPCInternalError(errs) } return s.store.CreateActorTemplate(ctx, outTemplate) } -func validateCreateActorTemplateRequest(ctx context.Context, req *ateapipb.CreateActorTemplateRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_CreateActorTemplateRequest(ctx, op, nil, req, nil) -} - -func validateActorTemplateUpdate(ctx context.Context, fldPath *field.Path, newVal, oldVal *ateapipb.ActorTemplate) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Update} - return Validate_ActorTemplate(ctx, op, fldPath, newVal, oldVal) -} - func (s *RPCService) GetActorTemplate(ctx context.Context, req *ateapipb.GetActorTemplateRequest) (*ateapipb.ActorTemplate, error) { - if errs := validateGetActorTemplateRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateGetActorTemplateRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -108,14 +93,8 @@ func (s *ServiceImpl) GetActorTemplate(ctx context.Context, templateRef resource return s.store.GetActorTemplate(ctx, templateRef) } -func validateGetActorTemplateRequest(ctx context.Context, req *ateapipb.GetActorTemplateRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_GetActorTemplateRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) ListActorTemplates(ctx context.Context, req *ateapipb.ListActorTemplatesRequest) (*ateapipb.ListActorTemplatesResponse, error) { - if errs := validateListActorTemplatesRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateListActorTemplatesRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -134,14 +113,8 @@ func (s *ServiceImpl) ListActorTemplates(ctx context.Context, atespace string, o return s.store.ListActorTemplates(ctx, atespace, opts) } -func validateListActorTemplatesRequest(ctx context.Context, req *ateapipb.ListActorTemplatesRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_ListActorTemplatesRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) DeleteActorTemplate(ctx context.Context, req *ateapipb.DeleteActorTemplateRequest) (*ateapipb.ActorTemplate, error) { - if errs := validateDeleteActorTemplateRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateDeleteActorTemplateRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -165,12 +138,6 @@ func (s *ServiceImpl) DeleteActorTemplate(ctx context.Context, templateRef resou return s.store.DeleteActorTemplate(ctx, templateRef) } -func validateDeleteActorTemplateRequest(ctx context.Context, req *ateapipb.DeleteActorTemplateRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_DeleteActorTemplateRequest(ctx, op, nil, req, nil) -} - func (s *ServiceImpl) UpdateActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef, precondition store.Precondition, mutate func(dbTemplate *ateapipb.ActorTemplate) error) (*ateapipb.ActorTemplate, error) { // ActorTemplates are immutable to clients: there is no update RPC, and // the only writer is the template reconciler, which updates status @@ -179,149 +146,6 @@ func (s *ServiceImpl) UpdateActorTemplate(ctx context.Context, templateRef resou return s.store.UpdateActorTemplate(ctx, templateRef, precondition, mutate) } -// httpGetPathRE constrains readyz paths to RFC 3986 path-segment -// characters only, with well-formed percent-escapes, and no query string -// or fragment. -var httpGetPathRE = regexp.MustCompile(`^/([A-Za-z0-9\-._~!$&'()*+,;=:@/]|%[0-9A-Fa-f]{2})*$`) - -func ValidateCustom_HTTPGetAction_Path(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - if !httpGetPathRE.MatchString(*value) { - return field.ErrorList{field.Invalid(fldPath, *value, "must be a URL path starting with '/', using only RFC 3986 path-segment characters, without query or fragment")} - } - return nil -} - -// mountPathBadSegmentRE matches '.' or '..' path segments. -var mountPathBadSegmentRE = regexp.MustCompile(`(^|/)[.][.]?(/|$)`) - -// ValidateCustom_VolumeMount_MountPath requires a clean absolute Unix path -// that starts with '/', is not '/', and contains no ':', '.' or '..' -// segments, '//', trailing '/', or control characters. -func ValidateCustom_VolumeMount_MountPath(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - p := *value - bad := !strings.HasPrefix(p, "/") || len(p) == 1 || - strings.HasSuffix(p, "/") || strings.Contains(p, "//") || - strings.Contains(p, ":") || mountPathBadSegmentRE.MatchString(p) - if !bad { - for _, r := range p { - if r < 0x20 || r == 0x7f { - bad = true - break - } - } - } - if bad { - return field.ErrorList{field.Invalid(fldPath, p, "must be a clean absolute Unix path: must start with '/', not be '/', and contain no ':', '..', '.', '//', trailing '/', or control characters")} - } - return nil -} - -// ValidateCustom_ImageVolumeSource_Reference requires image references to -// be pinned by digest, because changing the image content under a fixed -// reference invalidates snapshots. -func ValidateCustom_ImageVolumeSource_Reference(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - if !strings.Contains(*value, "@") { - return field.ErrorList{field.Invalid(fldPath, *value, "must be pinned by digest (changing the image invalidates snapshots)")} - } - return nil -} - -func ValidateCustom_ExternalVolumeTemplate_Capacity(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - if _, err := resource.ParseQuantity(*value); err != nil { - return field.ErrorList{field.Invalid(fldPath, *value, fmt.Sprintf("must be a Kubernetes resource quantity: %v", err))} - } - return nil -} - -// cpuLimitMax bounds cpu limits: they must be less than 1000 cores. -var cpuLimitMax = resource.MustParse("1k") - -// ValidateCustom_Resources_Limits validates the resource limits: only cpu -// and memory limits are supported, each quantity must be greater than zero, -// and the cpu limit must be less than 1000 cores. Presence and uniqueness -// of names are enforced by tags. -func ValidateCustom_Resources_Limits(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []*ateapipb.Limits) field.ErrorList { - var errs field.ErrorList - for i, limit := range value { - if limit == nil { - continue - } - if limit.Name != "cpu" && limit.Name != "memory" { - errs = append(errs, field.NotSupported(fldPath.Index(i).Child("name"), limit.Name, []string{"cpu", "memory"})) - continue - } - if limit.Quantity == "" { - continue // required is enforced by tags - } - q, err := resource.ParseQuantity(limit.Quantity) - if err != nil { - errs = append(errs, field.Invalid(fldPath.Index(i).Child("quantity"), limit.Quantity, fmt.Sprintf("must be a Kubernetes resource quantity: %v", err))) - continue - } - if q.Sign() <= 0 { - errs = append(errs, field.Invalid(fldPath.Index(i).Child("quantity"), limit.Quantity, "must be greater than zero")) - } - if limit.Name == "cpu" && q.Cmp(cpuLimitMax) >= 0 { - errs = append(errs, field.Invalid(fldPath.Index(i).Child("quantity"), limit.Quantity, "cpu limit must be less than 1000 cores")) - } - } - return errs -} - -// ValidateCustom_ActorTemplate_SnapshotsConfig requires on_commit to be a -// subset of on_pause. UNSPECIFIED means FULL, so an unset on_commit over a -// DATA on_pause is rejected too. -func ValidateCustom_ActorTemplate_SnapshotsConfig(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *ateapipb.SnapshotsConfig) field.ErrorList { - if value.GetOnPause() == ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA && - value.GetOnCommit() != ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA { - return field.ErrorList{field.Invalid(fldPath.Child("on_commit"), value.GetOnCommit().String(), "must be a subset of on_pause")} - } - return nil -} - -// envVarNameRE constrains env var names to any printable ASCII character -// except '='. -var envVarNameRE = regexp.MustCompile(`^[ -<>-~]+$`) - -func ValidateCustom_EnvVar_Name(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - if !envVarNameRE.MatchString(*value) { - return field.ErrorList{field.Invalid(fldPath, *value, "may contain any printable ASCII character except '='")} - } - return nil -} - -// capabilityRE constrains Linux capability names: uppercase, without the -// "CAP_" prefix (which is added when the OCI spec is written; the prefixed -// spelling would silently grant nothing). -var capabilityRE = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) - -func validateCapabilities(fldPath *field.Path, caps []string, allowAll bool) field.ErrorList { - var errs field.ErrorList - for i, c := range caps { - p := fldPath.Index(i) - switch { - case c == "ALL" && !allowAll: - errs = append(errs, field.Invalid(p, c, "add does not accept 'ALL'; name the individual capabilities the container needs")) - case c == "ALL": - case len(c) > 63: - errs = append(errs, field.TooLong(p, nil, 63)) - case strings.HasPrefix(c, "CAP_"): - errs = append(errs, field.Invalid(p, c, "must be named without the 'CAP_' prefix (e.g. 'NET_BIND_SERVICE')")) - case !capabilityRE.MatchString(c): - errs = append(errs, field.Invalid(p, c, "must be an uppercase capability name like 'NET_BIND_SERVICE'")) - } - } - return errs -} - -func ValidateCustom_Capabilities_Add(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []string) field.ErrorList { - return validateCapabilities(fldPath, value, false) -} - -func ValidateCustom_Capabilities_Drop(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []string) field.ErrorList { - return validateCapabilities(fldPath, value, true) -} - // actorTemplateGetter is the storage subset template resolution needs. type actorTemplateGetter interface { GetActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef) (*ateapipb.ActorTemplate, error) diff --git a/cmd/ateapi/internal/controlapi/actor_template_test.go b/cmd/ateapi/internal/controlapi/actor_template_test.go index ff55b64f9..5f8f5ad8a 100644 --- a/cmd/ateapi/internal/controlapi/actor_template_test.go +++ b/cmd/ateapi/internal/controlapi/actor_template_test.go @@ -17,8 +17,6 @@ package controlapi import ( "context" "errors" - "fmt" - "strings" "testing" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" @@ -28,8 +26,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/testing/protocmp" - "k8s.io/apimachinery/pkg/api/operation" - "k8s.io/apimachinery/pkg/util/validation/field" ) // validActorTemplate returns the smallest template that passes create @@ -47,133 +43,6 @@ func validActorTemplate(mutations ...func(*ateapipb.ActorTemplate)) *ateapipb.Ac return template } -func TestValidateCreateActorTemplateRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.CreateActorTemplateRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate()}, - nil, - }, { - "missing actor_template", - &ateapipb.CreateActorTemplateRequest{}, - field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, - }, { - "missing metadata.atespace", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Metadata.Atespace = "" - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "metadata", "atespace"), "")}, - }, { - "invalid metadata.atespace", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Metadata.Atespace = "NS_1" - })}, - field.ErrorList{field.Invalid(field.NewPath("actor_template", "metadata", "atespace"), "NS_1", "").WithOrigin("format=k8s-short-name")}, - }, { - "missing metadata.name", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Metadata.Name = "" - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "metadata", "name"), "")}, - }, { - "invalid metadata.name", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Metadata.Name = "Tmpl_A" - })}, - field.ErrorList{field.Invalid(field.NewPath("actor_template", "metadata", "name"), "Tmpl_A", "").WithOrigin("format=k8s-short-name")}, - }, { - "valid data-scoped snapshots", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA - tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA - })}, - nil, - }, { - "invalid worker_selector label key", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"bad key": "v"}} - })}, - field.ErrorList{field.Invalid(field.NewPath("actor_template", "worker_selector", "match_labels"), "bad key", "").WithOrigin("format=k8s-label-key")}, - }, { - "no containers", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers = nil - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "containers"), "")}, - }, { - "container missing name", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Name = "" - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "containers").Index(0).Child("name"), "")}, - }, { - "container invalid name", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Name = "Main_1" - })}, - field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("name"), "Main_1", "").WithOrigin("format=k8s-short-name")}, - }, { - "container missing image", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Image = "" - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "containers").Index(0).Child("image"), "")}, - }, { - "missing snapshots_config", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig = nil - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "snapshots_config"), "")}, - }, { - "missing snapshots_config.storage_location", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.StorageLocation = "" - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "snapshots_config", "storage_location"), "")}, - }, { - "on_commit broader than on_pause", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA - tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL - })}, - field.ErrorList{field.Invalid(field.NewPath("actor_template", "snapshots_config", "on_commit"), "SNAPSHOT_CONTENT_SCOPE_FULL", "")}, - }, { - // UNSPECIFIED defaults to FULL, so leaving on_commit unset over a DATA - // on_pause is also a subset violation. - "on_commit unset with data on_pause", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA - })}, - field.ErrorList{field.Invalid(field.NewPath("actor_template", "snapshots_config", "on_commit"), "SNAPSHOT_CONTENT_SCOPE_UNSPECIFIED", "")}, - }, { - "missing sandbox_config", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SandboxConfig = nil - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "sandbox_config"), "")}, - }, { - "unspecified sandbox_config.sandbox_class", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass_SANDBOX_CLASS_UNSPECIFIED - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "sandbox_config", "sandbox_class"), "")}, - }, { - "missing sandbox_config.config_name", - &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { - tmpl.SandboxConfig.ConfigName = "" - })}, - field.ErrorList{field.Required(field.NewPath("actor_template", "sandbox_config", "config_name"), "")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateCreateActorTemplateRequest(context.Background(), tt.req), tt.want) - }) - } -} - // TestCreateActorTemplate covers the atespace precondition: creation fails // while the atespace is missing, and succeeds once the atespace exists. func TestCreateActorTemplate(t *testing.T) { @@ -250,670 +119,6 @@ func TestCreateActorTemplateIgnoresServerOwnedFields(t *testing.T) { } } -func TestValidateGetActorTemplateRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.GetActorTemplateRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.GetActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl-a"}}, - nil, - }, { - "missing actor_template", - &ateapipb.GetActorTemplateRequest{}, - field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, - }, { - "missing atespace", - &ateapipb.GetActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Name: "tmpl-a"}}, - field.ErrorList{field.Required(field.NewPath("actor_template", "atespace"), "")}, - }, { - "missing name", - &ateapipb.GetActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor_template", "name"), "")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateGetActorTemplateRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateListActorTemplatesRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.ListActorTemplatesRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.ListActorTemplatesRequest{PageSize: 10}, - nil, - }, { - "zero page size", - &ateapipb.ListActorTemplatesRequest{}, - nil, - }, { - "valid atespace filter", - &ateapipb.ListActorTemplatesRequest{Atespace: "ns1"}, - nil, - }, { - "invalid atespace filter", - &ateapipb.ListActorTemplatesRequest{Atespace: "NS_1"}, - field.ErrorList{field.Invalid(field.NewPath("atespace"), "NS_1", "").WithOrigin("format=k8s-short-name")}, - }, { - "negative page size", - &ateapipb.ListActorTemplatesRequest{PageSize: -1}, - field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateListActorTemplatesRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateDeleteActorTemplateRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.DeleteActorTemplateRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl-a"}}, - nil, - }, { - "missing actor_template", - &ateapipb.DeleteActorTemplateRequest{}, - field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, - }, { - "missing atespace", - &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Name: "tmpl-a"}}, - field.ErrorList{field.Required(field.NewPath("actor_template", "atespace"), "")}, - }, { - "missing name", - &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor_template", "name"), "")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateDeleteActorTemplateRequest(context.Background(), tt.req), tt.want) - }) - } -} - -// TestValidateActorTemplate exercises the generated resource validation -// directly. The request handler still runs the hand-written validator; this -// pins each declarative rule as it is added, ahead of the conversion. -func TestValidateActorTemplate(t *testing.T) { - tests := []struct { - name string - mutate func(*ateapipb.ActorTemplate) // nil leaves the template valid - want field.ErrorList - }{{ - name: "valid", - }, { - name: "missing metadata", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata = nil }, - want: field.ErrorList{field.Required(field.NewPath("metadata"), "")}, - }, { - name: "missing metadata.atespace", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata.Atespace = "" }, - want: field.ErrorList{field.Required(field.NewPath("metadata", "atespace"), "")}, - }, { - name: "invalid metadata.atespace", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata.Atespace = "NS1" }, - want: field.ErrorList{field.Invalid(field.NewPath("metadata", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "invalid metadata.name", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata.Name = "TMPL A" }, - want: field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "worker_selector with an invalid label value", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "Not Valid"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("worker_selector", "match_labels").Key("tier"), nil, "").WithOrigin("format=k8s-label-value")}, - }, { - name: "missing sandbox_config", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig = nil }, - want: field.ErrorList{field.Required(field.NewPath("sandbox_config"), "")}, - }, { - name: "unspecified sandbox_class", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass_SANDBOX_CLASS_UNSPECIFIED - }, - want: field.ErrorList{field.Required(field.NewPath("sandbox_config", "sandbox_class"), "")}, - }, { - name: "sandbox_class outside the enum", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass(99) }, - want: field.ErrorList{field.Invalid(field.NewPath("sandbox_config", "sandbox_class"), nil, "").WithOrigin("maximum")}, - }, { - name: "negative sandbox_class", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass(-1) }, - want: field.ErrorList{field.Invalid(field.NewPath("sandbox_config", "sandbox_class"), nil, "").WithOrigin("minimum")}, - }, { - name: "missing config_name", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.ConfigName = "" }, - want: field.ErrorList{field.Required(field.NewPath("sandbox_config", "config_name"), "")}, - }, { - name: "invalid config_name", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.ConfigName = "NOT_A_NAME" }, - want: field.ErrorList{field.Invalid(field.NewPath("sandbox_config", "config_name"), nil, "").WithOrigin("format=k8s-long-name")}, - }, { - name: "missing snapshots_config", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SnapshotsConfig = nil }, - want: field.ErrorList{field.Required(field.NewPath("snapshots_config"), "")}, - }, { - name: "storage_location too long", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.StorageLocation = "gs://" + strings.Repeat("x", 1020) - }, - want: field.ErrorList{field.TooLong(field.NewPath("snapshots_config", "storage_location"), nil, 1024).WithOrigin("maxLength")}, - }, { - name: "missing storage_location", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SnapshotsConfig.StorageLocation = "" }, - want: field.ErrorList{field.Required(field.NewPath("snapshots_config", "storage_location"), "")}, - }, { - name: "unspecified snapshot scopes are allowed", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_UNSPECIFIED - tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_UNSPECIFIED - }, - }, { - name: "on_commit outside the enum", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope(99) - }, - want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_commit"), nil, "").WithOrigin("maximum")}, - }, { - name: "negative on_pause", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope(-1) - }, - want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_pause"), nil, "").WithOrigin("minimum")}, - }, { - name: "valid on_resume", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnResume = &ateapipb.OnResumeConfig{FromData: ateapipb.ResumeSource_RESUME_SOURCE_COLD_BOOT} - }, - }, { - name: "negative on_resume from_data", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnResume = &ateapipb.OnResumeConfig{FromData: ateapipb.ResumeSource(-1)} - }, - want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_resume", "from_data"), nil, "").WithOrigin("minimum")}, - }, { - name: "on_resume from_data outside the enum", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.SnapshotsConfig.OnResume = &ateapipb.OnResumeConfig{FromData: ateapipb.ResumeSource(99)} - }, - want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_resume", "from_data"), nil, "").WithOrigin("maximum")}, - }, { - name: "no containers", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers = nil }, - want: field.ErrorList{field.Required(field.NewPath("containers"), "")}, - }, { - name: "too many containers", - mutate: func(tmpl *ateapipb.ActorTemplate) { - for i := 0; i < 10; i++ { - tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: fmt.Sprintf("c-%d", i), Image: "example.com/app:v1"}) - } - }, - want: field.ErrorList{field.TooMany(field.NewPath("containers"), 11, 10).WithOrigin("maxItems")}, - }, { - name: "duplicate container name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: "main", Image: "example.com/other:v1"}) - }, - want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(1), nil)}, - }, { - name: "duplicate env name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "PORT", Value: "1"}, {Name: "PORT", Value: "2"}} - }, - want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("env").Index(1), nil)}, - }, { - // One mount per volume for now; see the TODO on volume_mounts. - name: "same volume mounted twice is rejected", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{ - {Name: "data", MountPath: "/var/data"}, - {Name: "data", MountPath: "/mnt/data"}, - } - }, - want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("volume_mounts").Index(1), nil)}, - }, { - name: "two volumes at distinct paths are allowed", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{ - {Name: "data", MountPath: "/var/data"}, - {Name: "other", MountPath: "/mnt/other"}, - } - }, - }, { - name: "duplicate volume name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{ - {Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}, - {Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}, - } - }, - want: field.ErrorList{field.Duplicate(field.NewPath("volumes").Index(1), nil)}, - }, { - name: "too many command entries", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Command = make([]string, 65) - }, - want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("command"), 65, 64).WithOrigin("maxItems")}, - }, { - name: "command entry too long", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Command = []string{strings.Repeat("x", 4097)} - }, - want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("command").Index(0), nil, 4096).WithOrigin("maxLength")}, - }, { - name: "valid command and args", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Command = []string{"/bin/app"} - // Repeated argv values are legitimate; these lists are atomic, - // not sets. - tmpl.Containers[0].Args = []string{"--serve", "-v", "-v"} - }, - }, { - name: "too many args entries", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Args = make([]string, 65) - }, - want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("args"), 65, 64).WithOrigin("maxItems")}, - }, { - name: "args entry too long", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Args = []string{strings.Repeat("x", 4097)} - }, - want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("args").Index(0), nil, 4096).WithOrigin("maxLength")}, - }, { - name: "too many volume_mounts", - mutate: func(tmpl *ateapipb.ActorTemplate) { - for i := 0; i < 33; i++ { - tmpl.Containers[0].VolumeMounts = append(tmpl.Containers[0].VolumeMounts, - &ateapipb.VolumeMount{Name: "data", MountPath: fmt.Sprintf("/mnt/p%d", i)}) - } - }, - want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("volume_mounts"), 33, 32).WithOrigin("maxItems")}, - }, { - name: "too many env entries", - mutate: func(tmpl *ateapipb.ActorTemplate) { - for i := 0; i < 33; i++ { - tmpl.Containers[0].Env = append(tmpl.Containers[0].Env, &ateapipb.EnvVar{Name: fmt.Sprintf("VAR_%d", i)}) - } - }, - want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("env"), 33, 32).WithOrigin("maxItems")}, - }, { - name: "image too long", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Image = strings.Repeat("x", 513) - }, - want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("image"), nil, 512).WithOrigin("maxLength")}, - }, { - name: "container missing name", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers[0].Name = "" }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("name"), "")}, - }, { - name: "container invalid name", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers[0].Name = "Main_1" }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "container missing image", - mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers[0].Image = "" }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("image"), "")}, - }, { - name: "valid env", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "PORT", Value: "8080"}, {Name: "DEBUG"}} - }, - }, { - name: "env name with unusual printable characters is allowed", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "my.var-2 (test)!", Value: "v"}} - }, - }, { - name: "env name with an equals sign", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "FOO=BAR"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("env").Index(0).Child("name"), nil, "")}, - }, { - name: "env name with a control character", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "FOO BAR"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("env").Index(0).Child("name"), nil, "")}, - }, { - name: "env missing name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Value: "8080"}} - }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("env").Index(0).Child("name"), "")}, - }, { - name: "valid security_context capabilities", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{ - Add: []string{"NET_BIND_SERVICE"}, - Drop: []string{"ALL"}, - }} - }, - }, { - name: "capabilities add rejects ALL", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: []string{"ALL"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add").Index(0), nil, "")}, - }, { - name: "capability with CAP_ prefix", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: []string{"CAP_NET_BIND_SERVICE"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add").Index(0), nil, "")}, - }, { - name: "lowercase capability", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Drop: []string{"net_raw"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "drop").Index(0), nil, "")}, - }, { - name: "duplicate capability", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: []string{"NET_BIND_SERVICE", "NET_BIND_SERVICE"}}} - }, - want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add").Index(1), nil)}, - }, { - name: "too many capabilities", - mutate: func(tmpl *ateapipb.ActorTemplate) { - caps := make([]string, 65) - for i := range caps { - caps[i] = fmt.Sprintf("CAP%d", i) - } - tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: caps}} - }, - want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add"), 65, 64).WithOrigin("maxItems")}, - }, { - name: "valid readyz", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{ - HttpGet: &ateapipb.HTTPGetAction{Path: "/healthz", Port: 8080}, - TimeoutSeconds: 60, - } - }, - }, { - name: "readyz missing http_get", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{TimeoutSeconds: 60} - }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("readyz", "http_get"), "")}, - }, { - name: "readyz timeout_seconds out of range", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{ - HttpGet: &ateapipb.HTTPGetAction{Port: 8080}, - TimeoutSeconds: 3601, - } - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "timeout_seconds"), nil, "").WithOrigin("maximum")}, - }, { - name: "negative readyz timeout_seconds", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{ - HttpGet: &ateapipb.HTTPGetAction{Port: 8080}, - TimeoutSeconds: -1, - } - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "timeout_seconds"), nil, "").WithOrigin("minimum")}, - }, { - name: "readyz missing port", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{}} - }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("readyz", "http_get", "port"), "")}, - }, { - name: "negative readyz port", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Port: -1}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "port"), nil, "").WithOrigin("minimum")}, - }, { - name: "readyz port out of range", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Port: 65536}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "port"), nil, "").WithOrigin("maximum")}, - }, { - name: "readyz path with query string", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Path: "/readyz?verbose=1", Port: 8080}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "path"), nil, "")}, - }, { - name: "readyz path not starting with slash", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Path: "readyz", Port: 8080}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "path"), nil, "")}, - }, { - name: "valid volume_mount", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data"}} - }, - }, { - name: "volume_mount missing name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{MountPath: "/var/data"}} - }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("name"), "")}, - }, { - name: "volume_mount invalid name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "Data_1", MountPath: "/var/data"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "volume_mount missing mount_path", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data"}} - }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), "")}, - }, { - name: "relative mount_path", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "var/data"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, - }, { - name: "root mount_path", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, - }, { - name: "mount_path with dot-dot segment", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/../etc"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, - }, { - name: "mount_path with trailing slash", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data/"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, - }, { - name: "valid durable_dir volume", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}} - }, - }, { - name: "too many volumes", - mutate: func(tmpl *ateapipb.ActorTemplate) { - for i := 0; i < 33; i++ { - tmpl.Volumes = append(tmpl.Volumes, &ateapipb.Volume{Name: fmt.Sprintf("vol-%d", i), DurableDir: &ateapipb.DurableDirVolumeSource{}}) - } - }, - want: field.ErrorList{field.TooMany(field.NewPath("volumes"), 33, 32).WithOrigin("maxItems")}, - }, { - name: "volume missing name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{DurableDir: &ateapipb.DurableDirVolumeSource{}}} - }, - want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("name"), "")}, - }, { - name: "volume invalid name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "Scratch_1", DurableDir: &ateapipb.DurableDirVolumeSource{}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "volume with no source", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "scratch"}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0), nil, "one of").WithOrigin("union")}, - }, { - name: "volume with two sources", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{ - Name: "scratch", - DurableDir: &ateapipb.DurableDirVolumeSource{}, - Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:abc"}, - }} - }, - want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0), nil, "one of").WithOrigin("union")}, - }, { - name: "valid image volume", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:abc"}}} - }, - }, { - name: "image volume missing reference", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{}}} - }, - want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("image", "reference"), "")}, - }, { - name: "image volume reference not pinned by digest", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app:v1"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("image", "reference"), nil, "")}, - }, { - name: "valid external volume template", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "fast-ssd"}}} - }, - }, { - name: "external volume template missing capacity", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{StorageClassName: "fast-ssd"}}} - }, - want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), "")}, - }, { - name: "external volume template malformed capacity", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "ten gigs", StorageClassName: "fast-ssd"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), nil, "")}, - }, { - name: "external volume template missing storage_class_name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi"}}} - }, - want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("external_volume_template", "storage_class_name"), "")}, - }, { - name: "external volume template invalid storage_class_name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "Fast SSD"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("external_volume_template", "storage_class_name"), nil, "").WithOrigin("format=k8s-long-name")}, - }, { - name: "valid resources", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{ - {Name: "cpu", Quantity: "500m"}, {Name: "memory", Quantity: "2Gi"}, - }} - }, - }, { - name: "limit missing name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Quantity: "2Gi"}}} - }, - want: field.ErrorList{ - field.Required(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("name"), ""), - field.NotSupported[string](field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("name"), nil, nil), - }, - }, { - name: "limit missing quantity", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "cpu"}}} - }, - want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), "")}, - }, { - name: "unsupported limit name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "gpu", Quantity: "1"}}} - }, - want: field.ErrorList{field.NotSupported[string](field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("name"), nil, nil)}, - }, { - name: "duplicate limit name", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{ - {Name: "cpu", Quantity: "1"}, {Name: "cpu", Quantity: "2"}, - }} - }, - want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("resources", "limits").Index(1), nil)}, - }, { - name: "malformed limit quantity", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "memory", Quantity: "two gigs"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), nil, "")}, - }, { - name: "zero limit quantity", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "memory", Quantity: "0"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), nil, "")}, - }, { - name: "cpu limit of 1000 cores", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "cpu", Quantity: "1k"}}} - }, - want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), nil, "")}, - }, { - name: "too many limits", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{ - {Name: "cpu", Quantity: "1"}, {Name: "memory", Quantity: "1Gi"}, {Name: "cpu", Quantity: "2"}, - }} - }, - // maxItems short-circuits the per-item and uniqueness checks. - want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("resources", "limits"), 3, 2).WithOrigin("maxItems")}, - }, { - name: "template-level resources validated too", - mutate: func(tmpl *ateapipb.ActorTemplate) { - tmpl.Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "gpu", Quantity: "1"}}} - }, - want: field.ErrorList{field.NotSupported[string](field.NewPath("resources", "limits").Index(0).Child("name"), nil, nil)}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpl := validActorTemplate() - if tt.mutate != nil { - tt.mutate(tmpl) - } - op := operation.Operation{Type: operation.Create} - assertValidateErr(t, Validate_ActorTemplate(context.Background(), op, nil, tmpl, nil), tt.want) - }) - } -} - // seedSubstrateTemplate stores a minimal substrate ActorTemplate in team-a. func seedSubstrateTemplate(t *testing.T, ctx context.Context, persistence store.Interface, name string) *ateapipb.ActorTemplate { t.Helper() diff --git a/cmd/ateapi/internal/controlapi/actor_test.go b/cmd/ateapi/internal/controlapi/actor_test.go index 936f1b090..6ce1fb0e1 100644 --- a/cmd/ateapi/internal/controlapi/actor_test.go +++ b/cmd/ateapi/internal/controlapi/actor_test.go @@ -16,8 +16,6 @@ package controlapi import ( "context" - "fmt" - "strings" "testing" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" @@ -28,733 +26,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/testing/protocmp" - "k8s.io/apimachinery/pkg/util/validation/field" ) -func TestValidateCreateActorRequest(t *testing.T) { - // This test verifies validation of user input for creation. Since status - // is scrubbed on input, we don't need to test the status field here, other - // than that it is optional. TestValidateActorUpdate covers status - // validation and updates. - validReq := func(actor *ateapipb.Actor, mods ...func(actor *ateapipb.CreateActorRequest)) *ateapipb.CreateActorRequest { - req := &ateapipb.CreateActorRequest{ - Actor: actor, - } - for _, m := range mods { - m(req) - } - return req - } - withStatus := withActorStatus - withMetadata := withActorMetadata - withActorTemplate := withActorActorTemplate - withSourceSnapshotTag := withActorSourceSnapshotTag - withWorkerSelector := withActorWorkerSelector - - tests := []struct { - name string - req *ateapipb.CreateActorRequest - want field.ErrorList - }{{ - "valid", - validReq(validActor()), - nil, - }, { - "valid with status", - validReq(validActor(withStatus())), - nil, // ignored on input - }, { - "missing actor", - &ateapipb.CreateActorRequest{Actor: nil}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.metadata", - validReq(validActor(func(a *ateapipb.Actor) { a.Metadata = nil })), - field.ErrorList{field.Required(field.NewPath("actor", "metadata"), "")}, - }, { - "missing actor.metadata.atespace", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "" }))), - field.ErrorList{field.Required(field.NewPath("actor", "metadata", "atespace"), "")}, - }, { - "invalid actor.metadata.atespace", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "NS1" }))), - field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.metadata.name", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))), - field.ErrorList{field.Required(field.NewPath("actor", "metadata", "name"), "")}, - }, { - "invalid actor.metadata.name", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "ID1" }))), - field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "valid actor.actor_template", - validReq(validActor(withActorTemplate("as", "tmpl"))), - nil, - }, { - "missing actor.actor_template", - validReq(validActor(func(a *ateapipb.Actor) { a.ActorTemplate = nil })), - field.ErrorList{field.Required(field.NewPath("actor", "actor_template"), "")}, - }, { - "missing actor.actor_template.atespace", - validReq(validActor(withActorTemplate("", "tmpl"))), - field.ErrorList{field.Required(field.NewPath("actor", "actor_template", "atespace"), "")}, - }, { - "invalid actor.actor_template.atespace", - validReq(validActor(withActorTemplate("invalid value", "tmpl"))), - field.ErrorList{field.Invalid(field.NewPath("actor", "actor_template", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.actor_template.name", - validReq(validActor(withActorTemplate("as", ""))), - field.ErrorList{field.Required(field.NewPath("actor", "actor_template", "name"), "")}, - }, { - "invalid actor.actor_template.name", - validReq(validActor(withActorTemplate("as", "invalid value"))), - field.ErrorList{field.Invalid(field.NewPath("actor", "actor_template", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "valid worker_selector", - validReq(validActor(withWorkerSelector(map[string]string{"tier": "1"}))), - nil, - }, { - "worker_selector with nil match_labels", - validReq(validActor(func(a *ateapipb.Actor) { a.WorkerSelector = &ateapipb.Selector{} })), - field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector"), nil, "one of").WithOrigin("union")}, - }, { - "worker_selector with empty match_labels", - validReq(validActor(withWorkerSelector(map[string]string{}))), - field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector"), nil, "one of").WithOrigin("union")}, - }, { - "worker_selector with exactly max match_labels", - validReq(validActor(withWorkerSelector(selectorLabelsOfSize(10)))), - nil, - }, { - "too many worker_selector.match_labels", - validReq(validActor(withWorkerSelector(selectorLabelsOfSize(11)))), - field.ErrorList{field.TooMany(field.NewPath("actor", "worker_selector", "match_labels"), 11, 10).WithOrigin("maxProperties")}, - }, { - "invalid worker_selector label key", - validReq(validActor(withWorkerSelector(map[string]string{"bad key!": "1"}))), - field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector", "match_labels"), "bad key!", "").WithOrigin("format=k8s-label-key")}, - }, { - "invalid worker_selector label value", - validReq(validActor(withWorkerSelector(map[string]string{"tier": "not valid!"}))), - field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector", "match_labels").Key("tier"), "not valid!", "").WithOrigin("format=k8s-label-value")}, - }, { - "valid actor.source_snapshot_tag", - validReq(validActor(withSourceSnapshotTag("as", "tag"))), - nil, - }, { - "missing actor.source_snapshot_tag.atespace", - validReq(validActor(withSourceSnapshotTag("", "tag"))), - field.ErrorList{field.Required(field.NewPath("actor", "source_snapshot_tag", "atespace"), "")}, - }, { - "invalid actor.source_snapshot_tag.atespace", - validReq(validActor(withSourceSnapshotTag("invalid value", "tag"))), - field.ErrorList{field.Invalid(field.NewPath("actor", "source_snapshot_tag", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.source_snapshot_tag.name", - validReq(validActor(withSourceSnapshotTag("as", ""))), - field.ErrorList{field.Required(field.NewPath("actor", "source_snapshot_tag", "name"), "")}, - }, { - "invalid actor.source_snapshot_tag.name", - validReq(validActor(withSourceSnapshotTag("as", "invalid value"))), - field.ErrorList{field.Invalid(field.NewPath("actor", "source_snapshot_tag", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateCreateActorRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateActorUpdate(t *testing.T) { - // This test validates input and output fields, including status. It also - // tests updates to all fields. This is where the majority of validation - // test cases should live. - validInput := validActor - withStatus := withActorStatus - validOutput := func(mods ...func(*ateapipb.Actor)) *ateapipb.Actor { - allMods := []func(*ateapipb.Actor){withStatus()} // this needs to go first - allMods = append(allMods, mods...) - a := validActor(allMods...) - return a - } - withMetadata := withActorMetadata - withWorkerSelector := withActorWorkerSelector - withActorTemplate := withActorActorTemplate - withSourceSnapshotTag := withActorSourceSnapshotTag - withWorkerAssignment := withActorWorkerAssignment - - tests := []struct { - name string - oldVal *ateapipb.Actor - newVal *ateapipb.Actor - want field.ErrorList - }{{ - "valid", - validInput(), - validOutput(), - nil, - }, { - "missing actor.metadata", - validInput(), - validOutput(func(a *ateapipb.Actor) { a.Metadata = nil }), - field.ErrorList{field.Required(field.NewPath("metadata"), "")}, - }, { - "missing actor.metadata.atespace", - validInput(), - validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "" })), - field.ErrorList{ - field.Required(field.NewPath("metadata", "atespace"), ""), - field.Invalid(field.NewPath("metadata", "atespace"), nil, "").WithOrigin("immutable"), - }, - }, { - "invalid actor.metadata.atespace", - validInput(), - validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "invalid value" })), - field.ErrorList{field.Invalid(field.NewPath("metadata", "atespace"), nil, "").WithOrigin("immutable")}, - }, { - "missing actor.metadata.name", - validInput(), - validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" })), - field.ErrorList{ - field.Required(field.NewPath("metadata", "name"), ""), - field.Invalid(field.NewPath("metadata", "name"), nil, "").WithOrigin("immutable"), - }, - }, { - "invalid actor.metadata.name", - validInput(), - validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "invalid value" })), - field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), nil, "").WithOrigin("immutable")}, - }, { - "change actor.actor_template is allowed", - validInput(withActorTemplate("as1", "nm1")), - validOutput(withActorTemplate("as2", "nm2")), - nil, - }, { - "clear actor.actor_template", - validInput(withActorTemplate("as", "nm")), - validOutput(func(a *ateapipb.Actor) { a.ActorTemplate = nil }), - field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, - }, { - "add actor.source_snapshot_tag", - validInput(), - validOutput(withSourceSnapshotTag("as", "nm")), - field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, - }, { - "clear actor.source_snapshot_tag", - validInput(withSourceSnapshotTag("as", "nm")), - validOutput(func(a *ateapipb.Actor) { a.SourceSnapshotTag = nil }), - field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, - }, { - "change actor.source_snapshot_tag", - validInput(withSourceSnapshotTag("as1", "nm1")), - validOutput(withSourceSnapshotTag("as2", "nm2")), - field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, - }, { - "set valid worker_selector", - validInput(), - validOutput(withWorkerSelector(map[string]string{"tier": "1"})), - nil, - }, { - "clear worker_selector", - validInput(withWorkerSelector(map[string]string{"tier": "1"})), - validOutput(), - nil, - }, { - "modify worker_selector", - validInput(withWorkerSelector(map[string]string{"tier": "1"})), - validOutput(withWorkerSelector(map[string]string{"tier": "2"})), - nil, - }, { - "invalid worker_selector with nil match_labels", - validInput(), - validOutput(func(a *ateapipb.Actor) { a.WorkerSelector = &ateapipb.Selector{} }), - field.ErrorList{field.Invalid(field.NewPath("worker_selector"), nil, "one of").WithOrigin("union")}, - }, { - "invalid worker_selector label key", - validInput(), - validOutput(withWorkerSelector(map[string]string{"bad key": "2"})), - field.ErrorList{field.Invalid(field.NewPath("worker_selector", "match_labels"), nil, "").WithOrigin("format=k8s-label-key")}, - }, { - "invalid worker_selector label value", - validInput(), - validOutput(withWorkerSelector(map[string]string{"tier": "bad value"})), - field.ErrorList{field.Invalid(field.NewPath("worker_selector", "match_labels").Key("tier"), nil, "").WithOrigin("format=k8s-label-value")}, - }, { - "too many worker_selector.match_labels", - validInput(), - validOutput(withWorkerSelector(selectorLabelsOfSize(11))), - field.ErrorList{field.TooMany(field.NewPath("worker_selector", "match_labels"), 11, 10).WithOrigin("maxProperties")}, - }, { - "add actor.source_snapshot_tag", - validInput(), - validOutput(withSourceSnapshotTag("as", "nm")), - field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, - }, { - "clear actor.source_snapshot_tag", - validInput(withSourceSnapshotTag("as", "nm")), - validOutput(func(a *ateapipb.Actor) { a.SourceSnapshotTag = nil }), - field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, - }, { - "change actor.source_snapshot_tag", - validInput(withSourceSnapshotTag("as1", "nm1")), - validOutput(withSourceSnapshotTag("as2", "nm2")), - field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, - }, { - "unspecified actor.status", - validInput(withStatus()), - validOutput(func(a *ateapipb.Actor) { a.Status = nil }), - field.ErrorList{field.Required(field.NewPath("status"), "")}, - }, { - "unspecified actor.status.state", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = 0 })), - field.ErrorList{field.Required(field.NewPath("status", "state"), "")}, - }, { - "change actor.status.state", - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = ateapipb.ActorState_ACTOR_STATE_PAUSED })), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = ateapipb.ActorState_ACTOR_STATE_CRASHED })), - nil, - }, { - "negative actor.status.state", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = -1 })), - field.ErrorList{field.Invalid(field.NewPath("status", "state"), nil, "").WithOrigin("minimum")}, - }, { - "just out of bounds actor.status.state", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = 9 })), - field.ErrorList{field.Invalid(field.NewPath("status", "state"), nil, "").WithOrigin("maximum")}, - }, { - "invalid actor.status.state", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = 1234567890 })), - field.ErrorList{field.Invalid(field.NewPath("status", "state"), nil, "").WithOrigin("maximum")}, - }, { - "set valid actor.status.worker_assignment, IPv4", - validInput(withStatus()), - validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "1.2.3.4" }))), - nil, - }, { - "set valid actor.status.worker_assignment, IPv6", - validInput(withStatus()), - validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "1234::5678" }))), - nil, - }, { - "clear actor.status.worker_assignment", - validInput(withStatus(withWorkerAssignment())), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.WorkerAssignment = nil })), - nil, - }, { - "modify actor.status.worker_assignment", - validInput(withStatus(withWorkerAssignment())), - validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPod = "pod2" }))), - field.ErrorList{field.Invalid(field.NewPath("status", "worker_assignment"), nil, "").WithOrigin("update")}, - }, { - "empty actor.status.worker_assignment", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.WorkerAssignment = &ateapipb.WorkerAssignment{} })), - field.ErrorList{ - field.Required(field.NewPath("status", "worker_assignment", "worker"), ""), - field.Required(field.NewPath("status", "worker_assignment", "worker_namespace"), ""), - field.Required(field.NewPath("status", "worker_assignment", "worker_pool"), ""), - field.Required(field.NewPath("status", "worker_assignment", "worker_pod"), ""), - field.Required(field.NewPath("status", "worker_assignment", "worker_pod_uid"), ""), - field.Required(field.NewPath("status", "worker_assignment", "worker_pod_ip"), ""), - }, - }, { - "invalid actor.status.worker_assignment", - validInput(), - validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { - wa.Worker = &ateapipb.ObjectRef{Atespace: "not-allowed", Name: "bad value"} - wa.WorkerNamespace = "invalid namespace" - wa.WorkerPool = "invalid pool" - wa.WorkerPod = "invalid pod" - wa.WorkerPodUid = "invalid UUID" - wa.WorkerPodIp = "invalid IP" - }))), - field.ErrorList{ - field.Forbidden(field.NewPath("status", "worker_assignment", "worker", "atespace"), ""), - field.Invalid(field.NewPath("status", "worker_assignment", "worker", "name"), nil, "").WithOrigin("format=k8s-short-name"), - field.Invalid(field.NewPath("status", "worker_assignment", "worker_namespace"), nil, "").WithOrigin("format=k8s-short-name"), - field.Invalid(field.NewPath("status", "worker_assignment", "worker_pool"), nil, "").WithOrigin("format=k8s-long-name"), - field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod"), nil, "").WithOrigin("format=k8s-long-name"), - field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_uid"), nil, "").WithOrigin("format=k8s-uuid"), - field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_ip"), nil, "").WithOrigin("format=ip-strict"), - }, - }, { - // because we have manual IP format validation, let's be sure - "invalid actor.status.worker_assignment_worker_pod_ip: leading 0s", - validInput(), - validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "001.002.003.004" }))), - field.ErrorList{ - field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_ip"), nil, "").WithOrigin("format=ip-strict"), - }, - }, { - // because we have manual IP format validation, let's be sure - "invalid actor.status.worker_assignment_worker_pod_ip: non-canonical", - validInput(), - validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "0012::0034" }))), - field.ErrorList{ - field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_ip"), nil, "").WithOrigin("format=ip-strict"), - }, - }, { - "valid actor.status.in_progress_snapshot_name", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressSnapshotName = "snap-1" })), - nil, - }, { - "invalid actor.status.in_progress_snapshot_name", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressSnapshotName = "SNAP 1" })), - field.ErrorList{field.Invalid(field.NewPath("status", "in_progress_snapshot_name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "valid actor.status.latest_snapshot", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LatestSnapshot = &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"} - })), - nil, - }, { - "missing actor.status.latest_snapshot.atespace", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LatestSnapshot = &ateapipb.ObjectRef{Name: "snap-1"} - })), - field.ErrorList{field.Required(field.NewPath("status", "latest_snapshot", "atespace"), "")}, - }, { - "valid actor.status.local_snapshot_info.snapshot_name", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{SnapshotName: "snap-1"} - })), - nil, - }, { - "invalid actor.status.local_snapshot_info.snapshot_name", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{SnapshotName: "SNAP 1"} - })), - field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "snapshot_name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "invalid actor.status.local_snapshot_info.node_vms entry", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{NodeVmsWithLocalSnapshots: []string{"node-1", "NOT A NODE"}} - })), - field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "node_vms_with_local_snapshots").Index(1), nil, "").WithOrigin("format=k8s-long-name")}, - }, { - "too many actor.status.local_snapshot_info.node_vms entries", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - nodes := make([]string, 257) - for i := range nodes { - nodes[i] = fmt.Sprintf("node-%d", i) - } - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{NodeVmsWithLocalSnapshots: nodes} - })), - field.ErrorList{field.TooMany(field.NewPath("status", "local_snapshot_info", "node_vms_with_local_snapshots"), 257, 256).WithOrigin("maxItems")}, - }, { - "duplicate actor.status.local_snapshot_info.node_vms entry", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{NodeVmsWithLocalSnapshots: []string{"node-1", "node-1"}} - })), - field.ErrorList{field.Duplicate(field.NewPath("status", "local_snapshot_info", "node_vms_with_local_snapshots").Index(1), nil)}, - }, { - "valid actor.status.local_snapshot_info.content_scope", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA} - })), - nil, - }, { - "negative actor.status.local_snapshot_info.content_scope", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{ContentScope: ateapipb.SnapshotContentScope(-1)} - })), - field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "content_scope"), nil, "").WithOrigin("minimum")}, - }, { - "invalid actor.status.local_snapshot_info.content_scope", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{ContentScope: ateapipb.SnapshotContentScope(3)} - })), - field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "content_scope"), nil, "").WithOrigin("maximum")}, - }, { - "negative actor.status.in_progress_snapshot_source_actor_version", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressSnapshotSourceActorVersion = -1 })), - field.ErrorList{field.Invalid(field.NewPath("status", "in_progress_snapshot_source_actor_version"), nil, "").WithOrigin("minimum")}, - }, { - "too many actor_volumes", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - vols := make([]*ateapipb.ExternalVolume, 33) - for i := range vols { - vols[i] = &ateapipb.ExternalVolume{VolumeName: fmt.Sprintf("vol-%d", i), VolumeType: "substrate.io/mock"} - } - s.ActorVolumes = vols - })), - field.ErrorList{field.TooMany(field.NewPath("status", "actor_volumes"), 33, 32).WithOrigin("maxItems")}, - }, { - // Set-once fields permit the nil->set transition, so a volume added - // in an update validates like one added at creation. - "adding a volume on update is allowed", - validInput(withStatus()), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.ActorVolumes = []*ateapipb.ExternalVolume{{VolumeName: "vol-a", VolumeType: "substrate.io/mock"}} - })), - nil, - }, { - "duplicate actor_volumes volume_name", - validInput(withStatus()), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.ActorVolumes = []*ateapipb.ExternalVolume{ - {VolumeName: "vol-a", VolumeType: "substrate.io/mock"}, - {VolumeName: "vol-a", VolumeType: "substrate.io/mock"}, - } - })), - field.ErrorList{field.Duplicate(field.NewPath("status", "actor_volumes").Index(1), nil)}, - }, { - "provisioning transition on an existing volume is valid", - validInput(withStatus(func(s *ateapipb.ActorStatus) { - s.ActorVolumes = []*ateapipb.ExternalVolume{{VolumeName: "vol-a", VolumeType: "substrate.io/mock", Status: ateapipb.ExternalVolume_STATUS_PENDING}} - })), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.ActorVolumes = []*ateapipb.ExternalVolume{{ - VolumeName: "vol-a", - VolumeType: "substrate.io/mock", - StorageVolumeId: "csi-426d29b7", - Status: ateapipb.ExternalVolume_STATUS_CREATED, - VolumeContext: map[string]string{"attachment": "iqn.2026-08.io.ate:vol-a"}, - }} - })), - nil, - }, { - "invalid actor.status.in_progress_local_snapshot_name", - validInput(), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressLocalSnapshotName = "BAD NAME" })), - field.ErrorList{field.Invalid(field.NewPath("status", "in_progress_local_snapshot_name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "set actor.status.source_snapshot", - validInput(withStatus()), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ - Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"}, - SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", - } - })), - nil, - }, { - "clear actor.status.source_snapshot", - validInput(withStatus(func(s *ateapipb.ActorStatus) { - s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ - Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"}, - SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", - } - })), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.SourceSnapshot = nil })), - field.ErrorList{field.Invalid(field.NewPath("status", "source_snapshot"), nil, "").WithOrigin("update")}, - }, { - "change actor.status.source_snapshot", - validInput(withStatus(func(s *ateapipb.ActorStatus) { - s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ - Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"}, - SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", - } - })), - validOutput(withStatus(func(s *ateapipb.ActorStatus) { - s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ - Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-2"}, - SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", - } - })), - field.ErrorList{field.Invalid(field.NewPath("status", "source_snapshot"), nil, "").WithOrigin("update")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateActorUpdate(context.Background(), nil, tt.newVal, tt.oldVal, true), tt.want) - }) - } -} - -func TestValidateGetActorRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.GetActorRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, - nil, - }, { - "missing actor", - &ateapipb.GetActorRequest{}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.atespace", - &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, - }, { - "invalid actor.atespace", - &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.name", - &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, - }, { - "invalid actor.name", - &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateGetActorRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateListActorsRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.ListActorsRequest - want field.ErrorList - }{{ - "valid, atespace scoped", - &ateapipb.ListActorsRequest{Atespace: "ns1"}, - nil, - }, { - // Empty atespace means "all atespaces" (kubectl ate get actors -A). - "valid, empty atespace means all atespaces", - &ateapipb.ListActorsRequest{}, - nil, - }, { - "invalid atespace", - &ateapipb.ListActorsRequest{Atespace: "NS1"}, - field.ErrorList{field.Invalid(field.NewPath("atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, - }, { - "valid, positive page_size", - &ateapipb.ListActorsRequest{Atespace: "ns1", PageSize: 10}, - nil, - }, { - "negative page_size", - &ateapipb.ListActorsRequest{Atespace: "ns1", PageSize: -1}, - field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")}, - }, { - "valid page_token", - &ateapipb.ListActorsRequest{Atespace: "ns1", PageToken: strings.Repeat("x", 256)}, - nil, - }, { - "too-large page_token", - &ateapipb.ListActorsRequest{Atespace: "ns1", PageToken: strings.Repeat("x", 257)}, - field.ErrorList{field.TooLongCharacters(field.NewPath("page_token"), "", 256).WithOrigin("maxLength")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateListActorsRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateUpdateActorRequest(t *testing.T) { - // This test verifies validation of user input for update. Since status - // is scrubbed on input, we don't need to test the status field here, other - // than that it is optional. TestValidateActorUpdate covers status - // validation and updates. - validReq := func(actor *ateapipb.Actor, mods ...func(actor *ateapipb.UpdateActorRequest)) *ateapipb.UpdateActorRequest { - req := &ateapipb.UpdateActorRequest{ - Actor: actor, - } - for _, m := range mods { - m(req) - } - return req - } - validActor := func(mods ...func(*ateapipb.Actor)) *ateapipb.Actor { - allMods := []func(*ateapipb.Actor){ - func(a *ateapipb.Actor) { // this needs to go first - a.Metadata.Uid = "12345678-1234-1234-1234-123456789abc" - a.Metadata.Version = 1 - }, - } - allMods = append(allMods, mods...) - a := validActor(allMods...) - return a - } - withStatus := withActorStatus - withMetadata := withActorMetadata - - tests := []struct { - name string - req *ateapipb.UpdateActorRequest - want field.ErrorList - }{{ - "valid", - validReq(validActor()), - nil, - }, { - "valid with status", - validReq(validActor(withStatus())), - nil, // ignored on input - }, { - "missing actor", - &ateapipb.UpdateActorRequest{Actor: nil}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.metadata", - validReq(validActor(func(a *ateapipb.Actor) { a.Metadata = nil })), - field.ErrorList{field.Required(field.NewPath("actor", "metadata"), "")}, - }, { - "missing actor.metadata.atespace", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "" }))), - field.ErrorList{field.Required(field.NewPath("actor", "metadata", "atespace"), "")}, - }, { - "invalid actor.metadata.atespace", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "NS1" }))), - field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.metadata.name", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))), - field.ErrorList{field.Required(field.NewPath("actor", "metadata", "name"), "")}, - }, { - "invalid actor.metadata.name", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "ID1" }))), - field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.metadata.uid precondition", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Uid = "" }))), - nil, - }, { - "invalid actor.metadata.uid precondition", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Uid = "not-a-uuid" }))), - field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "uid"), "not-a-uuid", "").WithOrigin("format=k8s-uuid")}, - }, { - "missing actor.metadata.version precondition", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Version = 0 }))), - nil, - }, { - "negative actor.metadata.version precondition", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Version = -1 }))), - field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "version"), int64(-1), "").WithOrigin("minimum")}, - }, { - "missing actor.metadata.version and actor.metadata.uid", - validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { - m.Uid = "" - m.Version = 0 - }))), - nil, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateUpdateActorRequest(context.Background(), tt.req), tt.want) - }) - } -} - func TestUpdateActor(t *testing.T) { const templateNS, templateName = "ns1", "tmpl1" @@ -998,77 +271,6 @@ func TestUpdateActor_ConcurrentDisjointUpdates(t *testing.T) { } } -// validActor returns a minimal Actor which should pass input validation. -func validActor(mods ...func(*ateapipb.Actor)) *ateapipb.Actor { - a := &ateapipb.Actor{ - Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "id1"}, - ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl1"}, - } - for _, m := range mods { - m(a) - } - return a -} - -// withActorMetadata returns a modifier func (see validActor) which sets -// the actor's resource metadata to a valid value. -func withActorMetadata(mutate func(*ateapipb.ResourceMetadata)) func(*ateapipb.Actor) { - return func(a *ateapipb.Actor) { mutate(a.Metadata) } -} - -// withActorStatus returns a modifier func (see validActor) which sets the -// actor's status to a valid value. -func withActorStatus(mods ...func(*ateapipb.ActorStatus)) func(*ateapipb.Actor) { - return func(a *ateapipb.Actor) { - a.Status = &ateapipb.ActorStatus{ - State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, - } - for _, m := range mods { - m(a.Status) - } - } -} - -// withActorWorkerSelector returns a modifier func (see validActor) which sets -// the actor's worker_selector to a valid value. -func withActorWorkerSelector(labels map[string]string) func(*ateapipb.Actor) { - return func(a *ateapipb.Actor) { - a.WorkerSelector = &ateapipb.Selector{ - MatchLabels: labels, - } - } -} - -// withActorActorTemplate returns a modifier func (see validActor) which sets -// the actor's actor_template to a valid value. -func withActorActorTemplate(atespace, name string) func(*ateapipb.Actor) { - return func(a *ateapipb.Actor) { a.ActorTemplate = &ateapipb.ObjectRef{Atespace: atespace, Name: name} } -} - -// withActorSourceSnapshotTag returns a modifier func (see validActor) which sets -// the actor's source_snapshot_tag to a valid value. -func withActorSourceSnapshotTag(atespace, name string) func(*ateapipb.Actor) { - return func(a *ateapipb.Actor) { a.SourceSnapshotTag = &ateapipb.ObjectRef{Atespace: atespace, Name: name} } -} - -// withActorWorkerAssignment returns a modifier func (see validActor) which sets -// the actor's worker_assignment to a valid value. -func withActorWorkerAssignment(mods ...func(*ateapipb.WorkerAssignment)) func(*ateapipb.ActorStatus) { - return func(s *ateapipb.ActorStatus) { - s.WorkerAssignment = &ateapipb.WorkerAssignment{ - Worker: &ateapipb.ObjectRef{Name: "worker"}, - WorkerNamespace: "ns", - WorkerPool: "pool", - WorkerPod: "pod", - WorkerPodUid: "12345678-1234-1234-1234-123456789abc", - WorkerPodIp: "1.2.3.4", - } - for _, m := range mods { - m(s.WorkerAssignment) - } - } -} - // rpcServiceWithActor seeds one actor in a PostgreSQL-backed store and returns an // RPCService over it. func rpcServiceWithActor(t *testing.T, actor *ateapipb.Actor) (*RPCService, *ateapipb.Actor) { @@ -1079,151 +281,3 @@ func rpcServiceWithActor(t *testing.T, actor *ateapipb.Actor) (*RPCService, *ate created := storetest.MustCreateActor(t, context.Background(), persistence, actor) return &RPCService{impl: newServiceImpl(persistence, nil)}, created } - -func TestValidateDeleteActorRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.DeleteActorRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, - nil, - }, { - "missing actor", - &ateapipb.DeleteActorRequest{}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.atespace", - &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, - }, { - "invalid actor.atespace", - &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.name", - &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, - }, { - "invalid actor.name", - &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateDeleteActorRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidatePauseActorRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.PauseActorRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, - nil, - }, { - "missing actor", - &ateapipb.PauseActorRequest{}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.atespace", - &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, - }, { - "invalid actor.atespace", - &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.name", - &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, - }, { - "invalid actor.name", - &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validatePauseActorRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateResumeActorRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.ResumeActorRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, - nil, - }, { - "missing actor", - &ateapipb.ResumeActorRequest{}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.atespace", - &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, - }, { - "invalid actor.atespace", - &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.name", - &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, - }, { - "invalid actor.name", - &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateResumeActorRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateSuspendActorRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.SuspendActorRequest - want field.ErrorList - }{{ - "valid", - &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, - nil, - }, { - "missing actor", - &ateapipb.SuspendActorRequest{}, - field.ErrorList{field.Required(field.NewPath("actor"), "")}, - }, { - "missing actor.atespace", - &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, - }, { - "invalid actor.atespace", - &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, - }, { - "missing actor.name", - &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, - field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, - }, { - "invalid actor.name", - &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, - field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateSuspendActorRequest(context.Background(), tt.req), tt.want) - }) - } -} diff --git a/cmd/ateapi/internal/controlapi/atespace.go b/cmd/ateapi/internal/controlapi/atespace.go index b38743919..bbeb8dda8 100644 --- a/cmd/ateapi/internal/controlapi/atespace.go +++ b/cmd/ateapi/internal/controlapi/atespace.go @@ -20,11 +20,10 @@ import ( "fmt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "k8s.io/apimachinery/pkg/api/operation" - "k8s.io/apimachinery/pkg/util/validation/field" ) func (s *RPCService) CreateAtespace(ctx context.Context, req *ateapipb.CreateAtespaceRequest) (*ateapipb.Atespace, error) { @@ -35,7 +34,7 @@ func (s *RPCService) CreateAtespace(ctx context.Context, req *ateapipb.CreateAte } // Validate the request, including the object within it. - if errs := validateCreateAtespaceRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateCreateAtespaceRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -58,14 +57,8 @@ func (s *ServiceImpl) CreateAtespace(ctx context.Context, inAtespace *ateapipb.A return stored, nil } -func validateCreateAtespaceRequest(ctx context.Context, req *ateapipb.CreateAtespaceRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_CreateAtespaceRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) GetAtespace(ctx context.Context, req *ateapipb.GetAtespaceRequest) (*ateapipb.Atespace, error) { - if errs := validateGetAtespaceRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateGetAtespaceRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -83,14 +76,8 @@ func (s *ServiceImpl) GetAtespace(ctx context.Context, name string) (*ateapipb.A return atespace, nil } -func validateGetAtespaceRequest(ctx context.Context, req *ateapipb.GetAtespaceRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_GetAtespaceRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) ListAtespaces(ctx context.Context, req *ateapipb.ListAtespacesRequest) (*ateapipb.ListAtespacesResponse, error) { - if errs := validateListAtespacesRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateListAtespacesRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -113,14 +100,8 @@ func (s *ServiceImpl) ListAtespaces(ctx context.Context, opts store.ListOptions) return page, nil } -func validateListAtespacesRequest(ctx context.Context, req *ateapipb.ListAtespacesRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_ListAtespacesRequest(ctx, op, nil, req, nil) -} - func (s *RPCService) DeleteAtespace(ctx context.Context, req *ateapipb.DeleteAtespaceRequest) (*ateapipb.Atespace, error) { - if errs := validateDeleteAtespaceRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateDeleteAtespaceRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -141,9 +122,3 @@ func (s *ServiceImpl) DeleteAtespace(ctx context.Context, name string) (*ateapip return deleted, nil } - -func validateDeleteAtespaceRequest(ctx context.Context, req *ateapipb.DeleteAtespaceRequest) field.ErrorList { - // Call the generated validation. - op := operation.Operation{Type: operation.Create} - return Validate_DeleteAtespaceRequest(ctx, op, nil, req, nil) -} diff --git a/cmd/ateapi/internal/controlapi/common_test.go b/cmd/ateapi/internal/controlapi/common_test.go index 26daa92ac..a55ec15b1 100644 --- a/cmd/ateapi/internal/controlapi/common_test.go +++ b/cmd/ateapi/internal/controlapi/common_test.go @@ -15,12 +15,8 @@ package controlapi import ( - "fmt" - "testing" - "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "google.golang.org/protobuf/testing/protocmp" - "k8s.io/apimachinery/pkg/util/validation/field" ) // Helpers shared by the unit tests in this package. @@ -33,16 +29,3 @@ var ( ignoreUID = protocmp.IgnoreFields(&ateapipb.ResourceMetadata{}, "uid") ignoreTimestamps = protocmp.IgnoreFields(&ateapipb.ResourceMetadata{}, "create_time", "update_time") ) - -func selectorLabelsOfSize(n int) map[string]string { - labels := make(map[string]string, n) - for i := 0; i < n; i++ { - labels[fmt.Sprintf("k%d", i)] = "v" - } - return labels -} - -func assertValidateErr(t *testing.T, got field.ErrorList, want field.ErrorList) { - t.Helper() - field.ErrorMatcher{}.ByType().ByField().ByOrigin().Test(t, want, got) -} diff --git a/cmd/ateapi/internal/controlapi/egress_policy.go b/cmd/ateapi/internal/controlapi/egress_policy.go index dffa49d2a..0fd58f2fe 100644 --- a/cmd/ateapi/internal/controlapi/egress_policy.go +++ b/cmd/ateapi/internal/controlapi/egress_policy.go @@ -18,18 +18,14 @@ import ( "context" "errors" "fmt" - "net/url" - "strings" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "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/operation" - "k8s.io/apimachinery/pkg/api/validate/content" - "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" ) @@ -38,7 +34,7 @@ func (s *RPCService) CreateActorEgressPolicy(ctx context.Context, req *ateapipb. if policy != nil { scrubResourceMetadataForCreate(policy.Metadata) } - if errs := validateCreateActorEgressPolicyRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateCreateActorEgressPolicyRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } actorRef := resources.ActorRefFromObjectRef(req.GetActor()) @@ -50,12 +46,8 @@ func (s *ServiceImpl) CreateEgressPolicy(ctx context.Context, actorRef resources return mapEgressPolicyWrite(created, err) } -func validateCreateActorEgressPolicyRequest(ctx context.Context, req *ateapipb.CreateActorEgressPolicyRequest) field.ErrorList { - return Validate_CreateActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) -} - func (s *RPCService) GetActorEgressPolicy(ctx context.Context, req *ateapipb.GetActorEgressPolicyRequest) (*ateapipb.EgressPolicy, error) { - if errs := validateGetActorEgressPolicyRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateGetActorEgressPolicyRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -73,16 +65,12 @@ func (s *ServiceImpl) GetEgressPolicy(ctx context.Context, actorRef resources.Ac return policy, nil } -func validateGetActorEgressPolicyRequest(ctx context.Context, req *ateapipb.GetActorEgressPolicyRequest) field.ErrorList { - return Validate_GetActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) -} - func (s *RPCService) UpdateActorEgressPolicy(ctx context.Context, req *ateapipb.UpdateActorEgressPolicyRequest) (*ateapipb.EgressPolicy, error) { policy := req.GetEgressPolicy() if policy != nil { scrubResourceMetadataForUpdate(policy.Metadata) } - if errs := validateUpdateActorEgressPolicyRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateUpdateActorEgressPolicyRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } actorRef := resources.ActorRefFromObjectRef(req.GetActor()) @@ -101,7 +89,7 @@ func (s *ServiceImpl) UpdateEgressPolicy(ctx context.Context, actorRef resources if err := mutate(toUpdate); err != nil { return err } - if errs := validateEgressPolicyUpdate(ctx, field.NewPath("egress_policy"), toUpdate, oldVal); len(errs) > 0 { + if errs := validation.ValidateEgressPolicyUpdate(ctx, field.NewPath("egress_policy"), toUpdate, oldVal); len(errs) > 0 { return toGRPCStatusError(errs) } // EgressPolicy has no status or other server-derived fields to verify. @@ -110,16 +98,8 @@ func (s *ServiceImpl) UpdateEgressPolicy(ctx context.Context, actorRef resources return mapEgressPolicyWrite(updated, err) } -func validateUpdateActorEgressPolicyRequest(ctx context.Context, req *ateapipb.UpdateActorEgressPolicyRequest) field.ErrorList { - return Validate_UpdateActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) -} - -func validateEgressPolicyUpdate(ctx context.Context, p *field.Path, newVal, oldVal *ateapipb.EgressPolicy) field.ErrorList { - return Validate_EgressPolicy(ctx, operation.Operation{Type: operation.Update}, p, newVal, oldVal) -} - func (s *RPCService) DeleteActorEgressPolicy(ctx context.Context, req *ateapipb.DeleteActorEgressPolicyRequest) (*ateapipb.EgressPolicy, error) { - if errs := validateDeleteActorEgressPolicyRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateDeleteActorEgressPolicyRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -131,167 +111,6 @@ func (s *ServiceImpl) DeleteEgressPolicy(ctx context.Context, actorRef resources return mapEgressPolicyWrite(deleted, err) } -func validateDeleteActorEgressPolicyRequest(ctx context.Context, req *ateapipb.DeleteActorEgressPolicyRequest) field.ErrorList { - return Validate_DeleteActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) -} - -func ValidateCustom_CreateActorEgressPolicyRequest(_ context.Context, _ operation.Operation, p *field.Path, req, _ *ateapipb.CreateActorEgressPolicyRequest) field.ErrorList { - return validateEgressPolicyParentAtespace(req.GetActor(), req.GetEgressPolicy(), p) -} - -func ValidateCustom_UpdateActorEgressPolicyRequest(_ context.Context, _ operation.Operation, p *field.Path, req, _ *ateapipb.UpdateActorEgressPolicyRequest) field.ErrorList { - return validateEgressPolicyParentAtespace(req.GetActor(), req.GetEgressPolicy(), p) -} - -func validateEgressPolicyParentAtespace(actor *ateapipb.ObjectRef, policy *ateapipb.EgressPolicy, p *field.Path) field.ErrorList { - if actor == nil || actor.Atespace == "" { - return nil // regular DV will handle it - } - actorAtespace := actor.GetAtespace() - if policy == nil || policy.Metadata == nil || policy.Metadata.Atespace == "" { - return nil // regular DV will handle it - } - policyAtespace := policy.GetMetadata().GetAtespace() - if actorAtespace != policyAtespace { - return field.ErrorList{ - field.Invalid(p.Child("egress_policy", "metadata", "atespace"), policyAtespace, "must match actor.atespace"), - } - } - return nil -} - -func ValidateCustom_EgressPolicy_Metadata(_ context.Context, _ operation.Operation, root *field.Path, meta, _ *ateapipb.ResourceMetadata) field.ErrorList { - if meta == nil || meta.Name == "" { - return nil // regular DV will handle it - } - if meta.Name != "default" { - return field.ErrorList{field.Invalid(root.Child("name"), meta.Name, `must be "default"`).WithOrigin("custom=default")} - } - return nil -} - -func ValidateCustom_HostnameRule_Patterns(_ context.Context, _ operation.Operation, p *field.Path, patterns, _ []string) field.ErrorList { - var errs field.ErrorList - for i, raw := range patterns { - errs = append(errs, validateHostnamePattern(raw, p.Index(i))...) - } - return errs -} - -func ValidateCustom_EgressRuleEffects(_ context.Context, _ operation.Operation, p *field.Path, effects, _ *ateapipb.EgressRuleEffects) field.ErrorList { - var errs field.ErrorList - if len(effects.GetInjectStaticHeaders()) == 0 { - errs = append(errs, field.Required(p, "at least one effect must be specified")) - } - return errs -} - -func ValidateCustom_EgressRuleEffects_InjectStaticHeaders(_ context.Context, _ operation.Operation, p *field.Path, injections, _ []*ateapipb.CredentialHeaderInjection) field.ErrorList { - var errs field.ErrorList - seenHeaders := map[string]bool{} - for i, inj := range injections { - if inj == nil { - continue // handled by DV - } - norm := strings.ToLower(inj.Header) - if seenHeaders[norm] { - errs = append(errs, field.Duplicate(p.Index(i).Child("header"), inj.Header)) - } - seenHeaders[norm] = true - } - return errs -} - -func ValidateCustom_IPBlockRule_Cidrs(_ context.Context, _ operation.Operation, p *field.Path, cidrs, _ []string) field.ErrorList { - var errs field.ErrorList - for i, cidr := range cidrs { - errs = append(errs, validation.IsValidCIDR(p.Index(i), cidr)...) - } - return errs -} - -func validateHostnamePattern(raw string, p *field.Path) field.ErrorList { - if raw == "" { - return field.ErrorList{field.Required(p, "")} - } - name := strings.TrimPrefix(raw, "*.") - if len(content.IsDNS1123Subdomain(name)) != 0 || len(validation.IsValidIP(p, name)) == 0 { - return field.ErrorList{ - field.Invalid(p, raw, "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - } - } - return nil -} - -func ValidateCustom_CredentialHeaderInjection_Header(_ context.Context, _ operation.Operation, p *field.Path, header, _ *string) field.ErrorList { - if !validHeaderName(*header) { - return field.ErrorList{ - field.Invalid(p, *header, "must be an HTTP header name"), - } - } - return nil -} - -func ValidateCustom_CredentialHeaderInjection_Prefix(_ context.Context, _ operation.Operation, p *field.Path, prefix, _ *string) field.ErrorList { - if !validHeaderValue(*prefix) { - return field.ErrorList{ - field.Invalid(p, *prefix, "must be a valid HTTP field value prefix"), - } - } - return nil -} - -func ValidateCustom_CredentialHeaderInjection_CredentialUri(_ context.Context, _ operation.Operation, p *field.Path, uri, _ *string) field.ErrorList { - if !validCredentialURI(*uri) { - return field.ErrorList{ - field.Invalid(p, *uri, "must be substrate-secret:////"), - } - } - return nil -} - -func validCredentialURI(raw string) bool { - u, err := url.Parse(raw) - if err != nil || u.Scheme != "substrate-secret" || u.Host == "" || u.Host != u.Hostname() || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(validation.IsDNS1123Subdomain(u.Host)) != 0 { - return false - } - escapedPath := u.EscapedPath() - if !strings.HasPrefix(escapedPath, "/") || strings.HasSuffix(escapedPath, "/") { - return false - } - parts := strings.Split(strings.TrimPrefix(escapedPath, "/"), "/") - if len(parts) < 2 { - return false - } - for _, part := range parts { - if part == "" { - return false - } - } - return true -} - -func validHeaderName(value string) bool { - if value == "" { - return false - } - for _, c := range []byte(value) { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c))) { - return false - } - } - return true -} - -func validHeaderValue(value string) bool { - for _, c := range []byte(value) { - if c != '\t' && (c < ' ' || c == 0x7f) { - return false - } - } - return true -} - func mapEgressPolicyWrite(policy *ateapipb.EgressPolicy, err error) (*ateapipb.EgressPolicy, error) { switch { case err == nil: diff --git a/cmd/ateapi/internal/controlapi/egress_policy_test.go b/cmd/ateapi/internal/controlapi/egress_policy_test.go index bb9f47f03..1eecd5868 100644 --- a/cmd/ateapi/internal/controlapi/egress_policy_test.go +++ b/cmd/ateapi/internal/controlapi/egress_policy_test.go @@ -15,8 +15,6 @@ package controlapi import ( - "context" - "fmt" "testing" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" @@ -26,729 +24,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/emptypb" - "k8s.io/apimachinery/pkg/util/validation/field" ) -func validEgressPolicy() *ateapipb.EgressPolicy { - return &ateapipb.EgressPolicy{ - Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "default"}, - Rules: []*ateapipb.EgressRule{{ - Hostnames: &ateapipb.HostnameRule{ - Patterns: []string{"api.example.com"}, - Effects: &ateapipb.EgressRuleEffects{ - InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{ - Header: "Authorization", - Prefix: "Bearer ", - CredentialUri: "substrate-secret://kubernetes.io/provider/ns/name", - }}, - }, - }, - }}, - } -} - -func TestValidateCreateActorEgressPolicyRequest(t *testing.T) { - validReq := func() *ateapipb.CreateActorEgressPolicyRequest { - return &ateapipb.CreateActorEgressPolicyRequest{ - Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, - EgressPolicy: validEgressPolicy(), - } - } - - tests := []struct { - name string - req *ateapipb.CreateActorEgressPolicyRequest - want field.ErrorList - }{{ - name: "valid", - req: validReq(), - }, { - name: "missing actor", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.Actor = nil - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor"), ""), - }, - }, { - name: "missing actor atespace", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.Actor.Atespace = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor", "atespace"), ""), - }, - }, { - name: "invalid actor atespace", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.Actor.Atespace = "invalid value" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("actor", "atespace"), nil, "").WithOrigin("format=k8s-short-name"), - field.Invalid(field.NewPath("egress_policy", "metadata", "atespace"), nil, ""), - }, - }, { - name: "missing actor name", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.Actor.Name = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor", "name"), ""), - }, - }, { - name: "invalid actor name", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.Actor.Name = "invalid value" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("actor", "name"), nil, "").WithOrigin("format=k8s-short-name"), - }, - }, { - name: "missing policy", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy = nil - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("egress_policy"), ""), - }, - }, { - name: "missing metadata", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Metadata = nil - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("egress_policy", "metadata"), ""), - }, - }, { - name: "missing policy atespace", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Metadata.Atespace = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("egress_policy", "metadata", "atespace"), ""), - }, - }, { - name: "missing default name", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Metadata.Name = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("egress_policy", "metadata", "name"), ""), - }, - }, { - name: "wrong policy name", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Metadata.Name = "other" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("egress_policy", "metadata", "name"), "other", `must be "default"`).WithOrigin("custom=default"), - }, - }, { - name: "mismatched policy atespace", - req: func() *ateapipb.CreateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Metadata.Atespace = "other" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("egress_policy", "metadata", "atespace"), "other", "must match actor.atespace"), - }, - }} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assertValidateErr(t, validateCreateActorEgressPolicyRequest(context.Background(), tc.req), tc.want) - }) - } -} - -func TestValidateGetActorEgressPolicyRequest(t *testing.T) { - validReq := func() *ateapipb.GetActorEgressPolicyRequest { - return &ateapipb.GetActorEgressPolicyRequest{ - Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, - } - } - tests := []struct { - name string - req *ateapipb.GetActorEgressPolicyRequest - want field.ErrorList - }{{ - name: "valid", - req: validReq(), - }, { - name: "missing actor", - req: &ateapipb.GetActorEgressPolicyRequest{}, - want: field.ErrorList{ - field.Required(field.NewPath("actor"), ""), - }, - }, { - name: "missing atespace", - req: func() *ateapipb.GetActorEgressPolicyRequest { - r := validReq() - r.Actor.Atespace = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor", "atespace"), ""), - }, - }, { - name: "invalid atespace", - req: func() *ateapipb.GetActorEgressPolicyRequest { - r := validReq() - r.Actor.Atespace = "invalid value" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("actor", "atespace"), nil, "").WithOrigin("format=k8s-short-name"), - }, - }, { - name: "missing name", - req: func() *ateapipb.GetActorEgressPolicyRequest { - r := validReq() - r.Actor.Name = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor", "name"), ""), - }, - }, { - name: "invalid name", - req: func() *ateapipb.GetActorEgressPolicyRequest { - r := validReq() - r.Actor.Name = "invalid value" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("actor", "name"), nil, "").WithOrigin("format=k8s-short-name"), - }, - }} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assertValidateErr(t, validateGetActorEgressPolicyRequest(context.Background(), tc.req), tc.want) - }) - } -} - -func TestValidateUpdateActorEgressPolicyRequest(t *testing.T) { - validReq := func() *ateapipb.UpdateActorEgressPolicyRequest { - return &ateapipb.UpdateActorEgressPolicyRequest{ - Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, - EgressPolicy: validEgressPolicy(), - } - } - tests := []struct { - name string - req *ateapipb.UpdateActorEgressPolicyRequest - want field.ErrorList - }{{ - name: "valid", - req: validReq(), - }, { - name: "missing actor", - req: func() *ateapipb.UpdateActorEgressPolicyRequest { - r := validReq() - r.Actor = nil - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor"), ""), - }, - }, { - name: "missing policy", - req: func() *ateapipb.UpdateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy = nil - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("egress_policy"), ""), - }, - }, { - name: "mismatched atespace", - req: func() *ateapipb.UpdateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Metadata.Atespace = "other" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("egress_policy", "metadata", "atespace"), "other", "must match actor.atespace"), - }, - }, { - name: "invalid rule", - req: func() *ateapipb.UpdateActorEgressPolicyRequest { - r := validReq() - r.EgressPolicy.Rules[0].Hostnames.Patterns = nil - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("egress_policy", "rules").Index(0).Child("hostnames", "patterns"), ""), - }, - }} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assertValidateErr(t, validateUpdateActorEgressPolicyRequest(context.Background(), tc.req), tc.want) - }) - } -} - -func TestValidateDeleteActorEgressPolicyRequest(t *testing.T) { - validReq := func() *ateapipb.DeleteActorEgressPolicyRequest { - return &ateapipb.DeleteActorEgressPolicyRequest{ - Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, - } - } - tests := []struct { - name string - req *ateapipb.DeleteActorEgressPolicyRequest - want field.ErrorList - }{{ - name: "valid", - req: validReq(), - }, { - name: "missing actor", - req: &ateapipb.DeleteActorEgressPolicyRequest{}, - want: field.ErrorList{ - field.Required(field.NewPath("actor"), ""), - }, - }, { - name: "missing atespace", - req: func() *ateapipb.DeleteActorEgressPolicyRequest { - r := validReq() - r.Actor.Atespace = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor", "atespace"), ""), - }, - }, { - name: "invalid atespace", - req: func() *ateapipb.DeleteActorEgressPolicyRequest { - r := validReq() - r.Actor.Atespace = "invalid value" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("actor", "atespace"), nil, "").WithOrigin("format=k8s-short-name"), - }, - }, { - name: "missing name", - req: func() *ateapipb.DeleteActorEgressPolicyRequest { - r := validReq() - r.Actor.Name = "" - return r - }(), - want: field.ErrorList{ - field.Required(field.NewPath("actor", "name"), ""), - }, - }, { - name: "invalid name", - req: func() *ateapipb.DeleteActorEgressPolicyRequest { - r := validReq() - r.Actor.Name = "invalid value" - return r - }(), - want: field.ErrorList{ - field.Invalid(field.NewPath("actor", "name"), nil, "").WithOrigin("format=k8s-short-name"), - }, - }} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assertValidateErr(t, validateDeleteActorEgressPolicyRequest(context.Background(), tc.req), tc.want) - }) - } -} - -func TestValidateEgressPolicyRules(t *testing.T) { - root := field.NewPath("egress_policy") - rule := root.Child("rules").Index(0) - hostnames := rule.Child("hostnames") - pattern := hostnames.Child("patterns").Index(0) - staticHeader := hostnames.Child("effects", "inject_static_headers").Index(0) - validReq := func() *ateapipb.CreateActorEgressPolicyRequest { - return &ateapipb.CreateActorEgressPolicyRequest{ - Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, - EgressPolicy: validEgressPolicy(), - } - } - withoutEffects := func(p *ateapipb.EgressPolicy) { p.Rules[0].Hostnames.Effects = nil } - trivialHostnameRule := func(hostname string) *ateapipb.EgressRule { - return &ateapipb.EgressRule{ - Hostnames: &ateapipb.HostnameRule{ - Patterns: []string{hostname}, - }, - } - } - - tests := []struct { - name string - mutate func(*ateapipb.EgressPolicy) - want field.ErrorList - }{{ - name: "valid", - }, { - name: "no rules", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules = nil - }, - }, { - name: "many rules", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules = nil - for i := range 256 { - p.Rules = append(p.Rules, trivialHostnameRule(fmt.Sprintf("api%d.example.com", i))) - } - }, - }, { - name: "too many rules", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules = nil - for i := range 257 { - p.Rules = append(p.Rules, trivialHostnameRule(fmt.Sprintf("api%d.example.com", i))) - } - }, - want: field.ErrorList{ - field.TooMany(root.Child("rules"), 257, 256).WithOrigin("maxItems"), - }, - }, { - name: "nil rule", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = nil - }, - want: field.ErrorList{ - field.Required(rule, ""), - }, - }, { - name: "no predicates", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{} - }, - want: field.ErrorList{ - field.Invalid(rule, nil, "one of").WithOrigin("union"), - }, - }, { - name: "multiple predicates", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].All = &emptypb.Empty{} - }, - want: field.ErrorList{ - field.Invalid(rule, nil, "one of").WithOrigin("union"), - }, - }, { - name: "match all", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{All: &emptypb.Empty{}} - }, - }, { - name: "empty hostname list", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns = nil - withoutEffects(p) - }, - want: field.ErrorList{ - field.Required(hostnames.Child("patterns"), ""), - }, - }, { - name: "long hostname list", - mutate: func(p *ateapipb.EgressPolicy) { - var pats []string - for i := range 256 { - pats = append(pats, fmt.Sprintf("api%d.example.com", i)) - } - p.Rules[0].Hostnames.Patterns = pats - withoutEffects(p) - }, - }, { - name: "too-long hostname list", - mutate: func(p *ateapipb.EgressPolicy) { - var pats []string - for i := range 257 { - pats = append(pats, fmt.Sprintf("api%d.example.com", i)) - } - p.Rules[0].Hostnames.Patterns = pats - withoutEffects(p) - }, - want: field.ErrorList{ - field.TooMany(hostnames.Child("patterns"), 257, 256).WithOrigin("maxItems"), - }, - }, { - name: "missing hostname", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "" - withoutEffects(p) - }, - want: field.ErrorList{ - field.Required(pattern, ""), - }, - }, { - name: "duplicate hostname", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns = append(p.Rules[0].Hostnames.Patterns, "api.example.com") - }, - want: field.ErrorList{ - field.Duplicate(hostnames.Child("patterns").Index(1), "api.example.com"), - }, - }, { - name: "invalid hostname", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "https://example.com" - }, - want: field.ErrorList{ - field.Invalid(pattern, "https://example.com", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - }, - }, { - name: "uppercase hostname", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "API.EXAMPLE.COM" - }, - want: field.ErrorList{ - field.Invalid(pattern, "API.EXAMPLE.COM", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - }, - }, { - name: "hostname with trailing dot", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "api.example.com." - }, - want: field.ErrorList{ - field.Invalid(pattern, "api.example.com.", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - }, - }, { - name: "IP literal hostname", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "192.0.2.1" - }, - want: field.ErrorList{ - field.Invalid(pattern, "192.0.2.1", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - }, - }, { - name: "hostname with port", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "example.com:443" - }, - want: field.ErrorList{ - field.Invalid(pattern, "example.com:443", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - }, - }, { - name: "hostname wildcard without effects", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "*.example.com" - withoutEffects(p) - }, - }, { - name: "hostname wildcard with effects", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "*.example.com" - }, - }, { - name: "invalid hostname wildcard", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Patterns[0] = "api.*.example.com" - }, - want: field.ErrorList{ - field.Invalid(pattern, "api.*.example.com", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), - }, - }, { - name: "canonical IPv4 CIDR", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"192.0.2.0/24"}}} - }, - }, { - name: "canonical IPv6 CIDR", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"2001:db8::/32"}}} - }, - }, { - name: "mixed IPv4 and IPv6 CIDRs", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{ - IpBlocks: &ateapipb.IPBlockRule{ - Cidrs: []string{"192.0.2.0/24", "2001:db8::/32"}, - }, - } - }, - }, { - name: "many CIDRs", - mutate: func(p *ateapipb.EgressPolicy) { - var cidrs []string - for i := range 256 { - cidrs = append(cidrs, fmt.Sprintf("192.0.2.%d/32", i)) - } - p.Rules[0] = &ateapipb.EgressRule{ - IpBlocks: &ateapipb.IPBlockRule{ - Cidrs: cidrs, - }, - } - }, - }, { - name: "too many CIDRs", - mutate: func(p *ateapipb.EgressPolicy) { - var cidrs []string - for i := range 257 { - cidrs = append(cidrs, fmt.Sprintf("192.0.2.%d/32", i)) - } - p.Rules[0] = &ateapipb.EgressRule{ - IpBlocks: &ateapipb.IPBlockRule{ - Cidrs: cidrs, - }, - } - }, - want: field.ErrorList{ - field.TooMany(rule.Child("ip_blocks", "cidrs"), 257, 256).WithOrigin("maxItems"), - }, - }, { - name: "missing CIDR", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{""}}} - }, - want: field.ErrorList{ - field.Invalid(rule.Child("ip_blocks", "cidrs").Index(0), "", "must be a canonical IPv4 or IPv6 prefix"), - }, - }, { - name: "empty CIDR list", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{}} - }, - want: field.ErrorList{ - field.Required(rule.Child("ip_blocks", "cidrs"), ""), - }, - }, { - name: "noncanonical CIDR", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"192.0.2.1/24"}}} - }, - want: field.ErrorList{ - field.Invalid(rule.Child("ip_blocks", "cidrs").Index(0), "192.0.2.1/24", "must be a canonical IPv4 or IPv6 prefix"), - }, - }, { - name: "duplicate CIDR", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"192.0.2.0/24", "192.0.2.0/24"}}} - }, - want: field.ErrorList{ - field.Duplicate(rule.Child("ip_blocks", "cidrs").Index(1), "192.0.2.0/24"), - }, - }, { - name: "missing static header", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].Header = "" - }, - want: field.ErrorList{ - field.Required(staticHeader.Child("header"), ""), - }, - }, { - name: "invalid static header", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].Header = "bad header" - }, - want: field.ErrorList{ - field.Invalid(staticHeader.Child("header"), "bad header", "must be an HTTP header name"), - }, - }, { - name: "duplicate header", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects.InjectStaticHeaders = append( - p.Rules[0].Hostnames.Effects.InjectStaticHeaders, - &ateapipb.CredentialHeaderInjection{Header: "authorization", CredentialUri: "substrate-secret://example.com/provider/secret"}, - ) - }, - want: field.ErrorList{ - field.Duplicate(hostnames.Child("effects", "inject_static_headers").Index(1).Child("header"), "authorization"), - }, - }, { - name: "same header in later rule", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules = append(p.Rules, proto.Clone(p.Rules[0]).(*ateapipb.EgressRule)) - }, - }, { - name: "many headers", - mutate: func(p *ateapipb.EgressPolicy) { - var injections []*ateapipb.CredentialHeaderInjection - for i := range 16 { - injections = append(injections, &ateapipb.CredentialHeaderInjection{ - Header: fmt.Sprintf("X-Header-%d", i), - CredentialUri: "substrate-secret://example.com/provider/secret", - }) - } - p.Rules[0].Hostnames.Effects.InjectStaticHeaders = injections - }, - }, { - name: "too many headers", - mutate: func(p *ateapipb.EgressPolicy) { - var injections []*ateapipb.CredentialHeaderInjection - for i := range 17 { - injections = append(injections, &ateapipb.CredentialHeaderInjection{ - Header: fmt.Sprintf("X-Header-%d", i), - CredentialUri: "substrate-secret://example.com/provider/secret", - }) - } - p.Rules[0].Hostnames.Effects.InjectStaticHeaders = injections - }, - want: field.ErrorList{ - field.TooMany(hostnames.Child("effects", "inject_static_headers"), 17, 16).WithOrigin("maxItems"), - }, - }, { - name: "invalid prefix", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].Prefix = "Bearer\r" - }, - want: field.ErrorList{ - field.Invalid(staticHeader.Child("prefix"), "Bearer\r", "must be a valid HTTP field value prefix"), - }, - }, { - name: "missing credential URI", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].CredentialUri = "" - }, - want: field.ErrorList{ - field.Required(staticHeader.Child("credential_uri"), ""), - }, - }, { - name: "invalid credential URI", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].CredentialUri = "https://example.com/secret" - }, - want: field.ErrorList{ - field.Invalid(staticHeader.Child("credential_uri"), "https://example.com/secret", "must be substrate-secret:////"), - }, - }, { - name: "empty effects", - mutate: func(p *ateapipb.EgressPolicy) { - p.Rules[0].Hostnames.Effects = &ateapipb.EgressRuleEffects{} - }, - want: field.ErrorList{ - field.Required(hostnames.Child("effects"), "at least one effect must be specified"), - }, - }} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - req := validReq() - if tc.mutate != nil { - tc.mutate(req.EgressPolicy) - } - assertValidateErr(t, validateCreateActorEgressPolicyRequest(context.Background(), req), tc.want) - }) - } -} - func TestActorEgressPolicy(t *testing.T) { persistence, cleanup := storetest.SetupTestStore(t) defer cleanup() @@ -871,51 +148,3 @@ func TestActorEgressPolicy(t *testing.T) { t.Fatalf("policy after delete status = %v, want NotFound", status.Code(err)) } } - -func TestCredentialURIValidation(t *testing.T) { - for _, uri := range []string{ - "substrate-secret://kubernetes.io/provider/ns/name", - "substrate-secret://vault.example/provider/secret", - } { - if !validCredentialURI(uri) { - t.Errorf("validCredentialURI(%q) = false", uri) - } - } - for _, uri := range []string{ - "https://kubernetes.io/provider/ns/name", - "substrate-secret://kubernetes.io/provider", - "substrate-secret://kubernetes.io//provider/secret", - "substrate-secret://kubernetes.io/provider/secret/", - "substrate-secret://kubernetes.io:443/provider/secret", - } { - if validCredentialURI(uri) { - t.Errorf("validCredentialURI(%q) = true", uri) - } - } -} - -func TestHeaderValueValidation(t *testing.T) { - for _, value := range []string{"Bearer token", "value\tvalue", "\u0080\u0081"} { - if !validHeaderValue(value) { - t.Errorf("validHeaderValue(%q) = false", value) - } - } - for _, value := range []string{"a\rb", "a\nb", "a\x00b", "a\x1fb", "a\x7fb"} { - if validHeaderValue(value) { - t.Errorf("validHeaderValue(%q) = true", value) - } - } -} - -func TestHeaderNameValidation(t *testing.T) { - for _, value := range []string{"Authorization", "x-custom_header", "!#$%&'*+-.^_`|~"} { - if !validHeaderName(value) { - t.Errorf("validHeaderName(%q) = false", value) - } - } - for _, value := range []string{"", "bad header", "bad:header", "héader"} { - if validHeaderName(value) { - t.Errorf("validHeaderName(%q) = true", value) - } - } -} diff --git a/cmd/ateapi/internal/controlapi/requests.go b/cmd/ateapi/internal/controlapi/requests.go new file mode 100644 index 000000000..a33ea9e7d --- /dev/null +++ b/cmd/ateapi/internal/controlapi/requests.go @@ -0,0 +1,53 @@ +// 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 ( + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// scrubResourceMetadataForCreate removes fields that should not be set by the +// user when creating a resource. +func scrubResourceMetadataForCreate(in *ateapipb.ResourceMetadata) { + if in == nil { + return // validation will flag it + } + in.Uid = "" // will be set later + in.Version = 0 // will be set later + in.CreateTime = nil // will be set later + in.UpdateTime = nil // will be set later +} + +// scrubResourceMetadataForUpdate removes fields that should not be set by the +// user when updating a resource. +func scrubResourceMetadataForUpdate(in *ateapipb.ResourceMetadata) { + if in == nil { + return // validation will flag it + } + // in.Uid and in.Version are preconditions, so we don't scrub them. + in.CreateTime = nil // will be set later + in.UpdateTime = nil // will be set later +} + +func toGRPCStatusError(errs field.ErrorList) error { + return status.Error(codes.InvalidArgument, errs.ToAggregate().Error()) +} + +func toGRPCInternalError(errs field.ErrorList) error { + return status.Error(codes.Internal, errs.ToAggregate().Error()) +} diff --git a/cmd/ateapi/internal/controlapi/validate.go b/cmd/ateapi/internal/controlapi/validate.go deleted file mode 100644 index 7a7765641..000000000 --- a/cmd/ateapi/internal/controlapi/validate.go +++ /dev/null @@ -1,126 +0,0 @@ -// 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" - "reflect" - "strings" - - "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/util/validation" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -func toGRPCStatusError(errs field.ErrorList) error { - return status.Error(codes.InvalidArgument, errs.ToAggregate().Error()) -} - -func toGRPCInternalError(errs field.ErrorList) error { - return status.Error(codes.Internal, errs.ToAggregate().Error()) -} - -// scrubResourceMetadataForCreate removes fields that should not be set by the -// user when creating a resource. -func scrubResourceMetadataForCreate(in *ateapipb.ResourceMetadata) { - if in == nil { - return // validation will flag it - } - in.Uid = "" // will be set later - in.Version = 0 // will be set later - in.CreateTime = nil // will be set later - in.UpdateTime = nil // will be set later -} - -// scrubResourceMetadataForUpdate removes fields that should not be set by the -// user when updating a resource. -func scrubResourceMetadataForUpdate(in *ateapipb.ResourceMetadata) { - if in == nil { - return // validation will flag it - } - // in.Uid and in.Version are preconditions, so we don't scrub them. - in.CreateTime = nil // will be set later - in.UpdateTime = nil // will be set later -} - -// ateDeepEqual compares two values of any type, using proto.Equal if both are -// proto messages, and reflect.DeepEqual otherwise. This is called by -// declarative validation's generated code. -func ateDeepEqual[T any](a, b T) bool { - asProto := func(x any) proto.Message { - pm, ok := x.(proto.Message) - if !ok { - return nil - } - return pm - } - - if pa, pb := asProto(a), asProto(b); pa != nil && pb != nil { - return proto.Equal(pa, pb) - } - return reflect.DeepEqual(a, b) -} - -// This is needed because DV doesn't have a standard format for IP addresses yet. -func ValidateCustom_WorkerAssignment_WorkerPodIp(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - return validation.IsValidIP(fldPath, *value) -} - -// maxCSRBytes bounds MintCertRequest's CSR. Real CSRs are a few KB; this is -// a guardrail, applied here because maxLength does not support bytes fields. -const maxCSRBytes = 16384 - -func ValidateCustom_MintCertRequest_CertificateSigningRequest(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []byte) field.ErrorList { - if len(value) > maxCSRBytes { - return field.ErrorList{field.TooLong(fldPath, nil, maxCSRBytes)} - } - return nil -} - -// ValidateCustom_ExternalVolume_VolumeType checks that a volume type string is well-formed. -// It allows an optional "substrate.io/" prefix, followed by a valid DNS-1123 subdomain. -func ValidateCustom_ExternalVolume_VolumeType(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - if value == nil || *value == "" { - return nil - } - var errs field.ErrorList - valToValidate := strings.TrimPrefix(*value, "substrate.io/") - for _, msg := range validation.IsDNS1123Subdomain(valToValidate) { - errs = append(errs, field.Invalid(fldPath, *value, msg)) - } - return errs -} - -// ValidateCustom_ExternalVolume_StorageVolumeId checks that an external volume's storage ID does not -// contain control characters (U+0000-U+0008, U+000B, U+000C, U+000E-U+001F, U+007F-U+009F). -func ValidateCustom_ExternalVolume_StorageVolumeId(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { - if value == nil || *value == "" { - return nil - } - for _, r := range *value { - if (r >= 0x0000 && r <= 0x0008) || - r == 0x000B || - r == 0x000C || - (r >= 0x000E && r <= 0x001F) || - (r >= 0x007F && r <= 0x009F) { - return field.ErrorList{field.Invalid(fldPath, *value, "must not contain control characters (U+0000-U+0008, U+000B, U+000C, U+000E-U+001F, U+007F-U+009F)")} - } - } - return nil -} diff --git a/cmd/ateapi/internal/controlapi/worker.go b/cmd/ateapi/internal/controlapi/worker.go index d130a8a04..3721dd049 100644 --- a/cmd/ateapi/internal/controlapi/worker.go +++ b/cmd/ateapi/internal/controlapi/worker.go @@ -20,18 +20,16 @@ import ( "fmt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/validation" "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" ) func (s *RPCService) ListWorkers(ctx context.Context, req *ateapipb.ListWorkersRequest) (*ateapipb.ListWorkersResponse, error) { - if errs := validateListWorkersRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateListWorkersRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -49,14 +47,8 @@ func (s *ServiceImpl) ListWorkers(ctx context.Context, opts store.ListOptions) ( 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 { + if errs := validation.ValidateGetWorkerRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } name := req.GetWorker().GetName() @@ -75,12 +67,6 @@ func (s *ServiceImpl) GetWorker(ctx context.Context, name string) (*ateapipb.Wor 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 @@ -92,7 +78,7 @@ func (s *RPCService) CreateWorker(ctx context.Context, req *ateapipb.CreateWorke } // Validate the request, including the object within it. - if errs := validateCreateWorkerRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateCreateWorkerRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -107,7 +93,7 @@ func (s *ServiceImpl) CreateWorker(ctx context.Context, inWorker *ateapipb.Worke outWorker.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE} // Verify that the result is properly valid before storing it. - if errs := validateWorkerUpdate(ctx, field.NewPath("worker"), outWorker, inWorker, true); len(errs) > 0 { + if errs := validation.ValidateWorkerUpdate(ctx, field.NewPath("worker"), outWorker, inWorker, true); len(errs) > 0 { return nil, toGRPCInternalError(errs) } @@ -122,12 +108,6 @@ func (s *ServiceImpl) CreateWorker(ctx context.Context, inWorker *ateapipb.Worke 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 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 @@ -142,7 +122,7 @@ func (s *RPCService) UpdateWorker(ctx context.Context, req *ateapipb.UpdateWorke } // Validate the request. - if errs := validateUpdateWorkerRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateUpdateWorkerRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } @@ -171,14 +151,14 @@ func (s *ServiceImpl) UpdateWorker(ctx context.Context, name string, preconditio // 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 { + if errs := validation.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 { + if errs := validation.ValidateWorkerUpdate(ctx, field.NewPath("worker"), newVal, oldVal, true); len(errs) > 0 { return toGRPCInternalError(errs) } @@ -186,18 +166,8 @@ func (s *ServiceImpl) UpdateWorker(ctx context.Context, name string, preconditio }) } -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) -} - func (s *RPCService) DeleteWorker(ctx context.Context, req *ateapipb.DeleteWorkerRequest) (*ateapipb.Worker, error) { - if errs := validateDeleteWorkerRequest(ctx, req); len(errs) > 0 { + if errs := validation.ValidateDeleteWorkerRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } // The delete releases the Actor bound to this Worker before removing the @@ -212,16 +182,8 @@ func (s *ServiceImpl) DeleteWorker(ctx context.Context, name string, pre store.D 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 { + if errs := validation.ValidateDrainWorkerRequest(ctx, req); len(errs) > 0 { return nil, toGRPCStatusError(errs) } name := req.GetWorker().GetName() @@ -251,12 +213,6 @@ func (s *RPCService) DrainWorker(ctx context.Context, req *ateapipb.DrainWorkerR }) } -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. @@ -294,44 +250,6 @@ type workerUnchanged struct { 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 -} diff --git a/cmd/ateapi/internal/controlapi/worker_test.go b/cmd/ateapi/internal/controlapi/worker_test.go index 30c202084..c04b17228 100644 --- a/cmd/ateapi/internal/controlapi/worker_test.go +++ b/cmd/ateapi/internal/controlapi/worker_test.go @@ -16,7 +16,6 @@ package controlapi import ( "context" - "fmt" "strings" "testing" @@ -28,7 +27,6 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" - "k8s.io/apimachinery/pkg/util/validation/field" ) // Worker names are pod UIDs, which are opaque to everything above the syncer. @@ -57,25 +55,6 @@ func validWorker(name string, mods ...func(*ateapipb.Worker)) *ateapipb.Worker { return w } -// withWorkerMetadata returns a modifier func (see validWorker) which sets -// the worker's resource metadata to a valid value. -func withWorkerMetadata(mutate func(*ateapipb.ResourceMetadata)) func(*ateapipb.Worker) { - return func(a *ateapipb.Worker) { mutate(a.Metadata) } -} - -// withWorkerStatus returns a modifier func (see validWorker) which sets the -// actor's status to a valid value. -func withWorkerStatus(mods ...func(*ateapipb.WorkerStatus)) func(*ateapipb.Worker) { - return func(a *ateapipb.Worker) { - a.Status = &ateapipb.WorkerStatus{ - State: ateapipb.WorkerState_WORKER_STATE_ACTIVE, - } - for _, m := range mods { - m(a.Status) - } - } -} - func newAPIAssignment(actorUID string) *ateapipb.ActorAssignment { return &ateapipb.ActorAssignment{ ActorTemplateRef: &ateapipb.ObjectRef{Atespace: "ate-system", Name: "tmpl"}, @@ -145,39 +124,6 @@ func updateFrom(observed *ateapipb.Worker, mutate func(*ateapipb.Worker)) *ateap return worker } -func TestValidateListWorkersRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.ListWorkersRequest - want field.ErrorList - }{{ - "valid, no page_size", - &ateapipb.ListWorkersRequest{}, - nil, - }, { - "valid, positive page_size", - &ateapipb.ListWorkersRequest{PageSize: 10}, - nil, - }, { - "negative page_size", - &ateapipb.ListWorkersRequest{PageSize: -1}, - field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")}, - }, { - "valid page_token", - &ateapipb.ListWorkersRequest{PageToken: strings.Repeat("x", 256)}, - nil, - }, { - "too-large page_token", - &ateapipb.ListWorkersRequest{PageToken: strings.Repeat("x", 257)}, - field.ErrorList{field.TooLongCharacters(field.NewPath("page_token"), "", 256).WithOrigin("maxLength")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateListWorkersRequest(context.Background(), tt.req), tt.want) - }) - } -} - func TestGetWorker(t *testing.T) { ctx := context.Background() svc, persistence := newWorkerAPIService(t) @@ -654,203 +600,6 @@ func TestDrainWorker_Errors(t *testing.T) { } } -// TestValidateWorker pins the field paths validateWorker reports. -// TestCreateWorker_InvalidArgument drives the same rules through the RPC, but -// only observes the status code. -func TestValidateCreateWorkerRequest(t *testing.T) { - // This test verifies validation of user input for creation. The RPC scrubs - // status before validating, so status is absent from the valid shape; when - // a request does carry one, it is validated like any other field. - validReq := func(actor *ateapipb.Worker, mods ...func(actor *ateapipb.CreateWorkerRequest)) *ateapipb.CreateWorkerRequest { - req := &ateapipb.CreateWorkerRequest{ - Worker: actor, - } - for _, m := range mods { - m(req) - } - return req - } - withStatus := withWorkerStatus - withMetadata := withWorkerMetadata - - tests := []struct { - name string - req *ateapipb.CreateWorkerRequest - want field.ErrorList - }{{ - name: "valid unassigned worker", - req: validReq(validWorker(apiWorkerName)), - }, { - name: "valid with status", - req: validReq(validWorker(apiWorkerName, withStatus())), - }, { - name: "missing worker", - req: &ateapipb.CreateWorkerRequest{Worker: nil}, - want: field.ErrorList{field.Required(field.NewPath("worker"), "")}, - }, { - name: "missing metadata", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Metadata = nil })), - want: field.ErrorList{field.Required(field.NewPath("worker", "metadata"), "")}, - }, { - name: "missing metadata.name", - req: validReq(validWorker(apiWorkerName, withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))), - want: field.ErrorList{field.Required(field.NewPath("worker", "metadata", "name"), "")}, - }, { - name: "invalid metadata.name", - req: validReq(validWorker(apiWorkerName, withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "not a name" }))), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "metadata.atespace set on a global-scoped Worker", - req: validReq(validWorker(apiWorkerName, withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "team-a" }))), - want: field.ErrorList{field.Forbidden(field.NewPath("worker", "metadata", "atespace"), "")}, - }, { - name: "missing worker_namespace", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerNamespace = "" })), - want: field.ErrorList{field.Required(field.NewPath("worker", "worker_namespace"), "")}, - }, { - name: "invalid worker_namespace", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerNamespace = "NS-1" })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_namespace"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "missing worker_pool", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPool = "" })), - want: field.ErrorList{field.Required(field.NewPath("worker", "worker_pool"), "")}, - }, { - name: "invalid worker_pool", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPool = "POOL_1" })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_pool"), nil, "").WithOrigin("format=k8s-long-name")}, - }, { - name: "missing worker_pod", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPod = "" })), - want: field.ErrorList{field.Required(field.NewPath("worker", "worker_pod"), "")}, - }, { - name: "invalid worker_pod", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPod = "POD_1" })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_pod"), nil, "").WithOrigin("format=k8s-long-name")}, - }, { - name: "missing worker_pod_uid", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPodUid = "" })), - want: field.ErrorList{field.Required(field.NewPath("worker", "worker_pod_uid"), "")}, - }, { - name: "invalid worker_pod_uid", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPodUid = "INVALID-UUID" })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_pod_uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }, { - name: "missing node_name", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.NodeName = "" })), - want: field.ErrorList{field.Required(field.NewPath("worker", "node_name"), "")}, - }, { - name: "invalid node_name", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.NodeName = "NODE_NAME" })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "node_name"), nil, "").WithOrigin("format=k8s-long-name")}, - }, { - name: "missing ip", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Ip = "" })), - want: field.ErrorList{field.Required(field.NewPath("worker", "ip"), "")}, - }, { - name: "invalid ip", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Ip = "not-an-ip" })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "ip"), nil, "").WithOrigin("format=ip-strict")}, - }, { - name: "sandbox_class too long", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.SandboxClass = strings.Repeat("x", 64) })), - want: field.ErrorList{field.TooLong(field.NewPath("worker", "sandbox_class"), nil, 63).WithOrigin("maxLength")}, - }, { - name: "valid labels", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { - w.Labels = map[string]string{"tier": "batch", "pool.ate.io/zone": "us-west1-c"} - })), - }, { - name: "too many labels", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { - labels := make(map[string]string, 65) - for i := 0; i < 65; i++ { - labels[fmt.Sprintf("key-%d", i)] = "v" - } - w.Labels = labels - })), - want: field.ErrorList{field.TooMany(field.NewPath("worker", "labels"), 65, 64).WithOrigin("maxProperties")}, - }, { - name: "invalid label key", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Labels = map[string]string{"bad key!": "batch"} })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "labels"), "bad key!", "").WithOrigin("format=k8s-label-key")}, - }, { - name: "invalid label value", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Labels = map[string]string{"tier": "not valid!"} })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "labels").Key("tier"), "not valid!", "").WithOrigin("format=k8s-label-value")}, - }, { - name: "absent capacity is allowed", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = nil })), - }, { - name: "valid capacity", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = &ateapipb.WorkerCapacity{CpuMilli: 2000, MemoryBytes: 4 << 30} })), - }, { - name: "negative capacity.cpu_milli", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = &ateapipb.WorkerCapacity{CpuMilli: -1, MemoryBytes: 4 << 30} })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "capacity", "cpu_milli"), nil, "").WithOrigin("minimum")}, - }, { - name: "negative capacity.memory_bytes", - req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = &ateapipb.WorkerCapacity{CpuMilli: 2000, MemoryBytes: -1} })), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "capacity", "memory_bytes"), nil, "").WithOrigin("minimum")}, - }, { - name: "status needs a state", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { s.State = 0 }))), - want: field.ErrorList{field.Required(field.NewPath("worker", "status", "state"), "")}, - }, { - name: "status invalid state (too small)", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { s.State = -1 }))), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "state"), nil, "").WithOrigin("minimum")}, - }, { - name: "status invalid state (too large)", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { s.State = 99 }))), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "state"), nil, "").WithOrigin("maximum")}, - }, { - name: "valid assignment, when carried, passes", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { - s.Assignment = newAPIAssignment(apiOtherWorkerName) - }))), - }, { - name: "assignment actor_uid must be a uuid", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { - s.Assignment = newAPIAssignment("not a uuid") - }))), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "assignment", "actor_uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }, { - name: "assignment actor ref needs an atespace", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { - s.Assignment = newAPIAssignment(apiOtherWorkerName) - s.Assignment.Actor.Atespace = "" - }))), - want: field.ErrorList{field.Required(field.NewPath("worker", "status", "assignment", "actor", "atespace"), "")}, - }, { - name: "assignment template ref needs an atespace", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { - s.Assignment = newAPIAssignment(apiOtherWorkerName) - s.Assignment.ActorTemplateRef = &ateapipb.ObjectRef{Name: "tmpl"} - }))), - want: field.ErrorList{field.Required(field.NewPath("worker", "status", "assignment", "actor_template_ref", "atespace"), "")}, - }, { - name: "assignment needs a template ref", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { - s.Assignment = newAPIAssignment(apiOtherWorkerName) - s.Assignment.ActorTemplateRef = nil - }))), - want: field.ErrorList{field.Required(field.NewPath("worker", "status", "assignment", "actor_template_ref"), "")}, - }, { - name: "assignment template name must be a short name", - req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { - s.Assignment = newAPIAssignment(apiOtherWorkerName) - s.Assignment.ActorTemplateRef.Name = "TMPL_1" - }))), - want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "assignment", "actor_template_ref", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assertValidateErr(t, validateCreateWorkerRequest(context.Background(), tc.req), 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 @@ -903,141 +652,6 @@ func TestServiceImplUpdateWorker_ImmutableFields(t *testing.T) { } } -func TestValidateDeleteWorkerRequest(t *testing.T) { - tests := []struct { - name string - req *ateapipb.DeleteWorkerRequest - want field.ErrorList - }{{ - "valid, no options", - &ateapipb.DeleteWorkerRequest{Worker: workerRef(apiWorkerName)}, - nil, - }, { - "valid, both guards", - &ateapipb.DeleteWorkerRequest{ - Worker: workerRef(apiWorkerName), - Options: &ateapipb.DeleteOptions{Uid: apiOtherWorkerName, Version: 3}, - }, - nil, - }, { - "missing worker", - &ateapipb.DeleteWorkerRequest{}, - field.ErrorList{field.Required(field.NewPath("worker"), "")}, - }, { - "missing worker.name", - &ateapipb.DeleteWorkerRequest{Worker: &ateapipb.ObjectRef{}}, - field.ErrorList{field.Required(field.NewPath("worker", "name"), "")}, - }, { - "worker.atespace must be empty", - &ateapipb.DeleteWorkerRequest{Worker: &ateapipb.ObjectRef{Atespace: "team-a", Name: apiWorkerName}}, - field.ErrorList{field.Forbidden(field.NewPath("worker", "atespace"), "")}, - }, { - "invalid options.uid", - &ateapipb.DeleteWorkerRequest{ - Worker: workerRef(apiWorkerName), - Options: &ateapipb.DeleteOptions{Uid: "not-a-uuid"}, - }, - field.ErrorList{field.Invalid(field.NewPath("options", "uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }, { - "negative options.version", - &ateapipb.DeleteWorkerRequest{ - Worker: workerRef(apiWorkerName), - Options: &ateapipb.DeleteOptions{Version: -1}, - }, - field.ErrorList{field.Invalid(field.NewPath("options", "version"), nil, "").WithOrigin("minimum")}, - }, { - // Zero values waive the guards, so they are never validated for shape. - "zero options are waived, not validated", - &ateapipb.DeleteWorkerRequest{ - Worker: workerRef(apiWorkerName), - Options: &ateapipb.DeleteOptions{}, - }, - nil, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateDeleteWorkerRequest(context.Background(), tt.req), tt.want) - }) - } -} - -func TestValidateUpdateWorkerRequest(t *testing.T) { - // This test verifies validation of user input for update. The worker body - // is deliberately not descended into here (updates are validated in two - // steps); only the metadata that addresses the resource is checked. - validReq := func(mods ...func(w *ateapipb.Worker)) *ateapipb.UpdateWorkerRequest { - worker := validWorker(apiWorkerName) - worker.Metadata.Uid = apiOtherWorkerName - worker.Metadata.Version = 3 - for _, m := range mods { - m(worker) - } - return &ateapipb.UpdateWorkerRequest{Worker: worker} - } - - tests := []struct { - name string - req *ateapipb.UpdateWorkerRequest - want field.ErrorList - }{{ - "valid", - validReq(), - nil, - }, { - // uid and version are preconditions the store requires; the request - // validation deliberately leaves their presence to the store. - "missing uid and version pass request validation", - validReq(func(w *ateapipb.Worker) { w.Metadata.Uid = ""; w.Metadata.Version = 0 }), - nil, - }, { - "missing worker", - &ateapipb.UpdateWorkerRequest{}, - field.ErrorList{field.Required(field.NewPath("worker"), "")}, - }, { - "missing metadata", - validReq(func(w *ateapipb.Worker) { w.Metadata = nil }), - field.ErrorList{field.Required(field.NewPath("worker", "metadata"), "")}, - }, { - "missing metadata.name", - validReq(func(w *ateapipb.Worker) { w.Metadata.Name = "" }), - field.ErrorList{field.Required(field.NewPath("worker", "metadata", "name"), "")}, - }, { - "invalid metadata.name", - validReq(func(w *ateapipb.Worker) { w.Metadata.Name = "Not A Name" }), - field.ErrorList{field.Invalid(field.NewPath("worker", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - "invalid metadata.uid", - validReq(func(w *ateapipb.Worker) { w.Metadata.Uid = "not-a-uuid" }), - field.ErrorList{field.Invalid(field.NewPath("worker", "metadata", "uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }, { - "metadata.atespace set on a global-scoped Worker", - validReq(func(w *ateapipb.Worker) { w.Metadata.Atespace = "team-a" }), - field.ErrorList{field.Forbidden(field.NewPath("worker", "metadata", "atespace"), "")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateUpdateWorkerRequest(context.Background(), tt.req), tt.want) - }) - } -} - -// TestValidateWorkerUpdate_RequireStatus pins the final-object check that the -// RPC path cannot reach: the server always sets status before storing, so only -// a direct call shows the guard catching a worker without one. -func TestValidateWorkerUpdate_RequireStatus(t *testing.T) { - oldVal := validWorker(apiWorkerName) - oldVal.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE} - newVal := proto.Clone(oldVal).(*ateapipb.Worker) - newVal.Status = nil - - want := field.ErrorList{field.Required(field.NewPath("worker", "status"), "")} - assertValidateErr(t, validateWorkerUpdate(context.Background(), field.NewPath("worker"), newVal, oldVal, true), want) - - // Without requireStatus the same worker passes: status is optional in the - // schema, and clearing it is not otherwise constrained. - assertValidateErr(t, validateWorkerUpdate(context.Background(), field.NewPath("worker"), newVal, oldVal, false), nil) -} - // 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. diff --git a/cmd/ateapi/internal/validation/actor.go b/cmd/ateapi/internal/validation/actor.go new file mode 100644 index 000000000..156644436 --- /dev/null +++ b/cmd/ateapi/internal/validation/actor.go @@ -0,0 +1,135 @@ +// 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 validation + +import ( + "context" + "strings" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "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" +) + +func ValidateCreateActorRequest(ctx context.Context, req *ateapipb.CreateActorRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_CreateActorRequest(ctx, op, nil, req, nil) +} + +func ValidateGetActorRequest(ctx context.Context, req *ateapipb.GetActorRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_GetActorRequest(ctx, op, nil, req, nil) +} + +func ValidateListActorsRequest(ctx context.Context, req *ateapipb.ListActorsRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_ListActorsRequest(ctx, op, nil, req, nil) +} + +func ValidateUpdateActorRequest(ctx context.Context, req *ateapipb.UpdateActorRequest) field.ErrorList { + // 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_UpdateActorRequest(ctx, op, nil, req, nil) +} + +func ValidateDeleteActorRequest(ctx context.Context, req *ateapipb.DeleteActorRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_DeleteActorRequest(ctx, op, nil, req, nil) +} + +func ValidatePauseActorRequest(ctx context.Context, req *ateapipb.PauseActorRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_PauseActorRequest(ctx, op, nil, req, nil) +} + +func ValidateResumeActorRequest(ctx context.Context, req *ateapipb.ResumeActorRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_ResumeActorRequest(ctx, op, nil, req, nil) +} + +func ValidateSuspendActorRequest(ctx context.Context, req *ateapipb.SuspendActorRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_SuspendActorRequest(ctx, op, nil, req, nil) +} + +func ValidateActorUpdate(ctx context.Context, fldPath *field.Path, newVal, oldVal *ateapipb.Actor, requireStatus bool) field.ErrorList { + op := operation.Operation{Type: operation.Update} + errs := Validate_Actor(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 +} + +// This exists only because nested subfield tags are not supported yet. +func ValidateCustom_UpdateActorRequest_Actor(ctx context.Context, op operation.Operation, fldPath *field.Path, actor, _ *ateapipb.Actor) field.ErrorList { + if actor == nil || actor.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:required + errs := Validate_ResourceMetadata(ctx, op, fldPath.Child("metadata"), actor.Metadata, nil) + errs = append(errs, validate.RequiredValue(ctx, op, fldPath.Child("metadata", "atespace"), &actor.Metadata.Atespace, nil)...) + return errs +} + +// This is needed because DV doesn't have a standard format for IP addresses yet. +func ValidateCustom_WorkerAssignment_WorkerPodIp(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + return validation.IsValidIP(fldPath, *value) +} + +// ValidateCustom_ExternalVolume_VolumeType checks that a volume type string is well-formed. +// It allows an optional "substrate.io/" prefix, followed by a valid DNS-1123 subdomain. +func ValidateCustom_ExternalVolume_VolumeType(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + if value == nil || *value == "" { + return nil + } + var errs field.ErrorList + valToValidate := strings.TrimPrefix(*value, "substrate.io/") + for _, msg := range validation.IsDNS1123Subdomain(valToValidate) { + errs = append(errs, field.Invalid(fldPath, *value, msg)) + } + return errs +} + +// ValidateCustom_ExternalVolume_StorageVolumeId checks that an external volume's storage ID does not +// contain control characters (U+0000-U+0008, U+000B, U+000C, U+000E-U+001F, U+007F-U+009F). +func ValidateCustom_ExternalVolume_StorageVolumeId(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + if value == nil || *value == "" { + return nil + } + for _, r := range *value { + if (r >= 0x0000 && r <= 0x0008) || + r == 0x000B || + r == 0x000C || + (r >= 0x000E && r <= 0x001F) || + (r >= 0x007F && r <= 0x009F) { + return field.ErrorList{field.Invalid(fldPath, *value, "must not contain control characters (U+0000-U+0008, U+000B, U+000C, U+000E-U+001F, U+007F-U+009F)")} + } + } + return nil +} diff --git a/cmd/ateapi/internal/validation/actor_snapshot.go b/cmd/ateapi/internal/validation/actor_snapshot.go new file mode 100644 index 000000000..defbc48b7 --- /dev/null +++ b/cmd/ateapi/internal/validation/actor_snapshot.go @@ -0,0 +1,148 @@ +// 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 validation + +import ( + "slices" + "strings" + + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// actorSnapshotTagScopes lists the scopes a client may set on an ActorSnapshotTag. +// ACTOR_SNAPSHOT_TAG_SCOPE_UNSPECIFIED is deliberately absent: scope is required +// on the wire, not defaulted. See validateActorSnapshotTagScope. +var actorSnapshotTagScopes = []ateapipb.ActorSnapshotTagScope{ + ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE, + ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, +} + +// actorSnapshotTagScopeNames names actorSnapshotTagScopes for error messages. +var actorSnapshotTagScopeNames = func() []string { + names := make([]string, len(actorSnapshotTagScopes)) + for i, scope := range actorSnapshotTagScopes { + names[i] = scope.String() + } + return names +}() + +func ValidateGetActorSnapshotRequest(req *ateapipb.GetActorSnapshotRequest) field.ErrorList { + var fldPath *field.Path + var errs field.ErrorList + + if val, fldPath := req.ActorSnapshot, fldPath.Child("actor_snapshot"); val == nil { + errs = append(errs, field.Required(fldPath, "")) + } else { + errs = append(errs, resources.ValidateObjectRef(val, fldPath)...) + } + + return errs +} + +func ValidateGetActorSnapshotTagRequest(req *ateapipb.GetActorSnapshotTagRequest) field.ErrorList { + var fldPath *field.Path + var errs field.ErrorList + + if val, fldPath := req.ActorSnapshotTag, fldPath.Child("actor_snapshot_tag"); val == nil { + errs = append(errs, field.Required(fldPath, "")) + } else { + errs = append(errs, resources.ValidateObjectRef(val, fldPath)...) + } + + return errs +} + +func ValidateListActorSnapshotsRequest(req *ateapipb.ListActorSnapshotsRequest) field.ErrorList { + var fldPath *field.Path + var errs field.ErrorList + + // An empty atespace is allowed here and means "all atespaces". + if val, fldPath := req.Atespace, fldPath.Child("atespace"); val != "" { + errs = append(errs, resources.ValidateResourceName(val, fldPath)...) + } + + 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 ValidateCreateActorSnapshotTagRequest(req *ateapipb.CreateActorSnapshotTagRequest) field.ErrorList { + var fldPath *field.Path + var errs field.ErrorList + + tag := req.ActorSnapshotTag + tagPath := fldPath.Child("actor_snapshot_tag") + if tag == nil { + errs = append(errs, field.Required(tagPath, "")) + return errs + } + + errs = append(errs, resources.ValidateObjectRef(&ateapipb.ObjectRef{Atespace: tag.GetMetadata().GetAtespace(), Name: tag.GetMetadata().GetName()}, tagPath.Child("metadata"))...) + + if val, p := tag.Snapshot, tagPath.Child("snapshot"); val == nil { + errs = append(errs, field.Required(p, "")) + } else { + errs = append(errs, resources.ValidateObjectRef(val, p)...) + } + + errs = append(errs, validateActorSnapshotTagScope(tag.GetScope(), tagPath.Child("scope"))...) + + return errs +} + +func ValidateUpdateActorSnapshotTagRequest(req *ateapipb.UpdateActorSnapshotTagRequest) field.ErrorList { + var fldPath *field.Path + var errs field.ErrorList + + tag := req.GetActorSnapshotTag() + tagPath := fldPath.Child("actor_snapshot_tag") + if tag == nil { + return field.ErrorList{field.Required(tagPath, "")} + } + + errs = append(errs, resources.ValidateUpdateMetadataRef(tag.GetMetadata(), tagPath.Child("metadata"))...) + + errs = append(errs, validateActorSnapshotTagScope(tag.GetScope(), tagPath.Child("scope"))...) + + return errs +} + +func ValidateDeleteActorSnapshotTagRequest(req *ateapipb.DeleteActorSnapshotTagRequest) field.ErrorList { + var fldPath *field.Path + var errs field.ErrorList + + if val, fldPath := req.ActorSnapshotTag, fldPath.Child("actor_snapshot_tag"); val == nil { + errs = append(errs, field.Required(fldPath, "")) + } else { + errs = append(errs, resources.ValidateObjectRef(val, fldPath)...) + } + + return errs +} + +// validateActorSnapshotTagScope checks that scope is one a client may set. +func validateActorSnapshotTagScope(scope ateapipb.ActorSnapshotTagScope, p *field.Path) field.ErrorList { + switch { + case scope == ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_UNSPECIFIED: + return field.ErrorList{field.Required(p, "must be one of: "+strings.Join(actorSnapshotTagScopeNames, ", "))} + case !slices.Contains(actorSnapshotTagScopes, scope): + return field.ErrorList{field.NotSupported(p, scope.String(), actorSnapshotTagScopeNames)} + } + return nil +} diff --git a/cmd/ateapi/internal/validation/actor_snapshot_test.go b/cmd/ateapi/internal/validation/actor_snapshot_test.go new file mode 100644 index 000000000..c0eb1bce8 --- /dev/null +++ b/cmd/ateapi/internal/validation/actor_snapshot_test.go @@ -0,0 +1,192 @@ +// 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 validation + +import ( + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestValidateUpdateActorSnapshotTagRequest(t *testing.T) { + // validUID is a well-formed uid to pass validation. + const validUID = "2a5f8c1e-9b3d-4f7a-8e6c-1d0b4a7f2e93" + scopes := []string{ + ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE.String(), + ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED.String(), + } + // Every case carries a uid and version guard, because an update that carries + // neither is rejected as a blind write before anything else is checked. + tests := []struct { + name string + req *ateapipb.UpdateActorSnapshotTagRequest + wantError field.ErrorList + }{ + { + name: "valid", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: nil, + }, + { + name: "missing tag", + req: &ateapipb.UpdateActorSnapshotTagRequest{}, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag"), "")}, + }, + { + name: "missing tag.metadata.atespace", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Name: "tag1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "atespace"), "")}, + }, + { + name: "invalid tag.metadata.atespace", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "NS1", Name: "tag1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "atespace"), "NS1", "")}, + }, + { + name: "missing tag.metadata.name", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "name"), "")}, + }, + { + name: "invalid tag.metadata.name", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "TAG1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "name"), "TAG1", "")}, + }, + { + name: "missing tag.metadata.uid precondition", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "uid"), "")}, + }, + { + name: "invalid tag.metadata.uid precondition", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: "not-a-uuid", Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "uid"), "not-a-uuid", "")}, + }, + { + name: "missing tag.metadata.version precondition", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "metadata", "version"), "")}, + }, + { + name: "negative tag.metadata.version precondition", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: -1}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{field.Invalid(field.NewPath("actor_snapshot_tag", "metadata", "version"), int64(-1), "")}, + }, + { + // A blind write: the caller never read the tag it is updating. + name: "guards on neither uid nor version", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED, + }, + }, + wantError: field.ErrorList{ + field.Required(field.NewPath("actor_snapshot_tag", "metadata", "uid"), ""), + field.Required(field.NewPath("actor_snapshot_tag", "metadata", "version"), ""), + }, + }, + { + name: "unset tag.scope", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, + }, + }, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "scope"), "")}, + }, + { + name: "explicit tag.scope UNSPECIFIED", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_UNSPECIFIED, + }, + }, + wantError: field.ErrorList{field.Required(field.NewPath("actor_snapshot_tag", "scope"), "")}, + }, + { + name: "tag.scope ATESPACE explicitly unpublishes", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE, + }, + }, + wantError: nil, + }, + { + name: "tag.scope outside the enum", + req: &ateapipb.UpdateActorSnapshotTagRequest{ + ActorSnapshotTag: &ateapipb.ActorSnapshotTag{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: validUID, Version: 7}, + Scope: ateapipb.ActorSnapshotTagScope(7), + }, + }, + wantError: field.ErrorList{field.NotSupported(field.NewPath("actor_snapshot_tag", "scope"), "7", scopes)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateUpdateActorSnapshotTagRequest(tt.req), tt.wantError) + }) + } +} diff --git a/cmd/ateapi/internal/validation/actor_template.go b/cmd/ateapi/internal/validation/actor_template.go new file mode 100644 index 000000000..203229e71 --- /dev/null +++ b/cmd/ateapi/internal/validation/actor_template.go @@ -0,0 +1,195 @@ +// 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 validation + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func ValidateCreateActorTemplateRequest(ctx context.Context, req *ateapipb.CreateActorTemplateRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_CreateActorTemplateRequest(ctx, op, nil, req, nil) +} + +func ValidateActorTemplateUpdate(ctx context.Context, fldPath *field.Path, newVal, oldVal *ateapipb.ActorTemplate) field.ErrorList { + op := operation.Operation{Type: operation.Update} + return Validate_ActorTemplate(ctx, op, fldPath, newVal, oldVal) +} + +func ValidateGetActorTemplateRequest(ctx context.Context, req *ateapipb.GetActorTemplateRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_GetActorTemplateRequest(ctx, op, nil, req, nil) +} + +func ValidateListActorTemplatesRequest(ctx context.Context, req *ateapipb.ListActorTemplatesRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_ListActorTemplatesRequest(ctx, op, nil, req, nil) +} + +func ValidateDeleteActorTemplateRequest(ctx context.Context, req *ateapipb.DeleteActorTemplateRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_DeleteActorTemplateRequest(ctx, op, nil, req, nil) +} + +// httpGetPathRE constrains readyz paths to RFC 3986 path-segment +// characters only, with well-formed percent-escapes, and no query string +// or fragment. +var httpGetPathRE = regexp.MustCompile(`^/([A-Za-z0-9\-._~!$&'()*+,;=:@/]|%[0-9A-Fa-f]{2})*$`) + +func ValidateCustom_HTTPGetAction_Path(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + if !httpGetPathRE.MatchString(*value) { + return field.ErrorList{field.Invalid(fldPath, *value, "must be a URL path starting with '/', using only RFC 3986 path-segment characters, without query or fragment")} + } + return nil +} + +// mountPathBadSegmentRE matches '.' or '..' path segments. +var mountPathBadSegmentRE = regexp.MustCompile(`(^|/)[.][.]?(/|$)`) + +// ValidateCustom_VolumeMount_MountPath requires a clean absolute Unix path +// that starts with '/', is not '/', and contains no ':', '.' or '..' +// segments, '//', trailing '/', or control characters. +func ValidateCustom_VolumeMount_MountPath(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + p := *value + bad := !strings.HasPrefix(p, "/") || len(p) == 1 || + strings.HasSuffix(p, "/") || strings.Contains(p, "//") || + strings.Contains(p, ":") || mountPathBadSegmentRE.MatchString(p) + if !bad { + for _, r := range p { + if r < 0x20 || r == 0x7f { + bad = true + break + } + } + } + if bad { + return field.ErrorList{field.Invalid(fldPath, p, "must be a clean absolute Unix path: must start with '/', not be '/', and contain no ':', '..', '.', '//', trailing '/', or control characters")} + } + return nil +} + +// ValidateCustom_ImageVolumeSource_Reference requires image references to +// be pinned by digest, because changing the image content under a fixed +// reference invalidates snapshots. +func ValidateCustom_ImageVolumeSource_Reference(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + if !strings.Contains(*value, "@") { + return field.ErrorList{field.Invalid(fldPath, *value, "must be pinned by digest (changing the image invalidates snapshots)")} + } + return nil +} + +func ValidateCustom_ExternalVolumeTemplate_Capacity(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + if _, err := resource.ParseQuantity(*value); err != nil { + return field.ErrorList{field.Invalid(fldPath, *value, fmt.Sprintf("must be a Kubernetes resource quantity: %v", err))} + } + return nil +} + +// cpuLimitMax bounds cpu limits: they must be less than 1000 cores. +var cpuLimitMax = resource.MustParse("1k") + +// ValidateCustom_Resources_Limits validates the resource limits: only cpu +// and memory limits are supported, each quantity must be greater than zero, +// and the cpu limit must be less than 1000 cores. Presence and uniqueness +// of names are enforced by tags. +func ValidateCustom_Resources_Limits(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []*ateapipb.Limits) field.ErrorList { + var errs field.ErrorList + for i, limit := range value { + if limit == nil { + continue + } + if limit.Name != "cpu" && limit.Name != "memory" { + errs = append(errs, field.NotSupported(fldPath.Index(i).Child("name"), limit.Name, []string{"cpu", "memory"})) + continue + } + if limit.Quantity == "" { + continue // required is enforced by tags + } + q, err := resource.ParseQuantity(limit.Quantity) + if err != nil { + errs = append(errs, field.Invalid(fldPath.Index(i).Child("quantity"), limit.Quantity, fmt.Sprintf("must be a Kubernetes resource quantity: %v", err))) + continue + } + if q.Sign() <= 0 { + errs = append(errs, field.Invalid(fldPath.Index(i).Child("quantity"), limit.Quantity, "must be greater than zero")) + } + if limit.Name == "cpu" && q.Cmp(cpuLimitMax) >= 0 { + errs = append(errs, field.Invalid(fldPath.Index(i).Child("quantity"), limit.Quantity, "cpu limit must be less than 1000 cores")) + } + } + return errs +} + +// ValidateCustom_ActorTemplate_SnapshotsConfig requires on_commit to be a +// subset of on_pause. UNSPECIFIED means FULL, so an unset on_commit over a +// DATA on_pause is rejected too. +func ValidateCustom_ActorTemplate_SnapshotsConfig(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *ateapipb.SnapshotsConfig) field.ErrorList { + if value.GetOnPause() == ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA && + value.GetOnCommit() != ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA { + return field.ErrorList{field.Invalid(fldPath.Child("on_commit"), value.GetOnCommit().String(), "must be a subset of on_pause")} + } + return nil +} + +// envVarNameRE constrains env var names to any printable ASCII character +// except '='. +var envVarNameRE = regexp.MustCompile(`^[ -<>-~]+$`) + +func ValidateCustom_EnvVar_Name(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList { + if !envVarNameRE.MatchString(*value) { + return field.ErrorList{field.Invalid(fldPath, *value, "may contain any printable ASCII character except '='")} + } + return nil +} + +// capabilityRE constrains Linux capability names: uppercase, without the +// "CAP_" prefix (which is added when the OCI spec is written; the prefixed +// spelling would silently grant nothing). +var capabilityRE = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +func validateCapabilities(fldPath *field.Path, caps []string, allowAll bool) field.ErrorList { + var errs field.ErrorList + for i, c := range caps { + p := fldPath.Index(i) + switch { + case c == "ALL" && !allowAll: + errs = append(errs, field.Invalid(p, c, "add does not accept 'ALL'; name the individual capabilities the container needs")) + case c == "ALL": + case len(c) > 63: + errs = append(errs, field.TooLong(p, nil, 63)) + case strings.HasPrefix(c, "CAP_"): + errs = append(errs, field.Invalid(p, c, "must be named without the 'CAP_' prefix (e.g. 'NET_BIND_SERVICE')")) + case !capabilityRE.MatchString(c): + errs = append(errs, field.Invalid(p, c, "must be an uppercase capability name like 'NET_BIND_SERVICE'")) + } + } + return errs +} + +func ValidateCustom_Capabilities_Add(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []string) field.ErrorList { + return validateCapabilities(fldPath, value, false) +} + +func ValidateCustom_Capabilities_Drop(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []string) field.ErrorList { + return validateCapabilities(fldPath, value, true) +} diff --git a/cmd/ateapi/internal/validation/actor_template_test.go b/cmd/ateapi/internal/validation/actor_template_test.go new file mode 100644 index 000000000..d61856401 --- /dev/null +++ b/cmd/ateapi/internal/validation/actor_template_test.go @@ -0,0 +1,981 @@ +// 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 validation + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// validActorTemplate returns the smallest template that passes create +// validation; mutations tweak it per test case. +func validActorTemplate(mutations ...func(*ateapipb.ActorTemplate)) *ateapipb.ActorTemplate { + template := &ateapipb.ActorTemplate{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tmpl-a"}, + 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, ConfigName: "gvisor-default"}, + } + for _, m := range mutations { + m(template) + } + return template +} + +func TestValidateCreateActorTemplateRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.CreateActorTemplateRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate()}, + nil, + }, { + "missing actor_template", + &ateapipb.CreateActorTemplateRequest{}, + field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, + }, { + "missing metadata.atespace", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Metadata.Atespace = "" + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "metadata", "atespace"), "")}, + }, { + "invalid metadata.atespace", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Metadata.Atespace = "NS_1" + })}, + field.ErrorList{field.Invalid(field.NewPath("actor_template", "metadata", "atespace"), "NS_1", "").WithOrigin("format=k8s-short-name")}, + }, { + "missing metadata.name", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Metadata.Name = "" + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "metadata", "name"), "")}, + }, { + "invalid metadata.name", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Metadata.Name = "Tmpl_A" + })}, + field.ErrorList{field.Invalid(field.NewPath("actor_template", "metadata", "name"), "Tmpl_A", "").WithOrigin("format=k8s-short-name")}, + }, { + "valid data-scoped snapshots", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA + tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA + })}, + nil, + }, { + "invalid worker_selector label key", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"bad key": "v"}} + })}, + field.ErrorList{field.Invalid(field.NewPath("actor_template", "worker_selector", "match_labels"), "bad key", "").WithOrigin("format=k8s-label-key")}, + }, { + "no containers", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers = nil + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "containers"), "")}, + }, { + "container missing name", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Name = "" + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "containers").Index(0).Child("name"), "")}, + }, { + "container invalid name", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Name = "Main_1" + })}, + field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("name"), "Main_1", "").WithOrigin("format=k8s-short-name")}, + }, { + "container missing image", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Image = "" + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "containers").Index(0).Child("image"), "")}, + }, { + "missing snapshots_config", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig = nil + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "snapshots_config"), "")}, + }, { + "missing snapshots_config.storage_location", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.StorageLocation = "" + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "snapshots_config", "storage_location"), "")}, + }, { + "on_commit broader than on_pause", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA + tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL + })}, + field.ErrorList{field.Invalid(field.NewPath("actor_template", "snapshots_config", "on_commit"), "SNAPSHOT_CONTENT_SCOPE_FULL", "")}, + }, { + // UNSPECIFIED defaults to FULL, so leaving on_commit unset over a DATA + // on_pause is also a subset violation. + "on_commit unset with data on_pause", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA + })}, + field.ErrorList{field.Invalid(field.NewPath("actor_template", "snapshots_config", "on_commit"), "SNAPSHOT_CONTENT_SCOPE_UNSPECIFIED", "")}, + }, { + "missing sandbox_config", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SandboxConfig = nil + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "sandbox_config"), "")}, + }, { + "unspecified sandbox_config.sandbox_class", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass_SANDBOX_CLASS_UNSPECIFIED + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "sandbox_config", "sandbox_class"), "")}, + }, { + "missing sandbox_config.config_name", + &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) { + tmpl.SandboxConfig.ConfigName = "" + })}, + field.ErrorList{field.Required(field.NewPath("actor_template", "sandbox_config", "config_name"), "")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateCreateActorTemplateRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateGetActorTemplateRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.GetActorTemplateRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.GetActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl-a"}}, + nil, + }, { + "missing actor_template", + &ateapipb.GetActorTemplateRequest{}, + field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, + }, { + "missing atespace", + &ateapipb.GetActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Name: "tmpl-a"}}, + field.ErrorList{field.Required(field.NewPath("actor_template", "atespace"), "")}, + }, { + "missing name", + &ateapipb.GetActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor_template", "name"), "")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateGetActorTemplateRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateListActorTemplatesRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.ListActorTemplatesRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.ListActorTemplatesRequest{PageSize: 10}, + nil, + }, { + "zero page size", + &ateapipb.ListActorTemplatesRequest{}, + nil, + }, { + "valid atespace filter", + &ateapipb.ListActorTemplatesRequest{Atespace: "ns1"}, + nil, + }, { + "invalid atespace filter", + &ateapipb.ListActorTemplatesRequest{Atespace: "NS_1"}, + field.ErrorList{field.Invalid(field.NewPath("atespace"), "NS_1", "").WithOrigin("format=k8s-short-name")}, + }, { + "negative page size", + &ateapipb.ListActorTemplatesRequest{PageSize: -1}, + field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateListActorTemplatesRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateDeleteActorTemplateRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.DeleteActorTemplateRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl-a"}}, + nil, + }, { + "missing actor_template", + &ateapipb.DeleteActorTemplateRequest{}, + field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, + }, { + "missing atespace", + &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Name: "tmpl-a"}}, + field.ErrorList{field.Required(field.NewPath("actor_template", "atespace"), "")}, + }, { + "missing name", + &ateapipb.DeleteActorTemplateRequest{ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor_template", "name"), "")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateDeleteActorTemplateRequest(context.Background(), tt.req), tt.want) + }) + } +} + +// TestValidateActorTemplate exercises the generated resource validation +// directly. The request handler still runs the hand-written validator; this +// pins each declarative rule as it is added, ahead of the conversion. +func TestValidateActorTemplate(t *testing.T) { + tests := []struct { + name string + mutate func(*ateapipb.ActorTemplate) // nil leaves the template valid + want field.ErrorList + }{{ + name: "valid", + }, { + name: "missing metadata", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata = nil }, + want: field.ErrorList{field.Required(field.NewPath("metadata"), "")}, + }, { + name: "missing metadata.atespace", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata.Atespace = "" }, + want: field.ErrorList{field.Required(field.NewPath("metadata", "atespace"), "")}, + }, { + name: "invalid metadata.atespace", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata.Atespace = "NS1" }, + want: field.ErrorList{field.Invalid(field.NewPath("metadata", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "invalid metadata.name", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Metadata.Name = "TMPL A" }, + want: field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "worker_selector with an invalid label value", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "Not Valid"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("worker_selector", "match_labels").Key("tier"), nil, "").WithOrigin("format=k8s-label-value")}, + }, { + name: "missing sandbox_config", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig = nil }, + want: field.ErrorList{field.Required(field.NewPath("sandbox_config"), "")}, + }, { + name: "unspecified sandbox_class", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass_SANDBOX_CLASS_UNSPECIFIED + }, + want: field.ErrorList{field.Required(field.NewPath("sandbox_config", "sandbox_class"), "")}, + }, { + name: "sandbox_class outside the enum", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass(99) }, + want: field.ErrorList{field.Invalid(field.NewPath("sandbox_config", "sandbox_class"), nil, "").WithOrigin("maximum")}, + }, { + name: "negative sandbox_class", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.SandboxClass = ateapipb.SandboxClass(-1) }, + want: field.ErrorList{field.Invalid(field.NewPath("sandbox_config", "sandbox_class"), nil, "").WithOrigin("minimum")}, + }, { + name: "missing config_name", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.ConfigName = "" }, + want: field.ErrorList{field.Required(field.NewPath("sandbox_config", "config_name"), "")}, + }, { + name: "invalid config_name", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SandboxConfig.ConfigName = "NOT_A_NAME" }, + want: field.ErrorList{field.Invalid(field.NewPath("sandbox_config", "config_name"), nil, "").WithOrigin("format=k8s-long-name")}, + }, { + name: "missing snapshots_config", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SnapshotsConfig = nil }, + want: field.ErrorList{field.Required(field.NewPath("snapshots_config"), "")}, + }, { + name: "storage_location too long", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.StorageLocation = "gs://" + strings.Repeat("x", 1020) + }, + want: field.ErrorList{field.TooLong(field.NewPath("snapshots_config", "storage_location"), nil, 1024).WithOrigin("maxLength")}, + }, { + name: "missing storage_location", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.SnapshotsConfig.StorageLocation = "" }, + want: field.ErrorList{field.Required(field.NewPath("snapshots_config", "storage_location"), "")}, + }, { + name: "unspecified snapshot scopes are allowed", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_UNSPECIFIED + tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_UNSPECIFIED + }, + }, { + name: "on_commit outside the enum", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnCommit = ateapipb.SnapshotContentScope(99) + }, + want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_commit"), nil, "").WithOrigin("maximum")}, + }, { + name: "negative on_pause", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnPause = ateapipb.SnapshotContentScope(-1) + }, + want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_pause"), nil, "").WithOrigin("minimum")}, + }, { + name: "valid on_resume", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnResume = &ateapipb.OnResumeConfig{FromData: ateapipb.ResumeSource_RESUME_SOURCE_COLD_BOOT} + }, + }, { + name: "negative on_resume from_data", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnResume = &ateapipb.OnResumeConfig{FromData: ateapipb.ResumeSource(-1)} + }, + want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_resume", "from_data"), nil, "").WithOrigin("minimum")}, + }, { + name: "on_resume from_data outside the enum", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.SnapshotsConfig.OnResume = &ateapipb.OnResumeConfig{FromData: ateapipb.ResumeSource(99)} + }, + want: field.ErrorList{field.Invalid(field.NewPath("snapshots_config", "on_resume", "from_data"), nil, "").WithOrigin("maximum")}, + }, { + name: "no containers", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers = nil }, + want: field.ErrorList{field.Required(field.NewPath("containers"), "")}, + }, { + name: "too many containers", + mutate: func(tmpl *ateapipb.ActorTemplate) { + for i := 0; i < 10; i++ { + tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: fmt.Sprintf("c-%d", i), Image: "example.com/app:v1"}) + } + }, + want: field.ErrorList{field.TooMany(field.NewPath("containers"), 11, 10).WithOrigin("maxItems")}, + }, { + name: "duplicate container name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: "main", Image: "example.com/other:v1"}) + }, + want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(1), nil)}, + }, { + name: "duplicate env name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "PORT", Value: "1"}, {Name: "PORT", Value: "2"}} + }, + want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("env").Index(1), nil)}, + }, { + // One mount per volume for now; see the TODO on volume_mounts. + name: "same volume mounted twice is rejected", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{ + {Name: "data", MountPath: "/var/data"}, + {Name: "data", MountPath: "/mnt/data"}, + } + }, + want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("volume_mounts").Index(1), nil)}, + }, { + name: "two volumes at distinct paths are allowed", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{ + {Name: "data", MountPath: "/var/data"}, + {Name: "other", MountPath: "/mnt/other"}, + } + }, + }, { + name: "duplicate volume name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{ + {Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}, + {Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}, + } + }, + want: field.ErrorList{field.Duplicate(field.NewPath("volumes").Index(1), nil)}, + }, { + name: "too many command entries", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Command = make([]string, 65) + }, + want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("command"), 65, 64).WithOrigin("maxItems")}, + }, { + name: "command entry too long", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Command = []string{strings.Repeat("x", 4097)} + }, + want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("command").Index(0), nil, 4096).WithOrigin("maxLength")}, + }, { + name: "valid command and args", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Command = []string{"/bin/app"} + // Repeated argv values are legitimate; these lists are atomic, + // not sets. + tmpl.Containers[0].Args = []string{"--serve", "-v", "-v"} + }, + }, { + name: "too many args entries", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Args = make([]string, 65) + }, + want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("args"), 65, 64).WithOrigin("maxItems")}, + }, { + name: "args entry too long", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Args = []string{strings.Repeat("x", 4097)} + }, + want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("args").Index(0), nil, 4096).WithOrigin("maxLength")}, + }, { + name: "too many volume_mounts", + mutate: func(tmpl *ateapipb.ActorTemplate) { + for i := 0; i < 33; i++ { + tmpl.Containers[0].VolumeMounts = append(tmpl.Containers[0].VolumeMounts, + &ateapipb.VolumeMount{Name: "data", MountPath: fmt.Sprintf("/mnt/p%d", i)}) + } + }, + want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("volume_mounts"), 33, 32).WithOrigin("maxItems")}, + }, { + name: "too many env entries", + mutate: func(tmpl *ateapipb.ActorTemplate) { + for i := 0; i < 33; i++ { + tmpl.Containers[0].Env = append(tmpl.Containers[0].Env, &ateapipb.EnvVar{Name: fmt.Sprintf("VAR_%d", i)}) + } + }, + want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("env"), 33, 32).WithOrigin("maxItems")}, + }, { + name: "image too long", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Image = strings.Repeat("x", 513) + }, + want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("image"), nil, 512).WithOrigin("maxLength")}, + }, { + name: "container missing name", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers[0].Name = "" }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("name"), "")}, + }, { + name: "container invalid name", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers[0].Name = "Main_1" }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "container missing image", + mutate: func(tmpl *ateapipb.ActorTemplate) { tmpl.Containers[0].Image = "" }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("image"), "")}, + }, { + name: "valid env", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "PORT", Value: "8080"}, {Name: "DEBUG"}} + }, + }, { + name: "env name with unusual printable characters is allowed", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "my.var-2 (test)!", Value: "v"}} + }, + }, { + name: "env name with an equals sign", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "FOO=BAR"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("env").Index(0).Child("name"), nil, "")}, + }, { + name: "env name with a control character", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Name: "FOO BAR"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("env").Index(0).Child("name"), nil, "")}, + }, { + name: "env missing name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Env = []*ateapipb.EnvVar{{Value: "8080"}} + }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("env").Index(0).Child("name"), "")}, + }, { + name: "valid security_context capabilities", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{ + Add: []string{"NET_BIND_SERVICE"}, + Drop: []string{"ALL"}, + }} + }, + }, { + name: "capabilities add rejects ALL", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: []string{"ALL"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add").Index(0), nil, "")}, + }, { + name: "capability with CAP_ prefix", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: []string{"CAP_NET_BIND_SERVICE"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add").Index(0), nil, "")}, + }, { + name: "lowercase capability", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Drop: []string{"net_raw"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "drop").Index(0), nil, "")}, + }, { + name: "duplicate capability", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: []string{"NET_BIND_SERVICE", "NET_BIND_SERVICE"}}} + }, + want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add").Index(1), nil)}, + }, { + name: "too many capabilities", + mutate: func(tmpl *ateapipb.ActorTemplate) { + caps := make([]string, 65) + for i := range caps { + caps[i] = fmt.Sprintf("CAP%d", i) + } + tmpl.Containers[0].SecurityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: caps}} + }, + want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("security_context", "capabilities", "add"), 65, 64).WithOrigin("maxItems")}, + }, { + name: "valid readyz", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{ + HttpGet: &ateapipb.HTTPGetAction{Path: "/healthz", Port: 8080}, + TimeoutSeconds: 60, + } + }, + }, { + name: "readyz missing http_get", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{TimeoutSeconds: 60} + }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("readyz", "http_get"), "")}, + }, { + name: "readyz timeout_seconds out of range", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{ + HttpGet: &ateapipb.HTTPGetAction{Port: 8080}, + TimeoutSeconds: 3601, + } + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "timeout_seconds"), nil, "").WithOrigin("maximum")}, + }, { + name: "negative readyz timeout_seconds", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{ + HttpGet: &ateapipb.HTTPGetAction{Port: 8080}, + TimeoutSeconds: -1, + } + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "timeout_seconds"), nil, "").WithOrigin("minimum")}, + }, { + name: "readyz missing port", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{}} + }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("readyz", "http_get", "port"), "")}, + }, { + name: "negative readyz port", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Port: -1}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "port"), nil, "").WithOrigin("minimum")}, + }, { + name: "readyz port out of range", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Port: 65536}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "port"), nil, "").WithOrigin("maximum")}, + }, { + name: "readyz path with query string", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Path: "/readyz?verbose=1", Port: 8080}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "path"), nil, "")}, + }, { + name: "readyz path not starting with slash", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Readyz = &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{Path: "readyz", Port: 8080}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("readyz", "http_get", "path"), nil, "")}, + }, { + name: "valid volume_mount", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data"}} + }, + }, { + name: "volume_mount missing name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{MountPath: "/var/data"}} + }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("name"), "")}, + }, { + name: "volume_mount invalid name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "Data_1", MountPath: "/var/data"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "volume_mount missing mount_path", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data"}} + }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), "")}, + }, { + name: "relative mount_path", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "var/data"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, + }, { + name: "root mount_path", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, + }, { + name: "mount_path with dot-dot segment", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/../etc"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, + }, { + name: "mount_path with trailing slash", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data/"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("volume_mounts").Index(0).Child("mount_path"), nil, "")}, + }, { + name: "valid durable_dir volume", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}} + }, + }, { + name: "too many volumes", + mutate: func(tmpl *ateapipb.ActorTemplate) { + for i := 0; i < 33; i++ { + tmpl.Volumes = append(tmpl.Volumes, &ateapipb.Volume{Name: fmt.Sprintf("vol-%d", i), DurableDir: &ateapipb.DurableDirVolumeSource{}}) + } + }, + want: field.ErrorList{field.TooMany(field.NewPath("volumes"), 33, 32).WithOrigin("maxItems")}, + }, { + name: "volume missing name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{DurableDir: &ateapipb.DurableDirVolumeSource{}}} + }, + want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("name"), "")}, + }, { + name: "volume invalid name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "Scratch_1", DurableDir: &ateapipb.DurableDirVolumeSource{}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "volume with no source", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "scratch"}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0), nil, "one of").WithOrigin("union")}, + }, { + name: "volume with two sources", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{ + Name: "scratch", + DurableDir: &ateapipb.DurableDirVolumeSource{}, + Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:abc"}, + }} + }, + want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0), nil, "one of").WithOrigin("union")}, + }, { + name: "valid image volume", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:abc"}}} + }, + }, { + name: "image volume missing reference", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{}}} + }, + want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("image", "reference"), "")}, + }, { + name: "image volume reference not pinned by digest", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app:v1"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("image", "reference"), nil, "")}, + }, { + name: "valid external volume template", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "fast-ssd"}}} + }, + }, { + name: "external volume template missing capacity", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{StorageClassName: "fast-ssd"}}} + }, + want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), "")}, + }, { + name: "external volume template malformed capacity", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "ten gigs", StorageClassName: "fast-ssd"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), nil, "")}, + }, { + name: "external volume template missing storage_class_name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi"}}} + }, + want: field.ErrorList{field.Required(field.NewPath("volumes").Index(0).Child("external_volume_template", "storage_class_name"), "")}, + }, { + name: "external volume template invalid storage_class_name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "Fast SSD"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("external_volume_template", "storage_class_name"), nil, "").WithOrigin("format=k8s-long-name")}, + }, { + name: "valid resources", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{ + {Name: "cpu", Quantity: "500m"}, {Name: "memory", Quantity: "2Gi"}, + }} + }, + }, { + name: "limit missing name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Quantity: "2Gi"}}} + }, + want: field.ErrorList{ + field.Required(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("name"), ""), + field.NotSupported[string](field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("name"), nil, nil), + }, + }, { + name: "limit missing quantity", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "cpu"}}} + }, + want: field.ErrorList{field.Required(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), "")}, + }, { + name: "unsupported limit name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "gpu", Quantity: "1"}}} + }, + want: field.ErrorList{field.NotSupported[string](field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("name"), nil, nil)}, + }, { + name: "duplicate limit name", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{ + {Name: "cpu", Quantity: "1"}, {Name: "cpu", Quantity: "2"}, + }} + }, + want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(0).Child("resources", "limits").Index(1), nil)}, + }, { + name: "malformed limit quantity", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "memory", Quantity: "two gigs"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), nil, "")}, + }, { + name: "zero limit quantity", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "memory", Quantity: "0"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), nil, "")}, + }, { + name: "cpu limit of 1000 cores", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "cpu", Quantity: "1k"}}} + }, + want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("resources", "limits").Index(0).Child("quantity"), nil, "")}, + }, { + name: "too many limits", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Containers[0].Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{ + {Name: "cpu", Quantity: "1"}, {Name: "memory", Quantity: "1Gi"}, {Name: "cpu", Quantity: "2"}, + }} + }, + // maxItems short-circuits the per-item and uniqueness checks. + want: field.ErrorList{field.TooMany(field.NewPath("containers").Index(0).Child("resources", "limits"), 3, 2).WithOrigin("maxItems")}, + }, { + name: "template-level resources validated too", + mutate: func(tmpl *ateapipb.ActorTemplate) { + tmpl.Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "gpu", Quantity: "1"}}} + }, + want: field.ErrorList{field.NotSupported[string](field.NewPath("resources", "limits").Index(0).Child("name"), nil, nil)}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := validActorTemplate() + if tt.mutate != nil { + tt.mutate(tmpl) + } + op := operation.Operation{Type: operation.Create} + assertValidateErr(t, Validate_ActorTemplate(context.Background(), op, nil, tmpl, nil), tt.want) + }) + } +} + +func validSystemInfoVolumeSource(mutate ...func(*ateapipb.SystemInfoVolumeSource)) *ateapipb.SystemInfoVolumeSource { + // This is valid with as many fields populated as possible. + s := &ateapipb.SystemInfoVolumeSource{ + DataSources: []*ateapipb.SystemInfoDataSource{{ + ActorMetadata: &ateapipb.ActorMetadataDataSource{ + Items: []*ateapipb.ActorMetadataItem{{ + Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, + Path: "actor-name", + }, { + Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, + Path: "actor-uid", + }}, + }, + }, { + TrustBundle: &ateapipb.TrustBundleDataSource{ + Name: "egress-mitm.ate.dev", + Path: "trust-bundle.pem", + }, + }}, + } + for _, m := range mutate { + m(s) + } + return s +} + +func TestValidateSystemInfoVolumeSource(t *testing.T) { + valid := validSystemInfoVolumeSource + dsPath := field.NewPath("data_sources") + itemsPath := dsPath.Index(0).Child("actor_metadata", "items") + + tests := []struct { + name string + obj *ateapipb.SystemInfoVolumeSource + want field.ErrorList + }{{ + name: "valid", + obj: valid(), + }, { + name: "valid: no data sources", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { s.DataSources = nil }), + }, { + name: "too many data sources", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + for len(s.DataSources) <= 8 { + s.DataSources = append(s.DataSources, &ateapipb.SystemInfoDataSource{ + TrustBundle: &ateapipb.TrustBundleDataSource{Name: "egress-mitm.ate.dev", Path: "tb.pem"}, + }) + } + }), + want: field.ErrorList{field.TooMany(dsPath, 9, 8).WithOrigin("maxItems")}, + }, { + name: "no union member set", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].ActorMetadata = nil + }), + want: field.ErrorList{field.Invalid(dsPath.Index(0), nil, "").WithOrigin("union")}, + }, { + name: "both union members set", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].TrustBundle = &ateapipb.TrustBundleDataSource{ + Name: "egress-mitm.ate.dev", + Path: "tb2.pem", + } + }), + want: field.ErrorList{field.Invalid(dsPath.Index(0), nil, "").WithOrigin("union")}, + }, { + name: "empty items", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].ActorMetadata.Items = nil + }), + want: field.ErrorList{field.Required(itemsPath, "")}, + }, { + name: "duplicate projected field", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].ActorMetadata.Items[1].Field = ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME + }), + want: field.ErrorList{field.Duplicate(itemsPath.Index(1), nil)}, + }, { + name: "unspecified item field", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].ActorMetadata.Items[0].Field = ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_UNSPECIFIED + }), + want: field.ErrorList{field.Required(itemsPath.Index(0).Child("field"), "")}, + }, { + name: "empty item path", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].ActorMetadata.Items[0].Path = "" + }), + want: field.ErrorList{field.Required(itemsPath.Index(0).Child("path"), "")}, + }, { + name: "item path too long", + obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { + s.DataSources[0].ActorMetadata.Items[0].Path = strings.Repeat("p", 256) + }), + want: field.ErrorList{field.TooLong(itemsPath.Index(0).Child("path"), nil, 255).WithOrigin("maxLength")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: operation.Create} + assertValidateErr(t, Validate_SystemInfoVolumeSource(context.Background(), op, nil, tt.obj, nil), tt.want) + }) + } +} + +func TestValidateTrustBundleDataSource(t *testing.T) { + valid := func(mutate ...func(*ateapipb.TrustBundleDataSource)) *ateapipb.TrustBundleDataSource { + tb := &ateapipb.TrustBundleDataSource{ + Name: "egress-mitm.ate.dev", + Path: "trust-bundle.pem", + } + for _, m := range mutate { + m(tb) + } + return tb + } + + tests := []struct { + name string + obj *ateapipb.TrustBundleDataSource + want field.ErrorList + }{{ + name: "valid", + obj: valid(), + }, { + name: "empty name", + obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Name = "" }), + want: field.ErrorList{field.Required(field.NewPath("name"), "")}, + }, { + name: "name too long", + obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Name = strings.Repeat("n", 254) }), + want: field.ErrorList{field.TooLong(field.NewPath("name"), nil, 253).WithOrigin("maxLength")}, + }, { + name: "empty path", + obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Path = "" }), + want: field.ErrorList{field.Required(field.NewPath("path"), "")}, + }, { + name: "path too long", + obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Path = strings.Repeat("p", 256) }), + want: field.ErrorList{field.TooLong(field.NewPath("path"), nil, 255).WithOrigin("maxLength")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: operation.Create} + assertValidateErr(t, Validate_TrustBundleDataSource(context.Background(), op, nil, tt.obj, nil), tt.want) + }) + } +} diff --git a/cmd/ateapi/internal/validation/actor_test.go b/cmd/ateapi/internal/validation/actor_test.go new file mode 100644 index 000000000..cae2872d3 --- /dev/null +++ b/cmd/ateapi/internal/validation/actor_test.go @@ -0,0 +1,1166 @@ +// 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 validation + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// validActor returns a minimal Actor which should pass input validation. +func validActor(mods ...func(*ateapipb.Actor)) *ateapipb.Actor { + a := &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "id1"}, + ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl1"}, + } + for _, m := range mods { + m(a) + } + return a +} + +// withActorMetadata returns a modifier func (see validActor) which sets +// the actor's resource metadata to a valid value. +func withActorMetadata(mutate func(*ateapipb.ResourceMetadata)) func(*ateapipb.Actor) { + return func(a *ateapipb.Actor) { mutate(a.Metadata) } +} + +// withActorStatus returns a modifier func (see validActor) which sets the +// actor's status to a valid value. +func withActorStatus(mods ...func(*ateapipb.ActorStatus)) func(*ateapipb.Actor) { + return func(a *ateapipb.Actor) { + a.Status = &ateapipb.ActorStatus{ + State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, + } + for _, m := range mods { + m(a.Status) + } + } +} + +// withActorWorkerSelector returns a modifier func (see validActor) which sets +// the actor's worker_selector to a valid value. +func withActorWorkerSelector(labels map[string]string) func(*ateapipb.Actor) { + return func(a *ateapipb.Actor) { + a.WorkerSelector = &ateapipb.Selector{ + MatchLabels: labels, + } + } +} + +// withActorActorTemplate returns a modifier func (see validActor) which sets +// the actor's actor_template to a valid value. +func withActorActorTemplate(atespace, name string) func(*ateapipb.Actor) { + return func(a *ateapipb.Actor) { a.ActorTemplate = &ateapipb.ObjectRef{Atespace: atespace, Name: name} } +} + +// withActorSourceSnapshotTag returns a modifier func (see validActor) which sets +// the actor's source_snapshot_tag to a valid value. +func withActorSourceSnapshotTag(atespace, name string) func(*ateapipb.Actor) { + return func(a *ateapipb.Actor) { a.SourceSnapshotTag = &ateapipb.ObjectRef{Atespace: atespace, Name: name} } +} + +// withActorWorkerAssignment returns a modifier func (see validActor) which sets +// the actor's worker_assignment to a valid value. +func withActorWorkerAssignment(mods ...func(*ateapipb.WorkerAssignment)) func(*ateapipb.ActorStatus) { + return func(s *ateapipb.ActorStatus) { + s.WorkerAssignment = &ateapipb.WorkerAssignment{ + Worker: &ateapipb.ObjectRef{Name: "worker"}, + WorkerNamespace: "ns", + WorkerPool: "pool", + WorkerPod: "pod", + WorkerPodUid: "12345678-1234-1234-1234-123456789abc", + WorkerPodIp: "1.2.3.4", + } + for _, m := range mods { + m(s.WorkerAssignment) + } + } +} + +func selectorLabelsOfSize(n int) map[string]string { + labels := make(map[string]string, n) + for i := 0; i < n; i++ { + labels[fmt.Sprintf("k%d", i)] = "v" + } + return labels +} + +func TestValidateCreateActorRequest(t *testing.T) { + // This test verifies validation of user input for creation. Since status + // is scrubbed on input, we don't need to test the status field here, other + // than that it is optional. TestValidateActorUpdate covers status + // validation and updates. + validReq := func(actor *ateapipb.Actor, mods ...func(actor *ateapipb.CreateActorRequest)) *ateapipb.CreateActorRequest { + req := &ateapipb.CreateActorRequest{ + Actor: actor, + } + for _, m := range mods { + m(req) + } + return req + } + withStatus := withActorStatus + withMetadata := withActorMetadata + withActorTemplate := withActorActorTemplate + withSourceSnapshotTag := withActorSourceSnapshotTag + withWorkerSelector := withActorWorkerSelector + + tests := []struct { + name string + req *ateapipb.CreateActorRequest + want field.ErrorList + }{{ + "valid", + validReq(validActor()), + nil, + }, { + "valid with status", + validReq(validActor(withStatus())), + nil, // ignored on input + }, { + "missing actor", + &ateapipb.CreateActorRequest{Actor: nil}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.metadata", + validReq(validActor(func(a *ateapipb.Actor) { a.Metadata = nil })), + field.ErrorList{field.Required(field.NewPath("actor", "metadata"), "")}, + }, { + "missing actor.metadata.atespace", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "" }))), + field.ErrorList{field.Required(field.NewPath("actor", "metadata", "atespace"), "")}, + }, { + "invalid actor.metadata.atespace", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "NS1" }))), + field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.metadata.name", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))), + field.ErrorList{field.Required(field.NewPath("actor", "metadata", "name"), "")}, + }, { + "invalid actor.metadata.name", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "ID1" }))), + field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "valid actor.actor_template", + validReq(validActor(withActorTemplate("as", "tmpl"))), + nil, + }, { + "missing actor.actor_template", + validReq(validActor(func(a *ateapipb.Actor) { a.ActorTemplate = nil })), + field.ErrorList{field.Required(field.NewPath("actor", "actor_template"), "")}, + }, { + "missing actor.actor_template.atespace", + validReq(validActor(withActorTemplate("", "tmpl"))), + field.ErrorList{field.Required(field.NewPath("actor", "actor_template", "atespace"), "")}, + }, { + "invalid actor.actor_template.atespace", + validReq(validActor(withActorTemplate("invalid value", "tmpl"))), + field.ErrorList{field.Invalid(field.NewPath("actor", "actor_template", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.actor_template.name", + validReq(validActor(withActorTemplate("as", ""))), + field.ErrorList{field.Required(field.NewPath("actor", "actor_template", "name"), "")}, + }, { + "invalid actor.actor_template.name", + validReq(validActor(withActorTemplate("as", "invalid value"))), + field.ErrorList{field.Invalid(field.NewPath("actor", "actor_template", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "valid worker_selector", + validReq(validActor(withWorkerSelector(map[string]string{"tier": "1"}))), + nil, + }, { + "worker_selector with nil match_labels", + validReq(validActor(func(a *ateapipb.Actor) { a.WorkerSelector = &ateapipb.Selector{} })), + field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector"), nil, "one of").WithOrigin("union")}, + }, { + "worker_selector with empty match_labels", + validReq(validActor(withWorkerSelector(map[string]string{}))), + field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector"), nil, "one of").WithOrigin("union")}, + }, { + "worker_selector with exactly max match_labels", + validReq(validActor(withWorkerSelector(selectorLabelsOfSize(10)))), + nil, + }, { + "too many worker_selector.match_labels", + validReq(validActor(withWorkerSelector(selectorLabelsOfSize(11)))), + field.ErrorList{field.TooMany(field.NewPath("actor", "worker_selector", "match_labels"), 11, 10).WithOrigin("maxProperties")}, + }, { + "invalid worker_selector label key", + validReq(validActor(withWorkerSelector(map[string]string{"bad key!": "1"}))), + field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector", "match_labels"), "bad key!", "").WithOrigin("format=k8s-label-key")}, + }, { + "invalid worker_selector label value", + validReq(validActor(withWorkerSelector(map[string]string{"tier": "not valid!"}))), + field.ErrorList{field.Invalid(field.NewPath("actor", "worker_selector", "match_labels").Key("tier"), "not valid!", "").WithOrigin("format=k8s-label-value")}, + }, { + "valid actor.source_snapshot_tag", + validReq(validActor(withSourceSnapshotTag("as", "tag"))), + nil, + }, { + "missing actor.source_snapshot_tag.atespace", + validReq(validActor(withSourceSnapshotTag("", "tag"))), + field.ErrorList{field.Required(field.NewPath("actor", "source_snapshot_tag", "atespace"), "")}, + }, { + "invalid actor.source_snapshot_tag.atespace", + validReq(validActor(withSourceSnapshotTag("invalid value", "tag"))), + field.ErrorList{field.Invalid(field.NewPath("actor", "source_snapshot_tag", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.source_snapshot_tag.name", + validReq(validActor(withSourceSnapshotTag("as", ""))), + field.ErrorList{field.Required(field.NewPath("actor", "source_snapshot_tag", "name"), "")}, + }, { + "invalid actor.source_snapshot_tag.name", + validReq(validActor(withSourceSnapshotTag("as", "invalid value"))), + field.ErrorList{field.Invalid(field.NewPath("actor", "source_snapshot_tag", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateCreateActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateActorUpdate(t *testing.T) { + // This test validates input and output fields, including status. It also + // tests updates to all fields. This is where the majority of validation + // test cases should live. + validInput := validActor + withStatus := withActorStatus + validOutput := func(mods ...func(*ateapipb.Actor)) *ateapipb.Actor { + allMods := []func(*ateapipb.Actor){withStatus()} // this needs to go first + allMods = append(allMods, mods...) + a := validActor(allMods...) + return a + } + withMetadata := withActorMetadata + withWorkerSelector := withActorWorkerSelector + withActorTemplate := withActorActorTemplate + withSourceSnapshotTag := withActorSourceSnapshotTag + withWorkerAssignment := withActorWorkerAssignment + + tests := []struct { + name string + oldVal *ateapipb.Actor + newVal *ateapipb.Actor + want field.ErrorList + }{{ + "valid", + validInput(), + validOutput(), + nil, + }, { + "missing actor.metadata", + validInput(), + validOutput(func(a *ateapipb.Actor) { a.Metadata = nil }), + field.ErrorList{field.Required(field.NewPath("metadata"), "")}, + }, { + "missing actor.metadata.atespace", + validInput(), + validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "" })), + field.ErrorList{ + field.Required(field.NewPath("metadata", "atespace"), ""), + field.Invalid(field.NewPath("metadata", "atespace"), nil, "").WithOrigin("immutable"), + }, + }, { + "invalid actor.metadata.atespace", + validInput(), + validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "invalid value" })), + field.ErrorList{field.Invalid(field.NewPath("metadata", "atespace"), nil, "").WithOrigin("immutable")}, + }, { + "missing actor.metadata.name", + validInput(), + validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" })), + field.ErrorList{ + field.Required(field.NewPath("metadata", "name"), ""), + field.Invalid(field.NewPath("metadata", "name"), nil, "").WithOrigin("immutable"), + }, + }, { + "invalid actor.metadata.name", + validInput(), + validOutput(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "invalid value" })), + field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), nil, "").WithOrigin("immutable")}, + }, { + "change actor.actor_template is allowed", + validInput(withActorTemplate("as1", "nm1")), + validOutput(withActorTemplate("as2", "nm2")), + nil, + }, { + "clear actor.actor_template", + validInput(withActorTemplate("as", "nm")), + validOutput(func(a *ateapipb.Actor) { a.ActorTemplate = nil }), + field.ErrorList{field.Required(field.NewPath("actor_template"), "")}, + }, { + "add actor.source_snapshot_tag", + validInput(), + validOutput(withSourceSnapshotTag("as", "nm")), + field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, + }, { + "clear actor.source_snapshot_tag", + validInput(withSourceSnapshotTag("as", "nm")), + validOutput(func(a *ateapipb.Actor) { a.SourceSnapshotTag = nil }), + field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, + }, { + "change actor.source_snapshot_tag", + validInput(withSourceSnapshotTag("as1", "nm1")), + validOutput(withSourceSnapshotTag("as2", "nm2")), + field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, + }, { + "set valid worker_selector", + validInput(), + validOutput(withWorkerSelector(map[string]string{"tier": "1"})), + nil, + }, { + "clear worker_selector", + validInput(withWorkerSelector(map[string]string{"tier": "1"})), + validOutput(), + nil, + }, { + "modify worker_selector", + validInput(withWorkerSelector(map[string]string{"tier": "1"})), + validOutput(withWorkerSelector(map[string]string{"tier": "2"})), + nil, + }, { + "invalid worker_selector with nil match_labels", + validInput(), + validOutput(func(a *ateapipb.Actor) { a.WorkerSelector = &ateapipb.Selector{} }), + field.ErrorList{field.Invalid(field.NewPath("worker_selector"), nil, "one of").WithOrigin("union")}, + }, { + "invalid worker_selector label key", + validInput(), + validOutput(withWorkerSelector(map[string]string{"bad key": "2"})), + field.ErrorList{field.Invalid(field.NewPath("worker_selector", "match_labels"), nil, "").WithOrigin("format=k8s-label-key")}, + }, { + "invalid worker_selector label value", + validInput(), + validOutput(withWorkerSelector(map[string]string{"tier": "bad value"})), + field.ErrorList{field.Invalid(field.NewPath("worker_selector", "match_labels").Key("tier"), nil, "").WithOrigin("format=k8s-label-value")}, + }, { + "too many worker_selector.match_labels", + validInput(), + validOutput(withWorkerSelector(selectorLabelsOfSize(11))), + field.ErrorList{field.TooMany(field.NewPath("worker_selector", "match_labels"), 11, 10).WithOrigin("maxProperties")}, + }, { + "add actor.source_snapshot_tag", + validInput(), + validOutput(withSourceSnapshotTag("as", "nm")), + field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, + }, { + "clear actor.source_snapshot_tag", + validInput(withSourceSnapshotTag("as", "nm")), + validOutput(func(a *ateapipb.Actor) { a.SourceSnapshotTag = nil }), + field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, + }, { + "change actor.source_snapshot_tag", + validInput(withSourceSnapshotTag("as1", "nm1")), + validOutput(withSourceSnapshotTag("as2", "nm2")), + field.ErrorList{field.Invalid(field.NewPath("source_snapshot_tag"), nil, "").WithOrigin("immutable")}, + }, { + "unspecified actor.status", + validInput(withStatus()), + validOutput(func(a *ateapipb.Actor) { a.Status = nil }), + field.ErrorList{field.Required(field.NewPath("status"), "")}, + }, { + "unspecified actor.status.state", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = 0 })), + field.ErrorList{field.Required(field.NewPath("status", "state"), "")}, + }, { + "change actor.status.state", + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = ateapipb.ActorState_ACTOR_STATE_PAUSED })), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = ateapipb.ActorState_ACTOR_STATE_CRASHED })), + nil, + }, { + "negative actor.status.state", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = -1 })), + field.ErrorList{field.Invalid(field.NewPath("status", "state"), nil, "").WithOrigin("minimum")}, + }, { + "just out of bounds actor.status.state", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = 9 })), + field.ErrorList{field.Invalid(field.NewPath("status", "state"), nil, "").WithOrigin("maximum")}, + }, { + "invalid actor.status.state", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.State = 1234567890 })), + field.ErrorList{field.Invalid(field.NewPath("status", "state"), nil, "").WithOrigin("maximum")}, + }, { + "set valid actor.status.worker_assignment, IPv4", + validInput(withStatus()), + validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "1.2.3.4" }))), + nil, + }, { + "set valid actor.status.worker_assignment, IPv6", + validInput(withStatus()), + validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "1234::5678" }))), + nil, + }, { + "clear actor.status.worker_assignment", + validInput(withStatus(withWorkerAssignment())), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.WorkerAssignment = nil })), + nil, + }, { + "modify actor.status.worker_assignment", + validInput(withStatus(withWorkerAssignment())), + validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPod = "pod2" }))), + field.ErrorList{field.Invalid(field.NewPath("status", "worker_assignment"), nil, "").WithOrigin("update")}, + }, { + "empty actor.status.worker_assignment", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.WorkerAssignment = &ateapipb.WorkerAssignment{} })), + field.ErrorList{ + field.Required(field.NewPath("status", "worker_assignment", "worker"), ""), + field.Required(field.NewPath("status", "worker_assignment", "worker_namespace"), ""), + field.Required(field.NewPath("status", "worker_assignment", "worker_pool"), ""), + field.Required(field.NewPath("status", "worker_assignment", "worker_pod"), ""), + field.Required(field.NewPath("status", "worker_assignment", "worker_pod_uid"), ""), + field.Required(field.NewPath("status", "worker_assignment", "worker_pod_ip"), ""), + }, + }, { + "invalid actor.status.worker_assignment", + validInput(), + validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { + wa.Worker = &ateapipb.ObjectRef{Atespace: "not-allowed", Name: "bad value"} + wa.WorkerNamespace = "invalid namespace" + wa.WorkerPool = "invalid pool" + wa.WorkerPod = "invalid pod" + wa.WorkerPodUid = "invalid UUID" + wa.WorkerPodIp = "invalid IP" + }))), + field.ErrorList{ + field.Forbidden(field.NewPath("status", "worker_assignment", "worker", "atespace"), ""), + field.Invalid(field.NewPath("status", "worker_assignment", "worker", "name"), nil, "").WithOrigin("format=k8s-short-name"), + field.Invalid(field.NewPath("status", "worker_assignment", "worker_namespace"), nil, "").WithOrigin("format=k8s-short-name"), + field.Invalid(field.NewPath("status", "worker_assignment", "worker_pool"), nil, "").WithOrigin("format=k8s-long-name"), + field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod"), nil, "").WithOrigin("format=k8s-long-name"), + field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_uid"), nil, "").WithOrigin("format=k8s-uuid"), + field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_ip"), nil, "").WithOrigin("format=ip-strict"), + }, + }, { + // because we have manual IP format validation, let's be sure + "invalid actor.status.worker_assignment_worker_pod_ip: leading 0s", + validInput(), + validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "001.002.003.004" }))), + field.ErrorList{ + field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_ip"), nil, "").WithOrigin("format=ip-strict"), + }, + }, { + // because we have manual IP format validation, let's be sure + "invalid actor.status.worker_assignment_worker_pod_ip: non-canonical", + validInput(), + validOutput(withStatus(withWorkerAssignment(func(wa *ateapipb.WorkerAssignment) { wa.WorkerPodIp = "0012::0034" }))), + field.ErrorList{ + field.Invalid(field.NewPath("status", "worker_assignment", "worker_pod_ip"), nil, "").WithOrigin("format=ip-strict"), + }, + }, { + "valid actor.status.in_progress_snapshot_name", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressSnapshotName = "snap-1" })), + nil, + }, { + "invalid actor.status.in_progress_snapshot_name", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressSnapshotName = "SNAP 1" })), + field.ErrorList{field.Invalid(field.NewPath("status", "in_progress_snapshot_name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "valid actor.status.latest_snapshot", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LatestSnapshot = &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"} + })), + nil, + }, { + "missing actor.status.latest_snapshot.atespace", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LatestSnapshot = &ateapipb.ObjectRef{Name: "snap-1"} + })), + field.ErrorList{field.Required(field.NewPath("status", "latest_snapshot", "atespace"), "")}, + }, { + "valid actor.status.local_snapshot_info.snapshot_name", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{SnapshotName: "snap-1"} + })), + nil, + }, { + "invalid actor.status.local_snapshot_info.snapshot_name", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{SnapshotName: "SNAP 1"} + })), + field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "snapshot_name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "invalid actor.status.local_snapshot_info.node_vms entry", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{NodeVmsWithLocalSnapshots: []string{"node-1", "NOT A NODE"}} + })), + field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "node_vms_with_local_snapshots").Index(1), nil, "").WithOrigin("format=k8s-long-name")}, + }, { + "too many actor.status.local_snapshot_info.node_vms entries", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + nodes := make([]string, 257) + for i := range nodes { + nodes[i] = fmt.Sprintf("node-%d", i) + } + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{NodeVmsWithLocalSnapshots: nodes} + })), + field.ErrorList{field.TooMany(field.NewPath("status", "local_snapshot_info", "node_vms_with_local_snapshots"), 257, 256).WithOrigin("maxItems")}, + }, { + "duplicate actor.status.local_snapshot_info.node_vms entry", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{NodeVmsWithLocalSnapshots: []string{"node-1", "node-1"}} + })), + field.ErrorList{field.Duplicate(field.NewPath("status", "local_snapshot_info", "node_vms_with_local_snapshots").Index(1), nil)}, + }, { + "valid actor.status.local_snapshot_info.content_scope", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA} + })), + nil, + }, { + "negative actor.status.local_snapshot_info.content_scope", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{ContentScope: ateapipb.SnapshotContentScope(-1)} + })), + field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "content_scope"), nil, "").WithOrigin("minimum")}, + }, { + "invalid actor.status.local_snapshot_info.content_scope", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.LocalSnapshotInfo = &ateapipb.LocalSnapshotInfo{ContentScope: ateapipb.SnapshotContentScope(3)} + })), + field.ErrorList{field.Invalid(field.NewPath("status", "local_snapshot_info", "content_scope"), nil, "").WithOrigin("maximum")}, + }, { + "negative actor.status.in_progress_snapshot_source_actor_version", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressSnapshotSourceActorVersion = -1 })), + field.ErrorList{field.Invalid(field.NewPath("status", "in_progress_snapshot_source_actor_version"), nil, "").WithOrigin("minimum")}, + }, { + "too many actor_volumes", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + vols := make([]*ateapipb.ExternalVolume, 33) + for i := range vols { + vols[i] = &ateapipb.ExternalVolume{VolumeName: fmt.Sprintf("vol-%d", i), VolumeType: "substrate.io/mock"} + } + s.ActorVolumes = vols + })), + field.ErrorList{field.TooMany(field.NewPath("status", "actor_volumes"), 33, 32).WithOrigin("maxItems")}, + }, { + // Set-once fields permit the nil->set transition, so a volume added + // in an update validates like one added at creation. + "adding a volume on update is allowed", + validInput(withStatus()), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.ActorVolumes = []*ateapipb.ExternalVolume{{VolumeName: "vol-a", VolumeType: "substrate.io/mock"}} + })), + nil, + }, { + "duplicate actor_volumes volume_name", + validInput(withStatus()), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.ActorVolumes = []*ateapipb.ExternalVolume{ + {VolumeName: "vol-a", VolumeType: "substrate.io/mock"}, + {VolumeName: "vol-a", VolumeType: "substrate.io/mock"}, + } + })), + field.ErrorList{field.Duplicate(field.NewPath("status", "actor_volumes").Index(1), nil)}, + }, { + "provisioning transition on an existing volume is valid", + validInput(withStatus(func(s *ateapipb.ActorStatus) { + s.ActorVolumes = []*ateapipb.ExternalVolume{{VolumeName: "vol-a", VolumeType: "substrate.io/mock", Status: ateapipb.ExternalVolume_STATUS_PENDING}} + })), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.ActorVolumes = []*ateapipb.ExternalVolume{{ + VolumeName: "vol-a", + VolumeType: "substrate.io/mock", + StorageVolumeId: "csi-426d29b7", + Status: ateapipb.ExternalVolume_STATUS_CREATED, + VolumeContext: map[string]string{"attachment": "iqn.2026-08.io.ate:vol-a"}, + }} + })), + nil, + }, { + "invalid actor.status.in_progress_local_snapshot_name", + validInput(), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.InProgressLocalSnapshotName = "BAD NAME" })), + field.ErrorList{field.Invalid(field.NewPath("status", "in_progress_local_snapshot_name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "set actor.status.source_snapshot", + validInput(withStatus()), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ + Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"}, + SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", + } + })), + nil, + }, { + "clear actor.status.source_snapshot", + validInput(withStatus(func(s *ateapipb.ActorStatus) { + s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ + Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"}, + SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", + } + })), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { s.SourceSnapshot = nil })), + field.ErrorList{field.Invalid(field.NewPath("status", "source_snapshot"), nil, "").WithOrigin("update")}, + }, { + "change actor.status.source_snapshot", + validInput(withStatus(func(s *ateapipb.ActorStatus) { + s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ + Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-1"}, + SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", + } + })), + validOutput(withStatus(func(s *ateapipb.ActorStatus) { + s.SourceSnapshot = &ateapipb.ActorSourceSnapshotStatus{ + Snapshot: &ateapipb.ObjectRef{Atespace: "as", Name: "snap-2"}, + SnapshotUid: "9d1f7b06-3c58-4a2e-8b40-5f7c1e9a2d63", + } + })), + field.ErrorList{field.Invalid(field.NewPath("status", "source_snapshot"), nil, "").WithOrigin("update")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateActorUpdate(context.Background(), nil, tt.newVal, tt.oldVal, true), tt.want) + }) + } +} + +func TestValidateGetActorRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.GetActorRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, + nil, + }, { + "missing actor", + &ateapipb.GetActorRequest{}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.atespace", + &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, + }, { + "invalid actor.atespace", + &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.name", + &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, + }, { + "invalid actor.name", + &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateGetActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateListActorsRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.ListActorsRequest + want field.ErrorList + }{{ + "valid, atespace scoped", + &ateapipb.ListActorsRequest{Atespace: "ns1"}, + nil, + }, { + // Empty atespace means "all atespaces" (kubectl ate get actors -A). + "valid, empty atespace means all atespaces", + &ateapipb.ListActorsRequest{}, + nil, + }, { + "invalid atespace", + &ateapipb.ListActorsRequest{Atespace: "NS1"}, + field.ErrorList{field.Invalid(field.NewPath("atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, + }, { + "valid, positive page_size", + &ateapipb.ListActorsRequest{Atespace: "ns1", PageSize: 10}, + nil, + }, { + "negative page_size", + &ateapipb.ListActorsRequest{Atespace: "ns1", PageSize: -1}, + field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")}, + }, { + "valid page_token", + &ateapipb.ListActorsRequest{Atespace: "ns1", PageToken: strings.Repeat("x", 256)}, + nil, + }, { + "too-large page_token", + &ateapipb.ListActorsRequest{Atespace: "ns1", PageToken: strings.Repeat("x", 257)}, + field.ErrorList{field.TooLongCharacters(field.NewPath("page_token"), "", 256).WithOrigin("maxLength")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateListActorsRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateUpdateActorRequest(t *testing.T) { + // This test verifies validation of user input for update. Since status + // is scrubbed on input, we don't need to test the status field here, other + // than that it is optional. TestValidateActorUpdate covers status + // validation and updates. + validReq := func(actor *ateapipb.Actor, mods ...func(actor *ateapipb.UpdateActorRequest)) *ateapipb.UpdateActorRequest { + req := &ateapipb.UpdateActorRequest{ + Actor: actor, + } + for _, m := range mods { + m(req) + } + return req + } + validActor := func(mods ...func(*ateapipb.Actor)) *ateapipb.Actor { + allMods := []func(*ateapipb.Actor){ + func(a *ateapipb.Actor) { // this needs to go first + a.Metadata.Uid = "12345678-1234-1234-1234-123456789abc" + a.Metadata.Version = 1 + }, + } + allMods = append(allMods, mods...) + a := validActor(allMods...) + return a + } + withStatus := withActorStatus + withMetadata := withActorMetadata + + tests := []struct { + name string + req *ateapipb.UpdateActorRequest + want field.ErrorList + }{{ + "valid", + validReq(validActor()), + nil, + }, { + "valid with status", + validReq(validActor(withStatus())), + nil, // ignored on input + }, { + "missing actor", + &ateapipb.UpdateActorRequest{Actor: nil}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.metadata", + validReq(validActor(func(a *ateapipb.Actor) { a.Metadata = nil })), + field.ErrorList{field.Required(field.NewPath("actor", "metadata"), "")}, + }, { + "missing actor.metadata.atespace", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "" }))), + field.ErrorList{field.Required(field.NewPath("actor", "metadata", "atespace"), "")}, + }, { + "invalid actor.metadata.atespace", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "NS1" }))), + field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "atespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.metadata.name", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))), + field.ErrorList{field.Required(field.NewPath("actor", "metadata", "name"), "")}, + }, { + "invalid actor.metadata.name", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "ID1" }))), + field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.metadata.uid precondition", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Uid = "" }))), + nil, + }, { + "invalid actor.metadata.uid precondition", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Uid = "not-a-uuid" }))), + field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "uid"), "not-a-uuid", "").WithOrigin("format=k8s-uuid")}, + }, { + "missing actor.metadata.version precondition", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Version = 0 }))), + nil, + }, { + "negative actor.metadata.version precondition", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Version = -1 }))), + field.ErrorList{field.Invalid(field.NewPath("actor", "metadata", "version"), int64(-1), "").WithOrigin("minimum")}, + }, { + "missing actor.metadata.version and actor.metadata.uid", + validReq(validActor(withMetadata(func(m *ateapipb.ResourceMetadata) { + m.Uid = "" + m.Version = 0 + }))), + nil, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateUpdateActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateDeleteActorRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.DeleteActorRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, + nil, + }, { + "missing actor", + &ateapipb.DeleteActorRequest{}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.atespace", + &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, + }, { + "invalid actor.atespace", + &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.name", + &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, + }, { + "invalid actor.name", + &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateDeleteActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidatePauseActorRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.PauseActorRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, + nil, + }, { + "missing actor", + &ateapipb.PauseActorRequest{}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.atespace", + &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, + }, { + "invalid actor.atespace", + &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.name", + &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, + }, { + "invalid actor.name", + &ateapipb.PauseActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidatePauseActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateResumeActorRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.ResumeActorRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, + nil, + }, { + "missing actor", + &ateapipb.ResumeActorRequest{}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.atespace", + &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, + }, { + "invalid actor.atespace", + &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.name", + &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, + }, { + "invalid actor.name", + &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateResumeActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateSuspendActorRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.SuspendActorRequest + want field.ErrorList + }{{ + "valid", + &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "id1"}}, + nil, + }, { + "missing actor", + &ateapipb.SuspendActorRequest{}, + field.ErrorList{field.Required(field.NewPath("actor"), "")}, + }, { + "missing actor.atespace", + &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Name: "id1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "atespace"), "")}, + }, { + "invalid actor.atespace", + &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "NS1", Name: "id1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "atespace"), "NS1", "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor.name", + &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1"}}, + field.ErrorList{field.Required(field.NewPath("actor", "name"), "")}, + }, { + "invalid actor.name", + &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "ns1", Name: "ID1"}}, + field.ErrorList{field.Invalid(field.NewPath("actor", "name"), "ID1", "").WithOrigin("format=k8s-short-name")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateSuspendActorRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func validExternalVolume(mutate ...func(*ateapipb.ExternalVolume)) *ateapipb.ExternalVolume { + v := &ateapipb.ExternalVolume{ + VolumeName: "my-vol", + StorageVolumeId: "valid-storage-id", + VolumeType: "mock", + Status: ateapipb.ExternalVolume_STATUS_CREATED, + } + for _, m := range mutate { + m(v) + } + return v +} + +func TestValidateExternalVolume(t *testing.T) { + valid := validExternalVolume + + tests := []struct { + name string + obj *ateapipb.ExternalVolume + want field.ErrorList + }{{ + name: "valid external volume", + obj: valid(), + }, { + name: "missing volume name", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "" }), + want: field.ErrorList{field.Required(field.NewPath("volume_name"), "")}, + }, { + name: "invalid volume name", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "NOT A VOLUME" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "valid external volume with empty storage volume id", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "" }), + }, { + name: "invalid storage volume id with null U+0000", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\x00id" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, + }, { + name: "invalid storage volume id with unit separator U+001F", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\x1fid" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, + }, { + name: "invalid storage volume id with DEL U+007F", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\x7fid" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, + }, { + name: "invalid storage volume id with C1 control U+0080", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\u0080id" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, + }, { + name: "invalid storage volume id with C1 control U+009F", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\u009fid" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, + }, { + name: "storage volume id too long", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = strings.Repeat("x", 257) }), + want: field.ErrorList{field.TooLong(field.NewPath("storage_volume_id"), nil, 256).WithOrigin("maxLength")}, + }, { + name: "valid csi volume type", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "pd.csi.storage.gke.io" }), + }, { + name: "missing volume type", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "" }), + want: field.ErrorList{field.Required(field.NewPath("volume_type"), "")}, + }, { + name: "invalid volume type with uppercase", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "MockPlugin" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, + }, { + name: "valid volume type with 253 characters", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = strings.Repeat("a", 253) }), + }, { + name: "invalid volume type exceeding 253 characters", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = strings.Repeat("a", 254) }), + want: field.ErrorList{ + field.Invalid(field.NewPath("volume_type"), nil, ""), + field.TooLong(field.NewPath("volume_type"), nil, 253).WithOrigin("maxLength"), + }, + }, { + name: "valid volume with substrate.io prefixed volume type", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "substrate.io/mock" }), + }, { + name: "invalid volume type with empty plugin after substrate.io prefix", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "substrate.io/" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, + }, { + name: "invalid volume type with invalid plugin name after substrate.io prefix", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "substrate.io/Mock_Plugin" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, + }, { + name: "invalid volume type with non-substrate prefix", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "other.io/mock" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, + }, { + name: "negative status", + obj: valid(func(v *ateapipb.ExternalVolume) { v.Status = ateapipb.ExternalVolume_Status(-1) }), + want: field.ErrorList{field.Invalid(field.NewPath("status"), nil, "").WithOrigin("minimum")}, + }, { + name: "status outside the enum", + obj: valid(func(v *ateapipb.ExternalVolume) { v.Status = ateapipb.ExternalVolume_Status(4) }), + want: field.ErrorList{field.Invalid(field.NewPath("status"), nil, "").WithOrigin("maximum")}, + }, { + name: "storage volume id at the bound", + obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = strings.Repeat("x", 256) }), + }, { + name: "too many volume_context entries", + obj: valid(func(v *ateapipb.ExternalVolume) { + ctxMap := make(map[string]string, 33) + for i := 0; i < 33; i++ { + ctxMap[fmt.Sprintf("key-%d", i)] = "v" + } + v.VolumeContext = ctxMap + }), + want: field.ErrorList{field.TooMany(field.NewPath("volume_context"), 33, 32).WithOrigin("maxProperties")}, + }, { + name: "volume_context key too long", + obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeContext = map[string]string{strings.Repeat("k", 129): "v"} }), + want: field.ErrorList{field.TooLong(field.NewPath("volume_context"), nil, 128).WithOrigin("maxLength")}, + }, { + name: "volume_context value too long", + obj: valid(func(v *ateapipb.ExternalVolume) { + v.VolumeContext = map[string]string{"attachment": strings.Repeat("v", 257)} + }), + want: field.ErrorList{field.TooLong(field.NewPath("volume_context").Key("attachment"), nil, 256).WithOrigin("maxLength")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: operation.Create} + assertValidateErr(t, Validate_ExternalVolume(context.Background(), op, nil, tt.obj, nil), tt.want) + }) + } +} + +func TestValidateExternalVolume_Update(t *testing.T) { + valid := validExternalVolume + + tests := []struct { + name string + oldObj *ateapipb.ExternalVolume + newObj *ateapipb.ExternalVolume + want field.ErrorList + }{{ + name: "unchanged volume is valid", + oldObj: valid(), + newObj: valid(), + }, { + name: "volume_name changed is invalid", + oldObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "vol1" }), + newObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "vol2" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_name"), nil, "").WithOrigin("update")}, + }, { + name: "storage_volume_id transition from empty to non-empty is valid", + oldObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "" }), + newObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-1" }), + }, { + name: "storage_volume_id changed once set is invalid", + oldObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-1" }), + newObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-2" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "").WithOrigin("update")}, + }, { + name: "storage_volume_id unset once set is invalid", + oldObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-1" }), + newObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "" }), + want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "").WithOrigin("update")}, + }, { + name: "volume_type changed is invalid", + oldObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "mock" }), + newObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "pd.csi.storage.gke.io" }), + want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "").WithOrigin("update")}, + }, { + name: "status and volume_context changed is valid", + oldObj: valid(func(v *ateapipb.ExternalVolume) { + v.Status = ateapipb.ExternalVolume_STATUS_PENDING + v.VolumeContext = nil + }), + newObj: valid(func(v *ateapipb.ExternalVolume) { + v.Status = ateapipb.ExternalVolume_STATUS_CREATED + v.VolumeContext = map[string]string{"foo": "bar"} + }), + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: operation.Update} + assertValidateErr(t, Validate_ExternalVolume(context.Background(), op, nil, tt.newObj, tt.oldObj), tt.want) + }) + } +} diff --git a/cmd/ateapi/internal/validation/atespace.go b/cmd/ateapi/internal/validation/atespace.go new file mode 100644 index 000000000..dc3c4f7ba --- /dev/null +++ b/cmd/ateapi/internal/validation/atespace.go @@ -0,0 +1,43 @@ +// 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 validation + +import ( + "context" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func ValidateCreateAtespaceRequest(ctx context.Context, req *ateapipb.CreateAtespaceRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_CreateAtespaceRequest(ctx, op, nil, req, nil) +} + +func ValidateGetAtespaceRequest(ctx context.Context, req *ateapipb.GetAtespaceRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_GetAtespaceRequest(ctx, op, nil, req, nil) +} + +func ValidateListAtespacesRequest(ctx context.Context, req *ateapipb.ListAtespacesRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_ListAtespacesRequest(ctx, op, nil, req, nil) +} + +func ValidateDeleteAtespaceRequest(ctx context.Context, req *ateapipb.DeleteAtespaceRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_DeleteAtespaceRequest(ctx, op, nil, req, nil) +} diff --git a/cmd/ateapi/internal/controlapi/atespace_test.go b/cmd/ateapi/internal/validation/atespace_test.go similarity index 96% rename from cmd/ateapi/internal/controlapi/atespace_test.go rename to cmd/ateapi/internal/validation/atespace_test.go index 9183e7186..c2270a26b 100644 --- a/cmd/ateapi/internal/controlapi/atespace_test.go +++ b/cmd/ateapi/internal/validation/atespace_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package controlapi +package validation import ( "context" @@ -67,7 +67,7 @@ func TestValidateCreateAtespaceRequest(t *testing.T) { }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateCreateAtespaceRequest(context.Background(), tt.req), tt.want) + assertValidateErr(t, ValidateCreateAtespaceRequest(context.Background(), tt.req), tt.want) }) } } @@ -111,7 +111,7 @@ func TestValidateGetAtespaceRequest(t *testing.T) { }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateGetAtespaceRequest(context.Background(), tt.req), tt.want) + assertValidateErr(t, ValidateGetAtespaceRequest(context.Background(), tt.req), tt.want) }) } } @@ -153,7 +153,7 @@ func TestValidateListAtespacesRequest(t *testing.T) { }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateListAtespacesRequest(context.Background(), tt.req), tt.want) + assertValidateErr(t, ValidateListAtespacesRequest(context.Background(), tt.req), tt.want) }) } } @@ -197,7 +197,7 @@ func TestValidateDeleteAtespaceRequest(t *testing.T) { }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assertValidateErr(t, validateDeleteAtespaceRequest(context.Background(), tt.req), tt.want) + assertValidateErr(t, ValidateDeleteAtespaceRequest(context.Background(), tt.req), tt.want) }) } } diff --git a/cmd/ateapi/internal/controlapi/validation_test.go b/cmd/ateapi/internal/validation/common_test.go similarity index 61% rename from cmd/ateapi/internal/controlapi/validation_test.go rename to cmd/ateapi/internal/validation/common_test.go index ccdf3c363..e0d1b0239 100644 --- a/cmd/ateapi/internal/controlapi/validation_test.go +++ b/cmd/ateapi/internal/validation/common_test.go @@ -12,11 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package controlapi +package validation import ( "context" - "fmt" "math" "strings" "testing" @@ -28,6 +27,11 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ) +func assertValidateErr(t *testing.T, got field.ErrorList, want field.ErrorList) { + t.Helper() + field.ErrorMatcher{}.ByType().ByField().ByOrigin().Test(t, want, got) +} + func validResourceMetadata(mutate ...func(*ateapipb.ResourceMetadata)) *ateapipb.ResourceMetadata { // This is valid with as many fields populated as possible. rm := &ateapipb.ResourceMetadata{ @@ -97,8 +101,7 @@ func TestValidateResourceMetadataCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { op := operation.Operation{Type: operation.Create} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_ResourceMetadata(context.Background(), op, nil, tt.obj, nil)) + assertValidateErr(t, Validate_ResourceMetadata(context.Background(), op, nil, tt.obj, nil), tt.want) }) } } @@ -208,8 +211,7 @@ func TestValidateResourceMetadataUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { op := operation.Operation{Type: operation.Update} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_ResourceMetadata(context.Background(), op, nil, tt.newObj, tt.oldObj)) + assertValidateErr(t, Validate_ResourceMetadata(context.Background(), op, nil, tt.newObj, tt.oldObj), tt.want) }) } } @@ -407,8 +409,7 @@ func TestValidateResourceMetadataNameAndAtespaceFormat(t *testing.T) { t.Run(tt.name, func(t *testing.T) { obj := proto.CloneOf(tt.obj) // avoid internal mutations op := operation.Operation{Type: operation.Create} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_ResourceMetadata(context.Background(), op, nil, obj, nil)) + assertValidateErr(t, Validate_ResourceMetadata(context.Background(), op, nil, obj, nil), tt.want) }) } } @@ -621,390 +622,7 @@ func TestValidateObjectRef(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { op := operation.Operation{Type: operation.Create} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_ObjectRef(context.Background(), op, nil, tt.ref, nil)) - }) - } -} - -func validSystemInfoVolumeSource(mutate ...func(*ateapipb.SystemInfoVolumeSource)) *ateapipb.SystemInfoVolumeSource { - // This is valid with as many fields populated as possible. - s := &ateapipb.SystemInfoVolumeSource{ - DataSources: []*ateapipb.SystemInfoDataSource{{ - ActorMetadata: &ateapipb.ActorMetadataDataSource{ - Items: []*ateapipb.ActorMetadataItem{{ - Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, - Path: "actor-name", - }, { - Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, - Path: "actor-uid", - }}, - }, - }, { - TrustBundle: &ateapipb.TrustBundleDataSource{ - Name: "egress-mitm.ate.dev", - Path: "trust-bundle.pem", - }, - }}, - } - for _, m := range mutate { - m(s) - } - return s -} - -func TestValidateSystemInfoVolumeSource(t *testing.T) { - valid := validSystemInfoVolumeSource - dsPath := field.NewPath("data_sources") - itemsPath := dsPath.Index(0).Child("actor_metadata", "items") - - tests := []struct { - name string - obj *ateapipb.SystemInfoVolumeSource - want field.ErrorList - }{{ - name: "valid", - obj: valid(), - }, { - name: "valid: no data sources", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { s.DataSources = nil }), - }, { - name: "too many data sources", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - for len(s.DataSources) <= 8 { - s.DataSources = append(s.DataSources, &ateapipb.SystemInfoDataSource{ - TrustBundle: &ateapipb.TrustBundleDataSource{Name: "egress-mitm.ate.dev", Path: "tb.pem"}, - }) - } - }), - want: field.ErrorList{field.TooMany(dsPath, 9, 8).WithOrigin("maxItems")}, - }, { - name: "no union member set", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].ActorMetadata = nil - }), - want: field.ErrorList{field.Invalid(dsPath.Index(0), nil, "").WithOrigin("union")}, - }, { - name: "both union members set", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].TrustBundle = &ateapipb.TrustBundleDataSource{ - Name: "egress-mitm.ate.dev", - Path: "tb2.pem", - } - }), - want: field.ErrorList{field.Invalid(dsPath.Index(0), nil, "").WithOrigin("union")}, - }, { - name: "empty items", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].ActorMetadata.Items = nil - }), - want: field.ErrorList{field.Required(itemsPath, "")}, - }, { - name: "duplicate projected field", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].ActorMetadata.Items[1].Field = ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME - }), - want: field.ErrorList{field.Duplicate(itemsPath.Index(1), nil)}, - }, { - name: "unspecified item field", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].ActorMetadata.Items[0].Field = ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_UNSPECIFIED - }), - want: field.ErrorList{field.Required(itemsPath.Index(0).Child("field"), "")}, - }, { - name: "empty item path", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].ActorMetadata.Items[0].Path = "" - }), - want: field.ErrorList{field.Required(itemsPath.Index(0).Child("path"), "")}, - }, { - name: "item path too long", - obj: valid(func(s *ateapipb.SystemInfoVolumeSource) { - s.DataSources[0].ActorMetadata.Items[0].Path = strings.Repeat("p", 256) - }), - want: field.ErrorList{field.TooLong(itemsPath.Index(0).Child("path"), nil, 255).WithOrigin("maxLength")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - op := operation.Operation{Type: operation.Create} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_SystemInfoVolumeSource(context.Background(), op, nil, tt.obj, nil)) - }) - } -} - -func TestValidateTrustBundleDataSource(t *testing.T) { - valid := func(mutate ...func(*ateapipb.TrustBundleDataSource)) *ateapipb.TrustBundleDataSource { - tb := &ateapipb.TrustBundleDataSource{ - Name: "egress-mitm.ate.dev", - Path: "trust-bundle.pem", - } - for _, m := range mutate { - m(tb) - } - return tb - } - - tests := []struct { - name string - obj *ateapipb.TrustBundleDataSource - want field.ErrorList - }{{ - name: "valid", - obj: valid(), - }, { - name: "empty name", - obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Name = "" }), - want: field.ErrorList{field.Required(field.NewPath("name"), "")}, - }, { - name: "name too long", - obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Name = strings.Repeat("n", 254) }), - want: field.ErrorList{field.TooLong(field.NewPath("name"), nil, 253).WithOrigin("maxLength")}, - }, { - name: "empty path", - obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Path = "" }), - want: field.ErrorList{field.Required(field.NewPath("path"), "")}, - }, { - name: "path too long", - obj: valid(func(tb *ateapipb.TrustBundleDataSource) { tb.Path = strings.Repeat("p", 256) }), - want: field.ErrorList{field.TooLong(field.NewPath("path"), nil, 255).WithOrigin("maxLength")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - op := operation.Operation{Type: operation.Create} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_TrustBundleDataSource(context.Background(), op, nil, tt.obj, nil)) - }) - } -} - -func validExternalVolume(mutate ...func(*ateapipb.ExternalVolume)) *ateapipb.ExternalVolume { - v := &ateapipb.ExternalVolume{ - VolumeName: "my-vol", - StorageVolumeId: "valid-storage-id", - VolumeType: "mock", - Status: ateapipb.ExternalVolume_STATUS_CREATED, - } - for _, m := range mutate { - m(v) - } - return v -} - -func TestValidateExternalVolume(t *testing.T) { - valid := validExternalVolume - - tests := []struct { - name string - obj *ateapipb.ExternalVolume - want field.ErrorList - }{{ - name: "valid external volume", - obj: valid(), - }, { - name: "missing volume name", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "" }), - want: field.ErrorList{field.Required(field.NewPath("volume_name"), "")}, - }, { - name: "invalid volume name", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "NOT A VOLUME" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_name"), nil, "").WithOrigin("format=k8s-short-name")}, - }, { - name: "valid external volume with empty storage volume id", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "" }), - }, { - name: "invalid storage volume id with null U+0000", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\x00id" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, - }, { - name: "invalid storage volume id with unit separator U+001F", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\x1fid" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, - }, { - name: "invalid storage volume id with DEL U+007F", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\x7fid" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, - }, { - name: "invalid storage volume id with C1 control U+0080", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\u0080id" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, - }, { - name: "invalid storage volume id with C1 control U+009F", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol\u009fid" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "")}, - }, { - name: "storage volume id too long", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = strings.Repeat("x", 257) }), - want: field.ErrorList{field.TooLong(field.NewPath("storage_volume_id"), nil, 256).WithOrigin("maxLength")}, - }, { - name: "valid csi volume type", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "pd.csi.storage.gke.io" }), - }, { - name: "missing volume type", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "" }), - want: field.ErrorList{field.Required(field.NewPath("volume_type"), "")}, - }, { - name: "invalid volume type with uppercase", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "MockPlugin" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, - }, { - name: "valid volume type with 253 characters", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = strings.Repeat("a", 253) }), - }, { - name: "invalid volume type exceeding 253 characters", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = strings.Repeat("a", 254) }), - want: field.ErrorList{ - field.Invalid(field.NewPath("volume_type"), nil, ""), - field.TooLong(field.NewPath("volume_type"), nil, 253).WithOrigin("maxLength"), - }, - }, { - name: "valid volume with substrate.io prefixed volume type", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "substrate.io/mock" }), - }, { - name: "invalid volume type with empty plugin after substrate.io prefix", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "substrate.io/" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, - }, { - name: "invalid volume type with invalid plugin name after substrate.io prefix", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "substrate.io/Mock_Plugin" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, - }, { - name: "invalid volume type with non-substrate prefix", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "other.io/mock" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "")}, - }, { - name: "negative status", - obj: valid(func(v *ateapipb.ExternalVolume) { v.Status = ateapipb.ExternalVolume_Status(-1) }), - want: field.ErrorList{field.Invalid(field.NewPath("status"), nil, "").WithOrigin("minimum")}, - }, { - name: "status outside the enum", - obj: valid(func(v *ateapipb.ExternalVolume) { v.Status = ateapipb.ExternalVolume_Status(4) }), - want: field.ErrorList{field.Invalid(field.NewPath("status"), nil, "").WithOrigin("maximum")}, - }, { - name: "storage volume id at the bound", - obj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = strings.Repeat("x", 256) }), - }, { - name: "too many volume_context entries", - obj: valid(func(v *ateapipb.ExternalVolume) { - ctxMap := make(map[string]string, 33) - for i := 0; i < 33; i++ { - ctxMap[fmt.Sprintf("key-%d", i)] = "v" - } - v.VolumeContext = ctxMap - }), - want: field.ErrorList{field.TooMany(field.NewPath("volume_context"), 33, 32).WithOrigin("maxProperties")}, - }, { - name: "volume_context key too long", - obj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeContext = map[string]string{strings.Repeat("k", 129): "v"} }), - want: field.ErrorList{field.TooLong(field.NewPath("volume_context"), nil, 128).WithOrigin("maxLength")}, - }, { - name: "volume_context value too long", - obj: valid(func(v *ateapipb.ExternalVolume) { - v.VolumeContext = map[string]string{"attachment": strings.Repeat("v", 257)} - }), - want: field.ErrorList{field.TooLong(field.NewPath("volume_context").Key("attachment"), nil, 256).WithOrigin("maxLength")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - op := operation.Operation{Type: operation.Create} - assertValidateErr(t, Validate_ExternalVolume(context.Background(), op, nil, tt.obj, nil), tt.want) - }) - } -} - -func TestValidateExternalVolume_Update(t *testing.T) { - valid := validExternalVolume - - tests := []struct { - name string - oldObj *ateapipb.ExternalVolume - newObj *ateapipb.ExternalVolume - want field.ErrorList - }{{ - name: "unchanged volume is valid", - oldObj: valid(), - newObj: valid(), - }, { - name: "volume_name changed is invalid", - oldObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "vol1" }), - newObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeName = "vol2" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_name"), nil, "").WithOrigin("update")}, - }, { - name: "storage_volume_id transition from empty to non-empty is valid", - oldObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "" }), - newObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-1" }), - }, { - name: "storage_volume_id changed once set is invalid", - oldObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-1" }), - newObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-2" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "").WithOrigin("update")}, - }, { - name: "storage_volume_id unset once set is invalid", - oldObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "vol-id-1" }), - newObj: valid(func(v *ateapipb.ExternalVolume) { v.StorageVolumeId = "" }), - want: field.ErrorList{field.Invalid(field.NewPath("storage_volume_id"), nil, "").WithOrigin("update")}, - }, { - name: "volume_type changed is invalid", - oldObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "mock" }), - newObj: valid(func(v *ateapipb.ExternalVolume) { v.VolumeType = "pd.csi.storage.gke.io" }), - want: field.ErrorList{field.Invalid(field.NewPath("volume_type"), nil, "").WithOrigin("update")}, - }, { - name: "status and volume_context changed is valid", - oldObj: valid(func(v *ateapipb.ExternalVolume) { - v.Status = ateapipb.ExternalVolume_STATUS_PENDING - v.VolumeContext = nil - }), - newObj: valid(func(v *ateapipb.ExternalVolume) { - v.Status = ateapipb.ExternalVolume_STATUS_CREATED - v.VolumeContext = map[string]string{"foo": "bar"} - }), - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - op := operation.Operation{Type: operation.Update} - assertValidateErr(t, Validate_ExternalVolume(context.Background(), op, nil, tt.newObj, tt.oldObj), tt.want) - }) - } -} - -func TestValidateDeleteOptions(t *testing.T) { - valid := func(mutate ...func(*ateapipb.DeleteOptions)) *ateapipb.DeleteOptions { - tb := &ateapipb.DeleteOptions{} - for _, m := range mutate { - m(tb) - } - return tb - } - - tests := []struct { - name string - obj *ateapipb.DeleteOptions - want field.ErrorList - }{{ - name: "valid", - obj: valid(), // all optional fields - }, { - name: "valid version", - obj: valid(func(do *ateapipb.DeleteOptions) { do.Version = 1 }), - want: nil, - }, { - name: "invalid version", - obj: valid(func(do *ateapipb.DeleteOptions) { do.Version = -1 }), - want: field.ErrorList{field.Invalid(field.NewPath("version"), nil, "").WithOrigin("minimum")}, - }, { - name: "valid uid", - obj: valid(func(do *ateapipb.DeleteOptions) { do.Uid = "11111111-2222-3333-4444-555555555555" }), - want: nil, - }, { - name: "invalid uid", - obj: valid(func(do *ateapipb.DeleteOptions) { do.Uid = "not a uid" }), - want: field.ErrorList{field.Invalid(field.NewPath("uid"), nil, "").WithOrigin("format=k8s-uuid")}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - op := operation.Operation{Type: operation.Create} - matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin() - matcher.Test(t, tt.want, Validate_DeleteOptions(context.Background(), op, nil, tt.obj, nil)) + assertValidateErr(t, Validate_ObjectRef(context.Background(), op, nil, tt.ref, nil), tt.want) }) } } diff --git a/cmd/ateapi/internal/validation/deepequal.go b/cmd/ateapi/internal/validation/deepequal.go new file mode 100644 index 000000000..4f308e8ea --- /dev/null +++ b/cmd/ateapi/internal/validation/deepequal.go @@ -0,0 +1,39 @@ +// 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 validation + +import ( + "reflect" + + "google.golang.org/protobuf/proto" +) + +// ateDeepEqual compares two values of any type, using proto.Equal if both are +// proto messages, and reflect.DeepEqual otherwise. This is called by +// declarative validation's generated code. +func ateDeepEqual[T any](a, b T) bool { + asProto := func(x any) proto.Message { + pm, ok := x.(proto.Message) + if !ok { + return nil + } + return pm + } + + if pa, pb := asProto(a), asProto(b); pa != nil && pb != nil { + return proto.Equal(pa, pb) + } + return reflect.DeepEqual(a, b) +} diff --git a/cmd/ateapi/internal/controlapi/doc.go b/cmd/ateapi/internal/validation/doc.go similarity index 97% rename from cmd/ateapi/internal/controlapi/doc.go rename to cmd/ateapi/internal/validation/doc.go index b953c9ef5..98ce8084a 100644 --- a/cmd/ateapi/internal/controlapi/doc.go +++ b/cmd/ateapi/internal/validation/doc.go @@ -20,4 +20,4 @@ // +k8s:validation-gen-scheme-registry=nil // +k8s:validation-gen-deep-equal-func=ateDeepEqual -package controlapi +package validation diff --git a/cmd/ateapi/internal/validation/egress_policy.go b/cmd/ateapi/internal/validation/egress_policy.go new file mode 100644 index 000000000..8f3a735fc --- /dev/null +++ b/cmd/ateapi/internal/validation/egress_policy.go @@ -0,0 +1,204 @@ +// 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 validation + +import ( + "context" + "net/url" + "strings" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/api/validate/content" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func ValidateCreateActorEgressPolicyRequest(ctx context.Context, req *ateapipb.CreateActorEgressPolicyRequest) field.ErrorList { + return Validate_CreateActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) +} + +func ValidateGetActorEgressPolicyRequest(ctx context.Context, req *ateapipb.GetActorEgressPolicyRequest) field.ErrorList { + return Validate_GetActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) +} + +func ValidateUpdateActorEgressPolicyRequest(ctx context.Context, req *ateapipb.UpdateActorEgressPolicyRequest) field.ErrorList { + return Validate_UpdateActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) +} + +func ValidateEgressPolicyUpdate(ctx context.Context, p *field.Path, newVal, oldVal *ateapipb.EgressPolicy) field.ErrorList { + return Validate_EgressPolicy(ctx, operation.Operation{Type: operation.Update}, p, newVal, oldVal) +} + +func ValidateDeleteActorEgressPolicyRequest(ctx context.Context, req *ateapipb.DeleteActorEgressPolicyRequest) field.ErrorList { + return Validate_DeleteActorEgressPolicyRequest(ctx, operation.Operation{Type: operation.Create}, nil, req, nil) +} + +func ValidateCustom_CreateActorEgressPolicyRequest(_ context.Context, _ operation.Operation, p *field.Path, req, _ *ateapipb.CreateActorEgressPolicyRequest) field.ErrorList { + return validateEgressPolicyParentAtespace(req.GetActor(), req.GetEgressPolicy(), p) +} + +func ValidateCustom_UpdateActorEgressPolicyRequest(_ context.Context, _ operation.Operation, p *field.Path, req, _ *ateapipb.UpdateActorEgressPolicyRequest) field.ErrorList { + return validateEgressPolicyParentAtespace(req.GetActor(), req.GetEgressPolicy(), p) +} + +func validateEgressPolicyParentAtespace(actor *ateapipb.ObjectRef, policy *ateapipb.EgressPolicy, p *field.Path) field.ErrorList { + if actor == nil || actor.Atespace == "" { + return nil // regular DV will handle it + } + actorAtespace := actor.GetAtespace() + if policy == nil || policy.Metadata == nil || policy.Metadata.Atespace == "" { + return nil // regular DV will handle it + } + policyAtespace := policy.GetMetadata().GetAtespace() + if actorAtespace != policyAtespace { + return field.ErrorList{ + field.Invalid(p.Child("egress_policy", "metadata", "atespace"), policyAtespace, "must match actor.atespace"), + } + } + return nil +} + +func ValidateCustom_EgressPolicy_Metadata(_ context.Context, _ operation.Operation, root *field.Path, meta, _ *ateapipb.ResourceMetadata) field.ErrorList { + if meta == nil || meta.Name == "" { + return nil // regular DV will handle it + } + if meta.Name != "default" { + return field.ErrorList{field.Invalid(root.Child("name"), meta.Name, `must be "default"`).WithOrigin("custom=default")} + } + return nil +} + +func ValidateCustom_HostnameRule_Patterns(_ context.Context, _ operation.Operation, p *field.Path, patterns, _ []string) field.ErrorList { + var errs field.ErrorList + for i, raw := range patterns { + errs = append(errs, validateHostnamePattern(raw, p.Index(i))...) + } + return errs +} + +func ValidateCustom_EgressRuleEffects(_ context.Context, _ operation.Operation, p *field.Path, effects, _ *ateapipb.EgressRuleEffects) field.ErrorList { + var errs field.ErrorList + if len(effects.GetInjectStaticHeaders()) == 0 { + errs = append(errs, field.Required(p, "at least one effect must be specified")) + } + return errs +} + +func ValidateCustom_EgressRuleEffects_InjectStaticHeaders(_ context.Context, _ operation.Operation, p *field.Path, injections, _ []*ateapipb.CredentialHeaderInjection) field.ErrorList { + var errs field.ErrorList + seenHeaders := map[string]bool{} + for i, inj := range injections { + if inj == nil { + continue // handled by DV + } + norm := strings.ToLower(inj.Header) + if seenHeaders[norm] { + errs = append(errs, field.Duplicate(p.Index(i).Child("header"), inj.Header)) + } + seenHeaders[norm] = true + } + return errs +} + +func ValidateCustom_IPBlockRule_Cidrs(_ context.Context, _ operation.Operation, p *field.Path, cidrs, _ []string) field.ErrorList { + var errs field.ErrorList + for i, cidr := range cidrs { + errs = append(errs, validation.IsValidCIDR(p.Index(i), cidr)...) + } + return errs +} + +func validateHostnamePattern(raw string, p *field.Path) field.ErrorList { + if raw == "" { + return field.ErrorList{field.Required(p, "")} + } + name := strings.TrimPrefix(raw, "*.") + if len(content.IsDNS1123Subdomain(name)) != 0 || len(validation.IsValidIP(p, name)) == 0 { + return field.ErrorList{ + field.Invalid(p, raw, "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + } + } + return nil +} + +func ValidateCustom_CredentialHeaderInjection_Header(_ context.Context, _ operation.Operation, p *field.Path, header, _ *string) field.ErrorList { + if !validHeaderName(*header) { + return field.ErrorList{ + field.Invalid(p, *header, "must be an HTTP header name"), + } + } + return nil +} + +func ValidateCustom_CredentialHeaderInjection_Prefix(_ context.Context, _ operation.Operation, p *field.Path, prefix, _ *string) field.ErrorList { + if !validHeaderValue(*prefix) { + return field.ErrorList{ + field.Invalid(p, *prefix, "must be a valid HTTP field value prefix"), + } + } + return nil +} + +func ValidateCustom_CredentialHeaderInjection_CredentialUri(_ context.Context, _ operation.Operation, p *field.Path, uri, _ *string) field.ErrorList { + if !validCredentialURI(*uri) { + return field.ErrorList{ + field.Invalid(p, *uri, "must be substrate-secret:////"), + } + } + return nil +} + +func validCredentialURI(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "substrate-secret" || u.Host == "" || u.Host != u.Hostname() || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(validation.IsDNS1123Subdomain(u.Host)) != 0 { + return false + } + escapedPath := u.EscapedPath() + if !strings.HasPrefix(escapedPath, "/") || strings.HasSuffix(escapedPath, "/") { + return false + } + parts := strings.Split(strings.TrimPrefix(escapedPath, "/"), "/") + if len(parts) < 2 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + } + return true +} + +func validHeaderName(value string) bool { + if value == "" { + return false + } + for _, c := range []byte(value) { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c))) { + return false + } + } + return true +} + +func validHeaderValue(value string) bool { + for _, c := range []byte(value) { + if c != '\t' && (c < ' ' || c == 0x7f) { + return false + } + } + return true +} diff --git a/cmd/ateapi/internal/validation/egress_policy_test.go b/cmd/ateapi/internal/validation/egress_policy_test.go new file mode 100644 index 000000000..c49aa5d88 --- /dev/null +++ b/cmd/ateapi/internal/validation/egress_policy_test.go @@ -0,0 +1,795 @@ +// 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 validation + +import ( + "context" + "fmt" + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/emptypb" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +const testAtespace = "test-atespace" + +func validEgressPolicy() *ateapipb.EgressPolicy { + return &ateapipb.EgressPolicy{ + Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "default"}, + Rules: []*ateapipb.EgressRule{{ + Hostnames: &ateapipb.HostnameRule{ + Patterns: []string{"api.example.com"}, + Effects: &ateapipb.EgressRuleEffects{ + InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{ + Header: "Authorization", + Prefix: "Bearer ", + CredentialUri: "substrate-secret://kubernetes.io/provider/ns/name", + }}, + }, + }, + }}, + } +} + +func TestValidateCreateActorEgressPolicyRequest(t *testing.T) { + validReq := func() *ateapipb.CreateActorEgressPolicyRequest { + return &ateapipb.CreateActorEgressPolicyRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, + EgressPolicy: validEgressPolicy(), + } + } + + tests := []struct { + name string + req *ateapipb.CreateActorEgressPolicyRequest + want field.ErrorList + }{{ + name: "valid", + req: validReq(), + }, { + name: "missing actor", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.Actor = nil + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor"), ""), + }, + }, { + name: "missing actor atespace", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.Actor.Atespace = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor", "atespace"), ""), + }, + }, { + name: "invalid actor atespace", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.Actor.Atespace = "invalid value" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("actor", "atespace"), nil, "").WithOrigin("format=k8s-short-name"), + field.Invalid(field.NewPath("egress_policy", "metadata", "atespace"), nil, ""), + }, + }, { + name: "missing actor name", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.Actor.Name = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor", "name"), ""), + }, + }, { + name: "invalid actor name", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.Actor.Name = "invalid value" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("actor", "name"), nil, "").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "missing policy", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy = nil + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("egress_policy"), ""), + }, + }, { + name: "missing metadata", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Metadata = nil + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("egress_policy", "metadata"), ""), + }, + }, { + name: "missing policy atespace", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Metadata.Atespace = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("egress_policy", "metadata", "atespace"), ""), + }, + }, { + name: "missing default name", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Metadata.Name = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("egress_policy", "metadata", "name"), ""), + }, + }, { + name: "wrong policy name", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Metadata.Name = "other" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("egress_policy", "metadata", "name"), "other", `must be "default"`).WithOrigin("custom=default"), + }, + }, { + name: "mismatched policy atespace", + req: func() *ateapipb.CreateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Metadata.Atespace = "other" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("egress_policy", "metadata", "atespace"), "other", "must match actor.atespace"), + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidateErr(t, ValidateCreateActorEgressPolicyRequest(context.Background(), tc.req), tc.want) + }) + } +} + +func TestValidateGetActorEgressPolicyRequest(t *testing.T) { + validReq := func() *ateapipb.GetActorEgressPolicyRequest { + return &ateapipb.GetActorEgressPolicyRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, + } + } + tests := []struct { + name string + req *ateapipb.GetActorEgressPolicyRequest + want field.ErrorList + }{{ + name: "valid", + req: validReq(), + }, { + name: "missing actor", + req: &ateapipb.GetActorEgressPolicyRequest{}, + want: field.ErrorList{ + field.Required(field.NewPath("actor"), ""), + }, + }, { + name: "missing atespace", + req: func() *ateapipb.GetActorEgressPolicyRequest { + r := validReq() + r.Actor.Atespace = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor", "atespace"), ""), + }, + }, { + name: "invalid atespace", + req: func() *ateapipb.GetActorEgressPolicyRequest { + r := validReq() + r.Actor.Atespace = "invalid value" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("actor", "atespace"), nil, "").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "missing name", + req: func() *ateapipb.GetActorEgressPolicyRequest { + r := validReq() + r.Actor.Name = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor", "name"), ""), + }, + }, { + name: "invalid name", + req: func() *ateapipb.GetActorEgressPolicyRequest { + r := validReq() + r.Actor.Name = "invalid value" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("actor", "name"), nil, "").WithOrigin("format=k8s-short-name"), + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidateErr(t, ValidateGetActorEgressPolicyRequest(context.Background(), tc.req), tc.want) + }) + } +} + +func TestValidateUpdateActorEgressPolicyRequest(t *testing.T) { + validReq := func() *ateapipb.UpdateActorEgressPolicyRequest { + return &ateapipb.UpdateActorEgressPolicyRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, + EgressPolicy: validEgressPolicy(), + } + } + tests := []struct { + name string + req *ateapipb.UpdateActorEgressPolicyRequest + want field.ErrorList + }{{ + name: "valid", + req: validReq(), + }, { + name: "missing actor", + req: func() *ateapipb.UpdateActorEgressPolicyRequest { + r := validReq() + r.Actor = nil + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor"), ""), + }, + }, { + name: "missing policy", + req: func() *ateapipb.UpdateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy = nil + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("egress_policy"), ""), + }, + }, { + name: "mismatched atespace", + req: func() *ateapipb.UpdateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Metadata.Atespace = "other" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("egress_policy", "metadata", "atespace"), "other", "must match actor.atespace"), + }, + }, { + name: "invalid rule", + req: func() *ateapipb.UpdateActorEgressPolicyRequest { + r := validReq() + r.EgressPolicy.Rules[0].Hostnames.Patterns = nil + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("egress_policy", "rules").Index(0).Child("hostnames", "patterns"), ""), + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidateErr(t, ValidateUpdateActorEgressPolicyRequest(context.Background(), tc.req), tc.want) + }) + } +} + +func TestValidateDeleteActorEgressPolicyRequest(t *testing.T) { + validReq := func() *ateapipb.DeleteActorEgressPolicyRequest { + return &ateapipb.DeleteActorEgressPolicyRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, + } + } + tests := []struct { + name string + req *ateapipb.DeleteActorEgressPolicyRequest + want field.ErrorList + }{{ + name: "valid", + req: validReq(), + }, { + name: "missing actor", + req: &ateapipb.DeleteActorEgressPolicyRequest{}, + want: field.ErrorList{ + field.Required(field.NewPath("actor"), ""), + }, + }, { + name: "missing atespace", + req: func() *ateapipb.DeleteActorEgressPolicyRequest { + r := validReq() + r.Actor.Atespace = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor", "atespace"), ""), + }, + }, { + name: "invalid atespace", + req: func() *ateapipb.DeleteActorEgressPolicyRequest { + r := validReq() + r.Actor.Atespace = "invalid value" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("actor", "atespace"), nil, "").WithOrigin("format=k8s-short-name"), + }, + }, { + name: "missing name", + req: func() *ateapipb.DeleteActorEgressPolicyRequest { + r := validReq() + r.Actor.Name = "" + return r + }(), + want: field.ErrorList{ + field.Required(field.NewPath("actor", "name"), ""), + }, + }, { + name: "invalid name", + req: func() *ateapipb.DeleteActorEgressPolicyRequest { + r := validReq() + r.Actor.Name = "invalid value" + return r + }(), + want: field.ErrorList{ + field.Invalid(field.NewPath("actor", "name"), nil, "").WithOrigin("format=k8s-short-name"), + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidateErr(t, ValidateDeleteActorEgressPolicyRequest(context.Background(), tc.req), tc.want) + }) + } +} + +func TestValidateEgressPolicyRules(t *testing.T) { + root := field.NewPath("egress_policy") + rule := root.Child("rules").Index(0) + hostnames := rule.Child("hostnames") + pattern := hostnames.Child("patterns").Index(0) + staticHeader := hostnames.Child("effects", "inject_static_headers").Index(0) + validReq := func() *ateapipb.CreateActorEgressPolicyRequest { + return &ateapipb.CreateActorEgressPolicyRequest{ + Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor"}, + EgressPolicy: validEgressPolicy(), + } + } + withoutEffects := func(p *ateapipb.EgressPolicy) { p.Rules[0].Hostnames.Effects = nil } + trivialHostnameRule := func(hostname string) *ateapipb.EgressRule { + return &ateapipb.EgressRule{ + Hostnames: &ateapipb.HostnameRule{ + Patterns: []string{hostname}, + }, + } + } + + tests := []struct { + name string + mutate func(*ateapipb.EgressPolicy) + want field.ErrorList + }{{ + name: "valid", + }, { + name: "no rules", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules = nil + }, + }, { + name: "many rules", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules = nil + for i := range 256 { + p.Rules = append(p.Rules, trivialHostnameRule(fmt.Sprintf("api%d.example.com", i))) + } + }, + }, { + name: "too many rules", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules = nil + for i := range 257 { + p.Rules = append(p.Rules, trivialHostnameRule(fmt.Sprintf("api%d.example.com", i))) + } + }, + want: field.ErrorList{ + field.TooMany(root.Child("rules"), 257, 256).WithOrigin("maxItems"), + }, + }, { + name: "nil rule", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = nil + }, + want: field.ErrorList{ + field.Required(rule, ""), + }, + }, { + name: "no predicates", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{} + }, + want: field.ErrorList{ + field.Invalid(rule, nil, "one of").WithOrigin("union"), + }, + }, { + name: "multiple predicates", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].All = &emptypb.Empty{} + }, + want: field.ErrorList{ + field.Invalid(rule, nil, "one of").WithOrigin("union"), + }, + }, { + name: "match all", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{All: &emptypb.Empty{}} + }, + }, { + name: "empty hostname list", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns = nil + withoutEffects(p) + }, + want: field.ErrorList{ + field.Required(hostnames.Child("patterns"), ""), + }, + }, { + name: "long hostname list", + mutate: func(p *ateapipb.EgressPolicy) { + var pats []string + for i := range 256 { + pats = append(pats, fmt.Sprintf("api%d.example.com", i)) + } + p.Rules[0].Hostnames.Patterns = pats + withoutEffects(p) + }, + }, { + name: "too-long hostname list", + mutate: func(p *ateapipb.EgressPolicy) { + var pats []string + for i := range 257 { + pats = append(pats, fmt.Sprintf("api%d.example.com", i)) + } + p.Rules[0].Hostnames.Patterns = pats + withoutEffects(p) + }, + want: field.ErrorList{ + field.TooMany(hostnames.Child("patterns"), 257, 256).WithOrigin("maxItems"), + }, + }, { + name: "missing hostname", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "" + withoutEffects(p) + }, + want: field.ErrorList{ + field.Required(pattern, ""), + }, + }, { + name: "duplicate hostname", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns = append(p.Rules[0].Hostnames.Patterns, "api.example.com") + }, + want: field.ErrorList{ + field.Duplicate(hostnames.Child("patterns").Index(1), "api.example.com"), + }, + }, { + name: "invalid hostname", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "https://example.com" + }, + want: field.ErrorList{ + field.Invalid(pattern, "https://example.com", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + }, + }, { + name: "uppercase hostname", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "API.EXAMPLE.COM" + }, + want: field.ErrorList{ + field.Invalid(pattern, "API.EXAMPLE.COM", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + }, + }, { + name: "hostname with trailing dot", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "api.example.com." + }, + want: field.ErrorList{ + field.Invalid(pattern, "api.example.com.", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + }, + }, { + name: "IP literal hostname", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "192.0.2.1" + }, + want: field.ErrorList{ + field.Invalid(pattern, "192.0.2.1", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + }, + }, { + name: "hostname with port", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "example.com:443" + }, + want: field.ErrorList{ + field.Invalid(pattern, "example.com:443", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + }, + }, { + name: "hostname wildcard without effects", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "*.example.com" + withoutEffects(p) + }, + }, { + name: "hostname wildcard with effects", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "*.example.com" + }, + }, { + name: "invalid hostname wildcard", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Patterns[0] = "api.*.example.com" + }, + want: field.ErrorList{ + field.Invalid(pattern, "api.*.example.com", "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), + }, + }, { + name: "canonical IPv4 CIDR", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"192.0.2.0/24"}}} + }, + }, { + name: "canonical IPv6 CIDR", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"2001:db8::/32"}}} + }, + }, { + name: "mixed IPv4 and IPv6 CIDRs", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{ + IpBlocks: &ateapipb.IPBlockRule{ + Cidrs: []string{"192.0.2.0/24", "2001:db8::/32"}, + }, + } + }, + }, { + name: "many CIDRs", + mutate: func(p *ateapipb.EgressPolicy) { + var cidrs []string + for i := range 256 { + cidrs = append(cidrs, fmt.Sprintf("192.0.2.%d/32", i)) + } + p.Rules[0] = &ateapipb.EgressRule{ + IpBlocks: &ateapipb.IPBlockRule{ + Cidrs: cidrs, + }, + } + }, + }, { + name: "too many CIDRs", + mutate: func(p *ateapipb.EgressPolicy) { + var cidrs []string + for i := range 257 { + cidrs = append(cidrs, fmt.Sprintf("192.0.2.%d/32", i)) + } + p.Rules[0] = &ateapipb.EgressRule{ + IpBlocks: &ateapipb.IPBlockRule{ + Cidrs: cidrs, + }, + } + }, + want: field.ErrorList{ + field.TooMany(rule.Child("ip_blocks", "cidrs"), 257, 256).WithOrigin("maxItems"), + }, + }, { + name: "missing CIDR", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{""}}} + }, + want: field.ErrorList{ + field.Invalid(rule.Child("ip_blocks", "cidrs").Index(0), "", "must be a canonical IPv4 or IPv6 prefix"), + }, + }, { + name: "empty CIDR list", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{}} + }, + want: field.ErrorList{ + field.Required(rule.Child("ip_blocks", "cidrs"), ""), + }, + }, { + name: "noncanonical CIDR", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"192.0.2.1/24"}}} + }, + want: field.ErrorList{ + field.Invalid(rule.Child("ip_blocks", "cidrs").Index(0), "192.0.2.1/24", "must be a canonical IPv4 or IPv6 prefix"), + }, + }, { + name: "duplicate CIDR", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0] = &ateapipb.EgressRule{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"192.0.2.0/24", "192.0.2.0/24"}}} + }, + want: field.ErrorList{ + field.Duplicate(rule.Child("ip_blocks", "cidrs").Index(1), "192.0.2.0/24"), + }, + }, { + name: "missing static header", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].Header = "" + }, + want: field.ErrorList{ + field.Required(staticHeader.Child("header"), ""), + }, + }, { + name: "invalid static header", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].Header = "bad header" + }, + want: field.ErrorList{ + field.Invalid(staticHeader.Child("header"), "bad header", "must be an HTTP header name"), + }, + }, { + name: "duplicate header", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects.InjectStaticHeaders = append( + p.Rules[0].Hostnames.Effects.InjectStaticHeaders, + &ateapipb.CredentialHeaderInjection{Header: "authorization", CredentialUri: "substrate-secret://example.com/provider/secret"}, + ) + }, + want: field.ErrorList{ + field.Duplicate(hostnames.Child("effects", "inject_static_headers").Index(1).Child("header"), "authorization"), + }, + }, { + name: "same header in later rule", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules = append(p.Rules, proto.Clone(p.Rules[0]).(*ateapipb.EgressRule)) + }, + }, { + name: "many headers", + mutate: func(p *ateapipb.EgressPolicy) { + var injections []*ateapipb.CredentialHeaderInjection + for i := range 16 { + injections = append(injections, &ateapipb.CredentialHeaderInjection{ + Header: fmt.Sprintf("X-Header-%d", i), + CredentialUri: "substrate-secret://example.com/provider/secret", + }) + } + p.Rules[0].Hostnames.Effects.InjectStaticHeaders = injections + }, + }, { + name: "too many headers", + mutate: func(p *ateapipb.EgressPolicy) { + var injections []*ateapipb.CredentialHeaderInjection + for i := range 17 { + injections = append(injections, &ateapipb.CredentialHeaderInjection{ + Header: fmt.Sprintf("X-Header-%d", i), + CredentialUri: "substrate-secret://example.com/provider/secret", + }) + } + p.Rules[0].Hostnames.Effects.InjectStaticHeaders = injections + }, + want: field.ErrorList{ + field.TooMany(hostnames.Child("effects", "inject_static_headers"), 17, 16).WithOrigin("maxItems"), + }, + }, { + name: "invalid prefix", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].Prefix = "Bearer\r" + }, + want: field.ErrorList{ + field.Invalid(staticHeader.Child("prefix"), "Bearer\r", "must be a valid HTTP field value prefix"), + }, + }, { + name: "missing credential URI", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].CredentialUri = "" + }, + want: field.ErrorList{ + field.Required(staticHeader.Child("credential_uri"), ""), + }, + }, { + name: "invalid credential URI", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects.InjectStaticHeaders[0].CredentialUri = "https://example.com/secret" + }, + want: field.ErrorList{ + field.Invalid(staticHeader.Child("credential_uri"), "https://example.com/secret", "must be substrate-secret:////"), + }, + }, { + name: "empty effects", + mutate: func(p *ateapipb.EgressPolicy) { + p.Rules[0].Hostnames.Effects = &ateapipb.EgressRuleEffects{} + }, + want: field.ErrorList{ + field.Required(hostnames.Child("effects"), "at least one effect must be specified"), + }, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := validReq() + if tc.mutate != nil { + tc.mutate(req.EgressPolicy) + } + assertValidateErr(t, ValidateCreateActorEgressPolicyRequest(context.Background(), req), tc.want) + }) + } +} + +func TestCredentialURIValidation(t *testing.T) { + for _, uri := range []string{ + "substrate-secret://kubernetes.io/provider/ns/name", + "substrate-secret://vault.example/provider/secret", + } { + if !validCredentialURI(uri) { + t.Errorf("validCredentialURI(%q) = false", uri) + } + } + for _, uri := range []string{ + "https://kubernetes.io/provider/ns/name", + "substrate-secret://kubernetes.io/provider", + "substrate-secret://kubernetes.io//provider/secret", + "substrate-secret://kubernetes.io/provider/secret/", + "substrate-secret://kubernetes.io:443/provider/secret", + } { + if validCredentialURI(uri) { + t.Errorf("validCredentialURI(%q) = true", uri) + } + } +} + +func TestHeaderValueValidation(t *testing.T) { + for _, value := range []string{"Bearer token", "value\tvalue", "\u0080\u0081"} { + if !validHeaderValue(value) { + t.Errorf("validHeaderValue(%q) = false", value) + } + } + for _, value := range []string{"a\rb", "a\nb", "a\x00b", "a\x1fb", "a\x7fb"} { + if validHeaderValue(value) { + t.Errorf("validHeaderValue(%q) = true", value) + } + } +} + +func TestHeaderNameValidation(t *testing.T) { + for _, value := range []string{"Authorization", "x-custom_header", "!#$%&'*+-.^_`|~"} { + if !validHeaderName(value) { + t.Errorf("validHeaderName(%q) = false", value) + } + } + for _, value := range []string{"", "bad header", "bad:header", "héader"} { + if validHeaderName(value) { + t.Errorf("validHeaderName(%q) = true", value) + } + } +} diff --git a/cmd/ateapi/internal/validation/identity.go b/cmd/ateapi/internal/validation/identity.go new file mode 100644 index 000000000..4eee4e7c4 --- /dev/null +++ b/cmd/ateapi/internal/validation/identity.go @@ -0,0 +1,44 @@ +// 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 validation + +import ( + "context" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func ValidateMintJWTRequest(ctx context.Context, req *ateapipb.MintJWTRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_MintJWTRequest(ctx, op, nil, req, nil) +} + +func ValidateMintCertRequest(ctx context.Context, req *ateapipb.MintCertRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_MintCertRequest(ctx, op, nil, req, nil) +} + +// maxCSRBytes bounds MintCertRequest's CSR. Real CSRs are a few KB; this is +// a guardrail, applied here because maxLength does not support bytes fields. +const maxCSRBytes = 16384 + +func ValidateCustom_MintCertRequest_CertificateSigningRequest(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []byte) field.ErrorList { + if len(value) > maxCSRBytes { + return field.ErrorList{field.TooLong(fldPath, nil, maxCSRBytes)} + } + return nil +} diff --git a/cmd/ateapi/internal/validation/identity_test.go b/cmd/ateapi/internal/validation/identity_test.go new file mode 100644 index 000000000..938345503 --- /dev/null +++ b/cmd/ateapi/internal/validation/identity_test.go @@ -0,0 +1,176 @@ +// 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 validation + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestValidateMintJWTRequest(t *testing.T) { + // This test verifies validation of user input for minting a JWT. + validReq := func(mods ...func(req *ateapipb.MintJWTRequest)) *ateapipb.MintJWTRequest { + req := &ateapipb.MintJWTRequest{ + Audience: []string{"aud1"}, + Atespace: "as1", + ActorName: "actor1", + ActorUid: "01234567-89ab-cdef-0123-456789abcdef", + } + for _, m := range mods { + m(req) + } + return req + } + + tests := []struct { + name string + req *ateapipb.MintJWTRequest + want field.ErrorList + }{{ + "valid", + validReq(), + nil, + }, { + "missing audience", + validReq(func(r *ateapipb.MintJWTRequest) { r.Audience = nil }), + field.ErrorList{field.Required(field.NewPath("audience"), "")}, + }, { + "too many audiences", + validReq(func(r *ateapipb.MintJWTRequest) { + r.Audience = make([]string, 17) + for i := range r.Audience { + r.Audience[i] = fmt.Sprintf("https://svc-%d.example.com", i) + } + }), + field.ErrorList{field.TooMany(field.NewPath("audience"), 17, 16).WithOrigin("maxItems")}, + }, { + "duplicate audience entry", + validReq(func(r *ateapipb.MintJWTRequest) { + r.Audience = []string{"https://a.example.com", "https://a.example.com"} + }), + field.ErrorList{field.Duplicate(field.NewPath("audience").Index(1), nil)}, + }, { + "audience entry too long", + validReq(func(r *ateapipb.MintJWTRequest) { r.Audience = []string{strings.Repeat("a", 513)} }), + field.ErrorList{field.TooLong(field.NewPath("audience").Index(0), nil, 512).WithOrigin("maxLength")}, + }, { + "missing atespace", + validReq(func(r *ateapipb.MintJWTRequest) { r.Atespace = "" }), + field.ErrorList{field.Required(field.NewPath("atespace"), "")}, + }, { + "invalid atespace", + validReq(func(r *ateapipb.MintJWTRequest) { r.Atespace = "AS1" }), + field.ErrorList{field.Invalid(field.NewPath("atespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing actor_name", + validReq(func(r *ateapipb.MintJWTRequest) { r.ActorName = "" }), + field.ErrorList{field.Required(field.NewPath("actor_name"), "")}, + }, { + "invalid actor_name", + validReq(func(r *ateapipb.MintJWTRequest) { r.ActorName = "invalid value" }), + field.ErrorList{field.Invalid(field.NewPath("actor_name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "unspecified actor_uid", + validReq(func(r *ateapipb.MintJWTRequest) { r.ActorUid = "" }), + nil, + }, { + "invalid actor_uid", + validReq(func(r *ateapipb.MintJWTRequest) { r.ActorUid = "not a uid" }), + field.ErrorList{field.Invalid(field.NewPath("actor_uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateMintJWTRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateMintCertRequest(t *testing.T) { + // This test verifies validation of user input for minting a certificate. + validReq := func(mods ...func(req *ateapipb.MintCertRequest)) *ateapipb.MintCertRequest { + req := &ateapipb.MintCertRequest{ + Worker: &ateapipb.ObjectRef{Name: "worker1"}, + CertificateSigningRequest: []byte{0x01}, + ExpectedActorUid: "01234567-89ab-cdef-0123-456789abcdef", + Purpose: ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_ATUNNEL, + } + for _, m := range mods { + m(req) + } + return req + } + + tests := []struct { + name string + req *ateapipb.MintCertRequest + want field.ErrorList + }{{ + "valid", + validReq(), + nil, + }, { + "oversized certificate_signing_request", + validReq(func(r *ateapipb.MintCertRequest) { r.CertificateSigningRequest = make([]byte, 16385) }), + field.ErrorList{field.TooLong(field.NewPath("certificate_signing_request"), nil, 16384)}, + }, { + "missing worker", + validReq(func(r *ateapipb.MintCertRequest) { r.Worker = nil }), + field.ErrorList{field.Required(field.NewPath("worker"), "")}, + }, { + "worker.atespace must be empty", + validReq(func(r *ateapipb.MintCertRequest) { r.Worker.Atespace = "as1" }), + field.ErrorList{field.Forbidden(field.NewPath("worker", "atespace"), "")}, + }, { + "missing worker.name", + validReq(func(r *ateapipb.MintCertRequest) { r.Worker.Name = "" }), + field.ErrorList{field.Required(field.NewPath("worker", "name"), "")}, + }, { + "invalid worker.name", + validReq(func(r *ateapipb.MintCertRequest) { r.Worker.Name = "invalid value" }), + field.ErrorList{field.Invalid(field.NewPath("worker", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "missing certificate_signing_request", + validReq(func(r *ateapipb.MintCertRequest) { r.CertificateSigningRequest = nil }), + field.ErrorList{field.Required(field.NewPath("certificate_signing_request"), "")}, + }, { + "missing expected_actor_uid", + validReq(func(r *ateapipb.MintCertRequest) { r.ExpectedActorUid = "" }), + field.ErrorList{field.Required(field.NewPath("expected_actor_uid"), "")}, + }, { + "invalid expected_actor_uid", + validReq(func(r *ateapipb.MintCertRequest) { r.ExpectedActorUid = "not a uid" }), + field.ErrorList{field.Invalid(field.NewPath("expected_actor_uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }, { + "unspecified purpose", + validReq(func(r *ateapipb.MintCertRequest) { + r.Purpose = ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_UNSPECIFIED + }), + field.ErrorList{field.Required(field.NewPath("purpose"), "")}, + }, { + "out-of-range purpose", + validReq(func(r *ateapipb.MintCertRequest) { r.Purpose = ateapipb.ActorCertificatePurpose(99) }), + field.ErrorList{field.Invalid(field.NewPath("purpose"), nil, "").WithOrigin("maximum")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateMintCertRequest(context.Background(), tt.req), tt.want) + }) + } +} diff --git a/cmd/ateapi/internal/validation/worker.go b/cmd/ateapi/internal/validation/worker.go new file mode 100644 index 000000000..66e86bc9c --- /dev/null +++ b/cmd/ateapi/internal/validation/worker.go @@ -0,0 +1,98 @@ +// 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 validation + +import ( + "context" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "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" +) + +func ValidateListWorkersRequest(ctx context.Context, req *ateapipb.ListWorkersRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_ListWorkersRequest(ctx, op, nil, req, nil) +} + +func ValidateGetWorkerRequest(ctx context.Context, req *ateapipb.GetWorkerRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_GetWorkerRequest(ctx, op, nil, req, nil) +} + +func ValidateCreateWorkerRequest(ctx context.Context, req *ateapipb.CreateWorkerRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_CreateWorkerRequest(ctx, op, nil, req, nil) +} + +func ValidateUpdateWorkerRequest(ctx context.Context, req *ateapipb.UpdateWorkerRequest) field.ErrorList { + // 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) +} + +func ValidateDeleteWorkerRequest(ctx context.Context, req *ateapipb.DeleteWorkerRequest) field.ErrorList { + // 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 ValidateDrainWorkerRequest(ctx context.Context, req *ateapipb.DrainWorkerRequest) field.ErrorList { + op := operation.Operation{Type: operation.Create} + return Validate_DrainWorkerRequest(ctx, op, nil, req, nil) +} + +// 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 { + 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 +} + +// 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 +} diff --git a/cmd/ateapi/internal/validation/worker_test.go b/cmd/ateapi/internal/validation/worker_test.go new file mode 100644 index 000000000..da1410c79 --- /dev/null +++ b/cmd/ateapi/internal/validation/worker_test.go @@ -0,0 +1,489 @@ +// 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 validation + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/protobuf/proto" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +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", + Ip: "10.1.2.3", + SandboxClass: "gvisor", + Capacity: &ateapipb.WorkerCapacity{CpuMilli: 2000, MemoryBytes: 4 << 30}, + } + for _, m := range mods { + m(w) + } + return w +} + +// withWorkerMetadata returns a modifier func (see validWorker) which sets +// the worker's resource metadata to a valid value. +func withWorkerMetadata(mutate func(*ateapipb.ResourceMetadata)) func(*ateapipb.Worker) { + return func(a *ateapipb.Worker) { mutate(a.Metadata) } +} + +// withWorkerStatus returns a modifier func (see validWorker) which sets the +// actor's status to a valid value. +func withWorkerStatus(mods ...func(*ateapipb.WorkerStatus)) func(*ateapipb.Worker) { + return func(a *ateapipb.Worker) { + a.Status = &ateapipb.WorkerStatus{ + State: ateapipb.WorkerState_WORKER_STATE_ACTIVE, + } + for _, m := range mods { + m(a.Status) + } + } +} + +func newAPIAssignment(actorUID string) *ateapipb.ActorAssignment { + return &ateapipb.ActorAssignment{ + ActorTemplateRef: &ateapipb.ObjectRef{Atespace: "ate-system", Name: "tmpl"}, + Actor: &ateapipb.ObjectRef{Atespace: "team-a", Name: "actor-1"}, + ActorUid: actorUID, + } +} + +func workerRef(name string) *ateapipb.ObjectRef { + return &ateapipb.ObjectRef{Name: name} +} + +func TestValidateListWorkersRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.ListWorkersRequest + want field.ErrorList + }{{ + "valid, no page_size", + &ateapipb.ListWorkersRequest{}, + nil, + }, { + "valid, positive page_size", + &ateapipb.ListWorkersRequest{PageSize: 10}, + nil, + }, { + "negative page_size", + &ateapipb.ListWorkersRequest{PageSize: -1}, + field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")}, + }, { + "valid page_token", + &ateapipb.ListWorkersRequest{PageToken: strings.Repeat("x", 256)}, + nil, + }, { + "too-large page_token", + &ateapipb.ListWorkersRequest{PageToken: strings.Repeat("x", 257)}, + field.ErrorList{field.TooLongCharacters(field.NewPath("page_token"), "", 256).WithOrigin("maxLength")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateListWorkersRequest(context.Background(), tt.req), tt.want) + }) + } +} + +// TestValidateWorker pins the field paths validateWorker reports. +// TestCreateWorker_InvalidArgument drives the same rules through the RPC, but +// only observes the status code. +func TestValidateCreateWorkerRequest(t *testing.T) { + // This test verifies validation of user input for creation. The RPC scrubs + // status before validating, so status is absent from the valid shape; when + // a request does carry one, it is validated like any other field. + validReq := func(actor *ateapipb.Worker, mods ...func(actor *ateapipb.CreateWorkerRequest)) *ateapipb.CreateWorkerRequest { + req := &ateapipb.CreateWorkerRequest{ + Worker: actor, + } + for _, m := range mods { + m(req) + } + return req + } + withStatus := withWorkerStatus + withMetadata := withWorkerMetadata + + tests := []struct { + name string + req *ateapipb.CreateWorkerRequest + want field.ErrorList + }{{ + name: "valid unassigned worker", + req: validReq(validWorker(apiWorkerName)), + }, { + name: "valid with status", + req: validReq(validWorker(apiWorkerName, withStatus())), + }, { + name: "missing worker", + req: &ateapipb.CreateWorkerRequest{Worker: nil}, + want: field.ErrorList{field.Required(field.NewPath("worker"), "")}, + }, { + name: "missing metadata", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Metadata = nil })), + want: field.ErrorList{field.Required(field.NewPath("worker", "metadata"), "")}, + }, { + name: "missing metadata.name", + req: validReq(validWorker(apiWorkerName, withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))), + want: field.ErrorList{field.Required(field.NewPath("worker", "metadata", "name"), "")}, + }, { + name: "invalid metadata.name", + req: validReq(validWorker(apiWorkerName, withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "not a name" }))), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "metadata.atespace set on a global-scoped Worker", + req: validReq(validWorker(apiWorkerName, withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "team-a" }))), + want: field.ErrorList{field.Forbidden(field.NewPath("worker", "metadata", "atespace"), "")}, + }, { + name: "missing worker_namespace", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerNamespace = "" })), + want: field.ErrorList{field.Required(field.NewPath("worker", "worker_namespace"), "")}, + }, { + name: "invalid worker_namespace", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerNamespace = "NS-1" })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_namespace"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + name: "missing worker_pool", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPool = "" })), + want: field.ErrorList{field.Required(field.NewPath("worker", "worker_pool"), "")}, + }, { + name: "invalid worker_pool", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPool = "POOL_1" })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_pool"), nil, "").WithOrigin("format=k8s-long-name")}, + }, { + name: "missing worker_pod", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPod = "" })), + want: field.ErrorList{field.Required(field.NewPath("worker", "worker_pod"), "")}, + }, { + name: "invalid worker_pod", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPod = "POD_1" })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_pod"), nil, "").WithOrigin("format=k8s-long-name")}, + }, { + name: "missing worker_pod_uid", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPodUid = "" })), + want: field.ErrorList{field.Required(field.NewPath("worker", "worker_pod_uid"), "")}, + }, { + name: "invalid worker_pod_uid", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.WorkerPodUid = "INVALID-UUID" })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "worker_pod_uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }, { + name: "missing node_name", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.NodeName = "" })), + want: field.ErrorList{field.Required(field.NewPath("worker", "node_name"), "")}, + }, { + name: "invalid node_name", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.NodeName = "NODE_NAME" })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "node_name"), nil, "").WithOrigin("format=k8s-long-name")}, + }, { + name: "missing ip", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Ip = "" })), + want: field.ErrorList{field.Required(field.NewPath("worker", "ip"), "")}, + }, { + name: "invalid ip", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Ip = "not-an-ip" })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "ip"), nil, "").WithOrigin("format=ip-strict")}, + }, { + name: "sandbox_class too long", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.SandboxClass = strings.Repeat("x", 64) })), + want: field.ErrorList{field.TooLong(field.NewPath("worker", "sandbox_class"), nil, 63).WithOrigin("maxLength")}, + }, { + name: "valid labels", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { + w.Labels = map[string]string{"tier": "batch", "pool.ate.io/zone": "us-west1-c"} + })), + }, { + name: "too many labels", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { + labels := make(map[string]string, 65) + for i := 0; i < 65; i++ { + labels[fmt.Sprintf("key-%d", i)] = "v" + } + w.Labels = labels + })), + want: field.ErrorList{field.TooMany(field.NewPath("worker", "labels"), 65, 64).WithOrigin("maxProperties")}, + }, { + name: "invalid label key", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Labels = map[string]string{"bad key!": "batch"} })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "labels"), "bad key!", "").WithOrigin("format=k8s-label-key")}, + }, { + name: "invalid label value", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Labels = map[string]string{"tier": "not valid!"} })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "labels").Key("tier"), "not valid!", "").WithOrigin("format=k8s-label-value")}, + }, { + name: "absent capacity is allowed", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = nil })), + }, { + name: "valid capacity", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = &ateapipb.WorkerCapacity{CpuMilli: 2000, MemoryBytes: 4 << 30} })), + }, { + name: "negative capacity.cpu_milli", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = &ateapipb.WorkerCapacity{CpuMilli: -1, MemoryBytes: 4 << 30} })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "capacity", "cpu_milli"), nil, "").WithOrigin("minimum")}, + }, { + name: "negative capacity.memory_bytes", + req: validReq(validWorker(apiWorkerName, func(w *ateapipb.Worker) { w.Capacity = &ateapipb.WorkerCapacity{CpuMilli: 2000, MemoryBytes: -1} })), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "capacity", "memory_bytes"), nil, "").WithOrigin("minimum")}, + }, { + name: "status needs a state", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { s.State = 0 }))), + want: field.ErrorList{field.Required(field.NewPath("worker", "status", "state"), "")}, + }, { + name: "status invalid state (too small)", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { s.State = -1 }))), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "state"), nil, "").WithOrigin("minimum")}, + }, { + name: "status invalid state (too large)", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { s.State = 99 }))), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "state"), nil, "").WithOrigin("maximum")}, + }, { + name: "valid assignment, when carried, passes", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { + s.Assignment = newAPIAssignment(apiOtherWorkerName) + }))), + }, { + name: "assignment actor_uid must be a uuid", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { + s.Assignment = newAPIAssignment("not a uuid") + }))), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "assignment", "actor_uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }, { + name: "assignment actor ref needs an atespace", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { + s.Assignment = newAPIAssignment(apiOtherWorkerName) + s.Assignment.Actor.Atespace = "" + }))), + want: field.ErrorList{field.Required(field.NewPath("worker", "status", "assignment", "actor", "atespace"), "")}, + }, { + name: "assignment template ref needs an atespace", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { + s.Assignment = newAPIAssignment(apiOtherWorkerName) + s.Assignment.ActorTemplateRef = &ateapipb.ObjectRef{Name: "tmpl"} + }))), + want: field.ErrorList{field.Required(field.NewPath("worker", "status", "assignment", "actor_template_ref", "atespace"), "")}, + }, { + name: "assignment needs a template ref", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { + s.Assignment = newAPIAssignment(apiOtherWorkerName) + s.Assignment.ActorTemplateRef = nil + }))), + want: field.ErrorList{field.Required(field.NewPath("worker", "status", "assignment", "actor_template_ref"), "")}, + }, { + name: "assignment template name must be a short name", + req: validReq(validWorker(apiWorkerName, withStatus(func(s *ateapipb.WorkerStatus) { + s.Assignment = newAPIAssignment(apiOtherWorkerName) + s.Assignment.ActorTemplateRef.Name = "TMPL_1" + }))), + want: field.ErrorList{field.Invalid(field.NewPath("worker", "status", "assignment", "actor_template_ref", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidateErr(t, ValidateCreateWorkerRequest(context.Background(), tc.req), tc.want) + }) + } +} + +func TestValidateDeleteWorkerRequest(t *testing.T) { + tests := []struct { + name string + req *ateapipb.DeleteWorkerRequest + want field.ErrorList + }{{ + "valid, no options", + &ateapipb.DeleteWorkerRequest{Worker: workerRef(apiWorkerName)}, + nil, + }, { + "valid, both guards", + &ateapipb.DeleteWorkerRequest{ + Worker: workerRef(apiWorkerName), + Options: &ateapipb.DeleteOptions{Uid: apiOtherWorkerName, Version: 3}, + }, + nil, + }, { + "missing worker", + &ateapipb.DeleteWorkerRequest{}, + field.ErrorList{field.Required(field.NewPath("worker"), "")}, + }, { + "missing worker.name", + &ateapipb.DeleteWorkerRequest{Worker: &ateapipb.ObjectRef{}}, + field.ErrorList{field.Required(field.NewPath("worker", "name"), "")}, + }, { + "worker.atespace must be empty", + &ateapipb.DeleteWorkerRequest{Worker: &ateapipb.ObjectRef{Atespace: "team-a", Name: apiWorkerName}}, + field.ErrorList{field.Forbidden(field.NewPath("worker", "atespace"), "")}, + }, { + "invalid options.uid", + &ateapipb.DeleteWorkerRequest{ + Worker: workerRef(apiWorkerName), + Options: &ateapipb.DeleteOptions{Uid: "not-a-uuid"}, + }, + field.ErrorList{field.Invalid(field.NewPath("options", "uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }, { + "negative options.version", + &ateapipb.DeleteWorkerRequest{ + Worker: workerRef(apiWorkerName), + Options: &ateapipb.DeleteOptions{Version: -1}, + }, + field.ErrorList{field.Invalid(field.NewPath("options", "version"), nil, "").WithOrigin("minimum")}, + }, { + // Zero values waive the guards, so they are never validated for shape. + "zero options are waived, not validated", + &ateapipb.DeleteWorkerRequest{ + Worker: workerRef(apiWorkerName), + Options: &ateapipb.DeleteOptions{}, + }, + nil, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateDeleteWorkerRequest(context.Background(), tt.req), tt.want) + }) + } +} + +func TestValidateUpdateWorkerRequest(t *testing.T) { + // This test verifies validation of user input for update. The worker body + // is deliberately not descended into here (updates are validated in two + // steps); only the metadata that addresses the resource is checked. + validReq := func(mods ...func(w *ateapipb.Worker)) *ateapipb.UpdateWorkerRequest { + worker := validWorker(apiWorkerName) + worker.Metadata.Uid = apiOtherWorkerName + worker.Metadata.Version = 3 + for _, m := range mods { + m(worker) + } + return &ateapipb.UpdateWorkerRequest{Worker: worker} + } + + tests := []struct { + name string + req *ateapipb.UpdateWorkerRequest + want field.ErrorList + }{{ + "valid", + validReq(), + nil, + }, { + // uid and version are preconditions the store requires; the request + // validation deliberately leaves their presence to the store. + "missing uid and version pass request validation", + validReq(func(w *ateapipb.Worker) { w.Metadata.Uid = ""; w.Metadata.Version = 0 }), + nil, + }, { + "missing worker", + &ateapipb.UpdateWorkerRequest{}, + field.ErrorList{field.Required(field.NewPath("worker"), "")}, + }, { + "missing metadata", + validReq(func(w *ateapipb.Worker) { w.Metadata = nil }), + field.ErrorList{field.Required(field.NewPath("worker", "metadata"), "")}, + }, { + "missing metadata.name", + validReq(func(w *ateapipb.Worker) { w.Metadata.Name = "" }), + field.ErrorList{field.Required(field.NewPath("worker", "metadata", "name"), "")}, + }, { + "invalid metadata.name", + validReq(func(w *ateapipb.Worker) { w.Metadata.Name = "Not A Name" }), + field.ErrorList{field.Invalid(field.NewPath("worker", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")}, + }, { + "invalid metadata.uid", + validReq(func(w *ateapipb.Worker) { w.Metadata.Uid = "not-a-uuid" }), + field.ErrorList{field.Invalid(field.NewPath("worker", "metadata", "uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }, { + "metadata.atespace set on a global-scoped Worker", + validReq(func(w *ateapipb.Worker) { w.Metadata.Atespace = "team-a" }), + field.ErrorList{field.Forbidden(field.NewPath("worker", "metadata", "atespace"), "")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidateErr(t, ValidateUpdateWorkerRequest(context.Background(), tt.req), tt.want) + }) + } +} + +// TestValidateWorkerUpdate_RequireStatus pins the final-object check that the +// RPC path cannot reach: the server always sets status before storing, so only +// a direct call shows the guard catching a worker without one. +func TestValidateWorkerUpdate_RequireStatus(t *testing.T) { + oldVal := validWorker(apiWorkerName) + oldVal.Status = &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE} + newVal := proto.Clone(oldVal).(*ateapipb.Worker) + newVal.Status = nil + + want := field.ErrorList{field.Required(field.NewPath("worker", "status"), "")} + assertValidateErr(t, ValidateWorkerUpdate(context.Background(), field.NewPath("worker"), newVal, oldVal, true), want) + + // Without requireStatus the same worker passes: status is optional in the + // schema, and clearing it is not otherwise constrained. + assertValidateErr(t, ValidateWorkerUpdate(context.Background(), field.NewPath("worker"), newVal, oldVal, false), nil) +} + +func TestValidateDeleteOptions(t *testing.T) { + valid := func(mutate ...func(*ateapipb.DeleteOptions)) *ateapipb.DeleteOptions { + tb := &ateapipb.DeleteOptions{} + for _, m := range mutate { + m(tb) + } + return tb + } + + tests := []struct { + name string + obj *ateapipb.DeleteOptions + want field.ErrorList + }{{ + name: "valid", + obj: valid(), // all optional fields + }, { + name: "valid version", + obj: valid(func(do *ateapipb.DeleteOptions) { do.Version = 1 }), + want: nil, + }, { + name: "invalid version", + obj: valid(func(do *ateapipb.DeleteOptions) { do.Version = -1 }), + want: field.ErrorList{field.Invalid(field.NewPath("version"), nil, "").WithOrigin("minimum")}, + }, { + name: "valid uid", + obj: valid(func(do *ateapipb.DeleteOptions) { do.Uid = "11111111-2222-3333-4444-555555555555" }), + want: nil, + }, { + name: "invalid uid", + obj: valid(func(do *ateapipb.DeleteOptions) { do.Uid = "not a uid" }), + want: field.ErrorList{field.Invalid(field.NewPath("uid"), nil, "").WithOrigin("format=k8s-uuid")}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: operation.Create} + assertValidateErr(t, Validate_DeleteOptions(context.Background(), op, nil, tt.obj, nil), tt.want) + }) + } +} diff --git a/cmd/ateapi/internal/controlapi/zz_generated.validation.go b/cmd/ateapi/internal/validation/zz_generated.validation.go similarity index 99% rename from cmd/ateapi/internal/controlapi/zz_generated.validation.go rename to cmd/ateapi/internal/validation/zz_generated.validation.go index 0d5d7dd3a..e311395a5 100644 --- a/cmd/ateapi/internal/controlapi/zz_generated.validation.go +++ b/cmd/ateapi/internal/validation/zz_generated.validation.go @@ -17,7 +17,7 @@ // Code generated by validation-gen. DO NOT EDIT. -package controlapi +package validation import ( context "context"