diff --git a/.gitignore b/.gitignore index 62d5b8ca..bc8af136 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc src/pylorax/version.py* +src/composer/version.py* *.swp .pylint.d/ _build/ diff --git a/Makefile b/Makefile index c295f22a..7db4efe3 100644 --- a/Makefile +++ b/Makefile @@ -11,10 +11,13 @@ USER_SITE_PACKAGES ?= $(shell sudo $(PYTHON) -m site --user-site) default: all +src/composer/version.py: lorax.spec + echo "num = '$(VERSION)-$(RELEASE)'" > src/composer/version.py + src/pylorax/version.py: lorax.spec echo "num = '$(VERSION)-$(RELEASE)'" > src/pylorax/version.py -all: src/pylorax/version.py +all: src/pylorax/version.py src/composer/version.py $(PYTHON) setup.py build install: all @@ -42,6 +45,7 @@ test: docs clean: -rm -rf build src/pylorax/version.py + -rm -rf build src/composer/version.py tag: git tag -f $(TAG) diff --git a/lorax.spec b/lorax.spec index aa78f779..385159f9 100644 --- a/lorax.spec +++ b/lorax.spec @@ -100,6 +100,16 @@ BuildRequires: systemd %description composer lorax-composer provides a REST API for building images using lorax. +%package -n composer-cli +Summary: A command line tool for use with the lorax-composer API server + +# From Distribution +Requires: python-urllib3 + +%description -n composer-cli +A command line tool for use with the lorax-composer API server. Examine recipes, +build images, etc. from the command line. + %prep %setup -q @@ -148,6 +158,9 @@ getent passwd weldr >/dev/null 2>&1 || useradd -r -g weldr -d / -s /sbin/nologin %{_sbindir}/lorax-composer %{_unitdir}/lorax-composer.service +%files -n composer-cli +%{_bindir}/composer-cli + %changelog * Thu Feb 22 2018 Brian C. Lane 19.7.10-1 - Add the partitioned-disk.ks file for the new output type (bcl) diff --git a/setup.py b/setup.py index 4ef4d666..4784df13 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,8 @@ for root, dnames, fnames in os.walk("share"): data_files.append(("/usr/sbin", ["src/sbin/lorax", "src/sbin/mkefiboot", "src/sbin/livemedia-creator", "src/sbin/lorax-composer"])) data_files.append(("/usr/bin", ["src/bin/image-minimizer", - "src/bin/mk-s390-cdboot"])) + "src/bin/mk-s390-cdboot", + "src/bin/composer-cli"])) # get the version sys.path.insert(0, "src") diff --git a/src/bin/composer-cli b/src/bin/composer-cli new file mode 100755 index 00000000..bc262f42 --- /dev/null +++ b/src/bin/composer-cli @@ -0,0 +1,124 @@ +#!/usr/bin/python +# +# composer-cli +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# +import logging +log = logging.getLogger("composer-cli") + +import os +import sys +import argparse + +from composer import vernum +from composer.cli import main + +VERSION = "{0}-{1}".format(os.path.basename(sys.argv[0]), vernum) + +# Documentation for the commands +epilog = """ +compose tar Depsolve Recipe and compose a tar file using export from bdcs +recipes list List the names of the available recipes. + show Display the recipe in TOML format. + changes Display the changes for each recipe. + diff Display the differences between 2 versions of a recipe. + Commit hash or NEWEST + Commit hash, NEWEST, or WORKSPACE + save Save the recipe to a file, .toml + delete Delete a recipe from the server + depsolve Display the packages needed to install the recipe. + push Push a recipe TOML file to the server. + freeze Display the frozen recipe's modules and packages. + freeze show Display the frozen recipe in TOML format. + freeze save Save the frozen recipe to a file, .frozen.toml. + tag Tag the most recent recipe commit as a release. + undo Undo changes to a recipe by reverting to the selected commit. + workspace Push the recipe TOML to the temporary workspace storage. +modules list List the available modules. +projects list List the available projects. +""" + +def get_parser(): + """ Return the ArgumentParser for composer-cli""" + + parser = argparse.ArgumentParser(description="Lorax Composer commandline tool", + epilog=epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, + fromfile_prefix_chars="@") + + parser.add_argument("-j", "--json", action="store_true", default=False, + help="Output the raw JSON response instead of the normal output.") + parser.add_argument("-s", "--socket", default="/run/weldr/api.socket", metavar="SOCKET", + help="Path to the socket file to listen on") + parser.add_argument("--log", dest="logfile", default="/var/log/lorax-composer/cli.log", metavar="LOG", + help="Path to logfile (/var/log/lorax-composer/cli.log)") + parser.add_argument("-a", "--api", dest="api_version", default="0", metavar="APIVER", + help="API Version to use") + parser.add_argument("-V", action="store_true", dest="showver", + help="show program's version number and exit") + + # Commands are implemented by parsing the remaining arguments outside of argparse + parser.add_argument('args', nargs=argparse.REMAINDER) + + + return parser + +def setup_logging(logfile): + # Setup logging to console and to logfile + log.setLevel(logging.DEBUG) + + sh = logging.StreamHandler() + sh.setLevel(logging.INFO) + fmt = logging.Formatter("%(asctime)s: %(message)s") + sh.setFormatter(fmt) + log.addHandler(sh) + + fh = logging.FileHandler(filename=logfile) + fh.setLevel(logging.DEBUG) + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + fh.setFormatter(fmt) + log.addHandler(fh) + +if __name__ == '__main__': + # parse the arguments + opts = get_parser().parse_args() + + if opts.showver: + print(VERSION) + sys.exit(0) + + logpath = os.path.abspath(os.path.dirname(opts.logfile)) + if not os.path.isdir(logpath): + os.makedirs(logpath) + setup_logging(opts.logfile) + log.debug("opts=%s", opts) + + errors = [] + + # Check to see if the socket exists and can be accessed + if not os.path.exists(opts.socket): + errors.append("%s does not exist" % opts.socket) + elif not os.access(opts.socket, os.R_OK|os.W_OK): + errors.append("This user cannot access %s" % opts.socket) + + # No point in continuing if there are errors + if errors: + for e in errors: + log.error(e) + sys.exit(1) + + sys.exit(main(opts)) diff --git a/src/composer/__init__.py b/src/composer/__init__.py new file mode 100644 index 00000000..a97b168f --- /dev/null +++ b/src/composer/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/python +# +# composer-cli +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# + +# get composer version +try: + import composer.version +except ImportError: + vernum = "devel" +else: + vernum = composer.version.num diff --git a/src/composer/cli/__init__.py b/src/composer/cli/__init__.py new file mode 100644 index 00000000..51b25326 --- /dev/null +++ b/src/composer/cli/__init__.py @@ -0,0 +1,49 @@ +#!/usr/bin/python +# +# composer-cli +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# +import logging +log = logging.getLogger("composer-cli") + +from composer.cli.recipes import recipes_cmd +from composer.cli.modules import modules_cmd +from composer.cli.projects import projects_cmd +from composer.cli.compose import compose_cmd + +command_map = { + "recipes": recipes_cmd, + "modules": modules_cmd, + "projects": projects_cmd, + "compose": compose_cmd + } + + +def main(opts): + """ Main program execution + + :param opts: Cmdline arguments + :type opts: argparse.Namespace + """ + if len(opts.args) > 0 and opts.args[0] in command_map: + return command_map[opts.args[0]](opts) + elif len(opts.args) == 0: + log.error("Unknown command: %s", opts.args) + return 1 + else: + log.error("Unknown command: %s", opts.args) + return 1 diff --git a/src/composer/cli/compose.py b/src/composer/cli/compose.py new file mode 100644 index 00000000..6f139c3e --- /dev/null +++ b/src/composer/cli/compose.py @@ -0,0 +1,26 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# + +def compose_cmd(opts): + """Process compose commands + + :param opts: Cmdline arguments + :type opts: argparse.Namespace + :returns: Value to return from sys.exit() + :rtype: int + """ + return 1 diff --git a/src/composer/cli/modules.py b/src/composer/cli/modules.py new file mode 100644 index 00000000..792e95db --- /dev/null +++ b/src/composer/cli/modules.py @@ -0,0 +1,26 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# + +def modules_cmd(opts): + """Process modules commands + + :param opts: Cmdline arguments + :type opts: argparse.Namespace + :returns: Value to return from sys.exit() + :rtype: int + """ + return 1 diff --git a/src/composer/cli/projects.py b/src/composer/cli/projects.py new file mode 100644 index 00000000..b3ed26ba --- /dev/null +++ b/src/composer/cli/projects.py @@ -0,0 +1,26 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# + +def projects_cmd(opts): + """Process projects commands + + :param opts: Cmdline arguments + :type opts: argparse.Namespace + :returns: Value to return from sys.exit() + :rtype: int + """ + return 1 diff --git a/src/composer/cli/recipes.py b/src/composer/cli/recipes.py new file mode 100644 index 00000000..ecd58b1f --- /dev/null +++ b/src/composer/cli/recipes.py @@ -0,0 +1,519 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# +import logging +log = logging.getLogger("composer-cli") + +import os +import json + +from composer import http_client as client +from composer.cli.utilities import argify, frozen_toml_filename, toml_filename, handle_api_result + +def recipes_cmd(opts): + """Process recipes commands + + :param opts: Cmdline arguments + :type opts: argparse.Namespace + :returns: Value to return from sys.exit() + :rtype: int + + This dispatches the recipes commands to a function + """ + cmd_map = { + "list": recipes_list, + "show": recipes_show, + "changes": recipes_changes, + "diff": recipes_diff, + "save": recipes_save, + "delete": recipes_delete, + "depsolve": recipes_depsolve, + "push": recipes_push, + "freeze": recipes_freeze, + "tag": recipes_tag, + "undo": recipes_undo, + "workspace": recipes_workspace + } + return cmd_map[opts.args[1]](opts.socket, opts.api_version, opts.args[2:], opts.json) + +def recipes_list(socket_path, api_version, args, show_json=False): + """Output the list of available recipes + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes list + """ + api_route = client.api_url(api_version, "/recipes/list") + result = client.get_url_json(socket_path, api_route) + if show_json: + print(json.dumps(result, indent=4)) + return 0 + + print("Recipes: " + ", ".join([r for r in result["recipes"]])) + + return 0 + +def recipes_show(socket_path, api_version, args, show_json=False): + """Show the recipes, in TOML format + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes show Display the recipe in TOML format. + + Multiple recipes will be separated by \n\n + """ + for recipe in argify(args): + api_route = client.api_url(api_version, "/recipes/info/%s?format=toml" % recipe) + print(client.get_url_raw(socket_path, api_route) + "\n\n") + + return 0 + +def recipes_changes(socket_path, api_version, args, show_json=False): + """Display the changes for each of the recipes + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes changes Display the changes for each recipe. + """ + api_route = client.api_url(api_version, "/recipes/changes/%s" % (",".join(argify(args)))) + result = client.get_url_json(socket_path, api_route) + if show_json: + print(json.dumps(result, indent=4)) + return 0 + + for recipe in result["recipes"]: + print(recipe["name"]) + for change in recipe["changes"]: + prettyCommitDetails(change) + + return 0 + +def prettyCommitDetails(change, indent=4): + """Print the recipe's change in a nice way + + :param change: The individual recipe change dict + :type change: dict + :param indent: Number of spaces to indent + :type indent: int + """ + def revision(): + if change["revision"]: + return " revision %d" % change["revision"] + else: + return "" + + print " " * indent + change["timestamp"] + " " + change["commit"] + revision() + print " " * indent + change["message"] + "\n" + +def recipes_diff(socket_path, api_version, args, show_json=False): + """Display the differences between 2 versions of a recipe + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes diff Display the differences between 2 versions of a recipe. + Commit hash or NEWEST + Commit hash, NEWEST, or WORKSPACE + """ + if len(args) == 0: + log.error("recipes diff is missing the recipe name, from commit, and to commit") + return 1 + elif len(args) == 1: + log.error("recipes diff is missing the from commit, and the to commit") + return 1 + elif len(args) == 2: + log.error("recipes diff is missing the to commit") + return 1 + + api_route = client.api_url(api_version, "/recipes/diff/%s/%s/%s" % (args[0], args[1], args[2])) + result = client.get_url_json(socket_path, api_route) + + if show_json: + print(json.dumps(result, indent=4)) + return 0 + + if result.get("error", False): + log.error(result["error"]["msg"]) + return 1 + + for diff in result["diff"]: + print(prettyDiffEntry(diff)) + + return 0 + +def prettyDiffEntry(diff): + """Generate nice diff entry string. + + :param diff: Difference entry dict + :type diff: dict + :returns: Nice string + """ + def change(diff): + if diff["old"] and diff["new"]: + return "Changed" + elif diff["new"] and not diff["old"]: + return "Added" + elif diff["old"] and not diff["new"]: + return "Removed" + else: + return "Unknown" + + def name(diff): + if diff["old"]: + return diff["old"].keys()[0] + elif diff["new"]: + return diff["new"].keys()[0] + else: + return "Unknown" + + def details(diff): + if change(diff) == "Changed": + if name(diff) == "Description": + return '"%s" -> "%s"' % (diff["old"][name(diff)], diff["old"][name(diff)]) + elif name(diff) == "Version": + return "%s -> %s" % (diff["old"][name(diff)], diff["old"][name(diff)]) + elif name(diff) in ["Module", "Package"]: + return "%s %s -> %s" % (diff["old"][name(diff)]["name"], diff["old"][name(diff)]["version"], + diff["new"][name(diff)]["version"]) + else: + return "Unknown" + elif change(diff) == "Added": + return " ".join([diff["new"][k] for k in diff["new"]]) + elif change(diff) == "Removed": + return " ".join([diff["old"][k] for k in diff["old"]]) + + return change(diff) + " " + name(diff) + " " + details(diff) + +def recipes_save(socket_path, api_version, args, show_json=False): + """Save the recipe to a TOML file + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes save Save the recipe to a file, .toml + """ + for recipe in argify(args): + api_route = client.api_url(api_version, "/recipes/info/%s?format=toml" % recipe) + recipe_toml = client.get_url_raw(socket_path, api_route) + open(toml_filename(recipe), "w").write(recipe_toml) + + return 0 + +def recipes_delete(socket_path, api_version, args, show_json=False): + """Delete a recipe from the server + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + delete Delete a recipe from the server + """ + api_route = client.api_url(api_version, "/recipes/delete/%s" % args[0]) + result = client.delete_url_json(socket_path, api_route) + + return handle_api_result(result, show_json) + +def recipes_depsolve(socket_path, api_version, args, show_json=False): + """Display the packages needed to install the recipe + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes depsolve Display the packages needed to install the recipe. + """ + api_route = client.api_url(api_version, "/recipes/depsolve/%s" % (",".join(argify(args)))) + result = client.get_url_json(socket_path, api_route) + + if show_json: + print(json.dumps(result, indent=4)) + return 0 + + for recipe in result["recipes"]: + if recipe["recipe"].get("version", ""): + print("Recipe: %s v%s" % (recipe["recipe"]["name"], recipe["recipe"]["version"])) + else: + print("Recipe: %s" % (recipe["recipe"]["name"])) + for dep in recipe["dependencies"]: + print(" " + packageNEVRA(dep)) + + return 0 + +def packageNEVRA(pkg): + """Return the package info as a NEVRA + + :param pkg: The package details + :type pkg: dict + :returns: name-[epoch:]version-release-arch + :rtype: str + """ + if pkg["epoch"]: + return "%s-%s:%s-%s.%s" % (pkg["name"], pkg["epoch"], pkg["version"], pkg["release"], pkg["arch"]) + else: + return "%s-%s-%s.%s" % (pkg["name"], pkg["version"], pkg["release"], pkg["arch"]) + +def recipes_push(socket_path, api_version, args, show_json=False): + """Push a recipe TOML file to the server, updating the recipe + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + push Push a recipe TOML file to the server. + """ + api_route = client.api_url(api_version, "/recipes/new") + rval = 0 + for recipe in argify(args): + if not os.path.exists(recipe): + log.error("Missing recipe file: %s", recipe) + continue + recipe_toml = open(recipe, "r").read() + + result = client.post_url_toml(socket_path, api_route, recipe_toml) + if handle_api_result(result, show_json): + rval = 1 + + return rval + +def recipes_freeze(socket_path, api_version, args, show_json=False): + """Handle the recipes freeze commands + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes freeze Display the frozen recipe's modules and packages. + recipes freeze show Display the frozen recipe in TOML format. + recipes freeze save Save the frozen recipe to a file, .frozen.toml. + """ + if args[0] == "show": + return recipes_freeze_show(socket_path, api_version, args[1:], show_json) + elif args[0] == "save": + return recipes_freeze_save(socket_path, api_version, args[1:], show_json) + + if len(args) == 0: + log.error("freeze is missing the recipe name") + return 1 + + api_route = client.api_url(api_version, "/recipes/freeze/%s" % (",".join(argify(args)))) + result = client.get_url_json(socket_path, api_route) + + if show_json: + print(json.dumps(result, indent=4)) + else: + for entry in result["recipes"]: + recipe = entry["recipe"] + if recipe.get("version", ""): + print("Recipe: %s v%s" % (recipe["name"], recipe["version"])) + else: + print("Recipe: %s" % (recipe["name"])) + + for m in recipe["modules"]: + print(" %s-%s" % (m["name"], m["version"])) + + for p in recipe["packages"]: + print(" %s-%s" % (p["name"], p["version"])) + + # Print any errors + for err in result.get("errors", []): + log.error("%s: %s", err["recipe"], err["msg"]) + + # Return a 1 if there are any errors + if result.get("errors", []): + return 1 + else: + return 0 + +def recipes_freeze_show(socket_path, api_version, args, show_json=False): + """Show the frozen recipe in TOML format + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes freeze show Display the frozen recipe in TOML format. + """ + if len(args) == 0: + log.error("freeze show is missing the recipe name") + return 1 + + for recipe in argify(args): + api_route = client.api_url(api_version, "/recipes/freeze/%s?format=toml" % recipe) + print(client.get_url_raw(socket_path, api_route)) + + return 0 + +def recipes_freeze_save(socket_path, api_version, args, show_json=False): + """Save the frozen recipe to a TOML file + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes freeze save Save the frozen recipe to a file, .frozen.toml. + """ + if len(args) == 0: + log.error("freeze save is missing the recipe name") + return 1 + + for recipe in argify(args): + api_route = client.api_url(api_version, "/recipes/freeze/%s?format=toml" % recipe) + recipe_toml = client.get_url_raw(socket_path, api_route) + open(frozen_toml_filename(recipe), "w").write(recipe_toml) + + return 0 + +def recipes_tag(socket_path, api_version, args, show_json=False): + """Tag the most recent recipe commit as a release + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes tag Tag the most recent recipe commit as a release. + """ + api_route = client.api_url(api_version, "/recipes/tag/%s" % args[0]) + result = client.post_url(socket_path, api_route, "") + + return handle_api_result(result, show_json) + +def recipes_undo(socket_path, api_version, args, show_json=False): + """Undo changes to a recipe + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes undo Undo changes to a recipe by reverting to the selected commit. + """ + if len(args) == 0: + log.error("undo is missing the recipe name and commit hash") + return 1 + elif len(args) == 1: + log.error("undo is missing commit hash") + return 1 + + api_route = client.api_url(api_version, "/recipes/undo/%s/%s" % (args[0], args[1])) + result = client.post_url(socket_path, api_route, "") + + return handle_api_result(result, show_json) + +def recipes_workspace(socket_path, api_version, args, show_json=False): + """Push the recipe TOML to the temporary workspace storage + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param api_version: Version of the API to talk to. eg. "0" + :type api_version: str + :param args: List of remaining arguments from the cmdline + :type args: list of str + :param show_json: Set to True to show the JSON output instead of the human readable output + :type show_json: bool + + recipes workspace Push the recipe TOML to the temporary workspace storage. + """ + api_route = client.api_url(api_version, "/recipes/workspace") + rval = 0 + for recipe in argify(args): + if not os.path.exists(recipe): + log.error("Missing recipe file: %s", recipe) + continue + recipe_toml = open(recipe, "r").read() + + result = client.post_url_toml(socket_path, api_route, recipe_toml) + if show_json: + print(json.dumps(result, indent=4)) + elif result.get("error", False): + log.error(result["error"]["msg"]) + + # Any errors results in returning a 1, but we continue with the rest first + if not result.get("status", False): + rval = 1 + + return rval diff --git a/src/composer/cli/utilities.py b/src/composer/cli/utilities.py new file mode 100644 index 00000000..e2adf350 --- /dev/null +++ b/src/composer/cli/utilities.py @@ -0,0 +1,70 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# +import logging +log = logging.getLogger("composer-cli") + +import json + +def argify(args): + """Take a list of human args and return a list with each item + + :param args: list of strings with possible commas and spaces + :type args: list of str + :returns: List of all the items + :rtype: list of str + + Examples: + + ["one,two", "three", ",four", ",five,"] returns ["one", "two", "three", "four", "five"] + """ + return filter(lambda i: i, [arg for entry in args for arg in entry.split(",")]) + +def toml_filename(recipe_name): + """Convert a recipe name into a filename.toml + + :param recipe_name: The recipe's name + :type recipe_name: str + :returns: The recipe name with ' ' converted to - and .toml appended + :rtype: str + """ + return recipe_name.replace(" ", "-") + ".toml" + +def frozen_toml_filename(recipe_name): + """Convert a recipe name into a filename.toml + + :param recipe_name: The recipe's name + :type recipe_name: str + :returns: The recipe name with ' ' converted to - and .toml appended + :rtype: str + """ + return recipe_name.replace(" ", "-") + ".frozen.toml" + +def handle_api_result(result, show_json=False): + """Log any errors, return the correct value + + :param result: JSON result from the http query + :type result: dict + """ + if show_json: + print(json.dumps(result, indent=4)) + elif result.get("error", False): + log.error(result["error"]["msg"]) + + if result["status"] == True: + return 0 + else: + return 1 diff --git a/src/composer/http_client.py b/src/composer/http_client.py new file mode 100644 index 00000000..d917b549 --- /dev/null +++ b/src/composer/http_client.py @@ -0,0 +1,106 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# +import logging +log = logging.getLogger("composer-cli") + +import os +import json + +from composer.unix_socket import UnixHTTPConnectionPool + +def api_url(api_version, url): + """Return the versioned path to the API route + + """ + return os.path.normpath("/api/v%s/%s" % (api_version, url)) + +def get_url_raw(socket_path, url): + """Return the raw results of a GET request + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param url: URL to request + :type url: str + :returns: The raw response from the server + :rtype: str + """ + http = UnixHTTPConnectionPool(socket_path) + r = http.request("GET", url) + return r.data.decode('utf-8') + +def get_url_json(socket_path, url): + """Return the JSON results of a GET request + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param url: URL to request + :type url: str + :returns: The json response from the server + :rtype: dict + """ + http = UnixHTTPConnectionPool(socket_path) + r = http.request("GET", url) + return json.loads(r.data.decode('utf-8')) + +def delete_url_json(socket_path, url): + """Send a DELETE request to the url and return JSON response + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param url: URL to send DELETE to + :type url: str + :returns: The json response from the server + :rtype: dict + """ + http = UnixHTTPConnectionPool(socket_path) + r = http.request("DELETE", url) + return json.loads(r.data.decode("utf-8")) + +def post_url(socket_path, url, body): + """POST raw data to the URL + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param url: URL to send POST to + :type url: str + :param body: The data for the body of the POST + :type body: str + :returns: The json response from the server + :rtype: dict + """ + http = UnixHTTPConnectionPool(socket_path) + r = http.request("POST", url, + body=body.encode("utf-8")) + return json.loads(r.data.decode("utf-8")) + +def post_url_toml(socket_path, url, body): + """POST a TOML recipe to the URL + + :param socket_path: Path to the Unix socket to use for API communication + :type socket_path: str + :param url: URL to send POST to + :type url: str + :param body: The data for the body of the POST + :type body: str + :returns: The json response from the server + :rtype: dict + """ + http = UnixHTTPConnectionPool(socket_path) + r = http.request("POST", url, + body=body.encode("utf-8"), + headers={"Content-Type": "text/x-toml"}) + return json.loads(r.data.decode("utf-8")) diff --git a/src/composer/unix_socket.py b/src/composer/unix_socket.py new file mode 100644 index 00000000..a390c5d9 --- /dev/null +++ b/src/composer/unix_socket.py @@ -0,0 +1,66 @@ +# +# Copyright (C) 2018 Red Hat, Inc. +# +# 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; either version 2 of the License, or +# (at your option) any later version. +# +# 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 . +# +import httplib +import socket +import urllib3 + + +# These 2 classes were adapted and simplified for use with just urllib3. +# Originally from https://github.com/msabramo/requests-unixsocket/blob/master/requests_unixsocket/adapters.py + +# The following was adapted from some code from docker-py +# https://github.com/docker/docker-py/blob/master/docker/transport/unixconn.py +class UnixHTTPConnection(httplib.HTTPConnection, object): + + def __init__(self, socket_path, timeout=60): + """Create an HTTP connection to a unix domain socket + + :param socket_path: The path to the Unix domain socket + :param timeout: Number of seconds to timeout the connection + """ + super(UnixHTTPConnection, self).__init__('localhost', timeout=timeout) + self.socket_path = socket_path + self.sock = None + + def __del__(self): # base class does not have d'tor + if self.sock: + self.sock.close() + + def connect(self): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(self.socket_path) + self.sock = sock + +class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): + + def __init__(self, socket_path, timeout=60): + """Create a connection pool using a Unix domain socket + + :param socket_path: The path to the Unix domain socket + :param timeout: Number of seconds to timeout the connection + """ + super(UnixHTTPConnectionPool, self).__init__('localhost', timeout=timeout) + self.socket_path = socket_path + + def _new_conn(self): + return UnixHTTPConnection(self.socket_path, self.timeout) + +if __name__ == '__main__': + http = UnixHTTPConnectionPool("/var/run/weldr/api.socket") + r = http.request("GET", "/api/v0/recipes/list") + print(r.data) diff --git a/tests/pylint/runpylint.py b/tests/pylint/runpylint.py index c51716d7..13ef7e33 100755 --- a/tests/pylint/runpylint.py +++ b/tests/pylint/runpylint.py @@ -28,6 +28,7 @@ class LoraxLintConfig(PocketLintConfig): FalsePositive(r"Instance of 'int' has no .* member"), FalsePositive(r"Catching too general exception Exception"), FalsePositive(r"^E0712.*: Catching an exception which doesn't inherit from (Base|)Exception: GError$"), + FalsePositive(r"Module 'composer' has no 'version' member"), ] @property