mirror of
https://github.com/hanzo-ml/trainer.git
synced 2026-09-24 00:31:34 +00:00
* 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]>
317 lines
11 KiB
Go
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)
|
|
}
|