mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-11 10:23:17 +08:00
To build a snapshot URI, we need the external storage location (which is stored in the actor template), actor atespace and UID (which are stored in the actor resource). If an actorTemplate was deleted, we'd leak the in-progress snapshot, because the storage information was gone. Fixes #1608 - [x] Tests pass - [ ] Appropriate changes to documentation are included in the PR
1530 lines
64 KiB
Go
1530 lines
64 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package controlapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
"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
|
|
withSourceTag := withActorSourceTag
|
|
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_tag",
|
|
validReq(validActor(withSourceTag("as", "tag"))),
|
|
nil,
|
|
}, {
|
|
"missing actor.source_tag.atespace",
|
|
validReq(validActor(withSourceTag("", "tag"))),
|
|
field.ErrorList{field.Required(field.NewPath("actor", "source_tag", "atespace"), "")},
|
|
}, {
|
|
"invalid actor.source_tag.atespace",
|
|
validReq(validActor(withSourceTag("invalid value", "tag"))),
|
|
field.ErrorList{field.Invalid(field.NewPath("actor", "source_tag", "atespace"), nil, "").WithOrigin("format=k8s-short-name")},
|
|
}, {
|
|
"missing actor.source_tag.name",
|
|
validReq(validActor(withSourceTag("as", ""))),
|
|
field.ErrorList{field.Required(field.NewPath("actor", "source_tag", "name"), "")},
|
|
}, {
|
|
"invalid actor.source_tag.name",
|
|
validReq(validActor(withSourceTag("as", "invalid value"))),
|
|
field.ErrorList{field.Invalid(field.NewPath("actor", "source_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
|
|
withSourceTag := withActorSourceTag
|
|
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_tag",
|
|
validInput(),
|
|
validOutput(withSourceTag("as", "nm")),
|
|
field.ErrorList{field.Invalid(field.NewPath("source_tag"), nil, "").WithOrigin("immutable")},
|
|
}, {
|
|
"clear actor.source_tag",
|
|
validInput(withSourceTag("as", "nm")),
|
|
validOutput(func(a *ateapipb.Actor) { a.SourceTag = nil }),
|
|
field.ErrorList{field.Invalid(field.NewPath("source_tag"), nil, "").WithOrigin("immutable")},
|
|
}, {
|
|
"change actor.source_tag",
|
|
validInput(withSourceTag("as1", "nm1")),
|
|
validOutput(withSourceTag("as2", "nm2")),
|
|
field.ErrorList{field.Invalid(field.NewPath("source_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_tag",
|
|
validInput(),
|
|
validOutput(withSourceTag("as", "nm")),
|
|
field.ErrorList{field.Invalid(field.NewPath("source_tag"), nil, "").WithOrigin("immutable")},
|
|
}, {
|
|
"clear actor.source_tag",
|
|
validInput(withSourceTag("as", "nm")),
|
|
validOutput(func(a *ateapipb.Actor) { a.SourceTag = nil }),
|
|
field.ErrorList{field.Invalid(field.NewPath("source_tag"), nil, "").WithOrigin("immutable")},
|
|
}, {
|
|
"change actor.source_tag",
|
|
validInput(withSourceTag("as1", "nm1")),
|
|
validOutput(withSourceTag("as2", "nm2")),
|
|
field.ErrorList{field.Invalid(field.NewPath("source_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_uri",
|
|
validInput(),
|
|
validOutput(withStatus(func(s *ateapipb.ActorStatus) {
|
|
s.InProgressSnapshotUri = "gs://private/atespaces/as/actors/" + someActorUID + "/snapshots/snap-1"
|
|
})),
|
|
nil,
|
|
}, {
|
|
"invalid actor.status.in_progress_snapshot_uri: too long",
|
|
validInput(),
|
|
validOutput(withStatus(func(s *ateapipb.ActorStatus) {
|
|
s.InProgressSnapshotUri = "gs://" + strings.Repeat("x", 2044)
|
|
})),
|
|
field.ErrorList{field.TooLong(field.NewPath("status", "in_progress_snapshot_uri"), nil, 2048).WithOrigin("maxLength")},
|
|
}, {
|
|
"valid actor.status.external_snapshot",
|
|
validInput(),
|
|
validOutput(withStatus(func(s *ateapipb.ActorStatus) {
|
|
s.ExternalSnapshot = &ateapipb.ExternalSnapshot{
|
|
SnapshotUri: "gs://private/atespaces/as/actors/" + someActorUID + "/snapshots/snap-1",
|
|
ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL,
|
|
}
|
|
})),
|
|
nil,
|
|
}, {
|
|
"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")},
|
|
}, {
|
|
"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")},
|
|
}}
|
|
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"
|
|
|
|
tests := []struct {
|
|
name string
|
|
stored *ateapipb.Actor
|
|
req *ateapipb.Actor
|
|
want *ateapipb.Actor
|
|
wantCode codes.Code
|
|
}{
|
|
{
|
|
name: "sets a worker_selector the stored actor does not have",
|
|
stored: &ateapipb.Actor{},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName},
|
|
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
|
|
},
|
|
want: &ateapipb.Actor{WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}}},
|
|
},
|
|
{
|
|
name: "overwrites an existing worker_selector",
|
|
stored: &ateapipb.Actor{WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "free"}}},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName},
|
|
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
|
|
},
|
|
want: &ateapipb.Actor{WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}}},
|
|
},
|
|
{
|
|
name: "an omitted worker_selector is cleared",
|
|
stored: &ateapipb.Actor{WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "free"}}},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName},
|
|
},
|
|
want: &ateapipb.Actor{},
|
|
},
|
|
{
|
|
name: "SourceTag immutable field is kept",
|
|
stored: &ateapipb.Actor{SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tag1"}},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName},
|
|
SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tag1"},
|
|
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
|
|
},
|
|
want: &ateapipb.Actor{
|
|
SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tag1"},
|
|
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
|
|
},
|
|
},
|
|
{
|
|
name: "changes to status in the request are ignored",
|
|
stored: &ateapipb.Actor{},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName},
|
|
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING},
|
|
},
|
|
want: &ateapipb.Actor{},
|
|
},
|
|
{
|
|
name: "an omitted immutable field is rejected",
|
|
stored: &ateapipb.Actor{SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tag1"}},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName},
|
|
// Omitted SourceTag
|
|
},
|
|
wantCode: codes.InvalidArgument,
|
|
},
|
|
{
|
|
name: "an immutable field the request rewrites is rejected",
|
|
stored: &ateapipb.Actor{SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tag1"}},
|
|
req: &ateapipb.Actor{
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: "attacker-ns", Name: "attacker-tmpl"},
|
|
SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tag2"},
|
|
},
|
|
wantCode: codes.InvalidArgument,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.stored.Metadata = &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID}
|
|
tt.stored.ActorTemplate = &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName}
|
|
tt.stored.Status = &ateapipb.ActorStatus{
|
|
State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED,
|
|
}
|
|
|
|
svc, created := rpcServiceWithActor(t, tt.stored)
|
|
|
|
tt.req.Metadata = created.GetMetadata()
|
|
updated, err := svc.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{Actor: tt.req})
|
|
|
|
if tt.wantCode != codes.OK {
|
|
if code := status.Code(err); code != tt.wantCode {
|
|
t.Errorf("UpdateActor error = %v (code %v), want code %v", err, code, tt.wantCode)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("UpdateActor failed: %v", err)
|
|
}
|
|
|
|
tt.want.Metadata = &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID, Version: 2}
|
|
tt.want.ActorTemplate = &ateapipb.ObjectRef{Atespace: templateNS, Name: templateName}
|
|
tt.want.Status = &ateapipb.ActorStatus{
|
|
State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED,
|
|
}
|
|
if diff := cmp.Diff(tt.want, updated, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
|
|
t.Errorf("UpdateActor response mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateActor_RepointTemplate covers the mutable actor_template ref: an
|
|
// update may point a suspended actor at a different template (it takes effect
|
|
// on the next ResumeActor), but the actor must be suspended, the new ref must
|
|
// resolve, and the replacement's sandbox config, volumes, and volume mounts
|
|
// must match the old template's.
|
|
func TestUpdateActor_RepointTemplate(t *testing.T) {
|
|
ctx := context.Background()
|
|
persistence, cleanup := storetest.SetupTestStore(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
storetest.MustCreateAtespace(t, ctx, persistence, testAtespace)
|
|
// tmpl-a and tmpl-b are volume-compatible; tmpl-c mounts the data volume
|
|
// elsewhere, tmpl-d declares an extra volume, and tmpl-e runs on a
|
|
// different sandbox class.
|
|
dataVolume := &ateapipb.Volume{Name: "data", DurableDir: &ateapipb.DurableDirVolumeSource{}}
|
|
scratchVolume := &ateapipb.Volume{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}
|
|
gvisorConfig := &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"}
|
|
microvmConfig := &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_MICROVM, ConfigName: "microvm"}
|
|
templates := map[string]struct {
|
|
mountPath string
|
|
volumes []*ateapipb.Volume
|
|
sandboxConfig *ateapipb.SandboxConfig
|
|
}{
|
|
"tmpl-a": {"/data", []*ateapipb.Volume{dataVolume}, gvisorConfig},
|
|
"tmpl-b": {"/data", []*ateapipb.Volume{dataVolume}, gvisorConfig},
|
|
"tmpl-c": {"/mnt/data", []*ateapipb.Volume{dataVolume}, gvisorConfig},
|
|
"tmpl-d": {"/data", []*ateapipb.Volume{dataVolume, scratchVolume}, gvisorConfig},
|
|
"tmpl-e": {"/data", []*ateapipb.Volume{dataVolume}, microvmConfig},
|
|
}
|
|
for name, tmpl := range templates {
|
|
if _, err := persistence.CreateActorTemplate(ctx, &ateapipb.ActorTemplate{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
|
|
Containers: []*ateapipb.Container{{
|
|
Name: "main",
|
|
Image: "example.com/app:v1",
|
|
VolumeMounts: []*ateapipb.VolumeMount{{Name: "data", MountPath: tmpl.mountPath}},
|
|
}},
|
|
Volumes: tmpl.volumes,
|
|
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
|
SandboxConfig: tmpl.sandboxConfig,
|
|
}); err != nil {
|
|
t.Fatalf("creating template %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
created := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-a"},
|
|
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
|
|
})
|
|
svc := &RPCService{impl: newServiceImpl(persistence, nil)}
|
|
|
|
// Repointing at a template that does not exist is rejected.
|
|
_, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: created.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "absent"},
|
|
}})
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Fatalf("UpdateActor to an absent template = %v, want FailedPrecondition (err: %v)", got, err)
|
|
}
|
|
|
|
// Repointing at a template with different volume mounts is rejected.
|
|
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: created.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-c"},
|
|
}})
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Fatalf("UpdateActor to a template with different mounts = %v, want FailedPrecondition (err: %v)", got, err)
|
|
}
|
|
|
|
// Repointing at a template with different volumes is rejected.
|
|
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: created.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-d"},
|
|
}})
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Fatalf("UpdateActor to a template with different volumes = %v, want FailedPrecondition (err: %v)", got, err)
|
|
}
|
|
|
|
// Repointing at a template naming a different SandboxConfig is rejected.
|
|
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: created.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-e"},
|
|
}})
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Fatalf("UpdateActor to a template with a different sandbox config = %v, want FailedPrecondition (err: %v)", got, err)
|
|
}
|
|
|
|
// Repointing at an existing template with identical volumes and mounts
|
|
// succeeds.
|
|
updated, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: created.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-b"},
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("UpdateActor failed: %v", err)
|
|
}
|
|
if got, want := updated.GetActorTemplate().GetName(), "tmpl-b"; got != want {
|
|
t.Errorf("updated actor_template.name = %q, want %q", got, want)
|
|
}
|
|
|
|
// When the old template no longer exists there is nothing left to
|
|
// compare the sandbox config or volume layout against, so the repoint
|
|
// only requires the new ref to resolve.
|
|
orphan := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "orphan-actor"},
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-gone"},
|
|
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
|
|
})
|
|
repointed, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: orphan.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-e"},
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("UpdateActor from a deleted template failed: %v", err)
|
|
}
|
|
if got, want := repointed.GetActorTemplate().GetName(), "tmpl-e"; got != want {
|
|
t.Errorf("updated actor_template.name = %q, want %q", got, want)
|
|
}
|
|
|
|
// Repointing an actor that is not suspended is rejected, even at a
|
|
// compatible template.
|
|
running := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "running-actor"},
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-a"},
|
|
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING},
|
|
})
|
|
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: running.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-b"},
|
|
}})
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Fatalf("UpdateActor repointing a running actor = %v, want FailedPrecondition (err: %v)", got, err)
|
|
}
|
|
|
|
// An update that keeps the template ref is still allowed while running:
|
|
// the suspended-state gate applies only to repoints.
|
|
kept, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: running.GetMetadata(),
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-a"},
|
|
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("UpdateActor keeping the template on a running actor failed: %v", err)
|
|
}
|
|
if got, want := kept.GetActorTemplate().GetName(), "tmpl-a"; got != want {
|
|
t.Errorf("updated actor_template.name = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestValidateTemplateVolumesUnchanged exercises the volumes and
|
|
// per-container mount comparison applied when an actor is repointed at a
|
|
// replacement template.
|
|
func TestValidateTemplateVolumesUnchanged(t *testing.T) {
|
|
dataVolume := &ateapipb.Volume{Name: "data", DurableDir: &ateapipb.DurableDirVolumeSource{}}
|
|
scratchVolume := &ateapipb.Volume{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}
|
|
template := func(volumes []*ateapipb.Volume, containers ...*ateapipb.Container) *ateapipb.ActorTemplate {
|
|
return &ateapipb.ActorTemplate{Volumes: volumes, Containers: containers}
|
|
}
|
|
container := func(name string, mounts ...*ateapipb.VolumeMount) *ateapipb.Container {
|
|
return &ateapipb.Container{Name: name, Image: "example.com/app:v1", VolumeMounts: mounts}
|
|
}
|
|
dataMount := &ateapipb.VolumeMount{Name: "data", MountPath: "/data"}
|
|
scratchMount := &ateapipb.VolumeMount{Name: "scratch", MountPath: "/scratch"}
|
|
|
|
oneVolume := []*ateapipb.Volume{dataVolume}
|
|
twoVolumes := []*ateapipb.Volume{dataVolume, scratchVolume}
|
|
|
|
tests := []struct {
|
|
name string
|
|
oldTmpl, newTmpl *ateapipb.ActorTemplate
|
|
wantErr bool
|
|
}{{
|
|
name: "identical volumes and mounts",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template(oneVolume, container("main", dataMount)),
|
|
}, {
|
|
name: "no volumes or mounts on either side",
|
|
oldTmpl: template(nil, container("main")),
|
|
newTmpl: template(nil, container("other")),
|
|
}, {
|
|
name: "volume added",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template(twoVolumes, container("main", dataMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "volume removed",
|
|
oldTmpl: template(twoVolumes, container("main", dataMount)),
|
|
newTmpl: template(oneVolume, container("main", dataMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "volume renamed",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template([]*ateapipb.Volume{{Name: "data2", DurableDir: &ateapipb.DurableDirVolumeSource{}}}, container("main", dataMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "volume source changed",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template([]*ateapipb.Volume{{Name: "data", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/data@sha256:0f9c04b7387d13ba9d15ec50355f9ad533fee2e5ad25378753a30671f8f9b938"}}}, container("main", dataMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "volume order changed",
|
|
oldTmpl: template(twoVolumes, container("main", dataMount)),
|
|
newTmpl: template([]*ateapipb.Volume{scratchVolume, dataVolume}, container("main", dataMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "mount path changed",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template(oneVolume, container("main", &ateapipb.VolumeMount{Name: "data", MountPath: "/mnt/data"})),
|
|
wantErr: true,
|
|
}, {
|
|
name: "mount added",
|
|
oldTmpl: template(twoVolumes, container("main", dataMount)),
|
|
newTmpl: template(twoVolumes, container("main", dataMount, scratchMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "mount removed",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template(oneVolume, container("main")),
|
|
wantErr: true,
|
|
}, {
|
|
name: "mounted container renamed",
|
|
oldTmpl: template(oneVolume, container("main", dataMount)),
|
|
newTmpl: template(oneVolume, container("renamed", dataMount)),
|
|
}, {
|
|
name: "container added with mounts",
|
|
oldTmpl: template(twoVolumes, container("main", dataMount)),
|
|
newTmpl: template(twoVolumes, container("main", dataMount), container("sidecar", scratchMount)),
|
|
}, {
|
|
name: "mount order changed",
|
|
oldTmpl: template(twoVolumes, container("main", dataMount, scratchMount)),
|
|
newTmpl: template(twoVolumes, container("main", scratchMount, dataMount)),
|
|
wantErr: true,
|
|
}, {
|
|
name: "mountless container renamed",
|
|
oldTmpl: template(oneVolume, container("main", dataMount), container("sidecar")),
|
|
newTmpl: template(oneVolume, container("main", dataMount), container("helper")),
|
|
}}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateTemplateVolumesUnchanged(tt.oldTmpl, tt.newTmpl)
|
|
if gotErr := err != nil; gotErr != tt.wantErr {
|
|
t.Fatalf("validateVolumeMountsUnchanged() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if err != nil {
|
|
if got := status.Code(err); got != codes.FailedPrecondition {
|
|
t.Errorf("status code = %v, want FailedPrecondition", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateActor_DeleteRecreateRace checks that an update is not applied
|
|
// if an actor was deleted and recreated during the update operation.
|
|
func TestUpdateActor_DeleteRecreateRace(t *testing.T) {
|
|
ctx := context.Background()
|
|
persistence, cleanup := storetest.SetupTestStore(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
actorRef := resources.ActorRef{Atespace: testAtespace, Name: testActorID}
|
|
|
|
// Actor A: what the client reads, and what its uid precondition names.
|
|
// Freshly created, so it sits at version 1.
|
|
original := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl1"},
|
|
Status: &ateapipb.ActorStatus{
|
|
State: ateapipb.ActorState_ACTOR_STATE_RUNNING,
|
|
WorkerAssignment: &ateapipb.WorkerAssignment{WorkerPod: "pod-a"},
|
|
},
|
|
})
|
|
|
|
// A concurrent client deletes A and recreates the same atespace/name as a
|
|
// brand new actor B, in the window the handler used to leave open between
|
|
// its own read and the store's WATCH.
|
|
var recreated *ateapipb.Actor
|
|
var err error
|
|
racing := &conflictInjectingStore{
|
|
Interface: persistence,
|
|
inject: func() {
|
|
if _, err := persistence.UpdateActor(ctx, actorRef, store.PreconditionFrom(original), func(toUpdate *ateapipb.Actor) error {
|
|
toUpdate.Status.State = ateapipb.ActorState_ACTOR_STATE_DELETING
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("racing writer: mark deleting: %v", err)
|
|
}
|
|
if _, err := persistence.DeleteActor(ctx, actorRef); err != nil {
|
|
t.Fatalf("racing writer: DeleteActor: %v", err)
|
|
}
|
|
recreated, err = persistence.CreateActor(ctx, &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl1"},
|
|
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("racing writer: recreate CreateActor: %v", err)
|
|
}
|
|
},
|
|
}
|
|
svc := &RPCService{impl: newServiceImpl(racing, nil)}
|
|
|
|
// The client asserts "only update the actor with uid A".
|
|
original.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}}
|
|
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: original})
|
|
if code := status.Code(err); code != codes.Aborted {
|
|
t.Errorf("UpdateActor error = %v (code %v), want code Aborted: the actor holding uid %s was deleted mid-update",
|
|
err, code, original.GetMetadata().GetUid())
|
|
}
|
|
|
|
stored, err := persistence.GetActor(ctx, actorRef)
|
|
if err != nil {
|
|
t.Fatalf("GetActor: %v", err)
|
|
}
|
|
if got, want := stored.GetMetadata().GetUid(), recreated.GetMetadata().GetUid(); got != want {
|
|
t.Fatalf("stored uid = %s, want recreated actor's uid %s", got, want)
|
|
}
|
|
// The stored record must still be actor B as its creator left it. Any of A's
|
|
// state showing up here is the clobber.
|
|
if got := stored.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
|
|
t.Errorf("stored state = %v, want %v: recreated actor was overwritten with the deleted actor's state",
|
|
got, ateapipb.ActorState_ACTOR_STATE_SUSPENDED)
|
|
}
|
|
if got := stored.GetStatus().GetWorkerAssignment(); got != nil {
|
|
t.Errorf("stored worker_assignment = %v, want nil: recreated actor inherited the deleted actor's worker", got)
|
|
}
|
|
if got := stored.GetWorkerSelector(); got != nil {
|
|
t.Errorf("stored worker_selector = %v, want nil: update meant for the deleted actor was applied", got)
|
|
}
|
|
}
|
|
|
|
// TestUpdateActor_ConcurrentDisjointUpdates checks that a concurrent write is
|
|
// reported even when it touched a field the update does not. The version guards
|
|
// the whole actor, not a single field, so the server cannot know the two
|
|
// writes commute: it reports the conflict and leaves reconciling to the client.
|
|
func TestUpdateActor_ConcurrentDisjointUpdates(t *testing.T) {
|
|
ctx := context.Background()
|
|
persistence, cleanup := storetest.SetupTestStore(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
actorRef := resources.ActorRef{Atespace: testAtespace, Name: testActorID}
|
|
|
|
original := storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
|
|
ActorTemplate: &ateapipb.ObjectRef{Atespace: "ns1", Name: "tmpl1"},
|
|
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING},
|
|
})
|
|
|
|
// A suspend workflow bumps state (a field that a later update operation will not touch)
|
|
// inside the handler's read-modify-write window.
|
|
racing := &conflictInjectingStore{
|
|
Interface: persistence,
|
|
inject: func() {
|
|
if _, err := persistence.UpdateActor(ctx, actorRef, store.PreconditionFrom(original), func(toUpdate *ateapipb.Actor) error {
|
|
toUpdate.Status.State = ateapipb.ActorState_ACTOR_STATE_SUSPENDING
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("racing writer: mark suspending: %v", err)
|
|
}
|
|
},
|
|
}
|
|
svc := &RPCService{impl: newServiceImpl(racing, nil)}
|
|
|
|
// Update operation is changing the worker_selector field, not the actor's state (like the concurrent op)
|
|
// This update must fail: the racing update bumped the version.
|
|
original.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}}
|
|
_, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: original})
|
|
if code := status.Code(err); code != codes.Aborted {
|
|
t.Errorf("UpdateActor error = %v (code %v), want code Aborted: the guarded version moved under the update", err, code)
|
|
}
|
|
|
|
stored, err := persistence.GetActor(ctx, actorRef)
|
|
if err != nil {
|
|
t.Fatalf("GetActor: %v", err)
|
|
}
|
|
// The concurrent writer's field survives; the rejected update wrote nothing.
|
|
if got := stored.GetWorkerSelector(); got != nil {
|
|
t.Errorf("stored worker_selector = %v, want nil: the rejected update was applied anyway", got)
|
|
}
|
|
if got := stored.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_SUSPENDING {
|
|
t.Errorf("stored state = %v, want %v: the concurrent writer's field must survive", got, ateapipb.ActorState_ACTOR_STATE_SUSPENDING)
|
|
}
|
|
}
|
|
|
|
// 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} }
|
|
}
|
|
|
|
// withActorSourceTag returns a modifier func (see validActor) which sets
|
|
// the actor's source_tag to a valid value.
|
|
func withActorSourceTag(atespace, name string) func(*ateapipb.Actor) {
|
|
return func(a *ateapipb.Actor) { a.SourceTag = &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) {
|
|
t.Helper()
|
|
persistence, cleanup := storetest.SetupTestStore(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateActor_GoldenTagDefault(t *testing.T) {
|
|
for _, scenario := range []string{"default", "explicit tag", "own snapshot", "missing", "pending", "wrong template", "data scope"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
persistence := newTestPersistence(t)
|
|
storetest.MustCreateAtespace(t, ctx, persistence, "team-a")
|
|
storetest.MustCreateAtespace(t, ctx, persistence, resources.GoldenActorAtespace)
|
|
tmpl := seedSubstrateTemplate(t, ctx, persistence, "tmpl")
|
|
ref := &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: "golden"}
|
|
tag := &ateapipb.Tag{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: ref.Atespace, Name: ref.Name},
|
|
SourceActor: ref,
|
|
Scope: ateapipb.TagScope_TAG_SCOPE_PUBLISHED,
|
|
Status: &ateapipb.TagStatus{
|
|
ActorTemplateUid: tmpl.GetMetadata().GetUid(),
|
|
Snapshot: &ateapipb.ExternalSnapshot{SnapshotUri: "gs://bucket/atespaces/ate-golden/tags/" + someActorUID, ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL},
|
|
},
|
|
}
|
|
wantCode := codes.OK
|
|
switch scenario {
|
|
case "missing":
|
|
wantCode = codes.NotFound
|
|
case "pending":
|
|
tag.Status.Snapshot = nil
|
|
wantCode = codes.FailedPrecondition
|
|
case "wrong template":
|
|
tag.Status.ActorTemplateUid = "other"
|
|
wantCode = codes.FailedPrecondition
|
|
case "data scope":
|
|
tag.Status.Snapshot.ContentScope = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA
|
|
wantCode = codes.FailedPrecondition
|
|
}
|
|
if scenario != "missing" {
|
|
if _, err := persistence.CreateTag(ctx, tag); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := persistence.UpdateActorTemplate(ctx, resources.ActorTemplateRefFromActorTemplate(tmpl), store.PreconditionFrom(tmpl), func(db *ateapipb.ActorTemplate) error {
|
|
db.Status = &ateapipb.ActorTemplateStatus{GoldenSnapshotStatus: &ateapipb.GoldenSnapshotStatus{GoldenTag: ref}}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actor := &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "actor"}, ActorTemplate: resources.ActorTemplateRefFromActorTemplate(tmpl).ToObjectRef()}
|
|
if scenario == "explicit tag" {
|
|
tag.Metadata.Name = "explicit"
|
|
tag.Status.Snapshot.SnapshotUri = "gs://bucket/atespaces/ate-golden/tags/explicit"
|
|
if _, err := persistence.CreateTag(ctx, tag); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actor.SourceTag = &ateapipb.ObjectRef{Atespace: ref.Atespace, Name: "explicit"}
|
|
}
|
|
svc := &ServiceImpl{store: persistence}
|
|
created, err := svc.CreateActor(ctx, actor)
|
|
if status.Code(err) != wantCode {
|
|
t.Fatalf("CreateActor = %v, want %v", err, wantCode)
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
if got := created.GetStatus(); got.GetExternalSnapshot().GetSnapshotUri() != tag.GetStatus().GetSnapshot().GetSnapshotUri() || got.GetCurrentActorTemplateUid() != tmpl.GetMetadata().GetUid() {
|
|
t.Fatalf("incorrect initial status: %v", got)
|
|
}
|
|
if scenario == "own snapshot" {
|
|
uri, err := resources.NewActorSnapshotURI(tmpl.GetSnapshotsConfig().GetStorageLocation(), "team-a", created.GetMetadata().GetUid(), "snapshot")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := persistence.UpdateActor(ctx, resources.ActorRefFromActor(created), store.PreconditionFrom(created), func(db *ateapipb.Actor) error {
|
|
db.Status.ExternalSnapshot.SnapshotUri = uri.String()
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
workflow := &ActorWorkflow{store: persistence}
|
|
_, _, src, err := workflow.loadActorForResume(ctx, resources.ActorRefFromActor(created))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if src.SnapshotURI.IsZero() {
|
|
t.Fatalf("missing snapshot source for %s", scenario)
|
|
}
|
|
})
|
|
}
|
|
}
|