Files
trainer/pkg/controller/trainjob_controller.go
T
Rob BellandGitHub 78b4a00929 feat: add support for tracking TrainJob progress and training metrics (#3227)
* feat(api): add progress types to api

Signed-off-by: Rob Bell <[email protected]>

* feat: progress server stub

Signed-off-by: Rob Bell <[email protected]>

* feat: setup tls for server

Signed-off-by: Rob Bell <[email protected]>

* refactor: pull out middleware

Signed-off-by: Rob Bell <[email protected]>

* refactor: remove logger message noise from tests

Signed-off-by: Rob Bell <[email protected]>

* fix: send server error logs to main logger

Signed-off-by: Rob Bell <[email protected]>

* feat: update the trainer status

Signed-off-by: Rob Bell <[email protected]>

* feat: use separate client to avoid impacting main reconciler client-side rate limits

Signed-off-by: Rob Bell <[email protected]>

* feat: configure progress server in helm chart and configmap

Signed-off-by: Rob Bell <[email protected]>

* fix: prevent race condition

Signed-off-by: Rob Bell <[email protected]>

* feat: inject progress runtime config

Signed-off-by: Rob Bell <[email protected]>

* fix: progress client config

Signed-off-by: Rob Bell <[email protected]>

* fix: update generated config

Signed-off-by: Rob Bell <[email protected]>

* feat: validate auth tokens in endpoint

Signed-off-by: Rob Bell <[email protected]>

* test: validate auth tokens in endpoint

Signed-off-by: Rob Bell <[email protected]>

* refactor: tidy tests to use same format as other tests

Signed-off-by: Rob Bell <[email protected]>

* fix: look up the cluster issuer from the service account token

Different clusters can use different issuers so look up the issuer from the trusted in-cluster service account token.

Signed-off-by: Rob Bell <[email protected]>

* refactor: rename authn middleware

Signed-off-by: Rob Bell <[email protected]>

* feat: add authn to status update endpoint

Signed-off-by: Rob Bell <[email protected]>

* refactor: tidy up reading in-cluster service account token

Signed-off-by: Rob Bell <[email protected]>

* fix: rbac permissions

Signed-off-by: Rob Bell <[email protected]>

* fix: better error response

Signed-off-by: Rob Bell <[email protected]>

* refactor: move kubebuilder annotation higher up

Signed-off-by: Rob Bell <[email protected]>

* fix: make lastUpdateTime required

Signed-off-by: Rob Bell <[email protected]>

* chore: fix config tests after rebase

Signed-off-by: Rob Bell <[email protected]>

* test: update config test with new progress server config

Signed-off-by: Rob Bell <[email protected]>

* test: add progress tracking e2e test

Signed-off-by: Rob Bell <[email protected]>

* fix: allow feature gates to be configurable from command line argument

So they can be overridden in e2e tests

Signed-off-by: Rob Bell <[email protected]>

* refactor: reuse shared progress StatusUrl

Signed-off-by: Rob Bell <[email protected]>

* test: tidy up tests

Signed-off-by: Rob Bell <[email protected]>

* refactor: allow plugins to use config

Signed-off-by: Rob Bell <[email protected]>

* refactor: centralise shared constants

Signed-off-by: Rob Bell <[email protected]>

* fix: disable http2 in progress server tls opts if disabled by config

Signed-off-by: Rob Bell <[email protected]>

* fix: minor improvements suggested by copilot

Signed-off-by: Rob Bell <[email protected]>

* chore: rename progress server => status server

Signed-off-by: Rob Bell <[email protected]>

* chore: update type doc

Signed-off-by: Rob Bell <[email protected]>

* chore: update name of feature gate

Signed-off-by: Rob Bell <[email protected]>

* fix: after rebase

Signed-off-by: Rob Bell <[email protected]>

* chore: fix typo

Signed-off-by: Rob Bell <[email protected]>

* chore: rename handler

Signed-off-by: Rob Bell <[email protected]>

* fix: run status server on all replicas

Signed-off-by: Rob Bell <[email protected]>

* fix: deep copy the rest config

Signed-off-by: Rob Bell <[email protected]>

* refactor: use ssa

Signed-off-by: Rob Bell <[email protected]>

* refactor: separate token audience per train job

Signed-off-by: Rob Bell <[email protected]>

* chore: rename status server

Signed-off-by: Rob Bell <[email protected]>

* fix: revert adding status pkg to manifest gen paths

Signed-off-by: Rob Bell <[email protected]>

* chore: update log messages

Signed-off-by: Rob Bell <[email protected]>

* chore: update api docs

Signed-off-by: Rob Bell <[email protected]>

* chore: update format

Signed-off-by: Rob Bell <[email protected]>

* fix: revert inadvertent change

Signed-off-by: Rob Bell <[email protected]>

* chore: update api name and type docs

Signed-off-by: Rob Bell <[email protected]>

* refactor: initialise oidc provider in manager startup

Signed-off-by: Rob Bell <[email protected]>

* fix: allow empty update request

Signed-off-by: Rob Bell <[email protected]>

* fix: wait for server shut down

Signed-off-by: Rob Bell <[email protected]>

* fix: handle missing train job

Signed-off-by: Rob Bell <[email protected]>

* refactor: update env var names

Signed-off-by: Rob Bell <[email protected]>

* chore: rename packages, plugins, feature gate

Signed-off-by: Rob Bell <[email protected]>

* refactor: update env var names

Signed-off-by: Rob Bell <[email protected]>

* chore: regenerate after rebase

Signed-off-by: Rob Bell <[email protected]>

* chore: fixes after rebase

Signed-off-by: Rob Bell <[email protected]>

* chore: minor doc updates

Signed-off-by: Rob Bell <[email protected]>

* chore: rename status server config

Signed-off-by: Rob Bell <[email protected]>

* chore: replace placeholder config with nil

Signed-off-by: Rob Bell <[email protected]>

* chore: revert separate runtime utils package

Signed-off-by: Rob Bell <[email protected]>

* chore: remove unused config from tests

Signed-off-by: Rob Bell <[email protected]>

---------

Signed-off-by: Rob Bell <[email protected]>
2026-03-13 20:01:08 +00:00

317 lines
11 KiB
Go

/*
Copyright 2024 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
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 controller
import (
"context"
"errors"
"fmt"
"iter"
"slices"
"time"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/events"
"k8s.io/klog/v2"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
jobsetv1alpha2 "sigs.k8s.io/jobset/api/jobset/v1alpha2"
trainer "github.com/kubeflow/trainer/v2/pkg/apis/trainer/v1alpha1"
"github.com/kubeflow/trainer/v2/pkg/constants"
jobruntimes "github.com/kubeflow/trainer/v2/pkg/runtime"
"github.com/kubeflow/trainer/v2/pkg/util/trainjob"
)
type TrainJobWatcher interface {
NotifyTrainJobUpdate(oldJob, newJob *trainer.TrainJob)
}
type TrainJobReconciler struct {
log logr.Logger
client client.Client
recorder events.EventRecorder
runtimes map[string]jobruntimes.Runtime
watchers iter.Seq[TrainJobWatcher]
}
type TrainJobReconcilerOptions struct {
Watchers iter.Seq[TrainJobWatcher]
}
type TrainJobReconcilerOption func(*TrainJobReconcilerOptions)
func WithWatchers(watchers ...TrainJobWatcher) TrainJobReconcilerOption {
return func(o *TrainJobReconcilerOptions) {
o.Watchers = slices.Values(watchers)
}
}
var _ reconcile.Reconciler = (*TrainJobReconciler)(nil)
var _ predicate.TypedPredicate[*trainer.TrainJob] = (*TrainJobReconciler)(nil)
func NewTrainJobReconciler(client client.Client, recorder events.EventRecorder, runtimes map[string]jobruntimes.Runtime, opts ...TrainJobReconcilerOption) *TrainJobReconciler {
options := &TrainJobReconcilerOptions{}
for _, opt := range opts {
opt(options)
}
return &TrainJobReconciler{
log: ctrl.Log.WithName("trainjob-controller"),
client: client,
recorder: recorder,
runtimes: runtimes,
watchers: options.Watchers,
}
}
// +kubebuilder:rbac:groups="",resources=events,verbs=create;watch;update;patch
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;watch;update;patch
// +kubebuilder:rbac:groups=trainer.kubeflow.org,resources=trainjobs,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=trainer.kubeflow.org,resources=trainjobs/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=trainer.kubeflow.org,resources=trainjobs/finalizers,verbs=get;update;patch
// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=create;get;list;update
func (r *TrainJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var trainJob trainer.TrainJob
if err := r.client.Get(ctx, req.NamespacedName, &trainJob); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
log := ctrl.LoggerFrom(ctx).WithValues("trainJob", klog.KObj(&trainJob))
ctx = ctrl.LoggerInto(ctx, log)
log.V(2).Info("Reconciling TrainJob")
var err error
// Keep track of the origin TrainJob status
prevTrainJob := trainJob.DeepCopy()
// Let's clear the failed condition that could have been set previously.
// An external change to the TrainJob spec may transition it out of the Failed state.
removeFailedCondition(&trainJob)
runtimeRefGK := jobruntimes.RuntimeRefToRuntimeRegistryKey(trainJob.Spec.RuntimeRef)
runtime, ok := r.runtimes[runtimeRefGK]
if !ok {
err = fmt.Errorf("unsupported runtime: %s", runtimeRefGK)
setFailedCondition(&trainJob, fmt.Sprintf("unsupported runtime: %s", runtimeRefGK), trainer.TrainJobRuntimeNotSupportedReason)
} else if !trainjob.IsTrainJobFinished(&trainJob) {
err = r.reconcileObjects(ctx, runtime, &trainJob)
if err != nil {
// TODO (astefanutti): the error should be surfaced in the TrainJob status to indicate
// the creation of the runtime resources failed and the TrainJob is backed off until
// the next retry attempt.
// The event message is truncated to stay within the maximum length limit (1024 chars).
message := fmt.Sprintf("TrainJob resources reconciliation failed: %.950v", err.Error())
if len(err.Error()) > 950 {
message = fmt.Sprintf("%s ...", message)
}
r.recorder.Eventf(&trainJob, nil, corev1.EventTypeWarning, "TrainJobResourcesCreationFailed", "Reconciling", message)
}
}
setSuspendedCondition(&trainJob)
if statusErr := setTrainJobStatus(ctx, runtime, &trainJob); statusErr != nil {
err = errors.Join(err, statusErr)
}
if deadlineResult, deadlineErr := r.reconcileDeadline(ctx, &trainJob); deadlineErr != nil || deadlineResult.RequeueAfter > 0 {
if !equality.Semantic.DeepEqual(&trainJob.Status, &prevTrainJob.Status) {
return deadlineResult, errors.Join(err, r.client.Status().Patch(ctx, &trainJob, client.MergeFrom(prevTrainJob)))
}
return deadlineResult, errors.Join(err, deadlineErr)
}
if !equality.Semantic.DeepEqual(&trainJob.Status, prevTrainJob.Status) {
// TODO(astefanutti): Consider using SSA once controller-runtime client has SSA support
// for sub-resources. See: https://github.com/kubernetes-sigs/controller-runtime/issues/3183
return ctrl.Result{}, errors.Join(err, r.client.Status().Patch(ctx, &trainJob, client.MergeFrom(prevTrainJob)))
}
return ctrl.Result{}, err
}
func (r *TrainJobReconciler) reconcileObjects(ctx context.Context, runtime jobruntimes.Runtime, trainJob *trainer.TrainJob) error {
objects, err := runtime.NewObjects(ctx, trainJob)
if err != nil {
return err
}
for _, object := range objects {
if err := r.client.Apply(ctx, object, client.FieldOwner("trainer"), client.ForceOwnership); err != nil {
return err
}
}
return nil
}
func (r *TrainJobReconciler) reconcileDeadline(ctx context.Context, trainJob *trainer.TrainJob) (ctrl.Result, error) {
if trainJob.Spec.ActiveDeadlineSeconds == 0 || trainjob.IsTrainJobFinished(trainJob) || ptr.Deref(trainJob.Spec.Suspend, false) {
return ctrl.Result{}, nil
}
startTime := trainJob.CreationTimestamp.Time
suspendedCond := meta.FindStatusCondition(trainJob.Status.Conditions, trainer.TrainJobSuspended)
if suspendedCond != nil && suspendedCond.Status == metav1.ConditionFalse {
startTime = suspendedCond.LastTransitionTime.Time
}
if startTime.IsZero() {
return ctrl.Result{}, nil
}
deadline := startTime.Add(time.Duration(trainJob.Spec.ActiveDeadlineSeconds) * time.Second)
now := time.Now()
if now.After(deadline) {
ctrl.LoggerFrom(ctx).V(2).Info("TrainJob deadline exceeded, marking as failed",
"activeDeadlineSeconds", trainJob.Spec.ActiveDeadlineSeconds,
"startTime", startTime,
"deadline", deadline)
setFailedCondition(trainJob, constants.TrainJobDeadlineExceededMessage, trainer.TrainJobDeadlineExceededReason)
jobSet := &jobsetv1alpha2.JobSet{
ObjectMeta: metav1.ObjectMeta{Name: trainJob.Name, Namespace: trainJob.Namespace},
}
if err := client.IgnoreNotFound(r.client.Delete(ctx, jobSet)); err != nil {
ctrl.LoggerFrom(ctx).V(2).Info("Failed to delete JobSet after deadline exceeded", "error", err)
}
return ctrl.Result{}, nil
}
requeueAfter := time.Until(deadline)
if requeueAfter <= 0 {
requeueAfter = 1 * time.Second
}
ctrl.LoggerFrom(ctx).V(2).Info("Scheduling deadline check",
"activeDeadlineSeconds", trainJob.Spec.ActiveDeadlineSeconds,
"requeueAfter", requeueAfter)
return ctrl.Result{RequeueAfter: requeueAfter}, nil
}
func (r *TrainJobReconciler) Create(e event.TypedCreateEvent[*trainer.TrainJob]) bool {
r.log.WithValues("trainJob", klog.KObj(e.Object)).Info("TrainJob create event")
defer r.notifyWatchers(nil, e.Object)
return true
}
func (r *TrainJobReconciler) Delete(e event.TypedDeleteEvent[*trainer.TrainJob]) bool {
r.log.WithValues("trainJob", klog.KObj(e.Object)).Info("TrainJob delete event")
defer r.notifyWatchers(e.Object, nil)
return true
}
func (r *TrainJobReconciler) Update(e event.TypedUpdateEvent[*trainer.TrainJob]) bool {
r.log.WithValues("trainJob", klog.KObj(e.ObjectNew)).Info("TrainJob update event")
defer r.notifyWatchers(e.ObjectOld, e.ObjectNew)
return true
}
func (r *TrainJobReconciler) Generic(e event.TypedGenericEvent[*trainer.TrainJob]) bool {
r.log.WithValues("trainJob", klog.KObj(e.Object)).Info("TrainJob generic event")
return true
}
func (r *TrainJobReconciler) notifyWatchers(oldJob, newJob *trainer.TrainJob) {
for w := range r.watchers {
w.NotifyTrainJobUpdate(oldJob, newJob)
}
}
func setSuspendedCondition(trainJob *trainer.TrainJob) {
var newCond metav1.Condition
switch {
case ptr.Deref(trainJob.Spec.Suspend, false):
newCond = metav1.Condition{
Type: trainer.TrainJobSuspended,
Status: metav1.ConditionTrue,
Message: constants.TrainJobSuspendedMessage,
Reason: trainer.TrainJobSuspendedReason,
}
case meta.IsStatusConditionTrue(trainJob.Status.Conditions, trainer.TrainJobSuspended):
newCond = metav1.Condition{
Type: trainer.TrainJobSuspended,
Status: metav1.ConditionFalse,
Message: constants.TrainJobResumedMessage,
Reason: trainer.TrainJobResumedReason,
}
default:
return
}
meta.SetStatusCondition(&trainJob.Status.Conditions, newCond)
}
func setFailedCondition(trainJob *trainer.TrainJob, message, reason string) {
newCond := metav1.Condition{
Type: trainer.TrainJobFailed,
Status: metav1.ConditionTrue,
Message: message,
Reason: reason,
}
meta.SetStatusCondition(&trainJob.Status.Conditions, newCond)
}
func removeFailedCondition(trainJob *trainer.TrainJob) {
cond := meta.FindStatusCondition(trainJob.Status.Conditions, trainer.TrainJobFailed)
if cond != nil && cond.Reason == trainer.TrainJobDeadlineExceededReason {
return
}
meta.RemoveStatusCondition(&trainJob.Status.Conditions, trainer.TrainJobFailed)
}
func setTrainJobStatus(ctx context.Context, runtime jobruntimes.Runtime, trainJob *trainer.TrainJob) error {
deadlineCond := meta.FindStatusCondition(trainJob.Status.Conditions, trainer.TrainJobFailed)
if deadlineCond != nil && deadlineCond.Reason != trainer.TrainJobDeadlineExceededReason {
deadlineCond = nil
}
status, err := runtime.TrainJobStatus(ctx, trainJob)
if err != nil {
return err
}
if status != nil {
trainJob.Status = *status
}
if deadlineCond != nil {
meta.SetStatusCondition(&trainJob.Status.Conditions, *deadlineCond)
}
return nil
}
func (r *TrainJobReconciler) SetupWithManager(mgr ctrl.Manager, options controller.Options) error {
b := builder.TypedControllerManagedBy[reconcile.Request](mgr).
Named("trainjob_controller").
WithOptions(options).
WatchesRawSource(source.TypedKind(
mgr.GetCache(),
&trainer.TrainJob{},
&handler.TypedEnqueueRequestForObject[*trainer.TrainJob]{},
r,
))
for _, runtime := range r.runtimes {
for _, registrar := range runtime.EventHandlerRegistrars() {
if registrar != nil {
b = registrar(b, mgr.GetClient(), mgr.GetCache())
}
}
}
return b.Complete(r)
}