def Generate(cls, name, delay=0): """Generates a Manifest plist and entity from matching PackageInfo entities. Args: name: str, manifest name. all PackageInfo entities with this name in the "manifests" property will be included in the generated manifest. delay: int. if > 0, Generate call is deferred this many seconds. """ if delay: now = datetime.datetime.utcnow() now_str = '%s-%d' % (now.strftime('%Y-%m-%d-%H-%M-%S'), now.microsecond) deferred_name = 'create-manifest-%s-%s' % (name, now_str) deferred.defer(cls.Generate, name, _name=deferred_name, _countdown=delay) return lock = 'manifest_lock_%s' % name if not gae_util.ObtainLock(lock): logging.debug('Manifest.Generate for %s is locked. Delaying....', name) cls.Generate(name, delay=5) return #logging.debug('Creating manifest: %s', name) try: package_infos = PackageInfo.all().filter('manifests =', name) if not package_infos: # TODO(user): if this happens we probably want to notify admins... raise ManifestGenerateError('PackageInfo entities found: %s' % name) install_types = {} for p in package_infos: # Add all installs to their appropriate install type containers. for install_type in p.install_types: if install_type not in install_types: install_types[install_type] = [] install_types[install_type].append(p.name) # Generate a dictionary of the manifest data. manifest_dict = {'catalogs': [name, 'apple_update_metadata']} for k, v in install_types.iteritems(): manifest_dict[k] = v # Save the new manifest to Datastore. manifest_entity = cls.get_or_insert(name) # Turn the manifest dictionary into XML. manifest_entity.plist.SetContents(manifest_dict) manifest_entity.put() cls.DeleteMemcacheWrap(name) #logging.debug( # 'Manifest %s created successfully', name) except (ManifestGenerateError, db.Error, plist_lib.Error): logging.exception('Manifest.Generate failure: %s', name) raise finally: gae_util.ReleaseLock(lock)
def ApproveProposal(self): """Approve a pending proposal. Raises: PackageInfoProposalApprovalError: Approver not alloed to approve. PackageInfoLockError: PackageInfo is locked. PackageInfoUpdateError: Package is not eligible for catalogs. """ lock = 'pkgsinfo_%s' % self.filename if not gae_util.ObtainLock(lock, timeout=5.0): raise PackageInfoLockError new_catalogs = [ c for c in self.catalogs if c not in self.pkginfo.catalogs ] try: self.pkginfo.VerifyPackageIsEligibleForNewCatalogs(new_catalogs) except PackageInfoUpdateError: gae_util.ReleaseLock(lock) raise approver = users.get_current_user().email() if approver == self.user: raise PackageInfoProposalApprovalError self.approver = approver self.status = 'approved' self._PutAndLogPackageInfoProposalUpdate(self, self.pkginfo.plist.GetXml(), self.catalogs, action='approve') original_plist = self.pkginfo.plist.GetXml() original_catalogs = self.pkginfo.catalogs for key in self.COMMON_PROPERTIES: setattr(self.pkginfo, key, getattr(self, key)) self.pkginfo.PutAndLogFromProposal(original_plist, original_catalogs) gae_util.ReleaseLock(lock) self.ProposalMailer('approval') self.delete()
def testReleaseLock(self): """Test ReleaseLock().""" lock = 'foo' self.mox.StubOutWithMock(gae_util, 'memcache') gae_util.memcache.delete('lock_%s' % lock) self.mox.ReplayAll() gae_util.ReleaseLock(lock) self.mox.VerifyAll()
def Generate(cls, name, delay=0): """Generates a Catalog plist and entity from matching PackageInfo entities. Args: name: str, catalog name. all PackageInfo entities with this name in the "catalogs" property will be included in the generated catalog. delay: int, if > 0, Generate call is deferred this many seconds. """ if delay: now = datetime.datetime.utcnow() now_str = '%s-%d' % (now.strftime('%Y-%m-%d-%H-%M-%S'), now.microsecond) deferred_name = 'create-catalog-%s-%s' % (name, now_str) deferred.defer(cls.Generate, name, _name=deferred_name, _countdown=delay) return lock = 'catalog_lock_%s' % name # Obtain a lock on the catalog name. if not gae_util.ObtainLock(lock): # If catalog creation for this name is already in progress then delay. logging.debug('Catalog creation for %s is locked. Delaying....', name) cls.Generate(name, delay=10) return #logging.debug('Creating catalog: %s', name) package_names = [] try: pkgsinfo_dicts = [] package_infos = PackageInfo.all().filter('catalogs =', name) if not package_infos: # TODO(user): if this happens we probably want to notify admins... raise CatalogGenerateError( 'No pkgsinfo found with catalog: %s' % name) for p in package_infos: package_names.append(p.name) pkgsinfo_dicts.append(p.plist.GetXmlContent(indent_num=1)) catalog = constants.CATALOG_PLIST_XML % '\n'.join(pkgsinfo_dicts) c = cls.get_or_insert(name) c.package_names = package_names c.name = name c.plist = catalog c.put() cls.DeleteMemcacheWrap(name, prop_name='plist_xml') #logging.debug('Generated catalog successfully: %s', name) # Generate manifest for newly generated catalog. Manifest.Generate(name, delay=1) except (CatalogGenerateError, db.Error, plist_lib.Error): logging.exception('Catalog.Generate failure for catalog: %s', name) raise finally: gae_util.ReleaseLock(lock)
def GenerateAppleSUSCatalog(os_version, track, _datetime=datetime.datetime): """Generates an Apple SUS catalog for a given os_version and track. This function loads the untouched/raw Apple SUS catalog, removes any products/updates that are not approved for the given track, then saves a new catalog (plist/xml) to Datastore for client consumption. Args: os_version: str OS version to generate the catalog for. track: str track name to generate the catalog for. _datetime: datetime module; only used for stub during testing. Returns: tuple, new models.AppleSUSCatalog object and plist.ApplePlist object. Or, if there is no "untouched" catalog for the os_version, then (None, None) is returned. """ logging.info('Generating catalog: %s_%s', os_version, track) # clear any locks on this track, potentially set by admin product changes. gae_util.ReleaseLock(CATALOG_REGENERATION_LOCK_NAME % track) catalog_key = '%s_untouched' % os_version untouched_catalog_obj = models.AppleSUSCatalog.get_by_key_name(catalog_key) if not untouched_catalog_obj: logging.warning('Apple Update catalog does not exist: %s', catalog_key) return None, None untouched_catalog_plist = plist.ApplePlist(untouched_catalog_obj.plist) untouched_catalog_plist.Parse() approved_product_ids = set() products_query = models.AppleSUSProduct.AllActive().filter( 'tracks =', track) for product in products_query: approved_product_ids.add(product.product_id) product_ids = untouched_catalog_plist.get('Products', {}).keys() new_plist = untouched_catalog_plist for product_id in product_ids: if product_id not in approved_product_ids: del new_plist['Products'][product_id] catalog_plist_xml = new_plist.GetXml() # Save the catalog using a time-specific key for rollback purposes. now = _datetime.utcnow() now_str = now.strftime('%Y-%m-%d-%H-%M-%S') backup = models.AppleSUSCatalog(key_name='backup_%s_%s_%s' % (os_version, track, now_str)) backup.plist = catalog_plist_xml backup.put() # Overwrite the catalog being served for this os_version/track pair. c = models.AppleSUSCatalog(key_name='%s_%s' % (os_version, track)) c.plist = catalog_plist_xml c.put() return c, new_plist
def get(self): """Handle GET.""" pkgs, unused_dt = models.ReportsCache.GetInstallCounts() for p in gae_util.QueryIterator(models.PackageInfo.all()): if not p.plist: continue # skip over pkginfos without plists. if p.munki_name not in pkgs: # Skip pkginfos that ReportsCache lacks. continue elif not pkgs[p.munki_name].get('duration_seconds_avg', None): # Skip pkginfos where there is no known average duration. continue # Obtain a lock on the PackageInfo entity for this package, or skip. lock = 'pkgsinfo_%s' % p.filename if not gae_util.ObtainLock(lock, timeout=5.0): continue # Skip; it'll get updated next time around. # Append the avg duration text to the description; in the future the # avg duration time and overall install count will be added to they're # own pkginfo keys so the information can be displayed independantly. # This requires MSU changes to read and display such values, so for now # simply append text to the description. old_desc = p.plist['description'] avg_duration_text = models.PackageInfo.AVG_DURATION_TEXT % ( pkgs[p.munki_name]['duration_count'], pkgs[p.munki_name]['duration_seconds_avg']) p.description = '%s\n\n%s' % (p.description, avg_duration_text) if p.plist['description'] != old_desc: p.put( ) # Only bother putting the entity if the description changed. gae_util.ReleaseLock(lock) # Asyncronously regenerate all Catalogs to include updated pkginfo plists. delay = 0 for track in common.TRACKS: delay += 5 models.Catalog.Generate(track, delay=delay)
def post(self): """POST method. This method behaves a little strangely. BlobstoreUploadHandler only allows returns statuses of 301, 302, 303 (not even 200), so one must redirect away to return more information to the caller. Parameters: file: package file contents pkginfo: packageinfo file contents name: filename of package e.g. 'Firefox-1.0.dmg' """ # Only blobstore/upload service/scotty requests should be # invoking this handler. if not handlers.IsBlobstore(): logging.critical('POST to /uploadpkg not from Blobstore: %s', self.request.headers) self.redirect('/') gaeserver.DoMunkiAuth(require_level=gaeserver.LEVEL_UPLOADPKG) user = self.request.get('user') filename = self.request.get('name') install_types = self.request.get('install_types') catalogs = self.request.get('catalogs', None) manifests = self.request.get('manifests', None) if catalogs is None or not install_types or not user or not filename: msg = 'uploadpkg POST required parameters missing' logging.error(msg) self.redirect('/uploadpkg?mode=error&msg=%s' % msg) return if catalogs == '': catalogs = [] else: catalogs = catalogs.split(',') if manifests in ['', None]: manifests = [] else: manifests = manifests.split(',') install_types = install_types.split(',') upload_files = self.get_uploads('file') upload_pkginfo_files = self.get_uploads('pkginfo') if not len(upload_pkginfo_files) and not self.request.get('pkginfo'): self.redirect('/uploadpkg?mode=error&msg=No%20file%20received') return if len(upload_pkginfo_files): # obtain the pkginfo from a blob, and then throw it away. this is # a necessary hack because the upload handler grabbed it, but we don't # intend to keep it in blobstore. pkginfo_str = gae_util.GetBlobAndDel(upload_pkginfo_files[0].key()) else: # otherwise, grab the form parameter. pkginfo_str = self.request.get('pkginfo') blob_info = upload_files[0] blobstore_key = str(blob_info.key()) # Parse, validate, and encode the pkginfo plist. plist = plist_lib.MunkiPackageInfoPlist(pkginfo_str) try: plist.Parse() except plist_lib.PlistError: logging.exception('Invalid pkginfo plist uploaded:\n%s\n', pkginfo_str) gae_util.SafeBlobDel(blobstore_key) self.redirect( '/uploadpkg?mode=error&msg=No%20valid%20pkginfo%20received') return filename = plist['installer_item_location'] pkgdata_sha256 = plist['installer_item_hash'] # verify the blob was actually written; in case Blobstore failed to write # the blob but still POSTed to this handler (very, very rare). blob_info = blobstore.BlobInfo.get(blobstore_key) if not blob_info: logging.critical( 'Blobstore returned a key for %s that does not exist: %s', filename, blobstore_key) self.redirect('/uploadpkg?mode=error&msg=Blobstore%20failure') return # Obtain a lock on the PackageInfo entity for this package. lock = 'pkgsinfo_%s' % filename if not gae_util.ObtainLock(lock, timeout=5.0): gae_util.SafeBlobDel(blobstore_key) self.redirect( '/uploadpkg?mode=error&msg=Could%20not%20lock%20pkgsinfo') return old_blobstore_key = None pkg = models.PackageInfo.get_or_insert(filename) if not pkg.IsSafeToModify(): gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) self.redirect( '/uploadpkg?mode=error&msg=Package%20is%20not%20modifiable') return if pkg.blobstore_key: # a previous blob exists. delete it when the update has succeeded. old_blobstore_key = pkg.blobstore_key pkg.blobstore_key = blobstore_key pkg.name = plist.GetPackageName() pkg.filename = filename pkg.user = user pkg.catalogs = catalogs pkg.manifests = manifests pkg.install_types = install_types pkg.plist = plist pkg.pkgdata_sha256 = pkgdata_sha256 # update the PackageInfo model with the new plist string and blobstore key. try: pkg.put() success = True except db.Error: logging.exception('error on PackageInfo.put()') success = False # if it failed, delete the blob that was just uploaded -- it's # an orphan. if not success: gae_util.SafeBlobDel(blobstore_key) # if this is a new entity (get_or_insert puts), attempt to delete it. if not old_blobstore_key: gae_util.SafeEntityDel(pkg) gae_util.ReleaseLock(lock) self.redirect('/uploadpkg?mode=error') return # if an old blob was associated with this Package, delete it. # the new blob that was just uploaded has replaced it. if old_blobstore_key: gae_util.SafeBlobDel(old_blobstore_key) gae_util.ReleaseLock(lock) # Generate catalogs for newly uploaded pkginfo plist. for catalog in pkg.catalogs: models.Catalog.Generate(catalog, delay=1) # Log admin upload to Datastore. admin_log = models.AdminPackageLog(user=user, action='uploadpkg', filename=filename, catalogs=catalogs, manifests=manifests, install_types=install_types, plist=pkg.plist.GetXml()) admin_log.put() self.redirect('/uploadpkg?mode=success&key=%s' % blobstore_key)
gaeserver.DoMunkiAuth(require_level=gaeserver.LEVEL_UPLOADPKG) # try loading for validation's sake c = plist.AppleSoftwareCatalogPlist(self.request.body) try: c.Parse() except plist.PlistError, e: logging.exception('Invalid Apple SUS catalog format: %s', str(e)) self.response.set_status(400) self.response.out.write(str(e)) return del(c) lock = 'applesus_%s' % name if not gae_util.ObtainLock(lock, timeout=5.0): self.response.set_status(403) self.response.out.write('Could not lock applesus') return try: asucatalog = models.AppleSUSCatalog.get_or_insert(name) asucatalog.plist = self.request.body # retain original appearance asucatalog.put() except (plist.PlistError, models.db.Error), e: logging.exception('applesus: %s', str(e)) self.response.set_status(500) self.response.out.write(str(e)) pass gae_util.ReleaseLock(lock)
def post(self): """POST Handler. This method behaves a little strangely. BlobstoreUploadHandler only allows returns statuses of 301, 302, 303 (not even 200), so one must redirect away to return more information to the caller. Parameters: file: package file contents pkginfo: packageinfo file contents name: filename of package e.g. 'Firefox-1.0.dmg' """ # Only blobstore/upload service/scotty requests should be # invoking this handler. if not handlers.IsBlobstore(): logging.critical('POST to uploadpkg not from Blobstore: %s', self.request.headers) self.redirect('/admin/packages') # TODO(user): do we check is admin? if not self.get_uploads('file'): logging.error('Upload package does not exist.') return blob_info = self.get_uploads('file')[0] blobstore_key = str(blob_info.key()) # Obtain a lock on the PackageInfo entity for this package. lock = 'pkgsinfo_%s' % blob_info.filename if not gae_util.ObtainLock(lock, timeout=5.0): gae_util.SafeBlobDel(blobstore_key) self.redirect( '/admin/uploadpkg?mode=error&msg=PackageInfo is locked') return p = models.PackageInfo.get_by_key_name(blob_info.filename) if not p: gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) self.redirect( '/admin/uploadpkg?mode=error&msg=PackageInfo not found') return if not p.IsSafeToModify(): gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) self.redirect( '/admin/uploadpkg?mode=error&msg=PackageInfo is not modifiable' ) return installer_item_size = p.plist['installer_item_size'] size_difference = int(blob_info.size / 1024) - installer_item_size if abs(size_difference) > 1: gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) msg = 'Blob size (%s) does not match PackageInfo plist size (%s)' % ( blob_info.size, installer_item_size) self.redirect('/admin/uploadpkg?mode=error&msg=%s' % msg) return old_blobstore_key = None if p.blob_info: # a previous blob exists. delete it when the update has succeeded. old_blobstore_key = p.blob_info.key() p.blob_info = blob_info # update the PackageInfo model with the new plist string and blobstore key. try: p.put() error = None except db.Error, e: logging.exception('error on PackageInfo.put()') error = 'pkginfo.put() failed with: %s' % str(e)
class UploadPackage(admin.AdminHandler, blobstore_handlers.BlobstoreUploadHandler): """Handler for /admin/uploadpkg.""" def get(self): """GET Handler. With no parameters, return the URL to use to submit uploads via multipart/form-data. With mode and key parameters, return status of the previous uploadpkg operation to the calling client. This method actually acts as a helper to the starting and finishing of the uploadpkg post() method. Parameters: mode: optionally, 'success' or 'error' key: optionally, blobstore key that was uploaded """ if not handlers.IsHttps(self): # TODO(user): Does blobstore support https yet? If so, we can # enforce security in app.yaml and not do this check here. return if not self.IsAdminUser(): self.error(403) return mode = self.request.get('mode') msg = self.request.get('msg', None) if mode == 'success': filename = self.request.get('filename') msg = '%s successfully uploaded and is ready for deployment.' % filename self.redirect('/admin/package/%s?msg=%s' % (filename, msg)) elif mode == 'error': self.response.set_status(400) self.response.out.write(msg) else: filename = self.request.get('filename') if not filename: self.response.set_status(404) self.response.out.write('Filename required') return p = models.PackageInfo.get_by_key_name(filename) if not p: self.response.set_status(400) self.response.out.write( 'You must first upload a pkginfo for %s' % filename) return elif p.blob_info: self.response.set_status(400) self.response.out.write('This file already exists.') return values = { 'upload_url': blobstore.create_upload_url('/admin/uploadpkg'), 'filename': filename, 'file_size_kbytes': p.plist['installer_item_size'], } self.Render('upload_pkg_form.html', values) def post(self): """POST Handler. This method behaves a little strangely. BlobstoreUploadHandler only allows returns statuses of 301, 302, 303 (not even 200), so one must redirect away to return more information to the caller. Parameters: file: package file contents pkginfo: packageinfo file contents name: filename of package e.g. 'Firefox-1.0.dmg' """ # Only blobstore/upload service/scotty requests should be # invoking this handler. if not handlers.IsBlobstore(): logging.critical('POST to uploadpkg not from Blobstore: %s', self.request.headers) self.redirect('/admin/packages') # TODO(user): do we check is admin? if not self.get_uploads('file'): logging.error('Upload package does not exist.') return blob_info = self.get_uploads('file')[0] blobstore_key = str(blob_info.key()) # Obtain a lock on the PackageInfo entity for this package. lock = 'pkgsinfo_%s' % blob_info.filename if not gae_util.ObtainLock(lock, timeout=5.0): gae_util.SafeBlobDel(blobstore_key) self.redirect( '/admin/uploadpkg?mode=error&msg=PackageInfo is locked') return p = models.PackageInfo.get_by_key_name(blob_info.filename) if not p: gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) self.redirect( '/admin/uploadpkg?mode=error&msg=PackageInfo not found') return if not p.IsSafeToModify(): gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) self.redirect( '/admin/uploadpkg?mode=error&msg=PackageInfo is not modifiable' ) return installer_item_size = p.plist['installer_item_size'] size_difference = int(blob_info.size / 1024) - installer_item_size if abs(size_difference) > 1: gae_util.ReleaseLock(lock) gae_util.SafeBlobDel(blobstore_key) msg = 'Blob size (%s) does not match PackageInfo plist size (%s)' % ( blob_info.size, installer_item_size) self.redirect('/admin/uploadpkg?mode=error&msg=%s' % msg) return old_blobstore_key = None if p.blob_info: # a previous blob exists. delete it when the update has succeeded. old_blobstore_key = p.blob_info.key() p.blob_info = blob_info # update the PackageInfo model with the new plist string and blobstore key. try: p.put() error = None except db.Error, e: logging.exception('error on PackageInfo.put()') error = 'pkginfo.put() failed with: %s' % str(e) # if it failed, delete the blob that was just uploaded -- it's # an orphan. if error is not None: gae_util.SafeBlobDel(blobstore_key) gae_util.ReleaseLock(lock) self.redirect('/admin/uploadpkg?mode=error&msg=%s' % error) return # if an old blob was associated with this Package, delete it. # the new blob that was just uploaded has replaced it. if old_blobstore_key: gae_util.SafeBlobDel(old_blobstore_key) gae_util.ReleaseLock(lock) user = users.get_current_user().email() # Log admin upload to Datastore. admin_log = models.AdminPackageLog(user=user, action='uploadpkg', filename=blob_info.filename) admin_log.put() self.redirect('/admin/uploadpkg?mode=success&filename=%s' % blob_info.filename)
class PackagesInfo(handlers.AuthenticationHandler): """Handler for /pkgsinfo/""" def get(self, filename=None): """GET Args: filename: string like Firefox-1.0.dmg """ auth_return = auth.DoAnyAuth() if hasattr(auth_return, 'email'): email = auth_return.email() if not any(( auth.IsAdminUser(email), auth.IsSupportUser(email), )): raise auth.IsAdminMismatch if filename: filename = urllib.unquote(filename) hash_str = self.request.get('hash') if hash_str: lock = 'pkgsinfo_%s' % filename if not gae_util.ObtainLock(lock, timeout=5.0): self.response.set_status(httplib.FORBIDDEN) self.response.out.write('Could not lock pkgsinfo') return pkginfo = models.PackageInfo.get_by_key_name(filename) if pkginfo: self.response.headers[ 'Content-Type'] = 'text/xml; charset=utf-8' if hash_str: self.response.headers['X-Pkgsinfo-Hash'] = self._Hash( pkginfo.plist) self.response.out.write(pkginfo.plist) else: if hash_str: gae_util.ReleaseLock(lock) self.response.set_status(httplib.NOT_FOUND) return if hash_str: gae_util.ReleaseLock(lock) else: query = models.PackageInfo.all() filename = self.request.get('filename') if filename: query.filter('filename', filename) install_types = self.request.get_all('install_types') for install_type in install_types: query.filter('install_types =', install_type) catalogs = self.request.get_all('catalogs') for catalog in catalogs: query.filter('catalogs =', catalog) pkgs = [] for p in query: pkg = {} for k in p.properties(): if k != '_plist': pkg[k] = getattr(p, k) pkgs.append(pkg) self.response.out.write('<?xml version="1.0" encoding="UTF-8"?>\n') self.response.out.write(plist.GetXmlStr(pkgs)) self.response.headers['Content-Type'] = 'text/xml; charset=utf-8' def _Hash(self, s): """Return a sha256 hash for a string. Args: s: str Returns: str, sha256 digest """ h = hashlib.sha256(str(s)) return h.hexdigest() def put(self, filename): """PUT Args: filename: string like Firefox-1.0.dmg """ session = gaeserver.DoMunkiAuth( require_level=gaeserver.LEVEL_UPLOADPKG) filename = urllib.unquote(filename) hash_str = self.request.get('hash') catalogs = self.request.get('catalogs', None) manifests = self.request.get('manifests', None) install_types = self.request.get('install_types') if catalogs == '': catalogs = [] elif catalogs: catalogs = catalogs.split(',') if manifests == '': manifests = [] elif manifests: manifests = manifests.split(',') if install_types: install_types = install_types.split(',') mpl = MunkiPackageInfoPlistStrict(self.request.body) try: mpl.Parse() except plist.PlistError, e: logging.exception('Invalid pkginfo plist PUT: \n%s\n', self.request.body) self.response.set_status(httplib.BAD_REQUEST) self.response.out.write(str(e)) return lock = 'pkgsinfo_%s' % filename if not gae_util.ObtainLock(lock, timeout=5.0): self.response.set_status(httplib.FORBIDDEN) self.response.out.write('Could not lock pkgsinfo') return # To avoid pkginfo uploads without corresponding packages, only allow # updates to existing PackageInfo entities, not creations of new ones. pkginfo = models.PackageInfo.get_by_key_name(filename) if pkginfo is None: logging.warning( 'pkginfo "%s" does not exist; PUT only allows updates.', filename) self.response.set_status(httplib.FORBIDDEN) self.response.out.write('Only updates supported') gae_util.ReleaseLock(lock) return # If the pkginfo is not modifiable, ensure only manifests have changed. if not pkginfo.IsSafeToModify(): if not mpl.EqualIgnoringManifestsAndCatalogs(pkginfo.plist): logging.warning( 'pkginfo "%s" is in stable or testing; change prohibited.', filename) self.response.set_status(httplib.FORBIDDEN) self.response.out.write('Changes to pkginfo not allowed') gae_util.ReleaseLock(lock) return # If the update parameter asked for a careful update, by supplying # a hash of the last known pkgsinfo, then compare the hash to help # the client make a non destructive update. if hash_str: if self._Hash(pkginfo.plist) != hash_str: self.response.set_status(httplib.CONFLICT) self.response.out.write('Update hash does not match') gae_util.ReleaseLock(lock) return # All verification has passed, so let's create the PackageInfo entity. pkginfo.plist = mpl pkginfo.name = mpl.GetPackageName() if catalogs is not None: pkginfo.catalogs = catalogs if manifests is not None: pkginfo.manifests = manifests if install_types: pkginfo.install_types = install_types pkginfo.put() gae_util.ReleaseLock(lock) for track in pkginfo.catalogs: models.Catalog.Generate(track, delay=1) # Log admin pkginfo put to Datastore. user = session.uuid admin_log = models.AdminPackageLog(user=user, action='pkginfo', filename=filename, catalogs=pkginfo.catalogs, manifests=pkginfo.manifests, install_types=pkginfo.install_types, plist=pkginfo.plist.GetXml()) admin_log.put()
def Update(self, **kwargs): """Updates properties and/or plist of an existing PackageInfo entity. Omitted properties are left unmodified on the PackageInfo entity. Args: **kwargs: many, below: catalogs: list, optional, a subset of common.TRACKS. manifests: list, optional, a subset of common.TRACKS. install_types: list, optional, a subset of common.INSTALL_TYPES. manifest_mod_access: list, optional, subset of common.MANIFEST_MOD_GROUPS. name: str, optional, pkginfo name value. display_name: str, optional, pkginfo display_name value. unattended_install: boolean, optional, True to set unattended_install. unattended_uninstall: boolean, optional, True to set unattended_uninstall. description: str, optional, pkginfo description. version: str, optional, pkginfo version. minimum_os_version: str, optional, pkginfo minimum_os_version value. maximum_os_version: str, optional, pkginfo maximum_os_version value. force_install_after_date: datetime, optional, pkginfo force_install_after_date value. Raises: PackageInfoLockError: if the package is already locked in the datastore. PackageInfoUpdateError: there were validation problems with the pkginfo. """ catalogs = kwargs.get('catalogs') manifests = kwargs.get('manifests') install_types = kwargs.get('install_types') manifest_mod_access = kwargs.get('manifest_mod_access') name = kwargs.get('name') display_name = kwargs.get('display_name') unattended_install = kwargs.get('unattended_install') unattended_uninstall = kwargs.get('unattended_uninstall') description = kwargs.get('description') version = kwargs.get('version') minimum_os_version = kwargs.get('minimum_os_version') maximum_os_version = kwargs.get('maximum_os_version') category = kwargs.get('category') developer = kwargs.get('developer') force_install_after_date = kwargs.get('force_install_after_date') original_plist = self.plist.GetXml() lock = 'pkgsinfo_%s' % self.filename if not gae_util.ObtainLock(lock, timeout=5.0): raise PackageInfoLockError if self.IsSafeToModify(): if name is not None: self.plist['name'] = name self.name = name if description is not None: self.description = description if 'display_name' in self.plist and display_name == '': self.plist.RemoveDisplayName() elif display_name != '' and display_name is not None: self.plist.SetDisplayName(display_name) if install_types is not None: self.install_types = install_types if manifest_mod_access is not None: self.manifest_mod_access = manifest_mod_access if version is not None: self.plist['version'] = version if minimum_os_version is not None: if not minimum_os_version and 'minimum_os_version' in self.plist: del self.plist['minimum_os_version'] elif minimum_os_version: self.plist['minimum_os_version'] = minimum_os_version if maximum_os_version is not None: if not maximum_os_version and 'maximum_os_version' in self.plist: del self.plist['maximum_os_version'] elif maximum_os_version: self.plist['maximum_os_version'] = maximum_os_version if force_install_after_date is not None: if force_install_after_date: self.plist['force_install_after_date'] = force_install_after_date else: if 'force_install_after_date' in self.plist: del self.plist['force_install_after_date'] self.plist.SetUnattendedInstall(unattended_install) self.plist.SetUnattendedUninstall(unattended_uninstall) self.plist['category'] = category self.plist['developer'] = developer else: # If not safe to modify, only catalogs/manifests can be changed. for k, v in kwargs.iteritems(): if v and k not in ['catalogs', 'manifests']: if self.approval_required: failure_message = ('PackageInfo is not safe to modify;' ' please remove from catalogs first.') else: failure_message = ('PackageInfo is not safe to modify;' ' please move to unstable first.') gae_util.ReleaseLock(lock) raise PackageInfoUpdateError(failure_message) original_catalogs = self.catalogs if self.approval_required and ( catalogs != self.catalogs or manifests != self.manifests): self.proposal.Propose(catalogs=catalogs, manifests=manifests) else: if catalogs is not None: self.catalogs = catalogs self.plist['catalogs'] = catalogs if manifests is not None: self.manifests = manifests try: self._PutAndLogPackageInfoUpdate(self, original_plist, original_catalogs) except PackageInfoUpdateError: gae_util.ReleaseLock(lock) raise gae_util.ReleaseLock(lock)
def UpdateFromPlist(cls, plist, create_new=False): """Updates a PackageInfo entity from a plist_lib.ApplePlist object or str. Args: plist: str or plist_lib.ApplePlist object. create_new: bool, optional, default False. If True, create a new PackageInfo entity, only otherwise update an existing one. Returns: pkginfo: Returns updated PackageInfo object. log: Returns AdminPackageLog record. Raises: PackageInfoLockError: if the package is already locked in the datastore. PackageInfoNotFoundError: if the filename is not a key in the datastore. PackageInfoUpdateError: there were validation problems with the pkginfo. """ if isinstance(plist, basestring) or isinstance(plist, unicode): plist = plist_lib.MunkiPackageInfoPlist(plist) plist.EncodeXml() try: plist.Parse() except plist_lib.PlistError as e: raise PackageInfoUpdateError( 'plist_lib.PlistError parsing plist XML: %s' % str(e)) filename = plist['installer_item_location'] lock = 'pkgsinfo_%s' % filename if not gae_util.ObtainLock(lock, timeout=5.0): raise PackageInfoLockError('This PackageInfo is locked.') if create_new: if cls.get_by_key_name(filename): gae_util.ReleaseLock(lock) raise PackageInfoUpdateError( 'An existing pkginfo exists for: %s' % filename) pkginfo = cls._New(filename) pkginfo.filename = filename # If we're uploading a new pkginfo plist, wipe out catalogs. plist['catalogs'] = [] original_plist = None else: pkginfo = cls.get_by_key_name(filename) if not pkginfo: gae_util.ReleaseLock(lock) raise PackageInfoNotFoundError('pkginfo not found: %s' % filename) original_plist = pkginfo.plist.GetXml() if not pkginfo.IsSafeToModify(): gae_util.ReleaseLock(lock) raise PackageInfoUpdateError( 'PackageInfo is not safe to modify; move to unstable first.') pkginfo.plist = plist pkginfo.name = plist['name'] original_catalogs = pkginfo.catalogs pkginfo.catalogs = plist['catalogs'] pkginfo.pkgdata_sha256 = plist['installer_item_hash'] try: log = cls._PutAndLogPackageInfoUpdate( pkginfo, original_plist, original_catalogs) except PackageInfoUpdateError: gae_util.ReleaseLock(lock) raise gae_util.ReleaseLock(lock) return pkginfo, log
class PackageInfo(BaseMunkiModel): """Munki pkginfo file, Blobstore key, etc., for the corresponding package. _plist contents are generated offline by Munki tools and uploaded by admins. name is something like: Adobe Flash, Mozilla Firefox, MS Office, etc. """ PLIST_LIB_CLASS = plist_lib.MunkiPackageInfoPlist AVG_DURATION_TEXT = ( '%d users have installed this with an average duration of %d seconds.') AVG_DURATION_REGEX = re.compile( '\d+ users have installed this with an average duration of \d+ seconds\.' ) # catalog names this pkginfo belongs to; unstable, testing, stable. catalogs = db.StringListProperty() # manifest names this pkginfo belongs to; unstable, testing, stable. manifests = db.StringListProperty() # install types for this pkg; managed_installs, managed_uninstalls, # managed_updates, etc. install_types = db.StringListProperty() # admin username that uploaded pkginfo. user = db.StringProperty() # filename for the package data filename = db.StringProperty() # key to Blobstore for package data. blobstore_key = db.StringProperty() # sha256 hash of package data pkgdata_sha256 = db.StringProperty() # munki name in the form of pkginfo '%s-%s' % (display_name, version) # this property is automatically updated on put() munki_name = db.StringProperty() # datetime when the PackageInfo was initially created. created = db.DateTimeProperty(auto_now_add=True) # str group name(s) in common.MANIFEST_MOD_GROUPS that have access to inject # this package into manifests. manifest_mod_access = db.StringListProperty() def _GetDescription(self): """Returns only admin portion of the desc, omitting avg duration text.""" desc = self.plist.get('description', None) if desc: match = self.AVG_DURATION_REGEX.search(desc) if match: avg_duration_text = match.group(0) return desc.replace(avg_duration_text, '').strip() return desc def _SetDescription(self, desc): """Sets the description to the plist, preserving any avg duration text.""" if self.AVG_DURATION_REGEX.search(desc): # If the new description has the avg duration text, just keep it all. self.plist['description'] = desc else: # Otherwise append the old avg duration text to the new description. match = self.AVG_DURATION_REGEX.search( self.plist.get('description', '')) if match: self.plist['description'] = '%s\n\n%s' % (desc, match.group(0)) else: self.plist['description'] = desc # Update the plist property with the new description. self.plist = self.plist.GetXml() description = property(_GetDescription, _SetDescription) def _GetBlobInfo(self): """Returns the blobstore.BlobInfo object for the PackageInfo.""" if not self.blobstore_key: return None return blobstore.BlobInfo.get(self.blobstore_key) def _SetBlobInfo(self, blob_info): """Sets the blobstore_key property from a given blobstore.BlobInfo object. This mimics the new blobstore.BlobReferenceProperty() without requiring a schema change, which isn't fun for external Simian customers. """ self.blobstore_key = str(blob_info.key()) blob_info = property(_GetBlobInfo, _SetBlobInfo) def IsSafeToModify(self): """Returns True if the pkginfo is modifiable, False otherwise.""" if common.STABLE in self.manifests: return False elif common.TESTING in self.manifests: return False return True def MakeSafeToModify(self): """Modifies a PackageInfo such that it is safe to modify.""" self.Update(catalogs=[], manifests=[]) def put(self, *args, **kwargs): """Put to Datastore, generating and setting the "munki_name" property. Args: args: list, optional, args to superclass put() kwargs: dict, optional, keyword args to superclass put() Returns: return value from superclass put() Raises: PackageInfoUpdateError: pkginfo property validation failed. """ # Ensure any defined manifests have matching catalogs. for manifest in self.manifests: if manifest not in self.catalogs: raise PackageInfoUpdateError( 'manifest did not have matching catalog: %s' % manifest) # Always update the munki_name property with the latest Pkg-<Version> name # for backwards compatibility. try: self.munki_name = self.plist.GetMunkiName() except plist_lib.PlistNotParsedError: self.munki_name = None return super(PackageInfo, self).put(*args, **kwargs) def delete(self, *args, **kwargs): """Deletes a PackageInfo and cleans up associated data in other models. Any Blobstore blob associated with the PackageInfo is deleted, and all Catalogs the PackageInfo was a member of are regenerated. Returns: return value from superlass delete() """ ret = super(PackageInfo, self).delete(*args, **kwargs) for catalog in self.catalogs: Catalog.Generate(catalog, delay=5) if self.blobstore_key: gae_util.SafeBlobDel(self.blobstore_key) return ret def VerifyPackageIsEligibleForNewCatalogs(self, new_catalogs): """Ensure a package with the same name does not exist in the new catalogs. Args: new_catalogs: list of str catalogs to verify the package name is not in. Raises: PackageInfoUpdateError: a new catalog contains a pkg with the same name. """ for catalog in new_catalogs: if self.name in Catalog.get_by_key_name(catalog).package_names: raise PackageInfoUpdateError( '%r already exists in %r catalog' % (self.name, catalog)) @classmethod def _PutAndLogPackageInfoUpdate(cls, pkginfo, original_plist, original_catalogs): """Helper method called by Update or UpdateFromPlist to put/log the update. Args: pkginfo: a PackageInfo entity ready to be put to Datastore. original_plist: str XML of the original pkginfo plist, before updates. original_catalogs: list of catalog names the pkg was previously in. Raises: PackageInfoUpdateError: there were validation problems with the pkginfo. """ new_catalogs = [ c for c in pkginfo.catalogs if c not in original_catalogs ] pkginfo.VerifyPackageIsEligibleForNewCatalogs(new_catalogs) pkginfo.put() delay = 0 changed_catalogs = set(original_catalogs + pkginfo.catalogs) for track in sorted(changed_catalogs, reverse=True): delay += 5 Catalog.Generate(track, delay=delay) # Log admin pkginfo put to Datastore. user = users.get_current_user().email() log = base.AdminPackageLog( user=user, action='pkginfo', filename=pkginfo.filename, catalogs=pkginfo.catalogs, manifests=pkginfo.manifests, original_plist=original_plist, install_types=pkginfo.install_types, manifest_mod_access=pkginfo.manifest_mod_access) # The plist property is a py property of _plist, and therefore cannot be # set in the constructure, so set here. log.plist = pkginfo.plist log.put() @classmethod def _New(cls, key_name): """Returns a new PackageInfo entity with a given key name. Only needed for unit test stubbing purposes. Args: key_name: str, key name for the entity. Returns: PackageInfo object isntance. """ return cls(key_name=key_name) @classmethod def UpdateFromPlist(cls, plist, create_new=False): """Updates a PackageInfo entity from a plist_lib.ApplePlist object or str. Args: plist: str or plist_lib.ApplePlist object. create_new: bool, optional, default False. If True, create a new PackageInfo entity, only otherwise update an existing one. Raises: PackageInfoLockError: if the package is already locked in the datastore. PackageInfoNotFoundError: if the filename is not a key in the datastore. PackageInfoUpdateError: there were validation problems with the pkginfo. """ if type(plist) is str or type(plist) is unicode: plist = plist_lib.MunkiPackageInfoPlist(plist) plist.EncodeXml() try: plist.Parse() except plist_lib.PlistError, e: raise PackageInfoUpdateError( 'plist_lib.PlistError parsing plist XML: %s', str(e)) filename = plist['installer_item_location'] lock = 'pkgsinfo_%s' % filename if not gae_util.ObtainLock(lock, timeout=5.0): raise PackageInfoLockError('This PackageInfo is locked.') if create_new: if cls.get_by_key_name(filename): gae_util.ReleaseLock(lock) raise PackageInfoUpdateError( 'An existing pkginfo exists for: %s' % filename) pkginfo = cls._New(filename) pkginfo.filename = filename # If we're uploading a new pkginfo plist, wipe out catalogs. plist['catalogs'] = [] original_plist = None else: pkginfo = cls.get_by_key_name(filename) if not pkginfo: gae_util.ReleaseLock(lock) raise PackageInfoNotFoundError('pkginfo not found: %s' % filename) original_plist = pkginfo.plist.GetXml() if not pkginfo.IsSafeToModify(): gae_util.ReleaseLock(lock) raise PackageInfoUpdateError( 'PackageInfo is not safe to modify; move to unstable first.') pkginfo.plist = plist pkginfo.name = plist['name'] original_catalogs = pkginfo.catalogs pkginfo.catalogs = plist['catalogs'] pkginfo.pkgdata_sha256 = plist['installer_item_hash'] try: cls._PutAndLogPackageInfoUpdate(pkginfo, original_plist, original_catalogs) except PackageInfoUpdateError: gae_util.ReleaseLock(lock) raise gae_util.ReleaseLock(lock) return pkginfo
def _GenerateInstallCounts(): """Generates a dictionary of all installs names and the count of each.""" # Obtain a lock. lock_name = 'pkgs_list_cron_lock' lock = gae_util.ObtainLock(lock_name) if not lock: logging.warning('GenerateInstallCounts: lock found; exiting.') return # Get a list of all packages that have previously been pushed. pkgs, unused_dt = models.ReportsCache.GetInstallCounts() # Generate a query of all InstallLog entites that haven't been read yet. query = models.InstallLog.all().order('server_datetime') cursor_obj = models.KeyValueCache.get_by_key_name('pkgs_list_cursor') if cursor_obj: query.with_cursor(cursor_obj.text_value) # Loop over new InstallLog entries. try: installs = query.fetch(1000) except: installs = None if not installs: models.ReportsCache.SetInstallCounts(pkgs) gae_util.ReleaseLock(lock_name) return for install in installs: pkg_name = install.package if pkg_name not in pkgs: pkgs[pkg_name] = { 'install_count': 0, 'install_fail_count': 0, 'applesus': install.applesus, } if install.IsSuccess(): pkgs[pkg_name]['install_count'] = ( pkgs[pkg_name].setdefault('install_count', 0) + 1) # (re)calculate avg_duration_seconds for this package. if 'duration_seconds_avg' not in pkgs[pkg_name]: pkgs[pkg_name]['duration_count'] = 0 pkgs[pkg_name]['duration_total_seconds'] = 0 pkgs[pkg_name]['duration_seconds_avg'] = None # only proceed if entity has "duration_seconds" property != None. if getattr(install, 'duration_seconds', None) is not None: pkgs[pkg_name]['duration_count'] += 1 pkgs[pkg_name]['duration_total_seconds'] += ( install.duration_seconds) pkgs[pkg_name]['duration_seconds_avg'] = int( pkgs[pkg_name]['duration_total_seconds'] / pkgs[pkg_name]['duration_count']) else: pkgs[pkg_name]['install_fail_count'] = ( pkgs[pkg_name].setdefault('install_fail_count', 0) + 1) # Update any changed packages. models.ReportsCache.SetInstallCounts(pkgs) if not cursor_obj: cursor_obj = models.KeyValueCache(key_name='pkgs_list_cursor') cursor_txt = str(query.cursor()) cursor_obj.text_value = cursor_txt cursor_obj.put() # Delete the lock. gae_util.ReleaseLock(lock_name) deferred.defer(_GenerateInstallCounts)
def _GenerateMsuUserSummary(self, since_days=None, now=None): """Generate summary of MSU user data. Args: since_days: int, optional, only report on the last x days now: datetime.datetime, optional, supply an alternative value for the current date/time """ lock_name = 'msu_user_summary_lock' cursor_name = 'msu_user_summary_cursor' if since_days is None: since = None else: since = '%dD' % since_days lock_name = '%s_%s' % (lock_name, since) cursor_name = '%s_%s' % (cursor_name, since) lock = gae_util.ObtainLock(lock_name) if not lock: logging.warning('GenerateMsuUserSummary lock found; exiting.') return interested_events = self.USER_EVENTS lquery = models.ComputerMSULog.all() cursor = models.KeyValueCache.MemcacheWrappedGet( cursor_name, 'text_value') summary, unused_dt = models.ReportsCache.GetMsuUserSummary(since=since, tmp=True) if cursor and summary: lquery.with_cursor(cursor) else: summary = {} for event in interested_events: summary[event] = 0 summary['total_events'] = 0 summary['total_users'] = 0 summary['total_uuids'] = 0 models.ReportsCache.SetMsuUserSummary(summary, since=since, tmp=True) begin = time.time() if now is None: now = datetime.datetime.utcnow() while True: reports = lquery.fetch(self.FETCH_LIMIT) if not reports: break userdata = {} last_user = None last_user_cursor = None prev_user_cursor = None n = 0 for report in reports: userdata.setdefault(report.user, {}) userdata[report.user].setdefault(report.uuid, {}).update( {report.event: report.mtime}) if last_user != report.user: last_user = report.user prev_user_cursor = last_user_cursor last_user_cursor = str(lquery.cursor()) n += 1 if n == self.FETCH_LIMIT: # full fetch, might not have finished this user -- rewind del userdata[last_user] last_user_cursor = prev_user_cursor for user in userdata: events = 0 for uuid in userdata[user]: if 'launched' not in userdata[user][uuid]: continue for event in userdata[user][uuid]: if since_days is None or IsTimeDelta( userdata[user][uuid][event], now, days=since_days): summary.setdefault(event, 0) summary[event] += 1 summary['total_events'] += 1 events += 1 if events: summary['total_uuids'] += 1 if events: summary['total_users'] += 1 summary.setdefault('total_users_%d_events' % events, 0) summary['total_users_%d_events' % events] += 1 lquery = models.ComputerMSULog.all() lquery.with_cursor(last_user_cursor) end = time.time() if (end - begin) > RUNTIME_MAX_SECS: break if reports: models.ReportsCache.SetMsuUserSummary(summary, since=since, tmp=True) models.KeyValueCache.MemcacheWrappedSet(cursor_name, 'text_value', last_user_cursor) if since_days: args = '/%d' % since_days else: args = '' taskqueue.add(url='/cron/reports_cache/msu_user_summary%s' % args, method='GET', countdown=5) else: models.ReportsCache.SetMsuUserSummary(summary, since=since) models.KeyValueCache.DeleteMemcacheWrap(cursor_name, prop_name='text_value') models.ReportsCache.DeleteMsuUserSummary(since=since, tmp=True) gae_util.ReleaseLock(lock_name)
def get(self, filename=None): """GET Args: filename: string like Firefox-1.0.dmg """ auth_return = auth.DoAnyAuth() if hasattr(auth_return, 'email'): email = auth_return.email() if not any(( auth.IsAdminUser(email), auth.IsSupportUser(email), )): raise auth.IsAdminMismatch if filename: filename = urllib.unquote(filename) hash_str = self.request.get('hash') if hash_str: lock = 'pkgsinfo_%s' % filename if not gae_util.ObtainLock(lock, timeout=5.0): self.response.set_status(httplib.FORBIDDEN) self.response.out.write('Could not lock pkgsinfo') return pkginfo = models.PackageInfo.get_by_key_name(filename) if pkginfo: self.response.headers[ 'Content-Type'] = 'text/xml; charset=utf-8' if hash_str: self.response.headers['X-Pkgsinfo-Hash'] = self._Hash( pkginfo.plist) self.response.out.write(pkginfo.plist) else: if hash_str: gae_util.ReleaseLock(lock) self.response.set_status(httplib.NOT_FOUND) return if hash_str: gae_util.ReleaseLock(lock) else: query = models.PackageInfo.all() filename = self.request.get('filename') if filename: query.filter('filename', filename) install_types = self.request.get_all('install_types') for install_type in install_types: query.filter('install_types =', install_type) catalogs = self.request.get_all('catalogs') for catalog in catalogs: query.filter('catalogs =', catalog) pkgs = [] for p in query: pkg = {} for k in p.properties(): if k != '_plist': pkg[k] = getattr(p, k) pkgs.append(pkg) self.response.out.write('<?xml version="1.0" encoding="UTF-8"?>\n') self.response.out.write(plist.GetXmlStr(pkgs)) self.response.headers['Content-Type'] = 'text/xml; charset=utf-8'