1515import json
1616import logging
1717import math
18+ import time
1819from typing import Any , Mapping , Sequence
1920from kubernetes import client
21+ from kubernetes import config as k8s_config
22+ import yaml
2023
2124# GKE sidecar containers restartPolicy compatibility placeholder.
2225
@@ -179,6 +182,14 @@ def __init__(
179182 "targetReplicatedJobs" : [PATHWAYS_HEAD_JOB_NAME ],
180183 }
181184
185+ @property
186+ def head_job_template (self ) -> client .V1JobTemplateSpec :
187+ return self ._head_job_template
188+
189+ @property
190+ def worker_job_template (self ) -> client .V1JobTemplateSpec :
191+ return self ._worker_job_template
192+
182193 def _build_head_job_template (
183194 self ,
184195 pathways_dir : str ,
@@ -696,29 +707,43 @@ def _compile_config(self) -> dict[str, Any]:
696707 self ._worker_job_template
697708 )
698709
699- replicated_jobs = [
700- {
701- "name" : PATHWAYS_HEAD_JOB_NAME ,
702- "replicas" : 1 ,
703- "template" : serialized_head ,
704- },
705- {
706- "name" : PATHWAYS_WORKER_JOB_NAME ,
707- "replicas" : self ._worker_replicas ,
708- "template" : serialized_worker ,
709- },
710- ]
710+ head_job = {
711+ "name" : PATHWAYS_HEAD_JOB_NAME ,
712+ "replicas" : 1 ,
713+ "template" : serialized_head ,
714+ }
715+ worker_job = {
716+ "name" : PATHWAYS_WORKER_JOB_NAME ,
717+ "replicas" : self ._worker_replicas ,
718+ "template" : serialized_worker ,
719+ }
720+
721+ coordinator = {
722+ "replicatedJob" : PATHWAYS_HEAD_JOB_NAME ,
723+ }
724+
725+ failure_policy : dict [str , Any ] = {
726+ "restartStrategy" : "Recreate" ,
727+ }
728+ if self ._max_restarts > 0 :
729+ failure_policy ["maxRestarts" ] = self ._max_restarts
711730
712731 jobset_config = {
713- "apiVersion" : f"jobset.sigs. k8s.io/{ self ._jobset_api_version } " ,
732+ "apiVersion" : f"jobset.x- k8s.io/{ self ._jobset_api_version } " ,
714733 "kind" : "JobSet" ,
715734 "metadata" : {
716735 "name" : self ._name ,
717736 "namespace" : self ._namespace ,
718737 },
719738 "spec" : {
720- "failurePolicy" : {"maxRestarts" : self ._max_restarts },
721- "replicatedJobs" : replicated_jobs ,
739+ "startupPolicy" : {"startupPolicyOrder" : "InOrder" },
740+ "failurePolicy" : failure_policy ,
741+ "network" : {
742+ "enableDNSHostnames" : True ,
743+ "publishNotReadyAddresses" : True ,
744+ },
745+ "coordinator" : coordinator ,
746+ "replicatedJobs" : [head_job , worker_job ],
722747 },
723748 }
724749 if self ._labels :
@@ -733,3 +758,153 @@ def _compile_config(self) -> dict[str, Any]:
733758 def to_dict (self ) -> dict [str , Any ]:
734759 """Returns the JobSet configuration as a dictionary."""
735760 return self ._compile_config ()
761+
762+ def export_yaml (self , filepath : str ) -> None :
763+ """Exports the JobSet configuration to a YAML file."""
764+ with open (filepath , "w" ) as f :
765+ yaml .dump (self .to_dict (), f , default_flow_style = False )
766+
767+ @classmethod
768+ def import_yaml (cls , filepath : str ) -> "PathwaysJobSet" :
769+ """Imports a JobSet configuration from a YAML file."""
770+ with open (filepath , "r" ) as f :
771+ config = yaml .safe_load (f )
772+
773+ cls ._validate_config (config )
774+
775+ instance = cls .__new__ (cls )
776+ instance ._name = config ["metadata" ]["name" ]
777+ instance ._namespace = config ["metadata" ].get ("namespace" , "default" )
778+ api_version_parts = config .get ("apiVersion" , "" ).split ("/" )
779+ instance ._jobset_api_version = (
780+ api_version_parts [- 1 ] if len (api_version_parts ) > 1 else "v1alpha2"
781+ )
782+ instance ._max_restarts = (
783+ config ["spec" ].get ("failurePolicy" , {}).get ("maxRestarts" , 0 )
784+ )
785+ instance ._labels = config ["metadata" ].get ("labels" , {})
786+ instance ._annotations = config ["metadata" ].get ("annotations" , {})
787+
788+ # Extract replicated jobs and deserialize.
789+ head_job_template : client .V1JobTemplateSpec | None = None
790+ worker_job_template : client .V1JobTemplateSpec | None = None
791+
792+ with client .ApiClient () as api_client :
793+ for job in config ["spec" ]["replicatedJobs" ]:
794+ if job ["name" ] == PATHWAYS_HEAD_JOB_NAME :
795+ head_job_template = _deserialize_dict (
796+ api_client , job ["template" ], client .V1JobTemplateSpec
797+ )
798+ elif job ["name" ] in ("worker" , PATHWAYS_WORKER_JOB_NAME ):
799+ worker_job_template = _deserialize_dict (
800+ api_client , job ["template" ], client .V1JobTemplateSpec
801+ )
802+ instance ._worker_replicas = job ["replicas" ]
803+
804+ if head_job_template is None :
805+ raise ValueError (f"Missing head job ({ PATHWAYS_HEAD_JOB_NAME } ) in config" )
806+ if worker_job_template is None :
807+ raise ValueError (
808+ f"Missing worker job ({ PATHWAYS_WORKER_JOB_NAME } ) in config"
809+ )
810+
811+ instance ._head_job_template = head_job_template
812+ instance ._worker_job_template = worker_job_template
813+
814+ instance ._success_policy = config ["spec" ].get ("successPolicy" )
815+ return instance
816+
817+ @classmethod
818+ def _validate_config (cls , config : dict [str , Any ]) -> None :
819+ """Validates that the config is a valid Pathways JobSet."""
820+ if config .get ("kind" ) != "JobSet" :
821+ raise ValueError ("Resource kind is not JobSet" )
822+ jobs = {
823+ j ["name" ]: j for j in config .get ("spec" , {}).get ("replicatedJobs" , [])
824+ }
825+ if "head" not in jobs and PATHWAYS_HEAD_JOB_NAME not in jobs :
826+ raise ValueError (
827+ f"Missing head replicated job ('head' or '{ PATHWAYS_HEAD_JOB_NAME } ')"
828+ )
829+ if "worker" not in jobs and PATHWAYS_WORKER_JOB_NAME not in jobs :
830+ raise ValueError (
831+ "Missing worker replicated job ('worker' or"
832+ f" '{ PATHWAYS_WORKER_JOB_NAME } ')"
833+ )
834+
835+ def apply (
836+ self , recreate : bool = False , field_manager : str = "pathwaysutils"
837+ ) -> None :
838+ """Applies the JobSet to the GKE cluster."""
839+
840+ try :
841+ k8s_config .load_kube_config ()
842+ except Exception : # pylint: disable=broad-except
843+ try :
844+ k8s_config .load_incluster_config ()
845+ except Exception as e :
846+ raise RuntimeError ("Failed to load Kubernetes configuration" ) from e
847+
848+ api = client .CustomObjectsApi ()
849+ group = "jobset.x-k8s.io"
850+ version = self ._jobset_api_version
851+ plural = "jobsets"
852+
853+ exists = False
854+ try :
855+ api .get_namespaced_custom_object (
856+ group , version , self ._namespace , plural , self ._name
857+ )
858+ exists = True
859+ except client .rest .ApiException as e :
860+ if e .status != 404 :
861+ raise
862+
863+ if exists :
864+ if recreate :
865+ _logger .info (
866+ "JobSet %s already exists. Deleting it first..." , self ._name
867+ )
868+ api .delete_namespaced_custom_object (
869+ group , version , self ._namespace , plural , self ._name
870+ )
871+
872+ # Poll for deletion.
873+ max_retries = 30
874+ for i in range (max_retries ):
875+ try :
876+ api .get_namespaced_custom_object (
877+ group , version , self ._namespace , plural , self ._name
878+ )
879+ _logger .info (
880+ "Waiting for JobSet %s to be deleted... (%d/%d)" ,
881+ self ._name ,
882+ i + 1 ,
883+ max_retries ,
884+ )
885+ time .sleep (2 )
886+ except client .rest .ApiException as e :
887+ if e .status == 404 :
888+ _logger .info ("JobSet %s deleted." , self ._name )
889+ break
890+ raise
891+ else :
892+ raise RuntimeError (
893+ f"Timeout waiting for JobSet { self ._name } to be deleted"
894+ )
895+ else :
896+ raise RuntimeError (
897+ f"JobSet { self ._name } already exists. Use recreate=True to"
898+ " overwrite."
899+ )
900+
901+ _logger .info ("Creating JobSet %s..." , self ._name )
902+ api .create_namespaced_custom_object (
903+ group = group ,
904+ version = version ,
905+ namespace = self ._namespace ,
906+ plural = plural ,
907+ body = self .to_dict (),
908+ field_manager = field_manager ,
909+ )
910+ _logger .info ("JobSet %s created successfully." , self ._name )
0 commit comments