From eca6fc2e76957ec74b0245769e26d1ac47dbd97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Soko=C5=82owski?= Date: Thu, 13 Jun 2024 17:19:32 +0200 Subject: [PATCH] ansible: add roles.py script to manage roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/status-im/infra-template/pull/5 Signed-off-by: Jakub SokoĊ‚owski --- Makefile | 21 +- ansible/bootstrap.yml | 2 +- ansible/era.yml | 2 +- ansible/eth1.yml | 2 +- ansible/fluffy.yml | 2 +- ansible/geth.yml | 2 +- ansible/holesky.yml | 2 +- ansible/logs.yml | 2 +- ansible/mainnet.yml | 2 +- ansible/requirements.yml | 30 --- ansible/roles.py | 401 +++++++++++++++++++++++++++++++++++++++ ansible/sepolia.yml | 2 +- ansible/versioncheck.py | 71 ------- 13 files changed, 419 insertions(+), 122 deletions(-) create mode 100755 ansible/roles.py delete mode 100755 ansible/versioncheck.py diff --git a/Makefile b/Makefile index 806721f..1246074 100644 --- a/Makefile +++ b/Makefile @@ -16,16 +16,19 @@ PROVISIONER_ARCHIVE = $(PROVISIONER_NAME)-$(subst _,-,$(ARCH))_$(PROVISIONER_VER PROVISIONER_URL = https://github.com/radekg/terraform-provisioner-ansible/releases/download/$(PROVISIONER_VERSION)/$(PROVISIONER_ARCHIVE) PROVISIONER_PATH = $(TF_PLUGINS_DIR)/$(ARCH)/$(PROVISIONER_NAME)_$(PROVISIONER_VERSION) -all: requirements install-provisioner secrets init-terraform +all: roles-install install-provisioner secrets init-terraform @echo "Success!" -requirements-install: - ansible-galaxy install --keep-scm-meta --ignore-errors --force -r ansible/requirements.yml +roles-install: + ansible/roles.py --install -requirements-check: - ansible/versioncheck.py +roles-check: + ansible/roles.py --check -requirements: requirements-install requirements-check +roles-update: + ansible/roles.py --update + +roles: roles-install roles-check $(PROVISIONER_PATH): @mkdir -p $(TF_PLUGINS_DIR)/$(ARCH); \ @@ -46,9 +49,3 @@ init-terraform: cleanup: rm -r $(TF_PLUGINS_DIR)/$(ARCHIVE) - -ssh-config: export SSH_CONFIG_DIR ?= $(HOME)/.ssh/config.d -ssh-config: export SSH_CONFIG_FILE ?= infra-nimbus -ssh-config: export SSH_USERNAME ?= $(USER) -ssh-config: - scripts/create-ssh-config.sh diff --git a/ansible/bootstrap.yml b/ansible/bootstrap.yml index 1586d38..0a25af9 100644 --- a/ansible/bootstrap.yml +++ b/ansible/bootstrap.yml @@ -16,7 +16,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Bootstrap Python support for Ansible diff --git a/ansible/era.yml b/ansible/era.yml index eeb8c41..781c004 100644 --- a/ansible/era.yml +++ b/ansible/era.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Configure ERA files hosting diff --git a/ansible/eth1.yml b/ansible/eth1.yml index a92a553..96bffd2 100644 --- a/ansible/eth1.yml +++ b/ansible/eth1.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Configure RocketPool & Eth1 nodes diff --git a/ansible/fluffy.yml b/ansible/fluffy.yml index acd6179..fe64a17 100644 --- a/ansible/fluffy.yml +++ b/ansible/fluffy.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Configure Nimbus Fluffy nodes diff --git a/ansible/geth.yml b/ansible/geth.yml index 7039935..b962931 100644 --- a/ansible/geth.yml +++ b/ansible/geth.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Configure geth nodes diff --git a/ansible/holesky.yml b/ansible/holesky.yml index d03e32c..4a59cc4 100644 --- a/ansible/holesky.yml +++ b/ansible/holesky.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Deploy Holesky Linux Beacon Nodes diff --git a/ansible/logs.yml b/ansible/logs.yml index be4884c..b7ed741 100644 --- a/ansible/logs.yml +++ b/ansible/logs.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Configure ElasticSearch servers diff --git a/ansible/mainnet.yml b/ansible/mainnet.yml index 2e45057..3f08d1f 100644 --- a/ansible/mainnet.yml +++ b/ansible/mainnet.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Configure network mainnet bootnodes diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 051ca67..2ab70d5 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -1,139 +1,109 @@ ---- - name: infra-role-bootstrap-linux src: git@github.com:status-im/infra-role-bootstrap-linux.git - scm: git - name: infra-role-bootstrap-windows src: git@github.com:status-im/infra-role-bootstrap-windows.git - scm: git - name: infra-role-bootstrap-macos src: git@github.com:status-im/infra-role-bootstrap-macos.git - scm: git - name: infra-role-wireguard src: git@github.com:status-im/infra-role-wireguard.git - scm: git - name: infra-role-open-ports src: git@github.com:status-im/infra-role-open-ports.git - scm: git - name: infra-role-swap-file src: git@github.com:status-im/infra-role-swap-file.git - scm: git - name: infra-role-nginx src: git@github.com:status-im/infra-role-nginx.git version: 3043c998cbc92a634a71dc48363b3e2525696d26 - scm: git - name: infra-role-origin-certs src: git@github.com:status-im/infra-role-origin-certs.git version: f2b061da8976444d6576b755578f0e799cde4372 - scm: git - name: infra-role-oauth-proxy src: git@github.com:status-im/infra-role-oauth-proxy.git version: 9119f7af1feb809b28fcf68ce044795affe1aa76 - scm: git - name: infra-role-consul-service src: git@github.com:status-im/infra-role-consul-service.git version: d62993069bd045edade5d5fd90c098655089db85 - scm: git - name: infra-role-beacon-node-linux src: git@github.com:status-im/infra-role-beacon-node-linux.git version: c2ba324a5b442b6e46e2bdf4746320e8e8a24579 - scm: git - name: infra-role-beacon-node-windows src: git@github.com:status-im/infra-role-beacon-node-windows.git version: ebfece8f56ef18ba6dbb413778fbf7f56de06683 - scm: git - name: infra-role-beacon-node-macos src: git@github.com:status-im/infra-role-beacon-node-macos.git version: f1411a2f0dfc3f2b35cdcda618cd81766a22e4e7 - scm: git - name: infra-role-validator-client src: git@github.com:status-im/infra-role-validator-client.git version: aaf86e765ab8024bcc359e26d72f7767dc24ab3d - scm: git - name: infra-role-nimbus-eth1 src: git@github.com:status-im/infra-role-nimbus-eth1.git version: f4dfbb31eea9a1fa1fed575749723f9576844c84 - scm: git - name: infra-role-nimbus-fluffy src: git@github.com:status-im/infra-role-nimbus-fluffy.git version: 164cd9b1f8bf18cdcc3cce7be43e73743394adaf - scm: git - name: infra-role-dist-validators src: git@github.com:status-im/infra-role-dist-validators.git version: 95a5de787aef58712121d2ac6c0bd97b6948baec - scm: git - name: infra-role-rocketpool src: git@github.com:status-im/infra-role-rocketpool.git version: ab6834c7d291c9bf115a8e106afc83246bf803ae - scm: git - name: infra-role-winsw src: git@github.com:status-im/infra-role-winsw.git version: 93c8a5b5ac091a128efe937c028b548751295297 - scm: git - name: infra-role-kibana src: git@github.com:status-im/infra-role-kibana.git version: ccc1c3a41d2ea2f125db9a3418e447958c72bfce - scm: git - name: infra-role-elasticsearch src: git@github.com:status-im/infra-role-elasticsearch.git version: 1e86928b7848ab5520ead99a5f1432770af4fc22 - scm: git - name: infra-role-elasticsearch-lb src: git@github.com:status-im/infra-role-elasticsearch-lb.git version: 5ff5e5d526e38a7faf1ff7dfb809c6b6e5bdc17a - scm: git - name: infra-role-systemd-timer src: git@github.com:status-im/infra-role-systemd-timer.git version: 9bbb72fe9df7379c5c63cfe9f144efa30ed828d9 - scm: git - name: infra-role-launchd-timer src: git@github.com:status-im/infra-role-launchd-timer.git version: 791174a9de57ffbbb4d60cdde05d74a66415f1bd - scm: git - name: infra-role-geth src: git@github.com:status-im/infra-role-geth.git version: cd4fd6403549673e86047c10e2289cb6d34a4e20 - scm: git - name: infra-role-geth-exporter src: git@github.com:status-im/infra-role-geth-exporter.git version: c129dc06a407b153371c1467e5293b7ebaca04ab - scm: git - name: infra-role-erigon src: git@github.com:status-im/infra-role-erigon.git version: 8568c7c0b8fefe85bad9a704d0e4d56f9c628631 - scm: git - name: infra-role-nethermind src: git@github.com:status-im/infra-role-nethermind.git version: c014302215821b0b7df07947a1259027b60a614e - scm: git - name: infra-role-smart-metrics src: git@github.com:status-im/infra-role-smart-metrics.git version: 76aba3c459e6c5e8afc6979dd9532c8cea0fbeea - scm: git diff --git a/ansible/roles.py b/ansible/roles.py new file mode 100755 index 0000000..2f12cf1 --- /dev/null +++ b/ansible/roles.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +import yaml +import logging +import ansible +import argparse +import subprocess +import functools +from enum import Enum +from os import path, getenv, symlink, readlink +from packaging.version import parse as version_parse +from concurrent import futures + +HELP_DESCRIPTION=''' +This tool managed Ansible roles as Git repositories. +It is both faster and simpler than Ansible Galaxy. + +By default ~/.ansible/roles is symlinked to ~/work. +Override it using --roles-symlink or ROLES_SYMLINK. + +Installation behavior: +- If no version is specified newest is pulled. +- If version is matching nothing is done. +- If repo is dirty or detached nothing is done. +- If version is newer user is notified. +''' +HELP_EXAMPLE='''Examples: +./roles.py --install +./roles.py --check +./roles.py --update +''' + +SCRIPT_DIR = path.dirname(path.realpath(__file__)) +# Where Ansible looks for installed roles. +ROLES_PATH = path.join(path.expanduser('~'), '.ansible/roles') +ROLES_SYMLINK = path.join(path.expanduser('~'), 'work') +ROLES_WORKERS = 20 +REQUIREMENTS_PATH = path.join(SCRIPT_DIR, 'requirements.yml') + +# Setup logging. +log_format = '[%(levelname)s] %(message)s' +logging.basicConfig(level=logging.INFO, format=log_format) +LOG = logging.getLogger(__name__) + +# Colors +RST = '\033[0m' +NORMAL = lambda x: f'\033[00m{x}{RST}' +PURPLE = lambda x: f'\033[35m{x}{RST}' +YELLOW = lambda x: f'\033[33m{x}{RST}' +BLUE = lambda x: f'\033[34m{x}{RST}' +GREY = lambda x: f'\033[90m{x}{RST}' +RED = lambda x: f'\033[31m{x}{RST}' +ORANGE = lambda x: f'\033[91m{x}{RST}' +GREEN = lambda x: f'\033[32m{x}{RST}' +CYAN = lambda x: f'\033[36m{x}{RST}' +BOLD = lambda x: f'\033[1m{x}{RST}{RST}' + +class State(Enum): + # Order is priority. Higher status trumps lower. + UNKNOWN = 0 + EXISTS = 1 + WRONG_VERSION = 2 + NEWER_VERSION = 3 + DIRTY = 4 + DETACHED = 5 + NO_VERSION = 6 + CLONE_FAILURE = 7 + MISSING = 8 + CLONED = 9 + UPDATED = 10 + VALID = 11 + SKIPPED = 12 + + def __str__(self): + match self: + case State.NEWER_VERSION: color = BOLD + case State.WRONG_VERSION: color = RED + case State.DIRTY: color = YELLOW + case State.DETACHED: color = YELLOW + case State.NO_VERSION: color = PURPLE + case State.CLONE_FAILURE: color = RED + case State.MISSING: color = RED + case State.CLONED: color = GREEN + case State.UPDATED: color = GREEN + case State.VALID: color = GREEN + case State.SKIPPED: color = GREY + case _: color = NORMAL + return color(self.name.replace('_', ' ')) + + # Allow calling max() to compare with previous state. + def __gt__(self, other): + if other is None: + return True + if self.__class__ is other.__class__: + return self.value > other.value + return NotImplemented + + # Decorator to manage Role state based on function return value. + def update(success=None, failure=None): + def decorator(func): + @functools.wraps(func) + def wrapper_decorator(self, *args, **kwargs): + # Set state to failure one on exception. + try: + rval = func(self, *args, **kwargs) + except: + self.state = max(failure, self.state) + raise + # Set state based on truthiness of result, higher one wins. + if rval: + self.state = max(success, self.state) + else: + self.state = max(failure, self.state) + LOG.debug('[%-27s]: %s%s: state = %s', + self.name, func.__name__, args, self.state) + return rval + return wrapper_decorator + return decorator + +class Role: + + def __init__(self, name, src, required): + self.state = State.UNKNOWN + self.name = name + self.src = src + self.required = required + + @classmethod + def from_requirement(cls, obj): + return cls(obj['name'], obj.get('src'), obj.get('version')) + + def __repr__(self): + return 'Role(name=%s, src=%s, required=%s, state=%s)' % ( + self.name, self.src, self.required, self.state, + ) + + def to_dict(self): + obj = { + 'name': self.name, + 'src': self.src, + } + if self.required: + obj['version'] = self.required + return obj + + def _git(self, *args, cwd=None): + cmd = ['git'] + list(args) + LOG.debug('[%-27s]: COMMAND: %s', self.name, ' '.join(cmd)) + rval = subprocess.run( + cmd, + capture_output=True, + cwd=cwd or self.path + ) + LOG.debug('[%-27s]: RETURN: %d', self.name, rval.returncode) + if rval.stdout: + LOG.debug('[%-27s]: STDOUT: %s', self.name, rval.stdout.decode().strip()) + if rval.stderr: + LOG.debug('[%-27s]: STDERR: %s', self.name, rval.stderr.decode().strip()) + rval.check_returncode() + return str(rval.stdout.strip(), 'utf-8') + + def _git_fail_is_false(self, *args, cwd=None): + try: + self._git(*args, cwd=cwd) + except: + return True + else: + return False + + @property + def repo_parent_dir(self): + return self.path.removesuffix(self.name) + + @property + def branch(self): + return self._git('rev-parse', '--abbrev-ref', 'HEAD') + + @property + def current_commit(self): + if not self.exists(): + return '........' + return self._git('rev-parse', 'HEAD') + + @State.update(success=State.DIRTY) + def is_dirty(self): + return self._git_fail_is_false('diff-files', '--quiet') + + @State.update(success=State.DETACHED) + def is_detached(self): + return self._git_fail_is_false('symbolic-ref', 'HEAD') + + @State.update(success=State.NEWER_VERSION) + def is_ancestor(self): + if self.required is None or self.required == self.current_commit: + return False + return not self._git_fail_is_false( + 'merge-base', self.required, '--is-ancestor', self.current_commit + ) + + @property + @State.update(failure=State.NO_VERSION) + def version(self): + return self.required + + @version.setter + @State.update(success=State.UPDATED, failure=State.SKIPPED) + def version(self, version): + if self.required is not None: + self.required = version + return self.required + + @State.update(success=State.VALID, failure=State.WRONG_VERSION) + def valid_version(self): + return self.required == self.current_commit + + @State.update(success=State.VALID, failure=State.WRONG_VERSION) + def pull(self): + self._git('remote', 'update') + status = self._git('status', '--untracked-files=no') + if 'branch is behind' not in status: + return None + + rval = self._git('pull') + return self.valid_version() + + @State.update(success=State.CLONED, failure=State.CLONE_FAILURE) + def clone(self): + LOG.debug('Clogning: %s', self.src) + try: + self._git( + 'clone', + self.src, self.name, + cwd=self.repo_parent_dir + ) + except Exception as ex: + LOG.error('Clone failed: %s', ex.stderr.decode()) + return False + return True + + @property + def path(self): + return path.join(ROLES_PATH, self.name) + + @State.update(success=State.EXISTS, failure=State.MISSING) + def exists(self): + return path.isdir(self.path) + + +def handle_role(role, check=False, update=False, install=False): + LOG.debug('[%-27s]: Processing role...', role.name) + if not role.exists(): + if not check and not update: + role.clone() + return role + + # Check if current version is newer. + if not update and role.is_ancestor(): + return role + + # Check if current version matches required. + if role.valid_version(): + return role + + # Verify if git repo is not dirty or has detached head. + if not update and (role.is_dirty() or role.is_detached()): + return role + + # No need to fail if no version is set. + if check and not role.version: + return role + + # Update config version or pull new changes. + if update: + role.version = role.current_commit + elif install: + # If version is not specified we just want the newest. + role.pull() + return role + + +# Special function to preserve order and separating newlines. +def roles_to_yaml(old_reqs, processed_roles): + # Get processed role when available, use original one when not. + return '\n'.join([ + yaml.dump([processed_roles.get(role.name, role).to_dict()]) + for role in old_reqs + ]) + +def commit_or_any(commit): + return '*' if commit is None else commit[:8] + +# Symlink only if folder or link doesn't exist. +def symlink_roles_dir(roles_symlink): + if path.islink(ROLES_PATH): + dest = readlink(ROLES_PATH) + if dest != roles_symlink: + LOG.error('Roles path is already a link to: %s', dest) + exit(1) + else: + return + elif path.isdir(ROLES_PATH): + LOG.error('Roles path is a directory, cannot symlink!') + exit(1) + + symlink(roles_symlink, ROLES_PATH) + +def parse_args(): + parser = argparse.ArgumentParser( + epilog=HELP_EXAMPLE, + description=HELP_DESCRIPTION, + formatter_class=argparse.RawTextHelpFormatter, + ) + + parser.add_argument('-f', '--filter', default='', + help='Filter role repo names.') + parser.add_argument('-w', '--workers', default=getenv('ROLES_WORKERS', ROLES_WORKERS), type=int, + help='Max workers to run in parallel.') + parser.add_argument('-r', '--requirements', default=getenv('REQUIREMENTS_PATH', REQUIREMENTS_PATH), + help='Location of requirements.yml file.') + parser.add_argument('-s', '--roles-symlink', default=getenv('ROLES_SYMLINK', ROLES_SYMLINK), + help='Actual location of installed roles.') + parser.add_argument('-l', '--log-level', default='INFO', + help='Logging level.') + parser.add_argument('-d', '--fail-dirty', action='store_true', + help='Fail if repo is dirty.') + parser.add_argument('-a', '--fail-detached', action='store_true', + help='Fail if repo has detached head.') + + group = parser.add_mutually_exclusive_group() + group.add_argument('-i', '--install', action='store_true', + help='Clone and update required roles.') + group.add_argument('-c', '--check', action='store_true', + help='Only check roles, no installing.') + group.add_argument('-u', '--update', action='store_true', + help='Update requirements with current commits.') + + args = parser.parse_args() + + assert args.install or args.check or args.update, \ + parser.error('Pick one: --install, --check, --update') + + return args + + +def main(): + args = parse_args() + + LOG.setLevel(args.log_level.upper()) + + # Verify Ansible version is 2.8 or newer. + if version_parse(ansible.__version__) < version_parse("2.8"): + LOG.error('Your Ansible version is lower than 2.8. Upgrade it.') + exit(1) + + # Symlink ansible roles directory to work directory. + symlink_roles_dir(args.roles_symlink) + + # Read Ansible requirements file. + with open(args.requirements, 'r') as f: + requirements = yaml.load(f, Loader=yaml.FullLoader) + + requirements = [ + Role.from_requirement(req) for req in requirements + ] + + # Check if each Ansible role is installed and has correct version. + with futures.ProcessPoolExecutor(max_workers=args.workers) as executor: + these_futures = [ + executor.submit(handle_role, role, args.check, args.update, args.install) + for role in requirements + if args.filter in role.name + ] + # Wait for all the workers to finishe and return their role. + processed_roles = { + r.name: r for r in + [r.result() for r in futures.as_completed(these_futures)] + } + + # Use the same order as requirements.yml file. + for req in requirements: + if args.filter not in req.name: + continue + role = processed_roles[req.name] + print('%s%-44s --- %22s (Git: %s | Req: %s)' % + (RST, BOLD(role.name), role.state, + CYAN(role.current_commit[:8]), + commit_or_any(role.required))) + + if args.update: + with open(args.requirements, 'w') as f: + f.write(roles_to_yaml(requirements, processed_roles)) + + fail_states = set([State.MISSING, State.WRONG_VERSION]) + if args.fail_dirty: + fail_states.append(State.DIRTY) + if args.fail_detached: + fail_states.append(State.DETACHED) + if fail_states.intersection([r.state for r in processed_roles.values()]): + exit(1) + +if __name__ == "__main__": + main() diff --git a/ansible/sepolia.yml b/ansible/sepolia.yml index afce8a1..43c6a3e 100644 --- a/ansible/sepolia.yml +++ b/ansible/sepolia.yml @@ -6,7 +6,7 @@ run_once: true gather_facts: false tasks: - - local_action: command ./versioncheck.py + - local_action: command ./roles.py --check changed_when: false - name: Deploy Spolia Testnet nodes diff --git a/ansible/versioncheck.py b/ansible/versioncheck.py deleted file mode 100755 index ba7c8ed..0000000 --- a/ansible/versioncheck.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -# WARNING: If importing this fails set PYTHONPATH. -import yaml -import ansible -import subprocess -from os import path, environ -from packaging import version - -SCRIPT_DIR = path.dirname(path.realpath(__file__)) -# Where Ansible looks for installed roles. -ANSIBLE_ROLES_PATH = path.join(environ['HOME'], '.ansible/roles') - - -class Role: - def __init__(self, name, version): - self.name = name - self.version = version - - @property - def path(self): - return path.join(ANSIBLE_ROLES_PATH, self.name) - - def exists(self): - return path.isdir(self.path) - - def local_version(self): - cmd = subprocess.run( - ['git', 'rev-parse', 'HEAD'], - capture_output=True, - cwd=self.path - ) - cmd.check_returncode() - return str(cmd.stdout.strip(), 'utf-8') - - -# Verify Ansible version is 2.8 or newer. -if version.parse(ansible.__version__) < version.parse("2.8"): - print('Your Ansible version is lower than 2.8. Upgrade it.') - exit(1) - -# Read Ansible requirements file. -with open(path.join(SCRIPT_DIR, 'requirements.yml'), 'r') as f: - requirements = yaml.load(f, Loader=yaml.FullLoader) - -# Check if each Ansible role is installed and has correct version. -errors = 0 -for req in requirements: - role = Role(req['name'], req.get('version')) - - if not role.exists(): - print('%25s - MISSING!' % role.name) - errors += 1 - continue - - # For now we allow not specifying versions for everyhing. - if role.version is None: - print('%25s - No version!' % role.name) - continue - - local_version = role.local_version() - if role.version != local_version: - print('%25s - MISMATCH: %s != %s' % - (role.name, role.version[:8], local_version[:8])) - errors += 1 - continue - - print('%25s - VALID' % role.name) - -# Any issue with any role should cause failure. -if errors > 0: - exit(1)