feat(api): Set RuntimePatch.Time field automatically during admission (#3319)

* feat(api): Set RuntimePatch.Time field automatically during admission

Signed-off-by: Antonin Stefanutti <[email protected]>

* Address @tenzen-y comments

Signed-off-by: Antonin Stefanutti <[email protected]>

* fix: Replace RuntimePatches time on UPDATE call

Signed-off-by: Andrey Velichkevich <[email protected]>

---------

Signed-off-by: Antonin Stefanutti <[email protected]>
Signed-off-by: Andrey Velichkevich <[email protected]>
Co-authored-by: Andrey Velichkevich <[email protected]>
This commit is contained in:
Antonin Stefanutti
2026-03-18 11:53:24 +00:00
committed by GitHub
co-authored by Andrey Velichkevich
parent 64a01ebbba
commit dc67f4e356
16 changed files with 645 additions and 13 deletions
@@ -54,6 +54,7 @@ rules:
- apiGroups:
- admissionregistration.k8s.io
resources:
- mutatingwebhookconfigurations
- validatingwebhookconfigurations
verbs:
- get
@@ -44,6 +44,13 @@ Create the name of the webhook service.
{{ include "trainer.manager.service.name" . }}
{{- end -}}
{{/*
Create the name of the mutating webhook configuration.
*/}}
{{- define "trainer.webhook.mutatingWebhookConfiguration.name" -}}
defaulter.trainer.kubeflow.org
{{- end -}}
{{/*
Create the name of the validating webhook configuration.
*/}}
@@ -0,0 +1,45 @@
{{- /*
Copyright 2025 The Kubeflow authors.
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
https://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.
*/ -}}
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: {{ include "trainer.webhook.mutatingWebhookConfiguration.name" . }}
labels:
{{- include "trainer.webhook.labels" . | nindent 4 }}
webhooks:
- name: defaulter.trainjob.trainer.kubeflow.org
admissionReviewVersions:
- v1
clientConfig:
service:
name: {{ include "trainer.webhook.service.name" . }}
namespace: {{ .Release.Namespace }}
path: /mutate-trainer-kubeflow-org-v1alpha1-trainjob
sideEffects: None
{{- with .Values.webhook.failurePolicy }}
failurePolicy: {{ . }}
{{- end }}
rules:
- apiGroups:
- trainer.kubeflow.org
apiVersions:
- v1alpha1
resources:
- trainjobs
operations:
- CREATE
- UPDATE
@@ -0,0 +1,41 @@
#
# Copyright 2025 The Kubeflow authors.
#
# 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
#
# https://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.
#
suite: Test MutatingWebhookConfiguration
templates:
- webhook/mutating_webhook_configuration.yaml
release:
name: kubeflow-trainer
namespace: kubeflow-system
tests:
- it: Should create MutatingWebhookConfiguration
asserts:
- containsDocument:
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
name: defaulter.trainer.kubeflow.org
- it: Should use the specified failurePolicy if `webhook.failurePolicy` is set
set:
webhook:
failurePolicy: Ignore
asserts:
- equal:
path: webhooks[*].failurePolicy
value: Ignore
+6 -4
View File
@@ -49,7 +49,8 @@ import (
)
const (
webhookConfigurationName = "validator.trainer.kubeflow.org"
validatingWebhookConfigurationName = "validator.trainer.kubeflow.org"
mutatingWebhookConfigurationName = "defaulter.trainer.kubeflow.org"
)
var (
@@ -128,9 +129,10 @@ func main() {
if config.IsCertManagementEnabled(&cfg) {
setupLog.Info("Setting up certificate management")
if err = cert.ManageCerts(mgr, cert.Config{
WebhookSecretName: cfg.CertManagement.WebhookSecretName,
WebhookServiceName: cfg.CertManagement.WebhookServiceName,
WebhookConfigurationName: webhookConfigurationName,
WebhookSecretName: cfg.CertManagement.WebhookSecretName,
WebhookServiceName: cfg.CertManagement.WebhookServiceName,
ValidatingWebhookConfigurationName: validatingWebhookConfigurationName,
MutatingWebhookConfigurationName: mutatingWebhookConfigurationName,
}, certsReady); err != nil {
setupLog.Error(err, "unable to set up cert rotation")
os.Exit(1)
+1
View File
@@ -37,6 +37,7 @@ rules:
- apiGroups:
- admissionregistration.k8s.io
resources:
- mutatingwebhookconfigurations
- validatingwebhookconfigurations
verbs:
- get
+6 -1
View File
@@ -3,7 +3,12 @@ kind: Kustomization
resources:
- manifests.yaml
patches:
- path: patch.yaml
- path: patch_mutating.yaml
target:
group: admissionregistration.k8s.io
version: v1
kind: MutatingWebhookConfiguration
- path: patch_validating.yaml
target:
group: admissionregistration.k8s.io
version: v1
@@ -1,6 +1,11 @@
# the following config is for teaching kustomize where to look at when substituting vars.
# It requires kustomize v2.1.0 or newer to work properly.
namespace:
- kind: MutatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
+26
View File
@@ -1,5 +1,31 @@
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: mutating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /mutate-trainer-kubeflow-org-v1alpha1-trainjob
failurePolicy: Fail
name: defaulter.trainjob.trainer.kubeflow.org
rules:
- apiGroups:
- trainer.kubeflow.org
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- trainjobs
sideEffects: None
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: validating-webhook-configuration
@@ -0,0 +1,6 @@
- op: replace
path: /webhooks/0/clientConfig/service/name
value: kubeflow-trainer-controller-manager
- op: replace
path: /metadata/name
value: defaulter.trainer.kubeflow.org
+15 -7
View File
@@ -45,13 +45,15 @@ func GetOperatorNamespace() string {
}
type Config struct {
WebhookServiceName string
WebhookSecretName string
WebhookConfigurationName string
WebhookServiceName string
WebhookSecretName string
ValidatingWebhookConfigurationName string
MutatingWebhookConfigurationName string
}
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;update
//+kubebuilder:rbac:groups="admissionregistration.k8s.io",resources=validatingwebhookconfigurations,verbs=get;list;watch;update
//+kubebuilder:rbac:groups="admissionregistration.k8s.io",resources=mutatingwebhookconfigurations,verbs=get;list;watch;update
// ManageCerts creates all certs for webhooks.
func ManageCerts(mgr ctrl.Manager, cfg Config, setupFinished chan struct{}) error {
@@ -70,10 +72,16 @@ func ManageCerts(mgr ctrl.Manager, cfg Config, setupFinished chan struct{}) erro
CAOrganization: caOrganization,
DNSName: dnsName,
IsReady: setupFinished,
Webhooks: []cert.WebhookInfo{{
Type: cert.Validating,
Name: cfg.WebhookConfigurationName,
}},
Webhooks: []cert.WebhookInfo{
{
Type: cert.Validating,
Name: cfg.ValidatingWebhookConfigurationName,
},
{
Type: cert.Mutating,
Name: cfg.MutatingWebhookConfigurationName,
},
},
// When Kubeflow Trainer is running in the leader election mode,
// we expect webhook server will run in primary and secondary instance
RequireLeaderElection: false,
+64
View File
@@ -18,9 +18,14 @@ package webhooks
import (
"context"
"encoding/json"
"fmt"
admissionv1 "k8s.io/api/admission/v1"
"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/klog/v2"
"k8s.io/utils/clock"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -28,6 +33,64 @@ import (
"github.com/kubeflow/trainer/v2/pkg/runtime"
)
// +kubebuilder:webhook:path=/mutate-trainer-kubeflow-org-v1alpha1-trainjob,mutating=true,failurePolicy=fail,sideEffects=None,groups=trainer.kubeflow.org,resources=trainjobs,verbs=create;update,versions=v1alpha1,name=defaulter.trainjob.trainer.kubeflow.org,admissionReviewVersions=v1
// TrainJobDefaulter defaults TrainJobs.
type TrainJobDefaulter struct {
clock clock.PassiveClock
}
var _ admission.Defaulter[*trainer.TrainJob] = (*TrainJobDefaulter)(nil)
func (d *TrainJobDefaulter) Default(ctx context.Context, trainJob *trainer.TrainJob) error {
log := ctrl.LoggerFrom(ctx).WithName("trainJob-webhook")
log.V(5).Info("Defaulting", "TrainJob", klog.KObj(trainJob))
now := metav1.NewTime(d.clock.Now())
req, err := admission.RequestFromContext(ctx)
if err != nil {
return err
}
var oldObj *trainer.TrainJob
if req.Operation == admissionv1.Update {
oldObj = &trainer.TrainJob{}
if err := json.Unmarshal(req.OldObject.Raw, oldObj); err != nil {
return err
}
}
if oldObj == nil {
for i := range trainJob.Spec.RuntimePatches {
if trainJob.Spec.RuntimePatches[i].Time == nil {
trainJob.Spec.RuntimePatches[i].Time = &now
}
}
return nil
}
oldByManager := make(map[string]trainer.RuntimePatch, len(oldObj.Spec.RuntimePatches))
for _, p := range oldObj.Spec.RuntimePatches {
oldByManager[p.Manager] = p
}
for i := range trainJob.Spec.RuntimePatches {
patch := &trainJob.Spec.RuntimePatches[i]
if old, ok := oldByManager[patch.Manager]; ok {
oldCmp, newCmp := old, *patch
oldCmp.Time, newCmp.Time = nil, nil
if equality.Semantic.DeepEqual(oldCmp, newCmp) {
patch.Time = old.Time
} else {
patch.Time = &now
}
} else if patch.Time == nil {
patch.Time = &now
}
}
return nil
}
// +kubebuilder:webhook:path=/validate-trainer-kubeflow-org-v1alpha1-trainjob,mutating=false,failurePolicy=fail,sideEffects=None,groups=trainer.kubeflow.org,resources=trainjobs,verbs=create;update,versions=v1alpha1,name=validator.trainjob.trainer.kubeflow.org,admissionReviewVersions=v1
// TrainJobValidator validates TrainJobs
@@ -39,6 +102,7 @@ var _ admission.Validator[*trainer.TrainJob] = (*TrainJobValidator)(nil)
func setupWebhookForTrainJob(mgr ctrl.Manager, run map[string]runtime.Runtime) error {
return ctrl.NewWebhookManagedBy(mgr, &trainer.TrainJob{}).
WithDefaulter(&TrainJobDefaulter{clock: clock.RealClock{}}).
WithValidator(&TrainJobValidator{runtimes: run}).
Complete()
}
+278
View File
@@ -18,12 +18,18 @@ package webhooks
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
admissionv1 "k8s.io/api/admission/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apiruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/klog/v2/ktesting"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -33,6 +39,278 @@ import (
testingutil "github.com/kubeflow/trainer/v2/pkg/util/testing"
)
func TestDefault(t *testing.T) {
fakeNow := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
fakeClock := clocktesting.NewFakeClock(fakeNow)
expectedTime := metav1.NewTime(fakeNow)
oldTime := metav1.NewTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
cases := map[string]struct {
oldObj *trainer.TrainJob
newObj *trainer.TrainJob
wantTime func(patches []trainer.RuntimePatch)
}{
"CREATE: all patches get timestamped": {
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/one"},
{Manager: "acme.io/two"},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
for _, p := range patches {
if !p.Time.Equal(&expectedTime) {
t.Errorf("patch %q: expected Time %v, got %v", p.Manager, expectedTime, p.Time)
}
}
},
},
"UPDATE, patch unchanged: Time preserved": {
oldObj: func() *trainer.TrainJob {
obj := testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "acme.io/one",
Time: &oldTime,
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: "node",
Template: &trainer.JobTemplatePatch{
Spec: &trainer.JobSpecPatch{
Template: &trainer.PodTemplatePatch{
Spec: &trainer.PodSpecPatch{
ServiceAccountName: ptr.To("sa"),
},
},
},
},
}},
},
},
},
},
}).Obj()
return obj
}(),
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "acme.io/one",
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: "node",
Template: &trainer.JobTemplatePatch{
Spec: &trainer.JobSpecPatch{
Template: &trainer.PodTemplatePatch{
Spec: &trainer.PodSpecPatch{
ServiceAccountName: ptr.To("sa"),
},
},
},
},
}},
},
},
},
},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
if !patches[0].Time.Equal(&oldTime) {
t.Errorf("expected Time to be preserved as %v, got %v", oldTime, patches[0].Time)
}
},
},
"UPDATE, patch changed: Time updated": {
oldObj: func() *trainer.TrainJob {
obj := testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "acme.io/one",
Time: &oldTime,
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: "node",
Template: &trainer.JobTemplatePatch{
Spec: &trainer.JobSpecPatch{
Template: &trainer.PodTemplatePatch{
Spec: &trainer.PodSpecPatch{
ServiceAccountName: ptr.To("old-sa"),
},
},
},
},
}},
},
},
},
},
}).Obj()
return obj
}(),
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "acme.io/one",
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: "node",
Template: &trainer.JobTemplatePatch{
Spec: &trainer.JobSpecPatch{
Template: &trainer.PodTemplatePatch{
Spec: &trainer.PodSpecPatch{
ServiceAccountName: ptr.To("new-sa"),
},
},
},
},
}},
},
},
},
},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
if !patches[0].Time.Equal(&expectedTime) {
t.Errorf("expected Time to be updated to %v, got %v", expectedTime, patches[0].Time)
}
},
},
"CREATE: externally-set Time is preserved": {
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/one", Time: &oldTime},
{Manager: "acme.io/two"},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
if !patches[0].Time.Equal(&oldTime) {
t.Errorf("patch %q: expected externally-set Time %v preserved, got %v", patches[0].Manager, oldTime, patches[0].Time)
}
if !patches[1].Time.Equal(&expectedTime) {
t.Errorf("patch %q: expected Time %v, got %v", patches[1].Manager, expectedTime, patches[1].Time)
}
},
},
"UPDATE, patch changed with pre-existing Time: Time updated": {
oldObj: func() *trainer.TrainJob {
obj := testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/one", Time: &oldTime},
}).Obj()
return obj
}(),
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "acme.io/one",
Time: &oldTime,
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: "node",
}},
},
},
},
},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
if !patches[0].Time.Equal(&expectedTime) {
t.Errorf("expected Time to be updated to %v, got %v", expectedTime, patches[0].Time)
}
},
},
"UPDATE, new patch added alongside unchanged patch": {
oldObj: func() *trainer.TrainJob {
obj := testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/existing", Time: &oldTime},
}).Obj()
return obj
}(),
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/existing"},
{Manager: "acme.io/new"},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
if !patches[0].Time.Equal(&oldTime) {
t.Errorf("existing patch: expected Time preserved as %v, got %v", oldTime, patches[0].Time)
}
if !patches[1].Time.Equal(&expectedTime) {
t.Errorf("new patch: expected Time %v, got %v", expectedTime, patches[1].Time)
}
},
},
"UPDATE, new patch added with externally-set Time: Time preserved": {
oldObj: func() *trainer.TrainJob {
obj := testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/existing", Time: &oldTime},
}).Obj()
return obj
}(),
newObj: testingutil.MakeTrainJobWrapper("default", "test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), "test-runtime").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/existing"},
{Manager: "acme.io/new", Time: &oldTime},
}).Obj(),
wantTime: func(patches []trainer.RuntimePatch) {
if !patches[0].Time.Equal(&oldTime) {
t.Errorf("existing patch: expected Time preserved as %v, got %v", oldTime, patches[0].Time)
}
if !patches[1].Time.Equal(&oldTime) {
t.Errorf("new patch: expected externally-set Time %v preserved, got %v", oldTime, patches[1].Time)
}
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
_, ctx := ktesting.NewTestContext(t)
operation := admissionv1.Create
req := admissionv1.AdmissionRequest{}
if tc.oldObj != nil {
operation = admissionv1.Update
raw, err := json.Marshal(tc.oldObj)
if err != nil {
t.Fatal(err)
}
req.OldObject = apiruntime.RawExtension{Raw: raw}
}
req.Operation = operation
ctx = admission.NewContextWithRequest(ctx, admission.Request{AdmissionRequest: req})
defaulter := &TrainJobDefaulter{clock: fakeClock}
if err := defaulter.Default(ctx, tc.newObj); err != nil {
t.Fatalf("Default returned unexpected error: %v", err)
}
tc.wantTime(tc.newObj.Spec.RuntimePatches)
})
}
}
func TestValidateCreate(t *testing.T) {
cases := map[string]struct {
obj *trainer.TrainJob
+124 -1
View File
@@ -1,6 +1,8 @@
package e2e
import (
"time"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
@@ -304,11 +306,14 @@ var _ = ginkgo.Describe("TrainJob e2e", func() {
ginkgo.When("Creating a TrainJob with RuntimePatches", func() {
ginkgo.It("should preserve user-provided manager fields", func() {
userTime := metav1.NewTime(time.Now().Add(-time.Hour).Truncate(time.Second))
trainJob := testingutil.MakeTrainJobWrapper(ns.Name, "e2e-test").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), torchRuntime).
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "test.io/manager-one",
Time: &userTime,
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
@@ -356,13 +361,131 @@ var _ = ginkgo.Describe("TrainJob e2e", func() {
gomega.Expect(k8sClient.Create(ctx, trainJob)).Should(gomega.Succeed())
})
ginkgo.By("Verify manager fields are preserved", func() {
ginkgo.By("Verify user-provided Time is preserved for test.io/manager-one and Time is set for kueue.k8s.io/manager", func() {
gomega.Eventually(func(g gomega.Gomega) {
gotTrainJob := &trainer.TrainJob{}
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(trainJob), gotTrainJob)).Should(gomega.Succeed())
g.Expect(gotTrainJob.Spec.RuntimePatches).Should(gomega.HaveLen(2))
g.Expect(gotTrainJob.Spec.RuntimePatches[0].Manager).To(gomega.Equal("test.io/manager-one"))
g.Expect(gotTrainJob.Spec.RuntimePatches[0].Time).ShouldNot(gomega.BeNil())
g.Expect(gotTrainJob.Spec.RuntimePatches[0].Time.Equal(&userTime)).To(gomega.BeTrue())
g.Expect(gotTrainJob.Spec.RuntimePatches[1].Manager).To(gomega.Equal("kueue.k8s.io/manager"))
g.Expect(gotTrainJob.Spec.RuntimePatches[1].Time).ShouldNot(gomega.BeNil())
}, util.Timeout, util.Interval).Should(gomega.Succeed())
})
})
ginkgo.It("should update Time only for the changed patch on update", func() {
userTime := metav1.NewTime(time.Now().Add(-time.Hour).Truncate(time.Second))
newPatchTime := metav1.NewTime(time.Now().Add(-30 * time.Minute).Truncate(time.Second))
trainJob := testingutil.MakeTrainJobWrapper(ns.Name, "e2e-update-time").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.ClusterTrainingRuntimeKind), torchRuntime).
Suspend(true).
RuntimePatches([]trainer.RuntimePatch{
{
Manager: "test.io/unchanged",
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: constants.Node,
Template: &trainer.JobTemplatePatch{
Spec: &trainer.JobSpecPatch{
Template: &trainer.PodTemplatePatch{
Spec: &trainer.PodSpecPatch{
NodeSelector: map[string]string{"zone": "keep"},
},
},
},
},
}},
},
},
},
},
{
Manager: "test.io/will-change",
Time: &userTime,
TrainingRuntimeSpec: &trainer.TrainingRuntimeSpecPatch{
Template: &trainer.JobSetTemplatePatch{
Spec: &trainer.JobSetSpecPatch{
ReplicatedJobs: []trainer.ReplicatedJobPatch{{
Name: constants.Node,
Template: &trainer.JobTemplatePatch{
Spec: &trainer.JobSpecPatch{
Template: &trainer.PodTemplatePatch{
Spec: &trainer.PodSpecPatch{
NodeSelector: map[string]string{"zone": "old"},
},
},
},
},
}},
},
},
},
},
}).
Obj()
ginkgo.By("Create a suspended TrainJob with two RuntimePatches", func() {
gomega.Expect(k8sClient.Create(ctx, trainJob)).Should(gomega.Succeed())
})
var unchangedTime *metav1.Time
ginkgo.By("Record the original Time values and verify user-provided Time is preserved", func() {
gomega.Eventually(func(g gomega.Gomega) {
gotTrainJob := &trainer.TrainJob{}
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(trainJob), gotTrainJob)).Should(gomega.Succeed())
g.Expect(gotTrainJob.Spec.RuntimePatches).Should(gomega.HaveLen(2))
g.Expect(gotTrainJob.Spec.RuntimePatches[0].Time).ShouldNot(gomega.BeNil())
// User-provided Time for test.io/will-change must be preserved on create.
g.Expect(gotTrainJob.Spec.RuntimePatches[1].Time).ShouldNot(gomega.BeNil())
g.Expect(gotTrainJob.Spec.RuntimePatches[1].Time.Equal(&userTime)).To(gomega.BeTrue())
unchangedTime = gotTrainJob.Spec.RuntimePatches[0].Time.DeepCopy()
}, util.Timeout, util.Interval).Should(gomega.Succeed())
})
// metav1.Time serializes to second precision (RFC3339), so we must
// wait at least one second to guarantee distinguishable timestamps.
time.Sleep(time.Second)
ginkgo.By("Update the second RuntimePatch and add two new patches", func() {
gomega.Eventually(func(g gomega.Gomega) {
gotTrainJob := &trainer.TrainJob{}
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(trainJob), gotTrainJob)).Should(gomega.Succeed())
gotTrainJob.Spec.RuntimePatches[1].TrainingRuntimeSpec.Template.Spec.ReplicatedJobs[0].
Template.Spec.Template.Spec.NodeSelector = map[string]string{"zone": "new"}
// New patch without Time: webhook should stamp it.
gotTrainJob.Spec.RuntimePatches = append(gotTrainJob.Spec.RuntimePatches, trainer.RuntimePatch{
Manager: "test.io/new-no-time",
})
// New patch with user-provided Time: should be preserved.
gotTrainJob.Spec.RuntimePatches = append(gotTrainJob.Spec.RuntimePatches, trainer.RuntimePatch{
Manager: "test.io/new-with-time",
Time: &newPatchTime,
})
g.Expect(k8sClient.Update(ctx, gotTrainJob)).Should(gomega.Succeed())
}, util.Timeout, util.Interval).Should(gomega.Succeed())
})
ginkgo.By("Verify Time behaviour for all patches after update", func() {
gomega.Eventually(func(g gomega.Gomega) {
gotTrainJob := &trainer.TrainJob{}
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(trainJob), gotTrainJob)).Should(gomega.Succeed())
g.Expect(gotTrainJob.Spec.RuntimePatches).Should(gomega.HaveLen(4))
// Unchanged pre-existing patch: Time must be preserved.
g.Expect(gotTrainJob.Spec.RuntimePatches[0].Time).ShouldNot(gomega.BeNil())
g.Expect(gotTrainJob.Spec.RuntimePatches[0].Time.Equal(unchangedTime)).To(gomega.BeTrue())
// Changed pre-existing patch: user-provided Time must be replaced.
g.Expect(gotTrainJob.Spec.RuntimePatches[1].Time).ShouldNot(gomega.BeNil())
g.Expect(gotTrainJob.Spec.RuntimePatches[1].Time.Equal(&userTime)).To(gomega.BeFalse())
// New patch without Time: webhook must stamp it.
g.Expect(gotTrainJob.Spec.RuntimePatches[2].Time).ShouldNot(gomega.BeNil())
// New patch with user-provided Time: must be preserved.
g.Expect(gotTrainJob.Spec.RuntimePatches[3].Time).ShouldNot(gomega.BeNil())
g.Expect(gotTrainJob.Spec.RuntimePatches[3].Time.Equal(&newPatchTime)).To(gomega.BeTrue())
}, util.Timeout, util.Interval).Should(gomega.Succeed())
})
})
@@ -468,6 +468,26 @@ var _ = ginkgo.Describe("TrainJob marker validations and defaulting", ginkgo.Ord
)
})
ginkgo.When("Defaulting RuntimePatch.Time on creation", func() {
ginkgo.It("Should set Time on each RuntimePatch when creating a TrainJob", func() {
trainJob := testingutil.MakeTrainJobWrapper(ns.Name, "time-defaulting").
RuntimeRef(trainer.SchemeGroupVersion.WithKind(trainer.TrainingRuntimeKind), "testing").
RuntimePatches([]trainer.RuntimePatch{
{Manager: "acme.io/manager-one"},
{Manager: "acme.io/manager-two"},
}).
Obj()
gomega.Expect(k8sClient.Create(ctx, trainJob)).Should(gomega.Succeed())
gomega.Eventually(func(g gomega.Gomega) {
got := &trainer.TrainJob{}
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(trainJob), got)).Should(gomega.Succeed())
g.Expect(got.Spec.RuntimePatches).Should(gomega.HaveLen(2))
g.Expect(got.Spec.RuntimePatches[0].Time).ShouldNot(gomega.BeNil())
g.Expect(got.Spec.RuntimePatches[1].Time).ShouldNot(gomega.BeNil())
}, util.Timeout, util.Interval).Should(gomega.Succeed())
})
})
ginkgo.When("Updating TrainJob", func() {
ginkgo.DescribeTable("Validate TrainJob on update", func(old func() *trainer.TrainJob, new func(*trainer.TrainJob) *trainer.TrainJob, errorMatcher gomega.OmegaMatcher) {
oldTrainJob := old()