diff --git a/atomicapp/cli/main.py b/atomicapp/cli/main.py index d408ed30..1bb3b706 100644 --- a/atomicapp/cli/main.py +++ b/atomicapp/cli/main.py @@ -37,6 +37,7 @@ from atomicapp.nulecule.exceptions import NuleculeException, DockerException from atomicapp.plugin import ProviderFailedException from atomicapp.utils import Utils +from atomicapp.index import Index logger = logging.getLogger(LOGGER_DEFAULT) @@ -112,6 +113,18 @@ def cli_init(args): sys.exit(1) +def cli_index(args): + argdict = args.__dict__ + i = Index() + if argdict["index_action"] == "list": + i.list() + elif argdict["index_action"] == "update": + i.update() + elif argdict["index_action"] == "generate": + i.generate(argdict["location"]) + sys.exit(0) + + # Create a custom action parser. Need this because for some args we don't # want to store a value if the user didn't provide one. "store_true" does # not allow this; it will always create an attribute and store a value. @@ -381,6 +394,25 @@ def create_parser(self): help='The name of a container image containing an Atomic App.') gena_subparser.set_defaults(func=cli_genanswers) + # === "index" SUBPARSER === + index_subparser = toplevel_subparsers.add_parser( + "index", parents=[globals_parser]) + index_action = index_subparser.add_subparsers(dest="index_action") + + index_list = index_action.add_parser("list") + index_list.set_defaults(func=cli_index) + + index_update = index_action.add_parser("update") + index_update.set_defaults(func=cli_index) + + index_generate = index_action.add_parser("generate") + index_generate.add_argument( + "location", + help=( + "Path containing Nulecule applications " + "which will be part of the generated index")) + index_generate.set_defaults(func=cli_index) + # === "init" SUBPARSER === init_subparser = toplevel_subparsers.add_parser( "init", parents=[globals_parser]) @@ -466,7 +498,7 @@ def run(self): # a directory if they want to for "run". For that reason we won't # default the RUN label for Atomic App to provide an app_spec argument. # In this case pick up app_spec from $IMAGE env var (set by RUN label). - if args.action != 'init' and args.app_spec is None: + if args.action != 'init' and args.action != 'index' and args.app_spec is None: if os.environ.get('IMAGE') is not None: logger.debug("Setting app_spec based on $IMAGE env var") args.app_spec = os.environ['IMAGE'] diff --git a/atomicapp/constants.py b/atomicapp/constants.py index d2068a23..7059f9e5 100644 --- a/atomicapp/constants.py +++ b/atomicapp/constants.py @@ -82,3 +82,10 @@ # If running in an openshift POD via `oc new-app`, the ca file is here OPENSHIFT_POD_CA_FILE = "/run/secrets/kubernetes.io/serviceaccount/ca.crt" + +# Index +INDEX_IMAGE = "projectatomic/nulecule-library" +INDEX_DEFAULT_IMAGE_LOCATION = "localhost" +INDEX_NAME = "index.yaml" +INDEX_LOCATION = ".atomicapp/" + INDEX_NAME +INDEX_GEN_DEFAULT_OUTPUT_LOC = "./" + INDEX_NAME diff --git a/atomicapp/index.py b/atomicapp/index.py new file mode 100644 index 00000000..e9799d78 --- /dev/null +++ b/atomicapp/index.py @@ -0,0 +1,203 @@ +""" + Copyright 2014-2016 Red Hat, Inc. + + This file is part of Atomic App. + + Atomic App is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Atomic App 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Atomic App. If not, see . +""" + +from __future__ import print_function +import os + +import logging +import errno +from constants import (INDEX_IMAGE, + INDEX_LOCATION, + INDEX_DEFAULT_IMAGE_LOCATION, + INDEX_GEN_DEFAULT_OUTPUT_LOC, + INDEX_NAME) +from nulecule.container import DockerHandler +from nulecule.base import Nulecule +from atomicapp.nulecule.exceptions import NuleculeException + +from copy import deepcopy + +import anymarkup +from atomicapp.utils import Utils + +logger = logging.getLogger(__name__) + + +class IndexException(Exception): + pass + + +class Index(object): + + """ + This class represents the 'index' command for Atomic App. This lists + all available packaged applications to use. + """ + + index_template = {"location": ".", "nulecules": []} + + def __init__(self): + + self.index = deepcopy(self.index_template) + self.index_location = os.path.join(Utils.getUserHome(), INDEX_LOCATION) + self._load_index_file(self.index_location) + + def list(self): + """ + This command lists all available Nulecule packaged applications in a + properly formatted way. + """ + + # In order to "format" it correctly, find the largest length of 'name', 'id', and 'appversion' + # Set a minimum length of '7' due to the length of each column name + id_length = 7 + app_length = 7 + location_length = 7 + + # Loop through each 'nulecule' and retrieve the largest string length + for entry in self.index["nulecules"]: + id = entry.get('id') or "" + version = entry['metadata'].get('appversion') or "" + location = entry['metadata'].get('location') or INDEX_DEFAULT_IMAGE_LOCATION + + if len(id) > id_length: + id_length = len(id) + if len(version) > app_length: + app_length = len(version) + if len(location) > location_length: + location_length = len(location) + + # Print out the "index bar" with the lengths + index_format = ("{0:%s} {1:%s} {2:10} {3:%s}" % (id_length, app_length, location_length)) + print(index_format.format("ID", "VER", "PROVIDERS", "LOCATION")) + + # Loop through each entry of the index and spit out the formatted line + for entry in self.index["nulecules"]: + # Get the list of providers (first letter) + providers = "" + for provider in entry["providers"]: + providers = "%s,%s" % (providers, provider[0].capitalize()) + + # Remove the first element, add brackets + providers = "{%s}" % providers[1:] + + # Retrieve the entry information + id = entry.get('id') or "" + version = entry['metadata'].get('appversion') or "" + location = entry['metadata'].get('location') or INDEX_DEFAULT_IMAGE_LOCATION + + # Print out the row + print(index_format.format( + id, + version, + providers, + location)) + + def update(self, index_image=INDEX_IMAGE): + """ + Fetch the latest index image and update the file based upon + the INDEX_IMAGE attribute. By default, this should pull the + 'official' Nulecule index. + """ + + logger.info("Updating the index list") + logger.info("Pulling latest index image...") + self._fetch_index_container() + logger.info("Index updated") + + # TODO: Error out if the locaiton does not have a Nulecule file / dir + def generate(self, location, output_location=INDEX_GEN_DEFAULT_OUTPUT_LOC): + """ + Generate an index.yaml with a provided directory location + """ + logger.info("Generating index.yaml from %s" % location) + self.index = deepcopy(self.index_template) + + if not os.path.isdir(location): + raise Exception("Location must be a directory") + + for f in os.listdir(location): + nulecule_dir = os.path.join(location, f) + if f.startswith("."): + continue + if os.path.isdir(nulecule_dir): + try: + index_info = self._nulecule_get_info(nulecule_dir) + except NuleculeException as e: + logger.warning("SKIPPING %s. %s" % + (nulecule_dir, e)) + continue + index_info["path"] = f + self.index["nulecules"].append(index_info) + + if len(index_info) > 0: + anymarkup.serialize_file(self.index, output_location, format="yaml") + logger.info("index.yaml generated") + + def _fetch_index_container(self, index_image=INDEX_IMAGE): + """ + Fetch the index container + """ + # Create the ".atomicapp" dir if it does not exist + if not os.path.exists(os.path.dirname(self.index_location)): + try: + os.makedirs(os.path.dirname(self.index_location)) + except OSError as exc: # Guard against race condition + if exc.errno != errno.EEXIST: + raise + + dh = DockerHandler() + dh.pull(index_image) + dh.extract_files(index_image, "/" + INDEX_NAME, self.index_location) + + def _load_index_file(self, index_file=INDEX_LOCATION): + """ + Load the index file. If it does not exist, fetch it. + """ + # If the file/path does not exist, retrieve the index yaml + if not os.path.exists(index_file): + logger.warning("Couldn't load index file: %s", index_file) + logger.info("Retrieving index...") + self._fetch_index_container() + self.index = anymarkup.parse_file(index_file) + + def _nulecule_get_info(self, nulecule_dir): + """ + Get the required information in order to generate an index.yaml + """ + index_info = {} + nulecule = Nulecule.load_from_path( + nulecule_dir, nodeps=True) + index_info["id"] = nulecule.id + index_info["metadata"] = nulecule.metadata + index_info["specversion"] = nulecule.specversion + + if len(nulecule.components) == 0: + raise IndexException("Unable to load any Nulecule components from folder %s" % nulecule_dir) + + providers_set = set() + for component in nulecule.components: + if component.artifacts: + if len(providers_set) == 0: + providers_set = set(component.artifacts.keys()) + else: + providers_set = providers_set.intersection(set(component.artifacts.keys())) + + index_info["providers"] = list(providers_set) + return index_info diff --git a/atomicapp/nulecule/container.py b/atomicapp/nulecule/container.py index 152525ca..edf0dae5 100644 --- a/atomicapp/nulecule/container.py +++ b/atomicapp/nulecule/container.py @@ -135,6 +135,9 @@ def extract_files(self, image, source, dest): except subprocess.CalledProcessError as e: raise DockerException('Removing docker container failed: %s. \n%s' % (rm_cmd, e.output)) + # Set the proper permissions on the extracted folder + Utils.setFileOwnerGroup(dest) + def extract_nulecule_data(self, image, source, dest, update=False): """ Extract the Nulecule contents from a container into a destination diff --git a/tests/units/index/test_index.py b/tests/units/index/test_index.py new file mode 100644 index 00000000..c055d9cf --- /dev/null +++ b/tests/units/index/test_index.py @@ -0,0 +1,50 @@ +""" + Copyright 2014-2016 Red Hat, Inc. + + This file is part of Atomic App. + + Atomic App is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Atomic App 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with Atomic App. If not, see . +""" + +import unittest +import mock +import os +import tempfile + +from atomicapp.index import Index + + +def mock_index_load_call(self, test): + self.index = {'location': '.', 'nulecules': [ + {'providers': ['docker'], 'id': 'test', 'metadata':{'appversion': '0.0.1', 'location': 'foo'}}]} + + +class TestIndex(unittest.TestCase): + + """ + Tests the index + """ + + # Tests listing the index with a patched self.index + @mock.patch("atomicapp.index.Index._load_index_file", mock_index_load_call) + def test_list(self): + a = Index() + a.list() + + # Test generation with current test_examples in cli + @mock.patch("atomicapp.index.Index._load_index_file", mock_index_load_call) + def test_generate(self): + self.tmpdir = tempfile.mkdtemp(prefix="atomicapp-generation-test", dir="/tmp") + a = Index() + a.generate("tests/units/cli/test_examples", os.path.join(self.tmpdir, "index.yaml"))