diff --git a/cmd/ateapi/internal/controlapi/actor_template.go b/cmd/ateapi/internal/controlapi/actor_template.go index 30d9f9958..a62aa6b0f 100644 --- a/cmd/ateapi/internal/controlapi/actor_template.go +++ b/cmd/ateapi/internal/controlapi/actor_template.go @@ -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 } diff --git a/cmd/ateapi/internal/controlapi/actor_template_test.go b/cmd/ateapi/internal/controlapi/actor_template_test.go index 2b88f4ad7..efbfa3531 100644 --- a/cmd/ateapi/internal/controlapi/actor_template_test.go +++ b/cmd/ateapi/internal/controlapi/actor_template_test.go @@ -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) { diff --git a/cmd/ateapi/internal/controlapi/functionaltest/actor_template_test.go b/cmd/ateapi/internal/controlapi/functionaltest/actor_template_test.go index d7b8216cf..4af49697f 100644 --- a/cmd/ateapi/internal/controlapi/functionaltest/actor_template_test.go +++ b/cmd/ateapi/internal/controlapi/functionaltest/actor_template_test.go @@ -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}, }, diff --git a/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go b/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go index 2c9e50966..fdb9ee2a0 100644 --- a/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go +++ b/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go @@ -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{ { diff --git a/cmd/ateapi/internal/controlapi/functionaltest/common_test.go b/cmd/ateapi/internal/controlapi/functionaltest/common_test.go index 339d2521a..f79912f30 100644 --- a/cmd/ateapi/internal/controlapi/functionaltest/common_test.go +++ b/cmd/ateapi/internal/controlapi/functionaltest/common_test.go @@ -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, }, diff --git a/cmd/ateapi/internal/controlapi/validate.go b/cmd/ateapi/internal/controlapi/validate.go index 2bb767f41..3de5e6289 100644 --- a/cmd/ateapi/internal/controlapi/validate.go +++ b/cmd/ateapi/internal/controlapi/validate.go @@ -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) diff --git a/cmd/ateapi/internal/controlapi/validation_test.go b/cmd/ateapi/internal/controlapi/validation_test.go index ac94ccba3..5e8953c18 100644 --- a/cmd/ateapi/internal/controlapi/validation_test.go +++ b/cmd/ateapi/internal/controlapi/validation_test.go @@ -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) { diff --git a/cmd/ateapi/internal/controlapi/zz_generated.validation.go b/cmd/ateapi/internal/controlapi/zz_generated.validation.go index d1c5fe568..78e503540 100644 --- a/cmd/ateapi/internal/controlapi/zz_generated.validation.go +++ b/cmd/ateapi/internal/controlapi/zz_generated.validation.go @@ -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, diff --git a/go.mod b/go.mod index 02ac3b84b..4edf49121 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index f08eff412..26633d120 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -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 diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index c7f61e687..50b6dbac7 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -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; }