diff --git a/bob/devtools/ci.py b/bob/devtools/ci.py index 0dea4c9cdb705627346f20c5017f9454cdc201d7..b61ff159035fa9d111cd2ce1822e24fea6191516 100644 --- a/bob/devtools/ci.py +++ b/bob/devtools/ci.py @@ -7,7 +7,7 @@ import git import distutils.version -from .log import get_logger +from .log import get_logger, echo_info from .build import load_order_file logger = get_logger(__name__) @@ -213,3 +213,46 @@ def select_user_condarc(paths, branch): """ return select_build_file("condarc", paths, branch) + + +def clean_betas(dry_run, username, password): + """Cleans-up betas (through the CI). Executes if ``dry_run==False`` only. + """ + + from .deploy import _setup_webdav_client + from .constants import WEBDAV_PATHS, SERVER + from .dav import remove_old_beta_packages + + for public in (True, False): + + server_info = WEBDAV_PATHS[False][public] + davclient = _setup_webdav_client( + SERVER, server_info["root"], username, password + ) + + # go through all possible variants: + archs = [ + 'linux-64', + 'linux-32', + 'linux-armv6l', + 'linux-armv7l', + 'linux-ppc64le', + 'osx-64', + 'osx-32', + 'win-64', + 'win-32', + 'noarch', + ] + + for arch in archs: + + arch_path = '/'.join((path, arch)) + + if not (davclient.check(arch_path) and davclient.is_dir(arch_path)): + # it is normal if the directory does not exist + continue + + server_path = davclient.get_url(arch_path) + echo_info('Cleaning beta packages from %s' % server_path) + remove_old_beta_packages(client=davclient, path=arch_path, + dry_run=dry_run, pyver=True) diff --git a/bob/devtools/data/gitlab-ci/nightlies.yaml b/bob/devtools/data/gitlab-ci/nightlies.yaml index aee65c5d5f702a18afa729e38cb964fd15c02213..57693946496ec837ed86144ebbacbb61c35f723e 100644 --- a/bob/devtools/data/gitlab-ci/nightlies.yaml +++ b/bob/devtools/data/gitlab-ci/nightlies.yaml @@ -10,9 +10,30 @@ variables: # Definition of our build pipeline order stages: + - cleanup - build +# Periodic cleanup of beta packages +cleanup: + stage: cleanup + tags: + - docker + image: continuumio/conda-concourse-ci + script: + - curl --silent "${BOOTSTRAP}" --output "bootstrap.py" + - python3 bootstrap.py -vv channel base + - source ${CONDA_ROOT}/etc/profile.d/conda.sh + - conda activate base + - bdt ci clean-betas -vv --dry-run + cache: &test_caches + key: "linux-cache" + paths: + - miniconda.sh + - ${CONDA_ROOT}/pkgs/*.tar.bz2 + - ${CONDA_ROOT}/pkgs/urls.txt + + # Build targets .build_template: stage: build diff --git a/bob/devtools/dav.py b/bob/devtools/dav.py index cd5c3d4463ffc83b3ff4db9cad7dec3dc697c7f2..40bec89eee9b40f9cf43923882951e910cc72a36 100644 --- a/bob/devtools/dav.py +++ b/bob/devtools/dav.py @@ -2,9 +2,13 @@ # -*- coding: utf-8 -*- import os +import re import configparser +import dateutil.parser -from .log import get_logger +from distutils.version import StrictVersion + +from .log import get_logger, echo_warning, echo_info, echo_normal from .deploy import _setup_webdav_client logger = get_logger(__name__) @@ -13,32 +17,36 @@ logger = get_logger(__name__) def _get_config(): """Returns a dictionary with server parameters, or ask them to the user""" - # tries to figure if we can authenticate using a global configuration - cfgs = ["~/.bdt-webdav.cfg"] + # tries to figure if we can authenticate using a configuration file + cfgs = ["~/.bdtrc"] cfgs = [os.path.expanduser(k) for k in cfgs] for k in cfgs: if os.path.exists(k): data = configparser.ConfigParser() data.read(k) - if 'global' not in data or \ - 'server' not in data['global'] or \ - 'username' not in data['global'] or \ - 'password' not in data['global']: - assert KeyError, 'The file %s should contain a single ' \ - '"global" section with 3 variables defined inside: ' \ + if ( + "webdav" not in data + or "server" not in data["webdav"] + or "username" not in data["webdav"] + or "password" not in data["webdav"] + ): + assert KeyError, ( + "The file %s should contain a single " + '"webdav" section with 3 variables defined inside: ' '"server", "username", "password".' % (k,) - return data['global'] + ) + return data["webdav"] # ask the user for the information, cache credentials for future use retval = dict() - retval['server'] = input("The base address of the server: ") - retval['username'] = input("Username: ") - retval['password'] = input("Password: ") + retval["server"] = input("The base address of the server: ") + retval["username"] = input("Username: ") + retval["password"] = input("Password: ") # record file for the user data = configparser.ConfigParser() - data['global'] = retval - with open(cfgs[0], 'w') as f: + data["webdav"] = retval + with open(cfgs[0], "w") as f: logger.warn('Recorded "%s" configuration file for next queries') data.write(f) os.chmod(cfgs[0], 0o600) @@ -51,7 +59,101 @@ def setup_webdav_client(private): """Returns a ready-to-use WebDAV client""" config = _get_config() - root = '/private-upload' if private else '/public-upload' - c = _setup_webdav_client(config['server'], root, config['username'], - config['password']) + root = "/private-upload" if private else "/public-upload" + c = _setup_webdav_client( + config["server"], root, config["username"], config["password"] + ) return c + + +def remove_old_beta_packages(client, path, dry_run, pyver=True): + """Removes old conda packages from a conda channel. + + What is an old package depends on how the packages are produced. In + BEAT/Bob, we build new beta packages with every commit in the CI and we + want to delete the old ones using this script so that we do not run out of + space. + + The core idea is to remove packages that are not (the latest version AND + the latest build number) for each package name. + + Our CI distributes its build into several jobs. Since each job runs + independently of each other (per OS and per Python version), the build + numbers are estimated independently and they will end up to be different + between jobs. + + So the core idea is needed to be applied on each CI job independently. + + + Parameters: + + client (object): The WebDAV client with a preset public/private path + + path (str): A path, within the preset root of the client, where to + search for beta packages. Beta packages are searched in the directory + itself. + + dry_run (bool): A flag indicating if we should just list what we will + be doing, or really execute the deletions + + pyver (:py:class:`bool`, Optional): If ``True``, the python version of + a package will be a part of a package's name. This is need to account + for the fact that our CI jobs run per Python version. + + """ + + server_path = client.get_url(path) + + if not client.is_dir(path): + echo_warning("Path %s is not a directory - ignoring...", server_path) + return + + betas = dict() + # python version regular expression: + pyver_finder = re.compile("py[1-9][0-9]h.*") + + for f in client.list(path): + + if f.startswith("."): + continue + if not f.endswith(".tar.bz2"): + continue + + name, version, build_string = f[:-8].rsplit("-", 2) + hash_, build = build_string.rsplit("_", 1) + + if pyver: + # try to find the python version if it exists + result = pyver_finder.match(hash_) + if result is not None: + name += "/" + result.string[:4] + + target = '/'.join((path, f)) + info = client.info(target) + + betas.setdefault(name, []).append( + ( + StrictVersion(version), + int(build), # build number + dateutil.parser.parse(info['modified']).timestamp(), + target, + ) + ) + + count = sum([len(k) for k in betas.values()]) - len(betas) + echo_normal(" - %d variants" % len(betas)) + echo_normal(" - %d packages found" % count) + echo_normal(" ---------------------") + + for name in sorted(betas.keys()): + echo_normal(" - packages for %s (%d)" % (name, len(betas[name]))) + sorted_packages = sorted(betas[name]) + keep_version, keep_build, _, _ = sorted_packages[-1] + for version, build, mtime, target in sorted_packages: + if version == keep_version and build == keep_build: + echo_normal("[keep] %s (time=%u)" % (target, mtime)) + else: + echo_warning("rm %s (time=%u)" % (target, mtime)) + if not dry_run: + #client.clean(target) + echo_info("boooom") diff --git a/bob/devtools/scripts/ci.py b/bob/devtools/scripts/ci.py index cffe0c09ec437a8a4317c4ecf05ad10f2b031a64..5534ad41774dbb372f18205356109fcdcff36a81 100644 --- a/bob/devtools/scripts/ci.py +++ b/bob/devtools/scripts/ci.py @@ -20,6 +20,7 @@ from ..ci import ( select_conda_build_config, select_conda_recipe_append, select_user_condarc, + clean_betas, ) from ..log import verbosity_option, get_logger, echo_normal @@ -946,3 +947,53 @@ def docs(ctx, requirement, dry_run): logger.info("Building documentation...") ctx.invoke(build, dry_run=dry_run) + + +@ci.command( + epilog=""" +Examples: + + 1. Cleans-up the excess of beta packages from all conda channels via WebDAV: + + $ bdt ci -vv clean-betas --dry-run + + Notice this does not do anything. Remove the --dry-run flag to execute + + + 2. Really removes (recursively), the excess of beta packages + + $ bdt ci -vv clean-betas + +""" +) +@click.option( + "-d", + "--dry-run/--no-dry-run", + default=False, + help="Only goes through the actions, but does not execute them " + "(combine with the verbosity flags - e.g. ``-vvv``) to enable " + "printing to help you understand what will be done", +) +@verbosity_option() +@bdt.raise_on_error +def clean_betas(dry_run): + """Cleans-up the excess of beta packages from a conda channel via WebDAV + + ATTENTION: There is no undo! Use --dry-run to test before using. + """ + + is_master = os.environ["CI_COMMIT_REF_NAME"] == "master" + if not is_master and dry_run == False: + echo_warning("Forcing dry-run mode - not in master branch") + echo_warning("... considering this is **not** a periodic run!") + dry_run = True + + if dry_run: + echo_warning("!!!! DRY RUN MODE !!!!") + echo_warning("Nothing is being executed on server.") + + clean_betas( + dry_run=dry_run, + username=os.environ["DOCUSER"], + password=os.environ["DOCPASS"], + ) diff --git a/bob/devtools/scripts/dav.py b/bob/devtools/scripts/dav.py index cb66d5b70190c123fd4bf626c3000126d0ab8d68..0642af521c27fe4d44ae521942c61fe31c5dc508 100644 --- a/bob/devtools/scripts/dav.py +++ b/bob/devtools/scripts/dav.py @@ -10,7 +10,7 @@ from click_plugins import with_plugins from . import bdt -from ..dav import setup_webdav_client +from ..dav import setup_webdav_client, remove_old_beta_packages from ..log import verbosity_option, get_logger, echo_normal, echo_info, \ echo_warning @@ -277,9 +277,18 @@ def upload(private, execute, local, remote): epilog=""" Examples: - 1. Lists the amount of free disk space on the WebDAV server: + 1. Cleans-up the excess of beta packages from our conda channels via WebDAV: - $ bdt dav -vv free + $ bdt dav -vv clean-betas remote/path/foo/bar + + Notice this does not do anything for security. It just displays what it + would do. To actually run the rmtree comment pass the --execute flag (or + -x) + + + 2. Really removes (recursively), the excess of beta packages + + $ bdt dav -vv clean-betas --execute remote/path/foo/bar """ ) @@ -289,13 +298,59 @@ Examples: default=False, help="If set, use the 'private' area instead of the public one", ) +@click.option( + "-x", + "--execute/--no-execute", + default=False, + help="If this flag is set, then execute the removal", +) +@click.argument( + "path", + required=True, +) @verbosity_option() @bdt.raise_on_error -def free(private): - """Lists the amount of free space on the webserver disk +def clean_betas(private, execute, path): + """Cleans-up the excess of beta packages from a conda channel via WebDAV + + ATTENTION: There is no undo! Use --execute to execute. """ + if not execute: + echo_warning("!!!! DRY RUN MODE !!!!") + echo_warning("Nothing is being executed on server. Use -x to execute.") + + if not path.startswith('/'): path = '/' + path cl = setup_webdav_client(private) - echo_info('free') - data = cl.free() - echo_normal(data) + remote_path = cl.get_url(path) + + if not cl.is_dir(path): + echo_warning('Path %s is not a directory - ignoring...', remote_path) + return + + # go through all possible variants: + archs = [ + 'linux-64', + 'linux-32', + 'linux-armv6l', + 'linux-armv7l', + 'linux-ppc64le', + 'osx-64', + 'osx-32', + 'win-64', + 'win-32', + 'noarch', + ] + + for arch in archs: + + arch_path = '/'.join((path, arch)) + + if not (cl.check(arch_path) and cl.is_dir(arch_path)): + # it is normal if the directory does not exist + continue + + server_path = cl.get_url(arch_path) + echo_info('Cleaning beta packages from %s' % server_path) + remove_old_beta_packages(client=cl, path=arch_path, + dry_run=(not execute), pyver=True) diff --git a/conda/meta.yaml b/conda/meta.yaml index ab99512be00d7ed3a0960eb5dc494d168638e6af..67b2b5afc904d35b55930af82631e84264304701 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -93,6 +93,13 @@ test: - bdt ci clean --help - bdt ci nightlies --help - bdt ci docs --help + - bdt ci clean-betas --help + - bdt dav --help + - bdt dav list --help + - bdt dav makedirs --help + - bdt dav rmtree --help + - bdt dav clean-betas --help + - bdt dav upload --help - sphinx-build -aEW ${PREFIX}/share/doc/{{ name }}/doc sphinx - if [ -n "${CI_PROJECT_DIR}" ]; then mv sphinx "${CI_PROJECT_DIR}/"; fi diff --git a/setup.py b/setup.py index 4d825c5833c1b7eb5f07133f702f15ddf7a22b65..a3d490ec52158faf0f15f483e47af5b5336b4804 100644 --- a/setup.py +++ b/setup.py @@ -79,6 +79,7 @@ setup( 'pypi = bob.devtools.scripts.ci:pypi', 'nightlies = bob.devtools.scripts.ci:nightlies', 'docs = bob.devtools.scripts.ci:docs', + 'clean-betas = bob.devtools.scripts.ci:clean_betas', ], 'bdt.local.cli': [ @@ -92,7 +93,7 @@ setup( 'makedirs = bob.devtools.scripts.dav:makedirs', 'rmtree = bob.devtools.scripts.dav:rmtree', 'upload = bob.devtools.scripts.dav:upload', - #'free = bob.devtools.scripts.dav:free', + 'clean-betas = bob.devtools.scripts.dav:clean_betas', ], },