summaryrefslogtreecommitdiff
path: root/migrations/migrations.py
diff options
context:
space:
mode:
Diffstat (limited to 'migrations/migrations.py')
-rw-r--r--migrations/migrations.py228
1 files changed, 228 insertions, 0 deletions
diff --git a/migrations/migrations.py b/migrations/migrations.py
new file mode 100644
index 00000000..22ed1328
--- /dev/null
+++ b/migrations/migrations.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python
+# Copyright (C) 2015 Codethink Limited
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 2 of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+'''Tools for migrating Baserock definitions from one format version to another.
+
+'''
+
+
+# ruamel.yaml is a fork of PyYAML which allows rewriting YAML files without
+# destroying all of the comments, ordering and formatting. The more
+# widely-used PyYAML library will produce output totally different to the
+# input file in most cases.
+#
+# See: <https://bitbucket.org/ruamel/yaml>
+import ruamel.yaml as yaml
+
+import logging
+import os
+import warnings
+
+
+# Uncomment this to cause all log messages to be written to stdout. By
+# default they are hidden, but if you are debugging something this might help!
+#
+# logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
+
+
+def pretty_warnings(message, category, filename, lineno,
+ file=None, line=None):
+ '''Format warning messages from warnings.warn().'''
+ return 'WARNING: %s\n' % (message)
+
+# Override the default warning formatter (which is ugly), and add a filter to
+# ensure duplicate warnings only get displayed once.
+warnings.simplefilter("once", append=True)
+warnings.formatwarning = pretty_warnings
+
+
+
+def parse_yaml_with_roundtrip_info(text):
+ return yaml.load(text, yaml.RoundTripLoader)
+
+def write_yaml_with_roundtrip_info(contents, stream, **kwargs):
+ yaml.dump(contents, stream, Dumper=yaml.RoundTripDumper, **kwargs)
+
+
+
+class VersionFileError(RuntimeError):
+ '''Represents errors in the version marker file (./VERSION).'''
+ pass
+
+
+class MigrationOutOfOrderError(RuntimeError):
+ '''Raised if a migration is run on too old a version of definitions.
+
+ It's not an error to run a migration on a version that is already migrated.
+
+ '''
+ pass
+
+
+def check_definitions_version(from_version, version_file='./VERSION',
+ to_version=None):
+ '''Check if migration between 'from_version' and 'to_version' is needed.
+
+ Both 'from_version' and 'to_version' should be whole numbers. The
+ 'to_version' defaults to from_version + 1.
+
+ This function reads the version marker file specified by 'version_file'.
+ Returns True if the version is between 'from_version' and 'to_version',
+ indicating that migration needs to be done. Returns False if the version is
+ already at or beyond 'to_version'. Raises MigrationOutOfOrderError if the
+ version is below 'from_version'.
+
+ If 'version_file' is missing or invalid, it raises VersionFileError. The
+ version file is expected to follow the following format:
+
+ version: 1
+
+ '''
+ to_version = to_version or (from_version + 1)
+ need_to_migrate = False
+
+ if os.path.exists(version_file):
+ logging.info("Found version information file: %s" % version_file)
+
+ with open(version_file) as f:
+ version_text = f.read()
+
+ if len(version_text) == 0:
+ raise VersionFileError(
+ "File %s exists but is empty." % version_file)
+
+ try:
+ version_info = yaml.safe_load(version_text)
+ current_version = version_info['version']
+
+ if current_version >= to_version:
+ logging.info(
+ "Already at version %i." % current_version)
+ elif current_version < from_version:
+ raise MigrationOutOfOrderError(
+ "This tool expects to migrate from version %i to version "
+ "%i of the Baserock Definitions syntax. These definitions "
+ "claim to be version %i." % (
+ from_version, to_version, current_version))
+ else:
+ logging.info("Need to migrate from %i to %i.",
+ current_version, to_version)
+ need_to_migrate = True
+ except (KeyError, TypeError, ValueError) as e:
+ logging.exception(e)
+ raise VersionFileError(
+ "Invalid version info: '%s'" % version_text)
+ else:
+ raise VersionFileError(
+ "No file %s was found. Please run the migration scripts in order,"
+ "starting from 000-version-info.py." % version_file)
+
+ return need_to_migrate
+
+
+def set_definitions_version(new_version, version_file='./VERSION'):
+ '''Update the version information stored in 'version_file'.
+
+ The new version must be a whole number. If 'version_file' doesn't exist,
+ it will be created.
+
+ '''
+ version_info = {'version': new_version}
+ with open(version_file, 'w') as f:
+ # If 'default_flow_style' is True (the default) then the output here
+ # will look like "{version: 0}" instead of "version: 0".
+ yaml.safe_dump(version_info, f, default_flow_style=False)
+
+
+def walk_definition_files(path='.', extensions=['.morph']):
+ '''Recursively yield all files under 'path' with the given extension(s).
+
+ This is safe to run in the top level of a Git repository, as anything under
+ '.git' will be ignored.
+
+ '''
+ for dirname, dirnames, filenames in os.walk('.'):
+ filenames.sort()
+ dirnames.sort()
+ if '.git' in dirnames:
+ dirnames.remove('.git')
+ for filename in filenames:
+ for extension in extensions:
+ if filename.endswith(extension):
+ yield os.path.join(dirname, filename)
+
+
+ALL_KINDS = ['cluster', 'system', 'stratum', 'chunk']
+
+
+def process_definitions(path='.', kinds=ALL_KINDS, validate_cb=None,
+ modify_cb=None):
+ '''Run callbacks for all Baserock definitions found in 'path'.
+
+ If 'validate_cb' is set, it will be called for each definition and can
+ return True or False to indicate whether that definition is valid according
+ a new version of the format. The process_definitions() function will return
+ True if all definitions were valid according to validate_cb(), and False
+ otherwise.
+
+ If 'modify_cb' is set, it will be called for each definition and can
+ modify the 'content' dict. It should return True if the dict was modified,
+ and in this case the definition file will be overwritten with the new
+ contents. The 'ruamel.yaml' library is used if it is available, which will
+ try to preserve comments, ordering and some formatting in the YAML
+ definition files.
+
+ If 'validate_cb' is set and returns False for a definition, 'modify_cb'
+ will not be called.
+
+ Both callbacks are passed two parameters: a dict containing the contents of
+ the definition file, and its filename. The filename is passed so you can
+ use it when reporting errors.
+
+ The 'kinds' setting can be used to ignore some definitions according to the
+ 'kind' field.
+
+ '''
+ all_valid = True
+
+ for filename in walk_definition_files(path=path):
+ with open(filename) as f:
+ text = f.read()
+
+ if modify_cb is None:
+ contents = yaml.load(text)
+ else:
+ contents = parse_yaml_with_roundtrip_info(text)
+
+ if 'kind' in contents:
+ if contents['kind'] in kinds:
+ valid = True
+ changed = False
+
+ if validate_cb is not None:
+ valid = validate_cb(contents, filename)
+ all_valid &= valid
+
+ if valid and modify_cb is not None:
+ changed = modify_cb(contents, filename)
+
+ if changed:
+ with open(filename, 'w') as f:
+ write_yaml_with_roundtrip_info(contents, f, width=80)
+ else:
+ warnings.warn("%s is invalid: no 'kind' field set." % filename)
+
+ return all_valid