mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-02 03:24:42 +08:00
validation: resolve validation todo in ateapi.proto (#1600)
Fixes a few todos in the `ateapi.proto` * Adds custom validation for `create_time` and `update_time` fields. * Adds better validation method for the `Container.image` field. - [ ] Tests pass - [ ] Appropriate changes to documentation are included in the PR
This commit is contained in:
@@ -21,6 +21,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
|
||||
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
|
||||
"github.com/agent-substrate/substrate/internal/resources"
|
||||
"github.com/agent-substrate/substrate/internal/volumepath"
|
||||
@@ -269,11 +271,21 @@ func ValidateCustom_SystemInfoVolumeSource_DataSources(_ context.Context, _ oper
|
||||
return errs
|
||||
}
|
||||
|
||||
// validatePinnedImage requires an image reference to include a digest
|
||||
// (e.g. "name@sha256:...").
|
||||
// validatePinnedImage requires a well-formed OCI image reference pinned by
|
||||
// digest (e.g. "name@sha256:..."): changing the image content under a fixed
|
||||
// reference invalidates snapshots. It parses with the same grammar the
|
||||
// container runtimes use, so a malformed digest is rejected rather than
|
||||
// treated as pinned.
|
||||
func validatePinnedImage(fldPath *field.Path, value string) field.ErrorList {
|
||||
if !strings.Contains(value, "@") {
|
||||
return field.ErrorList{field.Invalid(fldPath, value, "must include a digest")}
|
||||
if value == "" {
|
||||
return nil // required is enforced by tags
|
||||
}
|
||||
ref, err := reference.ParseNormalizedNamed(value)
|
||||
if err != nil {
|
||||
return field.ErrorList{field.Invalid(fldPath, value, fmt.Sprintf("must be a well-formed image reference: %v", err))}
|
||||
}
|
||||
if _, ok := ref.(reference.Digested); !ok {
|
||||
return field.ErrorList{field.Invalid(fldPath, value, "must be pinned by digest (changing the image invalidates snapshots)")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import (
|
||||
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@sha256:abc"}},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
|
||||
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
||||
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"},
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func TestCreateActorTemplateIgnoresServerOwnedFields(t *testing.T) {
|
||||
tmpl.Metadata.Uid = "11111111-1111-1111-1111-111111111111"
|
||||
tmpl.Metadata.Version = 42
|
||||
tmpl.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"pool": "default"}}
|
||||
tmpl.Containers = []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:abc"}}
|
||||
tmpl.Containers = []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}}
|
||||
tmpl.SnapshotsConfig = &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"}
|
||||
tmpl.Resources = &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "memory", Quantity: "1Gi"}}}
|
||||
// Server-owned status a client must not be able to set.
|
||||
@@ -550,14 +550,14 @@ func TestValidateActorTemplate(t *testing.T) {
|
||||
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@sha256:abc"})
|
||||
tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: fmt.Sprintf("c-%d", i), Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})
|
||||
}
|
||||
},
|
||||
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@sha256:abc"})
|
||||
tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: "main", Image: "example.com/other:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})
|
||||
},
|
||||
want: field.ErrorList{field.Duplicate(field.NewPath("containers").Index(1), nil)},
|
||||
}, {
|
||||
@@ -631,7 +631,7 @@ func TestValidateActorTemplate(t *testing.T) {
|
||||
}, {
|
||||
name: "the same path in different containers is allowed",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: "sidecar", Image: "example.com/side:v1@sha256:abc"})
|
||||
tmpl.Containers = append(tmpl.Containers, &ateapipb.Container{Name: "sidecar", Image: "example.com/side:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})
|
||||
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data"}}
|
||||
tmpl.Containers[1].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data"}}
|
||||
},
|
||||
@@ -712,9 +712,41 @@ func TestValidateActorTemplate(t *testing.T) {
|
||||
}, {
|
||||
name: "image too long",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers[0].Image = strings.Repeat("x", 513) + "@sha256:abc"
|
||||
tmpl.Containers[0].Image = strings.Repeat("x", 513) + "@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
want: field.ErrorList{field.TooLong(field.NewPath("containers").Index(0).Child("image"), nil, 512).WithOrigin("maxLength")},
|
||||
want: field.ErrorList{
|
||||
field.Invalid(field.NewPath("containers").Index(0).Child("image"), nil, ""),
|
||||
field.TooLong(field.NewPath("containers").Index(0).Child("image"), nil, 512).WithOrigin("maxLength"),
|
||||
},
|
||||
}, {
|
||||
name: "invalid image: bare repository without digest",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers[0].Image = "ubuntu"
|
||||
},
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("image"), nil, "")},
|
||||
}, {
|
||||
name: "valid image: pinned by digest",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers[0].Image = "example.com/app@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
}, {
|
||||
name: "invalid image: uppercase repository",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers[0].Image = "example.com/App:v1"
|
||||
},
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("image"), nil, "")},
|
||||
}, {
|
||||
name: "invalid image: malformed digest",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers[0].Image = "example.com/app@sha256:abc"
|
||||
},
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("image"), nil, "")},
|
||||
}, {
|
||||
name: "invalid image: empty tag",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Containers[0].Image = "example.com/app:"
|
||||
},
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("containers").Index(0).Child("image"), nil, "")},
|
||||
}, {
|
||||
name: "container image missing digest",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
@@ -949,14 +981,14 @@ func TestValidateActorTemplate(t *testing.T) {
|
||||
tmpl.Volumes = []*ateapipb.Volume{{
|
||||
Name: "scratch",
|
||||
DurableDir: &ateapipb.DurableDirVolumeSource{},
|
||||
Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:abc"},
|
||||
Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
|
||||
}}
|
||||
},
|
||||
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"}}}
|
||||
tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}}}
|
||||
},
|
||||
}, {
|
||||
name: "image volume missing reference",
|
||||
@@ -970,6 +1002,18 @@ func TestValidateActorTemplate(t *testing.T) {
|
||||
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: "image volume reference with malformed digest",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/app@sha256:abc"}}}
|
||||
},
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("image", "reference"), nil, "")},
|
||||
}, {
|
||||
name: "image volume reference not a reference at all",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
tmpl.Volumes = []*ateapipb.Volume{{Name: "tools", Image: &ateapipb.ImageVolumeSource{Reference: "@"}}}
|
||||
},
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("volumes").Index(0).Child("image", "reference"), nil, "")},
|
||||
}, {
|
||||
name: "valid external volume template",
|
||||
mutate: func(tmpl *ateapipb.ActorTemplate) {
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestActorTemplateCRUD(t *testing.T) {
|
||||
created, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
|
||||
ActorTemplate: &ateapipb.ActorTemplate{
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl-a"},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:abc"}},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
|
||||
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
||||
SandboxConfig: &ateapipb.SandboxConfig{
|
||||
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
|
||||
@@ -54,7 +54,7 @@ func TestActorTemplateCRUD(t *testing.T) {
|
||||
}
|
||||
want := &ateapipb.ActorTemplate{
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl-a", Version: 1},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:abc"}},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
|
||||
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
||||
SandboxConfig: &ateapipb.SandboxConfig{
|
||||
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
|
||||
@@ -70,7 +70,7 @@ func TestActorTemplateCRUD(t *testing.T) {
|
||||
_, err = tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
|
||||
ActorTemplate: &ateapipb.ActorTemplate{
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl-a"},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:abc"}},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
|
||||
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
||||
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"},
|
||||
},
|
||||
@@ -126,7 +126,7 @@ func TestActorTemplateCRUD(t *testing.T) {
|
||||
_, err = tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
|
||||
ActorTemplate: &ateapipb.ActorTemplate{
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl-unnamed-config"},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:abc"}},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
|
||||
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
||||
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR},
|
||||
},
|
||||
|
||||
@@ -184,7 +184,7 @@ func TestCreateActor_SubstrateTemplateRef(t *testing.T) {
|
||||
if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
|
||||
ActorTemplate: &ateapipb.ActorTemplate{
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "sub-tmpl"},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:abc"}},
|
||||
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
|
||||
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
|
||||
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"},
|
||||
},
|
||||
@@ -313,7 +313,7 @@ func TestCreateActor_RejectsSnapshotWithExternalVolumes(t *testing.T) {
|
||||
ConfigName: "gvisor-default",
|
||||
},
|
||||
Containers: []*ateapipb.Container{{
|
||||
Name: "main", Image: "main@sha256:abc", VolumeMounts: []*ateapipb.VolumeMount{{Name: "data", MountPath: "/data"}},
|
||||
Name: "main", Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", VolumeMounts: []*ateapipb.VolumeMount{{Name: "data", MountPath: "/data"}},
|
||||
}},
|
||||
Volumes: []*ateapipb.Volume{{
|
||||
Name: "data",
|
||||
@@ -753,7 +753,7 @@ func TestUpdateActor_RepointTemplate(t *testing.T) {
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
|
||||
Containers: []*ateapipb.Container{{
|
||||
Name: "main",
|
||||
Image: "example.com/app:v1@sha256:abc",
|
||||
Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
VolumeMounts: []*ateapipb.VolumeMount{{Name: "data", MountPath: tmpl.mountPath}},
|
||||
}},
|
||||
Volumes: tmpl.volumes,
|
||||
@@ -1865,7 +1865,7 @@ func TestResumeActorPassesLiteralEnv(t *testing.T) {
|
||||
createTemplateWithContainers(t, tc, ns, []*ateapipb.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Image: "main@sha256:abc",
|
||||
Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
Command: []string{"/main"},
|
||||
Env: []*ateapipb.EnvVar{
|
||||
{
|
||||
|
||||
@@ -385,7 +385,7 @@ func createTemplate(t *testing.T, tc *testContext, ns string) *ateapipb.ActorTem
|
||||
return createTemplateWithContainers(t, tc, ns, []*ateapipb.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Image: "main@sha256:abc",
|
||||
Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
Command: []string{"/main"},
|
||||
},
|
||||
})
|
||||
@@ -411,7 +411,7 @@ func createTemplateWithVolumes(t *testing.T, tc *testContext, ns string, volumes
|
||||
return createTemplateWithContainersAndVolumes(t, tc, ns, []*ateapipb.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Image: "main@sha256:abc",
|
||||
Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
Command: []string{"/main"},
|
||||
VolumeMounts: mounts,
|
||||
},
|
||||
@@ -476,7 +476,7 @@ func createTemplateWithContainersAndVolumes(t *testing.T, tc *testContext, ns st
|
||||
|
||||
// testPauseImage is the pause image the default test SandboxConfig carries;
|
||||
// it is what a resolved WorkloadSpec's sandbox assets should name.
|
||||
const testPauseImage = "pause@sha256:abc"
|
||||
const testPauseImage = "pause@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
// ensureDefaultGvisorSandboxConfig creates the cluster-scoped "gvisor-default"
|
||||
// SandboxConfig (idempotently) and waits for it to appear in the lister.
|
||||
@@ -527,7 +527,7 @@ func createWorkerPool(t *testing.T, tc *testContext, ns string, name string, lab
|
||||
},
|
||||
Spec: atev1alpha1.WorkerPoolSpec{
|
||||
Replicas: 1,
|
||||
WorkerImage: "ateom@sha256:abc",
|
||||
WorkerImage: "ateom@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
},
|
||||
}
|
||||
_, err := tc.substrateClient.ApiV1alpha1().WorkerPools(ns).Create(context.Background(), wp, metav1.CreateOptions{})
|
||||
@@ -571,7 +571,7 @@ func createTemplateWithSelector(t *testing.T, tc *testContext, name string, sele
|
||||
ConfigName: "gvisor-default",
|
||||
},
|
||||
Containers: []*ateapipb.Container{
|
||||
{Name: "main", Image: "main@sha256:abc", Command: []string{"/main"}},
|
||||
{Name: "main", Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", Command: []string{"/main"}},
|
||||
},
|
||||
WorkerSelector: selector,
|
||||
},
|
||||
|
||||
@@ -77,6 +77,31 @@ func ateDeepEqual[T any](a, b T) bool {
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
// ValidateCustom_ResourceMetadata checks the server-stamped timestamps: each,
|
||||
// when set, must be a valid google.protobuf.Timestamp, and update_time must
|
||||
// not precede create_time. Both fields are scrubbed from input, so a
|
||||
// violation here is a server stamping bug surfaced by the final-object
|
||||
// validation pass, not a client error.
|
||||
func ValidateCustom_ResourceMetadata(_ context.Context, _ operation.Operation, fldPath *field.Path, obj, _ *ateapipb.ResourceMetadata) field.ErrorList {
|
||||
var errs field.ErrorList
|
||||
createTimeValid := false
|
||||
if ct := obj.GetCreateTime(); ct != nil {
|
||||
if err := ct.CheckValid(); err != nil {
|
||||
errs = append(errs, field.Invalid(fldPath.Child("create_time"), ct.String(), err.Error()))
|
||||
} else {
|
||||
createTimeValid = true
|
||||
}
|
||||
}
|
||||
if ut := obj.GetUpdateTime(); ut != nil {
|
||||
if err := ut.CheckValid(); err != nil {
|
||||
errs = append(errs, field.Invalid(fldPath.Child("update_time"), ut.String(), err.Error()))
|
||||
} else if createTimeValid && ut.AsTime().Before(obj.GetCreateTime().AsTime()) {
|
||||
errs = append(errs, field.Invalid(fldPath.Child("update_time"), ut.String(), "must not precede create_time"))
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -93,6 +93,26 @@ func TestValidateResourceMetadataCreate(t *testing.T) {
|
||||
name: "unspecified updateTime",
|
||||
obj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime = nil }),
|
||||
want: nil,
|
||||
}, {
|
||||
name: "invalid createTime: seconds out of range",
|
||||
obj: valid(func(rm *ateapipb.ResourceMetadata) { rm.CreateTime = ×tamppb.Timestamp{Seconds: 253402300800} }),
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("create_time"), nil, "")},
|
||||
}, {
|
||||
name: "invalid updateTime: negative nanos",
|
||||
obj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime = ×tamppb.Timestamp{Seconds: 5309, Nanos: -1} }),
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("update_time"), nil, "")},
|
||||
}, {
|
||||
name: "invalid updateTime: precedes createTime",
|
||||
obj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime = ×tamppb.Timestamp{Seconds: 866} }),
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("update_time"), nil, "")},
|
||||
}, {
|
||||
name: "valid updateTime: equals createTime",
|
||||
obj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime = ×tamppb.Timestamp{Seconds: 867} }),
|
||||
want: nil,
|
||||
}, {
|
||||
name: "valid updateTime: set without createTime",
|
||||
obj: valid(func(rm *ateapipb.ResourceMetadata) { rm.CreateTime = nil }),
|
||||
want: nil,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -201,9 +221,14 @@ func TestValidateResourceMetadataUpdate(t *testing.T) {
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("update_time"), nil, "").WithOrigin("update")},
|
||||
}, {
|
||||
name: "update_time: changed to valid",
|
||||
oldObj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime.Seconds = 123 }),
|
||||
newObj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime.Seconds = 456 }),
|
||||
oldObj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime.Seconds = 1000 }),
|
||||
newObj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime.Seconds = 6000 }),
|
||||
want: nil,
|
||||
}, {
|
||||
name: "update_time: changed to precede create_time",
|
||||
oldObj: valid(),
|
||||
newObj: valid(func(rm *ateapipb.ResourceMetadata) { rm.UpdateTime.Seconds = 866 }),
|
||||
want: field.ErrorList{field.Invalid(field.NewPath("update_time"), nil, "")},
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -5033,6 +5033,11 @@ func Validate_ResourceMetadata(
|
||||
ctx context.Context, op operation.Operation, fldPath *field.Path,
|
||||
obj, oldObj *ateapipb.ResourceMetadata) (errs field.ErrorList) {
|
||||
|
||||
// custom validation
|
||||
if e := ValidateCustom_ResourceMetadata(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
|
||||
errs = append(errs, e...)
|
||||
}
|
||||
|
||||
{ // field ateapipb.ResourceMetadata.Atespace
|
||||
fn := func(
|
||||
fldPath *field.Path,
|
||||
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/aws/smithy-go v1.25.1
|
||||
github.com/container-storage-interface/spec v1.12.0
|
||||
github.com/containerd/ttrpc v1.2.8
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/envoyproxy/go-control-plane v0.14.0
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260812071801-353463cc7248
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
@@ -111,7 +112,6 @@ require (
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/cli v29.5.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
|
||||
@@ -722,6 +722,8 @@ func (x *Selector) GetMatchLabels() map[string]string {
|
||||
}
|
||||
|
||||
// ResourceMetadata holds the common fields carried by every Substrate resource.
|
||||
//
|
||||
// +k8s:customValidation # timestamps must be valid, and update_time must not precede create_time
|
||||
type ResourceMetadata struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// atespace is the namespace the resource belongs to. Empty for global-scoped
|
||||
@@ -766,7 +768,6 @@ type ResourceMetadata struct {
|
||||
//
|
||||
// +k8s:optional
|
||||
// +k8s:immutable
|
||||
// TODO: validate that this is a valid timestamp
|
||||
CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
|
||||
// update_time is the time the resource was last updated.
|
||||
//
|
||||
@@ -774,8 +775,6 @@ type ResourceMetadata struct {
|
||||
//
|
||||
// +k8s:optional
|
||||
// +k8s:update=NoUnset
|
||||
// TODO: validate that this is a valid timestamp
|
||||
// TODO: validate that UpdateTime >= CreateTime
|
||||
UpdateTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -2598,12 +2597,13 @@ type Container struct {
|
||||
// +k8s:required
|
||||
// +k8s:format=k8s-short-name
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
// image is the container image name. Must include a digest
|
||||
// image is the OCI image reference the container runs:
|
||||
// [registry/]repository[:tag]@digest. Must be pinned by digest
|
||||
// (e.g. "name@sha256:...").
|
||||
//
|
||||
// +k8s:required
|
||||
// +k8s:maxLength=512 # matches ImageVolumeSource.reference's bound
|
||||
// +k8s:customValidation
|
||||
// +k8s:customValidation # must be a well-formed image reference, pinned by digest
|
||||
Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"`
|
||||
// Entrypoint array; when set, the image's ENTRYPOINT and CMD are both
|
||||
// ignored and the process argv is command + args. Unlike Kubernetes,
|
||||
@@ -3161,7 +3161,7 @@ type ImageVolumeSource struct {
|
||||
//
|
||||
// +k8s:required
|
||||
// +k8s:maxLength=512
|
||||
// +k8s:customValidation
|
||||
// +k8s:customValidation # must be a well-formed image reference, pinned by digest
|
||||
Reference string `protobuf:"bytes,1,opt,name=reference,proto3" json:"reference,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
||||
@@ -221,6 +221,8 @@ message Selector {
|
||||
}
|
||||
|
||||
// ResourceMetadata holds the common fields carried by every Substrate resource.
|
||||
//
|
||||
// +k8s:customValidation # timestamps must be valid, and update_time must not precede create_time
|
||||
message ResourceMetadata {
|
||||
// atespace is the namespace the resource belongs to. Empty for global-scoped
|
||||
// resources. Caller-specified at creation and immutable thereafter.
|
||||
@@ -268,7 +270,6 @@ message ResourceMetadata {
|
||||
//
|
||||
// +k8s:optional
|
||||
// +k8s:immutable
|
||||
// TODO: validate that this is a valid timestamp
|
||||
google.protobuf.Timestamp create_time = 5;
|
||||
|
||||
// update_time is the time the resource was last updated.
|
||||
@@ -277,8 +278,6 @@ message ResourceMetadata {
|
||||
//
|
||||
// +k8s:optional
|
||||
// +k8s:update=NoUnset
|
||||
// TODO: validate that this is a valid timestamp
|
||||
// TODO: validate that UpdateTime >= CreateTime
|
||||
google.protobuf.Timestamp update_time = 6;
|
||||
}
|
||||
|
||||
@@ -912,12 +911,13 @@ message Container {
|
||||
// +k8s:format=k8s-short-name
|
||||
string name = 1;
|
||||
|
||||
// image is the container image name. Must include a digest
|
||||
// image is the OCI image reference the container runs:
|
||||
// [registry/]repository[:tag]@digest. Must be pinned by digest
|
||||
// (e.g. "name@sha256:...").
|
||||
//
|
||||
// +k8s:required
|
||||
// +k8s:maxLength=512 # matches ImageVolumeSource.reference's bound
|
||||
// +k8s:customValidation
|
||||
// +k8s:customValidation # must be a well-formed image reference, pinned by digest
|
||||
string image = 2;
|
||||
|
||||
// Entrypoint array; when set, the image's ENTRYPOINT and CMD are both
|
||||
@@ -1098,7 +1098,7 @@ message ImageVolumeSource {
|
||||
//
|
||||
// +k8s:required
|
||||
// +k8s:maxLength=512
|
||||
// +k8s:customValidation
|
||||
// +k8s:customValidation # must be a well-formed image reference, pinned by digest
|
||||
string reference = 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user