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]>
264 lines
8.5 KiB
Go
264 lines
8.5 KiB
Go
/*
|
|
Copyright 2026 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 statusserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-logr/logr"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/manager"
|
|
|
|
configapi "github.com/kubeflow/trainer/v2/pkg/apis/config/v1alpha1"
|
|
trainer "github.com/kubeflow/trainer/v2/pkg/apis/trainer/v1alpha1"
|
|
trainerv1alpha1ac "github.com/kubeflow/trainer/v2/pkg/client/applyconfiguration/trainer/v1alpha1"
|
|
)
|
|
|
|
const (
|
|
shutdownTimeout = 5 * time.Second
|
|
|
|
// HTTP Server timeouts to prevent resource exhaustion
|
|
readTimeout = 10 * time.Second
|
|
writeTimeout = 10 * time.Second
|
|
idleTimeout = 120 * time.Second
|
|
|
|
// Maximum request body size (64kB)
|
|
maxBodySize = 1 << 16
|
|
)
|
|
|
|
// Server for collecting runtime status updates.
|
|
type Server struct {
|
|
log logr.Logger
|
|
httpServer *http.Server
|
|
client client.Client
|
|
authorizer TokenAuthorizer
|
|
}
|
|
|
|
var (
|
|
_ manager.Runnable = &Server{}
|
|
_ manager.LeaderElectionRunnable = &Server{}
|
|
)
|
|
|
|
// NewServer creates a new Server for collecting runtime status updates.
|
|
func NewServer(c client.Client, cfg *configapi.StatusServer, tlsConfig *tls.Config, authorizer TokenAuthorizer) (*Server, error) {
|
|
if cfg == nil || cfg.Port == nil {
|
|
return nil, fmt.Errorf("cfg info is required")
|
|
}
|
|
if tlsConfig == nil {
|
|
return nil, fmt.Errorf("tlsConfig is required")
|
|
}
|
|
if authorizer == nil {
|
|
return nil, fmt.Errorf("authorizer is required")
|
|
}
|
|
|
|
log := ctrl.Log.WithName("runtime-status")
|
|
|
|
s := &Server{
|
|
log: log,
|
|
client: c,
|
|
authorizer: authorizer,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST "+StatusUrl("{namespace}", "{name}"), s.handleTrainJobRuntimeStatus)
|
|
mux.HandleFunc("/", s.handleDefault)
|
|
|
|
// Apply middleware (authentication happens in handler)
|
|
handler := chain(mux,
|
|
recoveryMiddleware(log),
|
|
loggingMiddleware(log),
|
|
bodySizeLimitMiddleware(log, maxBodySize),
|
|
)
|
|
|
|
httpServer := http.Server{
|
|
Addr: fmt.Sprintf(":%d", *cfg.Port),
|
|
Handler: handler,
|
|
TLSConfig: tlsConfig,
|
|
ErrorLog: slog.NewLogLogger(logr.ToSlogHandler(log), slog.LevelInfo),
|
|
ReadTimeout: readTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
IdleTimeout: idleTimeout,
|
|
}
|
|
|
|
s.httpServer = &httpServer
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Start implements manager.Runnable and starts the HTTPS Server.
|
|
// It blocks until the Server stops, either due to an error or graceful shutdown.
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
s.log.Info("Initializing token authorizer")
|
|
if err := s.authorizer.Init(ctx); err != nil {
|
|
return fmt.Errorf("token authorizer initialization failed: %w", err)
|
|
}
|
|
|
|
// Handle graceful shutdown in background
|
|
serverShutdown := make(chan struct{})
|
|
go func() {
|
|
<-ctx.Done()
|
|
s.log.Info("Shutting down runtime status server")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
defer cancel()
|
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
|
|
s.log.Error(err, "Error shutting down runtime status server")
|
|
}
|
|
}()
|
|
|
|
s.log.Info("Starting runtime status server with TLS", "address", s.httpServer.Addr)
|
|
if err := s.httpServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return fmt.Errorf("runtime status server failed: %w", err)
|
|
}
|
|
|
|
<-serverShutdown
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) NeedLeaderElection() bool {
|
|
// server needs to run on all replicas
|
|
return false
|
|
}
|
|
|
|
// handleTrainJobRuntimeStatus handles POST requests to update TrainJob status.
|
|
// Expected URL format: /apis/trainer.kubeflow.org/v1alpha1/namespaces/{namespace}/trainjobs/{name}/status
|
|
func (s *Server) handleTrainJobRuntimeStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
namespace := r.PathValue("namespace")
|
|
trainJobName := r.PathValue("name")
|
|
|
|
authorized, err := s.authorizer.Authorize(r.Context(), r.Header.Get("Authorization"), namespace, trainJobName)
|
|
if err != nil {
|
|
badRequest(w, s.log, "Internal error", metav1.StatusReasonInternalError, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !authorized {
|
|
badRequest(w, s.log, "Forbidden", metav1.StatusReasonForbidden, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var updateRequest trainer.UpdateTrainJobStatusRequest
|
|
decoder := json.NewDecoder(r.Body)
|
|
if err := decoder.Decode(&updateRequest); err != nil {
|
|
s.log.V(5).Error(err, "Failed to parse runtime status", "namespace", namespace, "trainJobName", trainJobName)
|
|
badRequest(w, s.log, "Invalid payload", metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
|
|
// If the update request is empty (no trainer status), return success without applying
|
|
if updateRequest.TrainerStatus == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(updateRequest); err != nil {
|
|
s.log.Error(err, "Failed to write response", "namespace", namespace, "name", trainJobName)
|
|
}
|
|
return
|
|
}
|
|
|
|
var trainJob = trainerv1alpha1ac.TrainJob(trainJobName, namespace).WithStatus(toApplyConfig(updateRequest))
|
|
|
|
if err := s.client.Status().Apply(r.Context(), trainJob, client.ForceOwnership, client.FieldOwner("trainer-status")); err != nil {
|
|
// Check if the error is due to validation failure
|
|
if apierrors.IsInvalid(err) || apierrors.IsBadRequest(err) {
|
|
// Extract the validation error message for the user
|
|
statusErr, ok := err.(*apierrors.StatusError)
|
|
if ok && statusErr.ErrStatus.Message != "" {
|
|
badRequest(w, s.log, statusErr.ErrStatus.Message, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
|
} else {
|
|
badRequest(w, s.log, "Validation failed: "+err.Error(), metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if the error is due to missing object
|
|
if apierrors.IsNotFound(err) {
|
|
badRequest(w, s.log, "Train job not found", metav1.StatusReasonNotFound, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// For other errors, return internal server error
|
|
s.log.Error(err, "Failed to update TrainJob", "namespace", namespace, "name", trainJobName)
|
|
badRequest(w, s.log, "Internal error", metav1.StatusReasonInternalError, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return the parsed payload
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(updateRequest); err != nil {
|
|
s.log.Error(err, "Failed to write TrainJob status", "namespace", namespace, "name", trainJobName)
|
|
}
|
|
}
|
|
|
|
// handleDefault is the default handler for unknown requests.
|
|
func (s *Server) handleDefault(w http.ResponseWriter, _ *http.Request) {
|
|
badRequest(w, s.log, "Not found", metav1.StatusReasonNotFound, http.StatusNotFound)
|
|
}
|
|
|
|
// badRequest sends a kubernetes Status response with the error message
|
|
func badRequest(w http.ResponseWriter, log logr.Logger, message string, reason metav1.StatusReason, code int32) {
|
|
status := metav1.Status{
|
|
Status: metav1.StatusFailure,
|
|
Message: message,
|
|
Reason: reason,
|
|
Code: code,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(int(code))
|
|
if err := json.NewEncoder(w).Encode(status); err != nil {
|
|
log.Error(err, "Failed to write bad request details")
|
|
}
|
|
}
|
|
|
|
func toApplyConfig(updateRequest trainer.UpdateTrainJobStatusRequest) *trainerv1alpha1ac.TrainJobStatusApplyConfiguration {
|
|
var s = trainerv1alpha1ac.TrainJobStatus()
|
|
|
|
trainerStatus := updateRequest.TrainerStatus
|
|
if trainerStatus != nil {
|
|
var ts = trainerv1alpha1ac.TrainerStatus()
|
|
|
|
if trainerStatus.ProgressPercentage != nil {
|
|
ts = ts.WithProgressPercentage(*trainerStatus.ProgressPercentage)
|
|
}
|
|
if trainerStatus.EstimatedRemainingSeconds != nil {
|
|
ts = ts.WithEstimatedRemainingSeconds(*trainerStatus.EstimatedRemainingSeconds)
|
|
}
|
|
for _, m := range trainerStatus.Metrics {
|
|
ts.WithMetrics(
|
|
trainerv1alpha1ac.Metric().
|
|
WithName(m.Name).
|
|
WithValue(m.Value),
|
|
)
|
|
}
|
|
|
|
ts = ts.WithLastUpdatedTime(trainerStatus.LastUpdatedTime)
|
|
s.WithTrainerStatus(ts)
|
|
}
|
|
return s
|
|
}
|