Source code for idmtools_platform_comps.utils.singularity_build
"""idmtools singularity build workitem.Notes: - TODO add examples here.Copyright 2021, Bill & Melinda Gates Foundation. All rights reserved."""importhashlibimportioimportjsonimportosimportreimportuuidfromdataclassesimportdataclass,field,InitVarfromloggingimportgetLogger,DEBUGfromosimportPathLikefrompathlibimportPurePathfromtypingimportList,Dict,Union,Optional,TYPE_CHECKINGfromurllib.parseimporturlparsefromuuidimportUUIDfromCOMPS.DataimportQueryCriteriafromjinja2importEnvironmentfromidmtoolsimportIdmConfigParserfromidmtools.assetsimportAssetCollection,Assetfromidmtools.assets.file_listimportFileListfromidmtools.coreimportEntityStatus,NoPlatformExceptionfromidmtools.core.loggingimportSUCCESSfromidmtools.entities.command_taskimportCommandTaskfromidmtools.entities.relation_typeimportRelationTypefromidmtools.utils.hashingimportcalculate_md5_streamfromidmtools_platform_comps.ssmt_work_items.comps_workitemsimportInputDataWorkItemfromidmtools_platform_comps.utils.generalimportsave_sif_asset_md5_from_ac_idfromidmtools_platform_comps.utils.package_versionimportget_docker_manifest,get_digest_from_docker_hubifTYPE_CHECKING:fromidmtools.entities.iplatformimportIPlatformSB_BASE_WORKER_PATH=os.path.join(os.path.dirname(__file__),'base_singularity_work_order.json')logger=getLogger(__name__)user_logger=getLogger('user')
[docs]@dataclass(repr=False)classSingularityBuildWorkItem(InputDataWorkItem):""" Provides a wrapper to build utilizing the COMPS build server. Notes: - TODO add references to examples """#: Path to definition filedefinition_file:Union[PathLike,str]=field(default=None)#: definition content. Alternative to filedefinition_content:str=field(default=None)#: Enables Jinja parsing of the definition file or contentis_template:bool=field(default=False)#: template_argstemplate_args:Dict[str,str]=field(default_factory=dict)#: Image Urlimage_url:InitVar[str]=None#: Destination image nameimage_name:str=field(default=None)#: Name of the workitemname:str=field(default=None)#: Tages to add to container asset collectionimage_tags:Dict[str,str]=field(default_factory=dict)#: Allows you to set a different library. (The default library is “https://library.sylabs.io”). See https://sylabs.io/guides/3.5/user-guide/cli/singularity_build.htmllibrary:str=field(default=None)#: only run specific section(s) of definition file (setup, post, files, environment, test, labels, none) (default [all])section:List[str]=field(default_factory=lambda:['all'])#: build using user namespace to fake root user (requires a privileged installation)fix_permissions:bool=field(default=False)# AssetCollection created by buildasset_collection:AssetCollection=field(default=None)#: Additional Mountsadditional_mounts:List[str]=field(default_factory=list)#: Environment vars for remote buildenvironment_variables:Dict[str,str]=field(default_factory=dict)#: Force buildforce:bool=field(default=False)#: Don't include default tagsdisable_default_tags:bool=field(default=None)# ID that is added to work item and then results collection that can be used to tied the items togetherrun_id:uuid.UUID=field(default_factory=uuid.uuid4)#: loaded if url is docker://. Used to determine if we need to re-run a build__digest:Dict[str,str]=field(default=None)__image_tag:str=field(default=None)#: rendered template. We have to store so it is calculated before RUN which means outside our normal pre-create hooks__rendered_template:str=field(default=None)def__post_init__(self,item_name:str,asset_collection_id:UUID,asset_files:FileList,user_files:FileList,image_url:str):"""Constructor."""self.work_item_type='ImageBuilderWorker'self._image_url=None# Set this for now. Later it should be replace with some type of Specialized worker identifierself.task=CommandTask("ImageBuilderWorker")super().__post_init__(item_name,asset_collection_id,asset_files,user_files)self.image_url=image_urlifisinstance(image_url,str)elseNoneifisinstance(self.definition_file,PathLike):self.definition_file=str(self.definition_file)
[docs]defget_container_info(self)->Dict[str,str]:"""Get container info. Notes: - TODO remove this """pass
@propertydefimage_url(self):# noqa: F811"""Get the image url."""returnself._image_url@image_url.setterdefimage_url(self,value:str):""" Set the image url. Args: value: Value to set value to Returns: None """url_info=urlparse(value)ifurl_info.scheme=="docker":if"packages.idmod.org"invalue:full_manifest,self.__image_tag=get_docker_manifest(url_info.path)self.__digest=full_manifest['config']['digest']else:self.__image_tag=url_info.netloc+":latest"if":"notinvalueelseurl_info.netlocimage,tag=url_info.netloc.split(":")self.__digest=get_digest_from_docker_hub(image,tag)ifself.fix_permissions:self.__digest+="--fix-perms"ifself.nameisNone:self.name=f"Load Singularity image from Docker {self.__image_tag}"# TODO how to do this for shubself._image_url=value
[docs]defcontext_checksum(self)->str:""" Calculate the context checksum of a singularity build. The context is the checksum of all the assets defined for input, the singularity definition file, and the environment variables Returns: Conext checksum. """file_hash=hashlib.sha256()# ensure our template is setself.__add_common_assets()forassetinsorted(self.assets+self.transient_assets,key=lambdaa:a.short_remote_path()):ifasset.absolute_path:withopen(asset.absolute_path,mode='rb')asain:calculate_md5_stream(ain,file_hash=file_hash)else:self.__add_file_to_context(json.dumps([asset.filename,asset.relative_path,str(asset.checksum)],sort_keys=True)ifasset.persistedelseasset.bytes,file_hash)iflen(self.environment_variables):contents=json.dumps(self.environment_variables,sort_keys=True)self.__add_file_to_context(contents,file_hash)iflogger.isEnabledFor(DEBUG):logger.debug(f'Context: sha256:{file_hash.hexdigest()}')returnf'sha256:{file_hash.hexdigest()}'
def__add_file_to_context(self,contents:Union[str,bytes],file_hash):""" Add a specific file content to context checksum. Args: contents: Contents file_hash: File hash to add to Returns: None """item=io.BytesIO()item.write(contents.encode('utf-8')ifisinstance(contents,str)elsecontents)item.seek(0)calculate_md5_stream(item,file_hash=file_hash)
[docs]defrender_template(self)->Optional[str]:""" Render template. Only applies when is_template is True. When true, it renders the template using Jinja to a cache value. Returns: Rendered Template """ifself.is_template:# We don't allow re-running template renderingifself.__rendered_templateisNone:iflogger.isEnabledFor(DEBUG):logger.debug("Rendering template")contents=None# try from file firstifself.definition_file:withopen(self.definition_file,mode='r')asain:contents=ain.read()elifself.definition_content:contents=self.definition_contentifcontents:env=Environment()template=env.from_string(contents)self.__rendered_template=template.render(env=os.environ,sbi=self,**self.template_args)returnself.__rendered_templatereturnNone
[docs]@staticmethoddeffind_existing_container(sbi:'SingularityBuildWorkItem',platform:'IPlatform'=None)->Optional[AssetCollection]:""" Find existing container. Args: sbi: SingularityBuildWorkItem to find existing container matching config platform: Platform To load the object from Returns: Existing Asset Collection """ifplatformisNone:fromidmtools.core.contextimportCURRENT_PLATFORMifCURRENT_PLATFORMisNone:raiseNoPlatformException("No Platform defined on object, in current context, or passed to run")platform=CURRENT_PLATFORMac=Noneifnotsbi.force:# don't search if it is going to be forcedqc=QueryCriteria().where_tag(['type=singularity']).select_children(['assets','tags']).orderby('date_created desc')ifsbi.__digest:qc.where_tag([f'digest={sbi.__digest}'])elifsbi.definition_fileorsbi.definition_content:qc.where_tag([f'build_context={sbi.context_checksum()}'])iflen(qc.tag_filters)>1:iflogger.isEnabledFor(DEBUG):logger.debug("Searching for existing containers")ac=platform._assets.get(None,query_criteria=qc)ifac:iflogger.isEnabledFor(DEBUG):logger.debug(f"Found: {len(ac)} previous builds")ac=platform._assets.to_entity(ac[0])iflogger.isEnabledFor(DEBUG):logger.debug(f'Found existing container in {ac.id}')else:ac=Nonereturnac
def__add_tags(self):""" Add default tags to the asset collection to be created. The most important part of this logic is the digest/run_id information we add. This is what enables the build/pull-cache through comps. Returns: None """self.image_tags['type']='singularity'# Disable all tags but image name and typeifnotself.disable_default_tags:ifself.platformisnotNoneandhasattr(self.platform,'get_username'):self.image_tags['created_by']=self.platform.get_username()# allow users to override run id using only the tagif'run_id'inself.tags:self.run_id=self.tags['run_id']else:# set the run id on the workitem and resulting tagsself.tags['run_id']=str(self.run_id)self.image_tags['run_id']=self.tags['run_id']# Check for the digestifself.__digestandisinstance(self.__digest,str):self.image_tags['digest']=self.__digestself.image_tags['image_from']=self.__image_tagifself.image_nameisNone:self.image_name=self.__image_tag.strip(" /").replace(":","_").replace("/","_")+".sif"# If we are building from a file, add the build contextelifself.definition_file:self.image_tags['build_context']=self.context_checksum()ifself.image_nameisNone:bn=PurePath(self.definition_file).namebn=str(bn).replace(".def",".sif")self.image_name=bnelifself.definition_content:self.image_tags['build_context']=self.context_checksum()ifself.image_url:self.image_tags['image_url']=self.image_url# Final fall back for image nameifself.image_nameisNone:self.image_name="image.sif"ifself.image_nameandnotself.image_name.endswith(".sif"):self.image_name=f'{self.image_name}.sif'# Add image name to the tagsself.image_tags['image_name']=self.image_namedef_prep_work_order_before_create(self)->Dict[str,str]:""" Prep work order before creation. Returns: Workorder for singularity build. """self.__add_tags()self.load_work_order(SB_BASE_WORKER_PATH)ifself.definition_fileorself.definition_content:self.work_order['Build']['Input']="Assets/Singularity.def"else:self.work_order['Build']['Input']=self.image_urliflen(self.environment_variables):self.work_order['Build']['StaticEnvironment']=self.environment_variablesiflen(self.additional_mounts):self.work_order['Build']['AdditionalMounts']=self.additional_mountsself.work_order['Build']['Output']=self.image_nameifself.image_nameelse"image.sif"self.work_order['Build']['Tags']=self.image_tagsself.work_order['Build']['Flags']=dict()ifself.fix_permissions:self.work_order['Build']['Flags']['Switches']=["--fix-perms"]ifself.library:self.work_order['Build']['Flags']['--library']=self.libraryifself.section:self.work_order['Build']['Flags']['--section']=self.sectionreturnself.work_order
def__add_common_assets(self):""" Add common assets which in this case is the singularity definition file. Returns: None """self.render_template()ifself.definition_file:opts=dict(content=self.__rendered_template)ifself.is_templateelsedict(absolute_path=self.definition_file)self.assets.add_or_replace_asset(Asset(filename="Singularity.def",**opts))elifself.definition_content:opts=dict(content=self.__rendered_templateifself.is_templateelseself.definition_content)self.assets.add_or_replace_asset(Asset(filename="Singularity.def",**opts))def__fetch_finished_asset_collection(self,platform:'IPlatform')->Union[AssetCollection,None]:""" Fetch the Singularity asset collection we created. Args: platform: Platform to fetch from. Returns: Asset Collection or None """comps_workitem=self.get_platform_object(force=True)acs=comps_workitem.get_related_asset_collections(RelationType.Created)ifacs:self.asset_collection=AssetCollection.from_id(acs[0].id,platform=platformifplatformelseself.platform)ifIdmConfigParser.is_output_enabled():user_logger.log(SUCCESS,f"Created Singularity image as Asset Collection: {self.asset_collection.id}")user_logger.log(SUCCESS,f"View AC at {self.platform.get_asset_collection_link(self.asset_collection)}")returnself.asset_collectionreturnNone
[docs]defrun(self,wait_until_done:bool=True,platform:'IPlatform'=None,wait_on_done_progress:bool=True,**run_opts)->Optional[AssetCollection]:""" Run the build. Args: wait_until_done: Wait until build completes platform: Platform to run on wait_on_done_progress: Show progress while waiting **run_opts: Extra run options Returns: Asset collection that was created if successful """p=super()._check_for_platform_from_context(platform)opts=dict(wait_on_done_progress=wait_on_done_progress,wait_until_done=wait_until_done,platform=p,wait_progress_desc=f"Waiting for build of Singularity container: {self.name}")ac=self.find_existing_container(self,platform=p)ifacisNoneorself.force:super().run(**opts)ac=self.asset_collectionelse:ifIdmConfigParser.is_output_enabled():user_logger.log(SUCCESS,f"Existing build of image found with Asset Collection ID of {ac.id}")user_logger.log(SUCCESS,f"View AC at {self.platform.get_asset_collection_link(ac)}")# Set id to Noneself.uid=Noneifac:self.image_tags=ac.tagsself.asset_collection=ac# how do we get id for original work item from AC?self.status=EntityStatus.SUCCEEDEDsave_sif_asset_md5_from_ac_id(ac.id)returnself.asset_collection
[docs]defwait(self,wait_on_done_progress:bool=True,timeout:int=None,refresh_interval=None,platform:'IPlatform'=None,wait_progress_desc:str=None)->Optional[AssetCollection]:""" Waits on Singularity Build Work item to finish and fetches the resulting asset collection. Args: wait_on_done_progress: When set to true, a progress bar will be shown from the item timeout: Timeout for waiting on item. If none, wait will be forever refresh_interval: How often to refresh progress platform: Platform wait_progress_desc: Wait Progress Description Text Returns: AssetCollection created if item succeeds """# wait on related items before we wait on our itemp=super()._check_for_platform_from_context(platform)opts=dict(wait_on_done_progress=wait_on_done_progress,timeout=timeout,refresh_interval=refresh_interval,platform=p,wait_progress_desc=wait_progress_desc)super().wait(**opts)ifself.status==EntityStatus.SUCCEEDED:returnself.__fetch_finished_asset_collection(p)returnNone
[docs]defget_id_filename(self,prefix:str=None)->str:""" Determine the id filename. Mostly used when use does not provide one. The logic is combine prefix and either * definition file minus extension * image url using with parts filtered out of the name. Args: prefix: Optional prefix. Returns: id file name Raises: ValueError - When the filename cannot be calculated """ifprefixisNone:prefix=''ifself.definition_file:base_name=PurePath(self.definition_file).name.replace(".def",".id")ifprefix:base_name=f"{prefix}{base_name}"filename=str(PurePath(self.definition_file).parent.joinpath(base_name))elifself.image_url:filename=re.sub(r"(docker|shub)://","",self.image_url).replace(":","_")iffilename:filename=f"{prefix}{filename}"else:raiseValueError("Could not calculate the filename. Please specify one")ifnotfilename.endswith(".id"):filename+=".id"returnfilename
[docs]defto_id_file(self,filename:Union[str,PathLike]=None,save_platform:bool=False):""" Create an ID File. If the filename is not provided, it will be calculate for definition files or for docker pulls Args: filename: Filename save_platform: Save Platform info to file as well Returns: None """iffilenameisNone:filename=self.get_id_filename(prefix='builder.')super(SingularityBuildWorkItem,self).to_id_file(filename,save_platform)