blob: fb80550151bce8570e59d7e057920036824331c5 [file] [log] [blame]
#!/usr/bin/env python3
from distutils.version import LooseVersion
import argparse
import base64
import collections
import copy
import itertools
import json
import jsonschema
import pathlib
import shutil
import sys
import tempfile
import re
import zipfile
def main():
parser = argparse.ArgumentParser(
description='This script generates all of the universe objects from '
'the universe repository. The files created in --out-dir are: '
'universe.json.')
parser.add_argument(
'--repository',
required=True,
type=pathlib.Path,
help='Path to the top level package directory. E.g. repo/packages')
parser.add_argument(
'--out-dir',
dest='outdir',
required=True,
type=pathlib.Path,
help='Path to the directory to use to store all universe objects')
args = parser.parse_args()
if not args.outdir.is_dir():
print('The path in --out-dir [{}] is not a directory. Please create it'
' before running this script.'.format(args.outdir))
return
if not args.repository.is_dir():
print('The path in --repository [{}] is not a directory.'.format(
args.repository))
return
packages = [
generate_package_from_path(
args.repository,
package_name,
release_version)
for package_name, release_version
in enumerate_dcos_packages(args.repository)
]
# Render entire universe
universe_path = args.outdir / 'universe.json'
with universe_path.open('w', encoding='utf-8') as universe_file:
json.dump({'packages': packages}, universe_file)
ct_universe_path = args.outdir / 'universe.content_type'
create_content_type_file(ct_universe_path, "v4")
# Render empty json
empty_path = args.outdir / 'repo-empty-v3.json'
with empty_path.open('w', encoding='utf-8') as universe_file:
json.dump({'packages': []}, universe_file)
ct_empty_path = args.outdir / 'repo-empty-v3.content_type'
create_content_type_file(ct_empty_path, "v3")
# create universe-by-version files for `dcos_versions`
dcos_versions = ["1.6.1", "1.7", "1.8", "1.9", "1.10", "1.11"]
[render_universe_by_version(
args.outdir, packages, version) for version in dcos_versions]
def render_universe_by_version(outdir, packages, version):
"""Render universe packages for `version`. Zip files for versions < 1.8,
and json files for version >= 1.8
:param outdir: Path to the directory to use to store all universe objects
:type outdir: str
:param packages: package dictionary
:type packages: dict
:param version: DC/OS version
:type version: str
:rtype: None
"""
if LooseVersion(version) < LooseVersion("1.8"):
render_zip_universe_by_version(outdir, packages, version)
else:
file_path = render_json_by_version(outdir, packages, version)
_validate_repo(file_path, version)
render_content_type_file_by_version(outdir, version)
def json_escape_compatibility(schema: collections.OrderedDict) -> collections.OrderedDict:
""" Further escape any singly escaped stringified JSON in config """
for value in schema.values():
if "description" in value:
value["description"] = escape_json_string(value["description"])
if "type" in value:
if value["type"] == "string" and "default" in value:
value["default"] = escape_json_string(value["default"])
elif value["type"] == "object" and "properties" in value:
value["properties"] = json_escape_compatibility(value["properties"])
return schema
def escape_json_string(string: str) -> str:
""" Makes any single escaped double quotes doubly escaped. """
def escape_underescaped_slash(matchobj):
""" Return adjacent character + extra escaped double quote. """
return matchobj.group(1) + "\\\""
# This regex means: match .\" except \\\" while capturing `.`
return re.sub('([^\\\\])\\\"', escape_underescaped_slash, string)
def render_content_type_file_by_version(outdir, version):
"""Render content type file for `version`
:param outdir: Path to the directory to use to store all universe objects
:type outdir: str
:param version: DC/OS version
:type version: str
:rtype: None
"""
universe_version = \
"v3" if LooseVersion(version) < LooseVersion("1.10") else "v4"
ct_file_path = \
outdir / 'repo-up-to-{}.content_type'.format(version)
create_content_type_file(ct_file_path, universe_version)
def create_content_type_file(path, universe_version):
""" Creates a file with universe repo version `universe_version` content-type
as its contents.
:param path: the name of the content-type file
:type path: str
:param universe_version: Universe content type version: "v3" or "v4"
:type universe_version: str
:rtype: None
"""
with path.open('w', encoding='utf-8') as ct_file:
content_type = format_universe_repo_content_type(universe_version)
ct_file.write(content_type)
def format_universe_repo_content_type(universe_version):
""" Formats a universe repo content-type of version `universe-version`
:param universe_version: Universe content type version: "v3" or "v4"
:type universe_version: str
:return: content-type of the universe repo version `universe_version`
:rtype: str
"""
content_type = "application/" \
"vnd.dcos.universe.repo+json;" \
"charset=utf-8;version=" \
+ universe_version
return content_type
def render_json_by_version(outdir, packages, version):
"""Render json file for `version`
:param outdir: Path to the directory to use to store all universe objects
:type outdir: str
:param packages: package dictionary
:type packages: dict
:param version: DC/OS version
:type version: str
:return: the path where the universe was stored
:rtype: str
"""
packages = filter_and_downgrade_packages_by_version(packages, version)
json_file_path = outdir / 'repo-up-to-{}.json'.format(version)
with json_file_path.open('w', encoding='utf-8') as universe_file:
json.dump({'packages': packages}, universe_file)
return json_file_path
def filter_and_downgrade_packages_by_version(packages, version):
"""Filter packages by `version` and the downgrade if needed
:param packages: package dictionary
:type packages: dict
:param version: DC/OS version
:type version: str
:return packages filtered (and may be downgraded) on `version`
:rtype package dictionary
"""
packages = [
package for package in packages if filter_by_version(package, version)
]
if LooseVersion(version) < LooseVersion('1.10'):
# Prior to 1.10, Cosmos had a rendering bug that required
# stringified JSON to be doubly escaped. This was corrected
# in 1.10, but it means that packages with stringified JSON parameters
# that need to bridge versions must be accomodated.
#
# < 1.9 style escaping:
# \\\"field\\\": \\\"value\\\"
#
# >= 1.10 style escaping:
# \"field\": \"value\"
for package in packages:
if "config" in package and "properties" in package["config"]:
# The rough shape of a config file is:
# {
# "schema": ...,
# "properties": { }
# }
# Send just the top level properties in to the recursive
# function json_escape_compatibility.
package["config"]["properties"] = json_escape_compatibility(
package["config"]["properties"])
packages = [downgrade_package_to_v3(package) for package in packages]
return packages
def render_zip_universe_by_version(outdir, packages, version):
"""Render zip universe for `version`
:param outdir: Path to the directory to use to store all universe objects
:type outdir: str
:param package: package dictionary
:type package: dict
:param version: DC/OS version
:type version: str
:rtype: None
"""
with tempfile.NamedTemporaryFile() as temp_file:
with zipfile.ZipFile(temp_file, mode='w') as zip_file:
render_universe_zip(
zip_file,
filter(
lambda package: filter_by_version(package, version),
packages)
)
zip_name = 'repo-up-to-{}.zip'.format(version)
shutil.copy(temp_file.name, str(outdir / zip_name))
def filter_by_version(package, version):
"""Prediate for checking for packages of version `version` or less
:param package: package dictionary
:type package: dict
:param version: DC/OS version
:type version: str
:rtype: bool
"""
package_version = LooseVersion(
package.get('minDcosReleaseVersion', '0.0')
)
filter_version = LooseVersion(version)
return package_version <= filter_version
def package_path(root, package_name, release_version):
"""Returns the path to the package directory
:param root: path to the root of the repository
:type root: pathlib.Path
:param package_name: name of the package
:type package_name: str
:param release_version: package release version
:type release_version: int
:rtype: pathlib.Path
"""
return (root /
package_name[:1].upper() /
package_name /
str(release_version))
def read_package(path):
"""Reads the package.json as a dict
:param path: path to the package
:type path: pathlib.Path
:rtype: dict
"""
path = path / 'package.json'
with path.open(encoding='utf-8') as file_object:
return json.load(file_object)
def read_resource(path):
"""Reads the resource.json as a dict
:param path: path to the package
:type path: pathlib.Path
:rtype: dict | None
"""
path = path / 'resource.json'
if path.is_file():
with path.open(encoding='utf-8') as file_object:
return json.load(file_object)
def read_marathon_template(path):
"""Reads the marathon.json.mustache as a base64 encoded string
:param path: path to the package
:type path: pathlib.Path
:rtype: str | None
"""
path = path / 'marathon.json.mustache'
if path.is_file():
with path.open(mode='rb') as file_object:
return base64.standard_b64encode(file_object.read()).decode()
def read_config(path):
"""Reads the config.json as a dict
:param path: path to the package
:type path: pathlib.Path
:rtype: dict | None
"""
path = path / 'config.json'
if path.is_file():
with path.open(encoding='utf-8') as file_object:
# Load config file into a OrderedDict to preserve order
return json.load(
file_object,
object_pairs_hook=collections.OrderedDict
)
def read_command(path):
"""Reads the command.json as a dict
:param path: path to the package
:type path: pathlib.Path
:rtype: dict | None
"""
path = path / 'command.json'
if path.is_file():
with path.open(encoding='utf-8') as file_object:
return json.load(file_object)
def generate_package_from_path(root, package_name, release_version):
"""Returns v3 package metadata for the specified package
:param root: path to the root of the repository
:type root: pathlib.Path
:param package_name: name of the package
:type package_name: str
:param release_version: package release version
:type release_version: int
:rtype: dict
"""
path = package_path(root, package_name, release_version)
return generate_package(
release_version,
read_package(path),
resource=read_resource(path),
marathon_template=read_marathon_template(path),
config=read_config(path),
command=read_command(path)
)
def generate_package(
release_version,
package,
resource,
marathon_template,
config,
command):
"""Returns v3 package object for package. See
repo/meta/schema/v3-repo-schema.json
:param release_version: package release version
:type release_version: int
:param package: content of package.json
:type package: dict
:param resource: content of resource.json
:type resource: dict | None
:param marathon_template: content of marathon.json.template as base64
:type marathon_template: str | None
:param config: content of config.json
:type config: dict | None
:param command: content of command.json
:type command: dict | None
:rtype: dict
"""
package = package.copy()
package['releaseVersion'] = release_version
if resource:
package['resource'] = resource
if marathon_template:
package['marathon'] = {
'v2AppMustacheTemplate': marathon_template
}
if config:
package['config'] = config
if command:
package['command'] = command
return package
def enumerate_dcos_packages(packages_path):
"""Enumerate all of the package and release version to include
:param packages_path: the path to the root of the packages
:type packages_path: str
:returns: generator of package name and release version
:rtype: gen((str, int))
"""
for letter_path in packages_path.iterdir():
for package in letter_path.iterdir():
for release_version in package.iterdir():
yield (package.name, int(release_version.name))
def render_universe_zip(zip_file, packages):
"""Populates a zipfile from a list of universe v3 packages. This function
creates directories to be backwards compatible with legacy Cosmos.
:param zip_file: zipfile where we need to write the packages
:type zip_file: zipfile.ZipFile
:param packages: list of packages
:type packages: [dict]
:rtype: None
"""
packages = sorted(
packages,
key=lambda package: (package['name'], package['releaseVersion']))
root = pathlib.Path('universe')
create_dir_in_zip(zip_file, root)
create_dir_in_zip(zip_file, root / 'repo')
create_dir_in_zip(zip_file, root / 'repo' / 'meta')
zip_file.writestr(
str(root / 'repo' / 'meta' / 'index.json'),
json.dumps(create_index(packages)))
zip_file.writestr(
str(root / 'repo' / 'meta' / 'version.json'),
json.dumps({'version': '2.0.0'}))
packagesDir = root / 'repo' / 'packages'
create_dir_in_zip(zip_file, packagesDir)
currentLetter = ''
currentPackageName = ''
for package in packages:
if currentLetter != package['name'][:1].upper():
currentLetter = package['name'][:1].upper()
create_dir_in_zip(zip_file, packagesDir / currentLetter)
if currentPackageName != package['name']:
currentPackageName = package['name']
create_dir_in_zip(
zip_file,
packagesDir / currentLetter / currentPackageName)
package_directory = (
packagesDir /
currentLetter /
currentPackageName /
str(package['releaseVersion'])
)
create_dir_in_zip(zip_file, package_directory)
write_package_in_zip(zip_file, package_directory, package)
def create_dir_in_zip(zip_file, directory):
"""Create a directory in a zip file
:param zip_file: zip file where the directory will get created
:type zip_file: zipfile.ZipFile
:param directory: path for the directory
:type directory: pathlib.Path
:rtype: None
"""
zip_file.writestr(str(directory) + '/', b'')
def write_package_in_zip(zip_file, path, package):
"""Write packages files in the zip file
:param zip_file: zip file where the files will get created
:type zip_file: zipfile.ZipFile
:param path: path for the package directory. E.g.
universe/repo/packages/M/marathon/0
:type path: pathlib.Path
:param package: package information dictionary
:type package: dict
:rtype: None
"""
package = downgrade_package_to_v2(package)
package.pop('releaseVersion')
resource = package.pop('resource', None)
if resource:
zip_file.writestr(
str(path / 'resource.json'),
json.dumps(resource))
marathon_template = package.pop(
'marathon',
{}
).get(
'v2AppMustacheTemplate'
)
if marathon_template:
zip_file.writestr(
str(path / 'marathon.json.mustache'),
base64.standard_b64decode(marathon_template))
config = package.pop('config', None)
if config:
zip_file.writestr(
str(path / 'config.json'),
json.dumps(config))
command = package.pop('command', None)
if command:
zip_file.writestr(
str(path / 'command.json'),
json.dumps(command))
zip_file.writestr(
str(path / 'package.json'),
json.dumps(package))
def create_index(packages):
"""Create an index for all of the packages
:param packages: list of packages
:type packages: [dict]
:rtype: dict
"""
index = {
'version': '2.0.0',
'packages': [
create_index_entry(same_packages)
for _, same_packages
in itertools.groupby(packages, key=lambda package: package['name'])
]
}
return index
def create_index_entry(packages):
"""Create index entry from packages with the same name.
:param packages: list of packages with the same name
:type packages: [dict]
:rtype: dict
"""
entry = {
'versions': {}
}
for package in packages:
entry.update({
'name': package['name'],
'currentVersion': package['version'],
'description': package['description'],
'framework': package.get('framework', False),
'tags': package['tags'],
'selected': package.get('selected', False)
})
entry['versions'][package['version']] = str(package['releaseVersion'])
return entry
def v3_to_v2_package(v3_package):
"""Converts a v3 package to a v2 package
:param v3_package: a v3 package
:type v3_package: dict
:return: a v2 package
:rtype: dict
"""
package = copy.deepcopy(v3_package)
package.pop('minDcosReleaseVersion', None)
package['packagingVersion'] = "2.0"
resource = package.get('resource', None)
if resource:
cli = resource.pop('cli', None)
if cli and 'command' not in package:
print(('WARNING: Removing binary CLI from ({}, {}) without a '
'Python CLI').format(package['name'], package['version']))
return package
def v4_to_v3_package(v4_package):
"""Converts a v4 package to a v3 package
:param v4_package: a v3 package
:type v4_package: dict
:return: a v3 package
:rtype: dict
"""
package = copy.deepcopy(v4_package)
package.pop('upgradesFrom', None)
package.pop('downgradesTo', None)
package["packagingVersion"] = "3.0"
return package
def downgrade_package_to_v2(package):
"""Converts a v4 or v3 package to a v2 package. If given a v2
package, it creates a deep copy but does not modify it. It does not
modify the original package.
:param package: v4, v3, or v2 package
:type package: dict
:return: a v2 package
:rtyte: dict
"""
packaging_version = package.get("packagingVersion")
if packaging_version == "2.0":
return copy.deepcopy(package)
elif packaging_version == "3.0":
return v3_to_v2_package(package)
else:
return v3_to_v2_package(v4_to_v3_package(package))
def downgrade_package_to_v3(package):
"""Converts a v4 package to a v3 package. If given a v3 or v2 package
it creates a deep copy of it, but does not modify it. It does not
modify the original package.
:param package: v4, v3, or v2 package
:type package: dict
:return: a v3 or v2 package
:rtyte: dict
"""
packaging_version = package.get("packagingVersion")
if packaging_version == "2.0" or packaging_version == "3.0":
return copy.deepcopy(package)
else:
return v4_to_v3_package(package)
def validate_repo_with_schema(repo_json_data, repo_version):
"""Validates a repo and its version against the corresponding schema
:param repo_json_data: The json of repo
:param repo_version: version of the repo (e.g.: v4)
:return: list of validation errors ( length == zero => No errors)
"""
validator = jsonschema.Draft4Validator(_load_jsonschema(repo_version))
errors = []
for error in validator.iter_errors(repo_json_data):
for suberror in sorted(error.context, key=lambda e: e.schema_path):
errors.append('{}: {}'.format(list(suberror.schema_path), suberror.message))
return errors
def _validate_repo(file_path, version):
"""Validates a repo JSON file against the given version.
:param file_path: the path where the universe was stored
:type file_path: str
:param version: DC/OS version
:type version: str
:rtype: None
"""
if LooseVersion(version) >= LooseVersion('1.10'):
repo_version = 'v4'
else:
repo_version = 'v3'
with file_path.open(encoding='utf-8') as repo_file:
repo = json.loads(repo_file.read())
errors = validate_repo_with_schema(repo, repo_version)
if len(errors) != 0:
sys.exit(
'ERROR\n\nRepo {} version {} validation errors: {}'.format(
file_path,
repo_version,
'\n'.join(errors)
)
)
def _load_jsonschema(repo_version):
"""Opens and parses the repo schema based on the version provided.
:param repo_version: repo schema version. E.g. v3 vs v4
:type repo_version: str
:return: the schema dictionary
:rtype: dict
"""
with open(
'repo/meta/schema/{}-repo-schema.json'.format(repo_version),
encoding='utf-8'
) as schema_file:
return json.loads(schema_file.read())
if __name__ == '__main__':
sys.exit(main())