blob: 840ddf8fd2b8179ccd9c0d8c04a212782a6303b9 [file] [log] [blame]
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import json
import logging
import os
from py_utils import cloud_storage
from dependency_manager import archive_info
from dependency_manager import cloud_storage_info
from dependency_manager import dependency_info
from dependency_manager import exceptions
from dependency_manager import local_path_info
from dependency_manager import uploader
class BaseConfig(object):
"""A basic config class for use with the DependencyManager.
Initiated with a json file in the following format:
{ "config_type": "BaseConfig",
"dependencies": {
"dep_name1": {
"cloud_storage_base_folder": "base_folder1",
"cloud_storage_bucket": "bucket1",
"file_info": {
"platform1": {
"cloud_storage_hash": "hash_for_platform1",
"download_path": "download_path111",
"version_in_cs": "1.11.1.11."
"local_paths": ["local_path1110", "local_path1111"]
},
"platform2": {
"cloud_storage_hash": "hash_for_platform2",
"download_path": "download_path2",
"local_paths": ["local_path20", "local_path21"]
},
...
}
},
"dependency_name_2": {
...
},
...
}
}
Required fields: "dependencies" and "config_type".
Note that config_type must be "BaseConfig"
Assumptions:
"cloud_storage_base_folder" is a top level folder in the given
"cloud_storage_bucket" where all of the dependency files are stored
at "dependency_name"_"cloud_storage_hash".
"download_path" and all paths in "local_paths" are relative to the
config file's location.
All or none of the following cloud storage related fields must be
included in each platform dictionary:
"cloud_storage_hash", "download_path", "cs_remote_path"
"version_in_cs" is an optional cloud storage field, but is dependent
on the above cloud storage related fields.
Also note that platform names are often of the form os_architechture.
Ex: "win_AMD64"
More information on the fields can be found in dependencies_info.py
"""
def __init__(self, file_path, writable=False):
""" Initialize a BaseConfig for the DependencyManager.
Args:
writable: False: This config will be used to lookup information.
True: This config will be used to update information.
file_path: Path to a file containing a json dictionary in the expected
json format for this config class. Base format expected:
{ "config_type": config_type,
"dependencies": dependencies_dict }
config_type: must match the return value of GetConfigType.
dependencies: A dictionary with the information needed to
create dependency_info instances for the given
dependencies.
See dependency_info.py for more information.
"""
self._config_path = file_path
self._writable = writable
self._pending_uploads = []
if not self._config_path:
raise ValueError('Must supply config file path.')
if not os.path.exists(self._config_path):
if not writable:
raise exceptions.EmptyConfigError(file_path)
self._config_data = {}
self._WriteConfigToFile(self._config_path, dependencies=self._config_data)
else:
with open(file_path, 'r') as f:
config_data = json.load(f)
if not config_data:
raise exceptions.EmptyConfigError(file_path)
config_type = config_data.pop('config_type', None)
if config_type != self.GetConfigType():
raise ValueError(
'Supplied config_type (%s) is not the expected type (%s) in file '
'%s' % (config_type, self.GetConfigType(), file_path))
self._config_data = config_data.get('dependencies', {})
def IterDependencyInfo(self):
""" Yields a DependencyInfo for each dependency/platform pair.
Raises:
ReadWriteError: If called when the config is writable.
ValueError: If any of the dependencies contain partial information for
downloading from cloud_storage. (See dependency_info.py)
"""
if self._writable:
raise exceptions.ReadWriteError(
'Trying to read dependency info from a writable config. File for '
'config: %s' % self._config_path)
base_path = os.path.dirname(self._config_path)
for dependency in self._config_data:
dependency_dict = self._config_data.get(dependency)
platforms_dict = dependency_dict.get('file_info', {})
for platform in platforms_dict:
platform_info = platforms_dict.get(platform)
local_info = None
local_paths = platform_info.get('local_paths', [])
if local_paths:
paths = []
for path in local_paths:
path = self._FormatPath(path)
paths.append(os.path.abspath(os.path.join(base_path, path)))
local_info = local_path_info.LocalPathInfo(paths)
cs_info = None
cs_bucket = dependency_dict.get('cloud_storage_bucket')
cs_base_folder = dependency_dict.get('cloud_storage_base_folder', '')
download_path = platform_info.get('download_path')
if download_path:
download_path = self._FormatPath(download_path)
download_path = os.path.abspath(
os.path.join(base_path, download_path))
cs_hash = platform_info.get('cloud_storage_hash')
if not cs_hash:
raise exceptions.ConfigError(
'Dependency %s has cloud storage info on platform %s, but is '
'missing a cloud storage hash.', dependency, platform)
cs_remote_path = self._CloudStorageRemotePath(
dependency, cs_hash, cs_base_folder)
version_in_cs = platform_info.get('version_in_cs')
zip_info = None
path_within_archive = platform_info.get('path_within_archive')
if path_within_archive:
unzip_path = os.path.abspath(
os.path.join(os.path.dirname(download_path),
'%s_%s_%s' % (dependency, platform, cs_hash)))
zip_info = archive_info.ArchiveInfo(
download_path, unzip_path, path_within_archive)
cs_info = cloud_storage_info.CloudStorageInfo(
cs_bucket, cs_hash, download_path, cs_remote_path,
version_in_cs=version_in_cs, archive_info=zip_info)
dep_info = dependency_info.DependencyInfo(
dependency, platform, self._config_path,
local_path_info=local_info, cloud_storage_info=cs_info)
yield dep_info
@classmethod
def GetConfigType(cls):
return 'BaseConfig'
@property
def config_path(self):
return self._config_path
def AddCloudStorageDependencyUpdateJob(
self, dependency, platform, dependency_path, version=None,
execute_job=True):
"""Update the file downloaded from cloud storage for a dependency/platform.
Upload a new file to cloud storage for the given dependency and platform
pair and update the cloud storage hash and the version for the given pair.
Example usage:
The following should update the default platform for 'dep_name':
UpdateCloudStorageDependency('dep_name', 'default', 'path/to/file')
The following should update both the mac and win platforms for 'dep_name',
or neither if either update fails:
UpdateCloudStorageDependency(
'dep_name', 'mac_x86_64', 'path/to/mac/file', execute_job=False)
UpdateCloudStorageDependency(
'dep_name', 'win_AMD64', 'path/to/win/file', execute_job=False)
ExecuteUpdateJobs()
Args:
dependency: The dependency to update.
platform: The platform to update the dependency info for.
dependency_path: Path to the new dependency to be used.
version: Version of the updated dependency, for checking future updates
against.
execute_job: True if the config should be written to disk and the file
should be uploaded to cloud storage after the update. False if
multiple updates should be performed atomically. Must call
ExecuteUpdateJobs after all non-executed jobs are added to complete
the update.
Raises:
ReadWriteError: If the config was not initialized as writable, or if
|execute_job| is True but the config has update jobs still pending
execution.
ValueError: If no information exists in the config for |dependency| on
|platform|.
"""
self._ValidateIsConfigUpdatable(
execute_job=execute_job, dependency=dependency, platform=platform)
cs_hash = cloud_storage.CalculateHash(dependency_path)
if version:
self._SetPlatformData(dependency, platform, 'version_in_cs', version)
self._SetPlatformData(dependency, platform, 'cloud_storage_hash', cs_hash)
cs_base_folder = self._GetPlatformData(
dependency, platform, 'cloud_storage_base_folder')
cs_bucket = self._GetPlatformData(
dependency, platform, 'cloud_storage_bucket')
cs_remote_path = self._CloudStorageRemotePath(
dependency, cs_hash, cs_base_folder)
self._pending_uploads.append(uploader.CloudStorageUploader(
cs_bucket, cs_remote_path, dependency_path))
if execute_job:
self.ExecuteUpdateJobs()
def ExecuteUpdateJobs(self, force=False):
"""Write all config changes to the config_path specified in __init__.
Upload all files pending upload and then write the updated config to
file. Attempt to remove all uploaded files on failure.
Args:
force: True if files should be uploaded to cloud storage even if a
file already exists in the upload location.
Returns:
True: if the config was dirty and the upload succeeded.
False: if the config was not dirty.
Raises:
CloudStorageUploadConflictError: If |force| is False and the potential
upload location of a file already exists.
CloudStorageError: If copying an existing file to the backup location
or uploading a new file fails.
"""
self._ValidateIsConfigUpdatable()
if not self._IsDirty():
logging.info('ExecuteUpdateJobs called on clean config')
return False
if not self._pending_uploads:
logging.debug('No files needing upload.')
else:
try:
for item_pending_upload in self._pending_uploads:
item_pending_upload.Upload(force)
self._WriteConfigToFile(self._config_path, self._config_data)
self._pending_uploads = []
except:
# Attempt to rollback the update in any instance of failure, even user
# interrupt via Ctrl+C; but don't consume the exception.
logging.error('Update failed, attempting to roll it back.')
for upload_item in reversed(self._pending_uploads):
upload_item.Rollback()
raise
return True
def GetVersion(self, dependency, platform):
"""Return the Version information for the given dependency."""
return self._GetPlatformData(
dependency, platform, data_type='version_in_cs')
def _IsDirty(self):
with open(self._config_path, 'r') as fstream:
curr_config_data = json.load(fstream)
curr_config_data = curr_config_data.get('dependencies', {})
return self._config_data != curr_config_data
def _SetPlatformData(self, dependency, platform, data_type, data):
self._ValidateIsConfigWritable()
dependency_dict = self._config_data.get(dependency, {})
platform_dict = dependency_dict.get('file_info', {}).get(platform)
if not platform_dict:
raise ValueError('No platform data for platform %s on dependency %s' %
(platform, dependency))
if (data_type == 'cloud_storage_bucket' or
data_type == 'cloud_storage_base_folder'):
self._config_data[dependency][data_type] = data
else:
self._config_data[dependency]['file_info'][platform][data_type] = data
def _GetPlatformData(self, dependency, platform, data_type=None):
dependency_dict = self._config_data.get(dependency, {})
if not dependency_dict:
raise ValueError('Dependency %s is not in config.' % dependency)
platform_dict = dependency_dict.get('file_info', {}).get(platform)
if not platform_dict:
raise ValueError('No platform data for platform %s on dependency %s' %
(platform, dependency))
if data_type:
if (data_type == 'cloud_storage_bucket' or
data_type == 'cloud_storage_base_folder'):
return dependency_dict.get(data_type)
return platform_dict.get(data_type)
return platform_dict
def _ValidateIsConfigUpdatable(
self, execute_job=False, dependency=None, platform=None):
self._ValidateIsConfigWritable()
if self._IsDirty() and execute_job:
raise exceptions.ReadWriteError(
'A change has already been made to this config. Either call without'
'using the execute_job option or first call ExecuteUpdateJobs().')
if dependency and not self._config_data.get(dependency):
raise ValueError('Cannot update information because dependency %s does '
'not exist.' % dependency)
if platform and not self._GetPlatformData(dependency, platform):
raise ValueError('No dependency info is available for the given '
'dependency: %s' % dependency)
def _ValidateIsConfigWritable(self):
if not self._writable:
raise exceptions.ReadWriteError(
'Trying to update the information from a read-only config. '
'File for config: %s' % self._config_path)
@staticmethod
def _CloudStorageRemotePath(dependency, cs_hash, cs_base_folder):
cs_remote_file = '%s_%s' % (dependency, cs_hash)
cs_remote_path = cs_remote_file if not cs_base_folder else (
'%s/%s' % (cs_base_folder, cs_remote_file))
return cs_remote_path
@classmethod
def _FormatPath(cls, file_path):
""" Format |file_path| for the current file system.
We may be downloading files for another platform, so paths must be
downloadable on the current system.
"""
if not file_path:
return file_path
if os.path.sep != '\\':
return file_path.replace('\\', os.path.sep)
elif os.path.sep != '/':
return file_path.replace('/', os.path.sep)
return file_path
@classmethod
def _WriteConfigToFile(cls, file_path, dependencies=None):
json_dict = cls._GetJsonDict(dependencies)
file_dir = os.path.dirname(file_path)
if not os.path.exists(file_dir):
os.makedirs(file_dir)
with open(file_path, 'w') as outfile:
json.dump(
json_dict, outfile, indent=2, sort_keys=True, separators=(',', ': '))
return json_dict
@classmethod
def _GetJsonDict(cls, dependencies=None):
dependencies = dependencies or {}
json_dict = {'config_type': cls.GetConfigType(),
'dependencies': dependencies}
return json_dict