diff --git a/README.md b/README.md index 65fb412cb4d0d02804932789a6604d2ce6105989..232098f30beefca87dd503f8da10ab8971c816dd 100644 --- a/README.md +++ b/README.md @@ -23,27 +23,48 @@ $ git pull --rebae And then the update to a new version of the core needs to be commited. -## Deploying -To deploy to devel ( For deploy of production, use according inventory): +## Deploying using the edd_tool (Recommended) +The edd_tool helps with clean build and deployments of the EDD. -``` -$ ansible-playbook -i effelsberg_devel effelsberg_config.yml --tags=buildbase,build --ask-vault-password -$ ansible-playbook -i effelsberg_devel effelsberg_config.yml --ask-vault-password -``` +Requirements + ansible (pip install ansible) + coloredlogs (pip install coloredlogs) -It is important, that these commands are executed in a clean environment, i.e. -with the locally checked out provision-core submodule put manually to the -correct state as defined in the `requirements.txt`. Use the -``` -$./clean_deploy effelsberg_production TAG -``` +Example Usage: + +1) List all available versions + `$ ./edd_tool -i effelsberg_devel list-versions` + +2) Deploy a specific version + `$ ./edd_tool -i ./effelsberg_devel deploy --version=221219.0` + +3) Build all containers of a version [necessary only on new releases] + `$ ./edd_tool buildall -i effelsberg_production --version=VERSION buildall` -for a clean deployment of the system with a given TAG. +4) Limit Re-deploy to a specific host, e.g. a single packetizer + `./edd_tool -i ./effelsberg_devel deploy --version=221219.0` +For more information check the help +`$ ./edd_tool --help` +## Manual deployment (not recommended) +System can stil be deployed manyually. However, care must be taken to checkout +/ install the correct versions of the base system + +Assuming a checkout with submodules +``` +$ cd ansible_collections/EDD/core +$ git co CORRECT_TAG +$ cd ../../../ +$ git co CORRECT_TAG +$ ansible-playbook -i effelsberg_devel effelsberg_config.yml --tags=buildbase,build --ask-vault-password +$ ansible-playbook -i effelsberg_devel effelsberg_config.yml --ask-vault-password +``` + + # Retagging only To retag the current 'latest' containers as production verions, you can use diff --git a/clean_deploy b/clean_deploy deleted file mode 100755 index f1d1aa032c2be9bd0937a2d9c18738491ea1b85a..0000000000000000000000000000000000000000 --- a/clean_deploy +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/python3 -__doc__ = "Deploy the EDD using a clean environment" - -import argparse -import os -import sys -import tempfile -import shlex -import subprocess -import getpass -import pathlib - -CFG_FILE = 'effelsberg_config.yml' - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('inventory', help='Inventory to deploy to') - parser.add_argument('tag', help='tags') - - args = parser.parse_args() - - vault_password = getpass.getpass('Please enter vault password: ') - - cwd = os.getcwd() - with tempfile.TemporaryDirectory() as build_directory: - print(f"--> Created temporary build directory: {build_directory}") - os.chdir(build_directory) - - print(f"--> Cloning repository and switching to {args.tag}") - subprocess.run(shlex.split(f'git clone {cwd} .'), check=True) - subprocess.run(shlex.split(f'git checkout {args.tag}'), check=True) - - pwdfile = os.path.join(build_directory, 'vault_pwd') - P = pathlib.Path(pwdfile).touch(mode=0o600) - with open(pwdfile, 'w', encoding='utf8') as f: - f.write(vault_password) - - env = {} - env.update(os.environ) - env['ANSIBLE_COLLECTIONS_PATH'] = build_directory - requirements_file = os.path.join(args.inventory, 'requirements.yml') - - print(f'--> Install collections from {requirements_file}') - print() - with open(requirements_file) as f: - print(f.read()) - - try: - subprocess.run(shlex.split(f'ansible-galaxy collection install -r {requirements_file} -p {build_directory}'), check=True) - except subprocess.CalledProcessError: - print('!!! ERROR during installation of collection. Deployment was NOT started!') - sys.exit(-1) - - print('--> Building base in clean environment') - try: - subprocess.run(shlex.split(f'ansible-playbook -i {args.inventory} {CFG_FILE} --tags=buildbase --vault-password-file={pwdfile}'), env=env, check=True) - except subprocess.CalledProcessError: - print('!!! Error during buildbase. Deployment unsuccessfull !') - sys.exit(-1) - - print('--> Building in clean environment') - try: - subprocess.run(shlex.split(f'ansible-playbook -i {args.inventory} {CFG_FILE} --tags=build --vault-password-file={pwdfile}'), env=env, check=True) - except subprocess.CalledProcessError: - print('!!! Error during build. Deployment unsuccessfull !') - sys.exit(-1) - - print('--> Deploying from clean build') - try: - subprocess.run(shlex.split(f'ansible-playbook -i {args.inventory} {CFG_FILE} --vault-password-file={pwdfile}'), env=env, check=True) - except subprocess.CalledProcessError: - print('!!! Error during deployment. deployment unsuccessfull !') - sys.exit(-1) - - os.chdir(cwd) - print(f'--> Successfully deployed the EDD on {args.inventory}!') diff --git a/deploy_tool/app.py b/deploy_tool/app.py new file mode 100755 index 0000000000000000000000000000000000000000..6b2aefcb589d3e91c47c4782503d2827589a678b --- /dev/null +++ b/deploy_tool/app.py @@ -0,0 +1,287 @@ +#!/usr/bin/python3 +__doc__ = "Deploy the EDD using a clean environment" + +import argparse +import os +import sys +import tempfile +import shlex +import shutil +import subprocess +import getpass +import pathlib +import logging +import datetime +import dataclasses +import git +import coloredlogs + + +_log = logging.getLogger("EDD") + +coloredlogs.install( + fmt=("[ %(levelname)s - %(asctime)s - %(name)s " + "- %(filename)s:%(lineno)s] %(message)s"), + level=1, # We manage the log level via the logger, not the handler + logger=_log) + +env_level = os.getenv("LOG_LEVEL") +if env_level: + _log.setLevel(env_level.upper()) +else: + _log.setLevel("INFO") + +CFG_FILE = 'effelsberg_config.yml' + + +class TempRepository: + """ + Checkout a repository into a temporary folder for version management with EDD versions. + """ + def __init__(self, origin): + self.origin = origin + self._tmpdir = tempfile.TemporaryDirectory() + self.repo = None + + def __del__(self): + self._tmpdir.cleanup() + + def init(self): + """Initialize repository""" + _log.debug('Initializing tmp repo') + self.repo = git.Repo.clone_from(self.origin, self._tmpdir.name) + + + def get_next_version(self, dto=None): + """Return next verson in EDD versioning scheme""" + _log.debug('getting next version') + if dto is None: + _log.debug('No date provided, using today') + dto = datetime.datetime.now() + + base = dto.strftime("%y%m%d") + today_tags = [t.name for t in self.repo.tags if t.name.startswith(base)] + _log.debug('Found %i tags for given date', len(today_tags)) + + return f"{base}.{len(today_tags)}" + + + def release(self, dto=None): + """ + Create a new tag in the repository and push it to the origin + """ + nt = self.get_next_version(dto) + _log.debug('Creating release %s', nt) + self.repo.create_tag(nt) + self.repo.remote().push(tags=True) + + +@dataclasses.dataclass +class BuildContext: + """Any informatino needed by the build command""" + build_directory: str + env: dict + inventory: str + no_act: bool + limit: str + config_file: str + + + +class CleanEnvironment: + """Contextmanager for checkouts in a clean build environment""" + def __init__(self, inventory, config_file, version, no_act, limit, keep_tempdir): + + self.inventory = inventory + self.config_file = config_file + self.version = version + self.no_act = no_act + self.keep_tempdir = keep_tempdir + + if limit: + self.limit = f"--limit={limit}" + else: + self.limit = "" + + self.cwd = None + self.build_directory = None + + + def __enter__(self): + self.cwd = os.getcwd() + # Resolve origin before changing directory + origin = os.path.abspath(os.path.dirname(self.inventory)) + + _log.debug('Creating temp directory') + self.build_directory = tempfile.mkdtemp() + os.chdir(self.build_directory) + + _log.debug('Cloning %s -> %s', origin, self.build_directory) + repo = git.Git() + + repo.clone(origin, self.build_directory) + + if self.version: + _log.debug('Checking out version %s', self.version) + try: + repo.checkout(self.version) + except git.exc.GitCommandError as E: + _log.exception(E) + _log.error("Version '%s' not known to git", self.version) + sys.exit(-1) + + vault_password = getpass.getpass('Please enter vault password: ') + + pwdfile = os.path.join(self.build_directory, 'vault_pwd') + pathlib.Path(pwdfile).touch(mode=0o600) + with open(pwdfile, 'w', encoding='utf8') as f: + f.write(vault_password) + + env = {} + env.update(os.environ) + env['ANSIBLE_COLLECTIONS_PATH'] = self.build_directory + + requirements_file = os.path.join(self.build_directory, os.path.basename(self.inventory), 'requirements.yml') + print('Install collections:') + with open(requirements_file) as f: + print(f.read()) + + try: + subprocess.run(shlex.split(f'ansible-galaxy collection install -r {requirements_file} -p {self.build_directory}'), check=True) + except subprocess.CalledProcessError as E: + _log.error('ERROR during installation of collection') + raise RuntimeError from E + + return BuildContext(build_directory=self.build_directory, env=env, inventory=self.inventory, no_act=self.no_act, limit=self.limit, config_file=self.config_file) + + def __exit__(self, *args, **kwargs): + _log.debug("Change back to initial working dir") + os.chdir(self.cwd) + if self.keep_tempdir: + _log.info('Temporary directory %s was not deleted as requested', self.build_directory) + else: + _log.debug("Cleaning up temporary directory") + shutil.rmtree(self.build_directory) + + +def get_version_list(remote): + """Get list of tags without cloning repository""" + R = subprocess.run(shlex.split(f"git -c 'versionsort.suffix=-' ls-remote --tags --sort='v:refname' {remote}"), capture_output=True, check=True) + _log.debug('Got reply from remote:\n%s', R.stdout.decode()) + result = [s.split('/')[-1] for s in R.stdout.decode().split('\n') if s] + return result + + +def list_versions(inventory, **kwargs): + """List all versions in the provision_repository""" + origin = os.path.abspath(os.path.dirname(inventory)) + R = get_version_list(origin) +# R.sort() + print(f"Versions in {origin}:\n") + print("\n".join(R)) + + +def buildbase(build_context): + """Execute ansible buildbase command""" + print('--> Building base in clean environment') + if build_context.no_act: + no_act = '-C' + else: + no_act = '' + try: + subprocess.run(shlex.split(f'ansible-playbook {no_act} -i {build_context.inventory} {build_context.config_file} --tags=buildbase --vault-password-file=vault_pwd {build_context.limit}'), env=build_context.env, check=True) + except subprocess.CalledProcessError as E: + print('!!! Error during buildbase. Deployment unsuccessfull !') + raise RuntimeError from E + + +def build(build_context): + """Execute ansible build command""" + print('--> Building in clean environment') + if build_context.no_act: + no_act = '-C' + else: + no_act = '' + try: + subprocess.run(shlex.split(f'ansible-playbook {no_act} -i {build_context.inventory} {build_context.config_file} --tags=build --vault-password-file=vault_pwd {build_context.limit}'), env=build_context.env, check=True) + except subprocess.CalledProcessError as E: + print('!!! Error during build. Deployment unsuccessfull !') + raise RuntimeError from E + + +def deploy(build_context): + """Execute ansible deploy command""" + print('--> Deploying from clean build') + if build_context.no_act: + no_act = '-C' + else: + no_act = '' + try: + subprocess.run(shlex.split(f'ansible-playbook {no_act} -i {build_context.inventory} {build_context.config_file} --vault-password-file=vault_pwd {build_context.limit}'), env=build_context.env, check=True) + except subprocess.CalledProcessError as E: + print('!!! Error during deployment. deployment unsuccessfull !') + raise RuntimeError from E + + +def buildall_action(**kwargs): + """Build base images and images""" + with CleanEnvironment(**kwargs) as ctx: + buildbase(ctx) + build(ctx) + + +def buildbase_action(**kwargs): + """Build base images only""" + with CleanEnvironment(**kwargs) as ctx: + buildbase(ctx) + +def build_action(**kwargs): + """Build all imagese xcept base images""" + with CleanEnvironment(**kwargs) as ctx: + build(ctx) + +def deploy_action(**kwargs): + """Deploy system""" + with CleanEnvironment(**kwargs) as ctx: + deploy(ctx) + + + + +actions = {} +actions['list-versions'] = list_versions +actions['build'] = build_action +actions['buildbase'] = buildbase_action +actions['buildall'] = buildall_action +actions['deploy'] = deploy_action + + +def action_help(): + """ + Compile help string from all actions + """ + res = [] + for k, v in actions.items(): + res.append(f"{k:16} - {v.__doc__}") + return "\n".join(res) + + +def main(): + """edd_tool main""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument('-i', '--inventory', help='Inventory to deploy to', required=True) + parser.add_argument('--version', help='Version to use. Defaults to head of branch', default=None) + parser.add_argument('-n', '--no-act', help='Do not execute ansible but add check parameter', action='store_true', default=False) + parser.add_argument('--limit', help='Limit action to selected host group', metavar='HOSTGROUP') + parser.add_argument('--keep-tempdir', help='Do not delete temporary build directory', action='store_true') + parser.add_argument('action', help=f'Action to perform. Valid actions are:\n{action_help()}', default=None) + + args = parser.parse_args() + + #with open(os.path.join(args.inventory, 'group_vars', 'all.yml')) as f: + # inventory_settings = yaml.load(f, Loader=yaml.SafeLoader) + actions[args.action]( + version=args.version, config_file=CFG_FILE, inventory=args.inventory, no_act=args.no_act, limit=args.limit, keep_tempdir=args.keep_tempdir) + +if __name__ == "__main__": + main() diff --git a/edd_tool b/edd_tool new file mode 120000 index 0000000000000000000000000000000000000000..8dbb8c09764474cc2b6710dfe2fdc2d65d8a1189 --- /dev/null +++ b/edd_tool @@ -0,0 +1 @@ +deploy_tool/app.py \ No newline at end of file diff --git a/tests/test_deploy_tool.py b/tests/test_deploy_tool.py new file mode 100644 index 0000000000000000000000000000000000000000..1d467382f0409cca1bc6d54bf93951e96590b84c --- /dev/null +++ b/tests/test_deploy_tool.py @@ -0,0 +1,93 @@ +import unittest +import unittest.mock +import git +import tempfile +import os +import io +import datetime + +import deploy_tool.app as deploy_tool + + +class TestTempRepository(unittest.TestCase): + def setUp(self): + # Set up a origin repository + self.tmpdir = tempfile.TemporaryDirectory() + + self.origin_repo = git.Repo.init(self.tmpdir.name) + foo_file = os.path.join(self.tmpdir.name, 'requirements.yml') + with open(foo_file, 'w') as f: + f.write('---') + self.origin_repo.index.add(foo_file) + self.origin_repo.index.commit('Added foo file') + + def tearDown(self): + self.tmpdir.cleanup() + + def test_init(self): + # After init, the repository should be cloned + repo = deploy_tool.TempRepository(self.tmpdir.name) + repo.init() + + with open(os.path.join(repo._tmpdir.name, 'requirements.yml')) as f: + data = f.read() + self.assertEqual(data, '---') + + def test_next_version(self): + # If no version exists, a well defined tag should be created + repo = deploy_tool.TempRepository(self.tmpdir.name) + repo.init() + + v = repo.get_next_version(datetime.datetime(2022, 3, 24)) + self.assertEqual(v, "220324.0") + repo.repo.create_tag(v) + + v = repo.get_next_version(datetime.datetime(2022, 3, 24)) + self.assertEqual(v, "220324.1") + + # There should be no tag for today, so there should be one ending with + # 0 next + vt = repo.get_next_version() + self.assertTrue(vt.endswith('.0')) + repo.repo.create_tag(vt) + vt = repo.get_next_version() + self.assertTrue(vt.endswith('.1')) + + def test_release(self): + # After release, the new tag should exist in the origin repository + repo = deploy_tool.TempRepository(self.tmpdir.name) + repo.init() + + nt = repo.get_next_version() + + repo.release() + + self.assertIn(nt, [t.name for t in self.origin_repo.tags]) + + + def test_list_version(self): + VL = deploy_tool.get_version_list(self.tmpdir.name) + self.assertListEqual(VL, []) + + self.origin_repo.create_tag('foo') + VL = deploy_tool.get_version_list(self.tmpdir.name) + self.assertListEqual(VL, ['foo']) + + + def test_cleanenvironment_passphrase(self): + pwd = 'scientiavinceretenebras' + # password should be queried and saved to a file in the tem directory + + inventory = self.tmpdir.name + inventory_settings = {} + version = '230201.0' + + with unittest.mock.patch('getpass.getpass', side_effect=[pwd]) as stdin, unittest.mock.patch('subprocess.run') as sub, deploy_tool.CleanEnvironment(inventory, inventory_settings, version) as ctx: + with open(os.path.join(ctx.build_directory, 'vault_pwd')) as f: + d = f.read() + self.assertEqual(pwd, d) + + + +if __name__ == "__main__": + unittest.main()