Add composer-cli utility and implement the recipes commands

composer-cli --help shows the commands.
Output defaults to human readable, but raw json can be displayed by
passing --json
This commit is contained in:
Brian C. Lane 2018-03-09 16:42:42 -08:00
parent 79fa1c957e
commit d2f784e5da
15 changed files with 1061 additions and 2 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
*.pyc
src/pylorax/version.py*
src/composer/version.py*
*.swp
.pylint.d/
_build/

View File

@ -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)

View File

@ -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 <bcl@redhat.com> 19.7.10-1
- Add the partitioned-disk.ks file for the new output type (bcl)

View File

@ -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")

124
src/bin/composer-cli Executable file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
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 <recipe> Depsolve Recipe and compose a tar file using export from bdcs
recipes list List the names of the available recipes.
show <recipe,...> Display the recipe in TOML format.
changes <recipe,...> Display the changes for each recipe.
diff <recipe-name> Display the differences between 2 versions of a recipe.
<from-commit> Commit hash or NEWEST
<to-commit> Commit hash, NEWEST, or WORKSPACE
save <recipe,...> Save the recipe to a file, <recipe-name>.toml
delete <recipe> Delete a recipe from the server
depsolve <recipe,...> Display the packages needed to install the recipe.
push <recipe> Push a recipe TOML file to the server.
freeze <recipe,...> Display the frozen recipe's modules and packages.
freeze show <recipe,...> Display the frozen recipe in TOML format.
freeze save <recipe,...> Save the frozen recipe to a file, <recipe-name>.frozen.toml.
tag <recipe> Tag the most recent recipe commit as a release.
undo <recipe> <commit> Undo changes to a recipe by reverting to the selected commit.
workspace <recipe> 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))

27
src/composer/__init__.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
# get composer version
try:
import composer.version
except ImportError:
vernum = "devel"
else:
vernum = composer.version.num

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

519
src/composer/cli/recipes.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
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 <recipe,...> 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 <recipe,...> 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 <recipe-name> Display the differences between 2 versions of a recipe.
<from-commit> Commit hash or NEWEST
<to-commit> 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 <recipe,...> Save the recipe to a file, <recipe-name>.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 <recipe> 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 <recipe,...> 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 <recipe> 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 <recipe,...> Display the frozen recipe's modules and packages.
recipes freeze show <recipe,...> Display the frozen recipe in TOML format.
recipes freeze save <recipe,...> Save the frozen recipe to a file, <recipe-name>.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 <recipe,...> 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 <recipe,...> Save the frozen recipe to a file, <recipe-name>.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 <recipe> 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 <recipe> <commit> 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 <recipe> 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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

106
src/composer/http_client.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
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"))

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -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