#!/usr/bin/python
########################################################################
# Copyright (C) 2010-2024 VMware, Inc.                                 #
# All Rights Reserved                                                  #
########################################################################
"""
This module contains class to parse, and create metadata.zip/notification.zip
"""

import datetime
import logging
import os
import shutil
import zipfile

from . import (Bulletin, ConfigSchema, Errors, ImageProfile, ReleaseCollection,
               Utils, VibCollection, Notification, VibExports)
from .Utils.Misc import isString
from . import PERSONALITY_MANAGER_DEPOT_RECALL_ENABLED

etree  = Utils.XmlUtils.FindElementTree()

log = logging.getLogger(os.path.basename(__file__))

class Metadata(object):
   """Provides a class for reading and writing metadata.zip. Note that this
      class must provide for writing legacy vmware.xml data with metadata.zip,
      but will not parse it. Please find more detail at vib20-spec.pdf.

      Class Variables:
         * METADATA_VERSION - version of metadata
         * INVENTORY_*      - types of inventory collections.
         * INVENTORY_CLASS  - a map from inventory types to their collection
                              classes.
         * JSON_INVENTORY   - inventory types that are stored in JSON.
   """
   METADATA_VERSION = '3.0'
   LEGACY_VIB_VERSION = '1.4.5'

   INVENTORY_VIBS = 'vibs'
   INVENTORY_PROFILES = 'profiles'
   INVENTORY_BULLETINS = 'bulletins'
   INVENTORY_NOTIFICATIONS = 'notifications'
   INVENTORY_BASEIMAGES = 'baseimages'
   INVENTORY_ADDONS = 'addons'
   INVENTORY_SOLUTIONS = 'solutions'
   INVENTORY_MANIFESTS = 'manifests'
   INVENTORY_CONFIGSCHEMAS = 'configSchemas'
   INVENTORY_VIBEXPORTS = 'vibExports'

   INVENTORY_CLASS = {
      INVENTORY_VIBS: VibCollection.VibCollection,
      INVENTORY_PROFILES: ImageProfile.ImageProfileCollection,
      INVENTORY_BULLETINS: Bulletin.BulletinCollection,
      INVENTORY_NOTIFICATIONS: Notification.NotificationCollection,
      INVENTORY_BASEIMAGES: ReleaseCollection.BaseImageCollection,
      INVENTORY_ADDONS: ReleaseCollection.AddonCollection,
      INVENTORY_SOLUTIONS: ReleaseCollection.SolutionCollection,
      INVENTORY_MANIFESTS: ReleaseCollection.ManifestCollection,
      INVENTORY_CONFIGSCHEMAS: ConfigSchema.ConfigSchemaCollection,
      INVENTORY_VIBEXPORTS: VibExports.VibExportCollection,
   }

   JSON_INVENTORY = (INVENTORY_BASEIMAGES, INVENTORY_ADDONS,
                     INVENTORY_SOLUTIONS, INVENTORY_MANIFESTS,
                     INVENTORY_CONFIGSCHEMAS)

   def __init__(self):
      """Initiate the collection classes.
      """
      for name, cls in self.INVENTORY_CLASS.items():
         setattr(self, name, cls())

   @property
   def components(self):
      """Returns components held in the Bulletin collection.
      """
      return Bulletin.ComponentCollection(self.bulletins,
                                          ignoreNonComponents=True)

   def _mergeCollection(self, other, name):
      """Merges a collection from another metadata/depots object.
      """
      myMeta = getattr(self, name)
      myMeta += getattr(other, name)
      setattr(self, name, myMeta)

   def Merge(self, other, excludes=None):
      """Merge another Metadata object in by merging each individual
         collection except the one given as exclusions.
      """
      for name in set(self.INVENTORY_CLASS.keys()) - (excludes or set()):
         self._mergeCollection(other, name)

   def PopulateWithDepots(self, depots):
      """Populate contents with a DepotCollection object.
      """
      # Depots do not have notfications and config schemas.
      for name in self.INVENTORY_CLASS.keys():
         if name == self.INVENTORY_NOTIFICATIONS:
            continue
         self._mergeCollection(depots, name)

   def ReadMetadataZip(self, source, validate=False, schemaVersionCheck=False):
      """Parse metadata.zip from source.

            Parameters:
               * source - A file path or file object containing metadata.zip.
               * validate - Enforces schema validation if True.
               * schemaVersionCheck - Enforces schema version check.
            Returns: None.
            Raises:
               * MetadataIOError     - If the source cannot be read.
               * MetadataFormatError - If the input cannot be extracted or
                                       parsed.
      """
      VMWARE_XML = 'vmware.xml'
      NOTIFICATIONS_XML = self.INVENTORY_NOTIFICATIONS + '.xml'

      #XXX zipfile bug, returning IOError if size < 22 bytes.
      # if source is a file object, we will still have IOError.
      if (isString(source) and os.path.isfile(source)
          and os.path.getsize(source) < 22):
         msg = "'%s' does not exist or is empty" % source
         raise Errors.MetadataFormatError(msg)
      log.info('Reading %s', source)

      try:
         z = zipfile.ZipFile(source, 'r')
      except IOError as e:
         raise Errors.MetadataIOError(e)
      except zipfile.BadZipfile as e:
         raise Errors.MetadataFormatError(e)

      for info in z.filelist:
         # 0x10 is MS-DOS attribute bit for a directory.
         if info.external_attr & 0x10:
            continue
         dn = os.path.dirname(info.filename)
         bn = os.path.basename(info.filename)
         try:
            content = z.read(info.filename)
         except Exception as e:
            msg = "Failed to read %s from %s: %s" % (
                  info.filename, source, e)
            raise Errors.MetadataIOError(msg)

         if PERSONALITY_MANAGER_DEPOT_RECALL_ENABLED:
            # notification meta only processes notification
            if getattr(self, 'isnotification', False):
               if bn == NOTIFICATIONS_XML:
                  log.debug('Processing %s', bn)
                  self.notifications.AddNotificationsFromXml(content)
               else:
                  log.debug('Ignore unexpected file %s in %s', bn, source)
               continue

         # non-notification meta only processes metadata
         if dn == self.INVENTORY_VIBS:
            log.debug('Processing vib xml %s', bn)
            # XXX: skip schema validation for vibs even if its requested
            # externally due to lack of schema forward/backward compatibility
            # and performance reason.
            self.vibs.AddVibFromXml(content, origdesc=None,
                                    signature=None, validate=False)

         elif dn == self.INVENTORY_PROFILES:
            log.debug('Processing profile xml %s', bn)
            # Guarding imageprofile schema validation in try-except
            # since its not forward/backward compatible.
            # TODO: Remove the try-except block.
            try:
               self.profiles.AddProfileFromXml(content, validate=validate)
            except Exception as err:
               log.error('Error occurred while adding profile from metadata: '
                         '%s', str(err))

         elif dn == self.INVENTORY_BULLETINS:
            log.debug('Processing bulletin xml %s', bn)
            self.bulletins.AddBulletinFromXml(content)
         elif dn in self.JSON_INVENTORY:
            log.debug('Processing %s %s', dn, bn)
            getattr(self, dn).AddFromJSON(content, validate=validate,
                                          schemaVersionCheck=schemaVersionCheck)
         elif dn.startswith("vibExports/"):
            loader = VibExports.getLoaderForMetadataPath(info.filename)
            if loader:
               self.vibExports.AddVibExport(loader(content))
            else:
               log.info('Unrecognized vib export %s', info.filename)
         elif info.filename == VMWARE_XML:
            continue
         elif bn == NOTIFICATIONS_XML:
            log.debug('Ignore %s in %s', bn, source)
         else:
            log.debug('Processing file %s', bn)
            rc = self._parseExtraFile(info.filename, content)
            # "true" if parsed successfully
            if rc != "true":
               log.info('Unrecognized file %s', info.filename)

      z.close()

   def ReadNotificationsZip(self, source):
      self.ReadMetadataZip(source)

   def SetVibRemoteLocations(self, baseurl):
      """Sets the remotelocations property of the VIB packages based on
         the metaurl and the VIB relative path.
         Parameters:
            * baseurl  - the base URL for VIB relative paths
      """
      for vib in self.vibs.values():
         if (vib.relativepath and
             isinstance(getattr(vib, 'remotelocations'), list)):
            url = Utils.PathUtils.UrlJoin(baseurl, vib.relativepath)
            if url not in vib.remotelocations:
               vib.remotelocations.append(url)

   def _parseExtraFile(self, filename, filecontent):
      """Private method to parse extra files in derived classes
         Returns "true" if the file was recocnized and parsed
         Returns "false" if the file is unknown
      """
      return "false"

   def _writeExtraMetaFiles(self, stagedir):
      """Private method to add more files to the staging directory
         No extra files for the Metadata class
      """
      return

   def WriteMetadataZip(self, dest, warnmissingvibs=True):
      """Write metadata.zip from metadata object.

            Parameters:
               * dest - A file path to write the metadata.zip, MUST directly
                        under depot root directory, as the directory is used to
                        calculate the relative path of a VIB.
            Returns: None.
            Raises:
               * MetadataBuildError - If legacy vmware.xml creation fails or
                                      metadata.zip is failed to be written
                                      to dest.
      """
      depotroot = os.path.dirname(dest)
      stagedir = os.path.join(depotroot, 'metadata-stage')

      self.ToDirectory(stagedir)

      # Add more files to the stagedir
      self._writeExtraMetaFiles(stagedir)

      vmwarenode = self._GetVmwareXml(depotroot, warnmissingvibs)
      vmwarexml = os.path.join(stagedir, 'vmware.xml')
      Utils.XmlUtils.IndentElementTree(vmwarenode)
      tree = etree.ElementTree(element=vmwarenode)
      try:
         tree.write(vmwarexml)
      except Exception as e:
         msg = 'Failed to write %s file: %s' % (vmwarexml, e)
         raise Errors.MetadataBuildError(msg)

      for b in self.bulletins.values():
         if b.releasetype == b.RELEASE_NOTIFICATION:
            self.notifications.AddNotification(b)

      if self.notifications:
         notificationsxml = os.path.join(stagedir, 'notifications.xml')
         self.WriteNotificationsXml(notificationsxml)

      # Build metadata zip file
      try:
         self._CreateMetadataZip(stagedir, dest)
      except Exception as e:
         msg = 'Failed to create metadatazip %s: %s' % (dest, e)
         raise Errors.MetadataBuildError(msg)

      # Clean up
      try:
         shutil.rmtree(stagedir)
      except EnvironmentError as e:
         pass

   def WriteNotificationsZip(self, dest="notifications.zip", force=False):
      """Write notifications.zip.
            Parameters:
               * dest - A file path to write the notifications.zip.
               * force - If True bypass the check for the notifications, to
                         generate notifications.zip.
            Returns: None.
            Raises:
               * MetadataBuildError - If building notifications.zip fails.
      """
      try:
         if not (force or \
                 self.notifications.values()):
            return
         z = zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED)
         root = self._GetNotificationsXml()
         Utils.XmlUtils.IndentElementTree(root)
         z.writestr('notifications.xml', etree.tostring(root))
         z.close()
      except Exception as e:
         msg = 'Failed to write notifications.zip file: %s' % e
         raise Errors.MetadataBuildError(msg)

   def WriteNotificationsXml(self, dest):
      try:
         root = self._GetNotificationsXml()
         Utils.XmlUtils.IndentElementTree(root)
         tree = etree.ElementTree(element=root)
         tree.write(dest)
      except Exception as e:
         msg = 'Failed to write notifications.xml file: %s' % e
         raise Errors.MetadataBuildError(msg)

   def ToDirectory(self, path):
      """Write this metadata instance to a directory. The content of the
         target directory will be clobbered.

         Parameters:
            * path - A directory path to write out the metadata
         Exceptions:
            * MetadataIOError - The specified directory is not a directory or
                                cannot create an empty directory
            * VibIOError      -
            * ProfileIOError  -
            * BulletinIOError -
      """
      try:
         if os.path.isdir(path):
            shutil.rmtree(path)
         os.makedirs(path)
      except EnvironmentError as e:
         raise Errors.MetadataIOError(path,
               'Unable to create metadata stage dir :%s' %(e))

      if not os.path.isdir(path):
         msg = 'Failed to write Metadata, %s is not a directory.' %(path)
         raise Errors.MetadataIOError(msg)

      vibsdir = os.path.join(path, 'vibs')
      # skip any original descriptor files or signature files
      self.vibs.ToDirectory(vibsdir, skipOrigAndSigFiles=True)

      # All other collections.
      for name in self.INVENTORY_CLASS.keys():
         if name == self.INVENTORY_VIBS:
            continue
         subPath = os.path.join(path, name)
         getattr(self, name).ToDirectory(subPath)

      if self.configSchemas:
         configSchemaDir = os.path.join(path, 'configSchemas')
         self.configSchemas.ToDirectory(configSchemaDir)

      if self.vibExports:
         vibExportsDir = os.path.join(path, 'vibExports')
         self.vibExports.ToDirectory(vibExportsDir)

   def _GetVmwareXml(self, depotroot, warnmissingvibs=True):
      """write vmware.xml to vmwarexml"""
      root = etree.Element('metadataResponse')

      elem = etree.SubElement(root, 'version')
      elem.text = self.METADATA_VERSION

      elem = etree.SubElement(root, 'timestamp')
      elem.text = datetime.datetime.utcnow().isoformat()

      #
      # check that vibs listed in all image profiles are present
      # in the vib-stage-dir,
      # and warn if not
      #
      if warnmissingvibs:
         for p in self.profiles.values():
            profilename = p.name

            for vibid in p.vibIDs:
               if vibid not in self.vibs:
                  msg = ('Can\'t resolve VIB %s in VibCollection %s '
                         'for %s image profile.'
                         % (vibid, list(self.vibs.keys()), profilename))
                  logging.warning(msg)

      for b in self.bulletins.values():
         if b.releasetype == b.RELEASE_NOTIFICATION:
            continue

         bullnode = b.ToXml()
         viblistnode = bullnode.find('vibList')
         vibnodes = []
         for vibidnode in list(viblistnode):
            assert vibidnode.tag.strip() == 'vibID', \
                   "vibList contains vibID only"
            vibid = vibidnode.text.strip()
            if vibid not in self.vibs:
               msg = ('can not resolve VIB %s in VibCollection %s, '
                      'please make sure the required VIB is in the depot '
                      'build directory.' % (vibid, self.vibs))
               raise Errors.MetadataBuildError(msg)
            vibnode = self._GetLegacyVibXml(self.vibs[vibid], depotroot)
            viblistnode.remove(vibidnode)
            vibnodes.append(vibnode)

         for v in vibnodes:
            viblistnode.append(v)
         root.append(bullnode)

      return root

   def _GetNotificationsXml(self):
      root = etree.Element('metadataResponse')

      elem = etree.SubElement(root, 'version')
      elem.text = self.METADATA_VERSION

      elem = etree.SubElement(root, 'timestamp')
      elem.text = datetime.datetime.utcnow().isoformat()

      for notification in self.notifications.values():
         root.append(notification.ToXml())

      return root

   def _GetLegacyVibXml(self, vib, depotroot):
      root = etree.Element('vib')
      etree.SubElement(root, 'vibVersion').text = self.LEGACY_VIB_VERSION

      attrmap = (('id', 'vibID'), ('name', 'name'), ('version', 'version'),
            ('vendor', 'vendor'), ('vibtype', 'vibType'),
            ('summary', 'summary'))
      for (attr, tag) in attrmap:
         v = str(getattr(vib, attr))
         etree.SubElement(root, tag).text = v

      #
      # systemReqs node
      #
      sysreqsnode = etree.SubElement(root, 'systemReqs')
      mmode = etree.SubElement(sysreqsnode, 'maintenanceMode')
      mmode.text = vib.maintenancemode.remove and "true" or "false"
      if vib.maintenancemode.remove and not vib.maintenancemode.install:
         mmode.set('install', 'false')
      # hwplatform
      for hwplatform in vib.hwplatforms:
         hw = etree.SubElement(sysreqsnode, 'hwPlatform')
         hw.set('vendor', hwplatform.vendor)
         if hwplatform.model:
            hw.set('model', hwplatform.model)
      if vib.swplatforms:
         for p in vib.swplatforms:
            sysreqsnode.append(p.ToXml(isVmwareXml=True))
      else:
         # For VIBs without software platform info, fake a swPlatform tag.
         etree.SubElement(sysreqsnode, 'swPlatform',
            productLineID='embeddedEsx', version='', locale='')

      rel = etree.SubElement(root, "relationships")
      for tag in ("depends", "conflicts", "replaces", "provides",
                  "compatibleWith"):
         elem = etree.SubElement(rel, tag)
         constraints = getattr(vib, tag)
         for constraint in constraints:
            if not constraint.implicit:
               elem.append(constraint.ToXml())

      #
      # postInstall node
      #
      postinstnode = etree.SubElement(root, 'postInstall')

      # reboot is required unless liveinstallok is true
      reboot = etree.SubElement(postinstnode, 'rebootRequired')
      reboot.text = (vib.liveinstallok and vib.liveremoveok
            and "false" or "true")

      # hostdrestart is always false
      hostd = etree.SubElement(postinstnode, 'hostdRestart')
      hostd.text = 'false'

      #
      # softwareTags node
      #
      if vib.swtags:
         softwaretagsnode = etree.SubElement(root, 'softwareTags')
         for swtag in vib.swtags:
            subelem = etree.SubElement(softwaretagsnode, 'tag').text = swtag

      #
      # vibFile properties
      #
      if vib.remotelocations:
         sourceurl = vib.remotelocations[0]
      else:
         sourceurl = ''
      vibfilenode = etree.SubElement(root, 'vibFile')
      etree.SubElement(vibfilenode, 'sourceUrl').text = sourceurl

      etree.SubElement(vibfilenode, 'relativePath').text = vib.relativepath

      etree.SubElement(vibfilenode, 'packedSize').text = str(vib.packedsize)

      # Checksum
      checksumnode = etree.SubElement(vibfilenode, 'checksum')
      etree.SubElement(checksumnode, 'checksumType').text = \
            vib.checksum.checksumtype
      etree.SubElement(checksumnode, 'checksum').text = \
            vib.checksum.checksum

      return root

   def _CreateMetadataZip(self, stagedir, destzip):
      """ Creates metadata.zip file in staging dir """
      z = zipfile.ZipFile(destzip, 'w', zipfile.ZIP_DEFLATED)
      for root, dirs, files in os.walk(stagedir):
         for f in files:
            src = os.path.join(root, f)
            dst = src[len(stagedir):]
            z.write(src, dst)
      z.close()

   def GetReleaseObjects(self, typeName):
      """ Get the collection of release obejcts; for one of VIB/bulletin/
          base image/addon/solution/manifest.
      """
      containerName = typeName + 's'
      container = getattr(self, containerName, None)
      if container is not None:
         return container
      raise ValueError('Wrong release unit type name: ' + typeName)

   def HasReleaseUnit(self, typeName, releaseID):
      """ Check this metadata contains a release unit with the provided
          type and ID.
      """
      return releaseID in self.GetReleaseObjects(typeName)
