#
# Copyright (C) 2018-2020 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 datetime import datetime
import sys
import json
import toml
from composer import http_client as client
from composer.cli.help import compose_help
from composer.cli.utilities import argify, handle_api_result, packageNEVRA, get_arg
[docs]def compose_cmd(opts):
"""Process compose commands
:param opts: Cmdline arguments
:type opts: argparse.Namespace
:returns: Value to return from sys.exit()
:rtype: int
This dispatches the compose commands to a function
compose_cmd expects api to be passed. eg.
{"version": 1, "backend": "lorax-composer"}
"""
result = client.get_url_json(opts.socket, "/api/status")
# Get the api version and fall back to 0 if it fails.
api_version = result.get("api", "0")
backend = result.get("backend", "unknown")
api = {"version": api_version, "backend": backend}
cmd_map = {
"list": compose_list,
"status": compose_status,
"types": compose_types,
"start": compose_start,
"log": compose_log,
"cancel": compose_cancel,
"delete": compose_delete,
"info": compose_info,
"metadata": compose_metadata,
"results": compose_results,
"logs": compose_logs,
"image": compose_image,
"start-ostree": compose_ostree,
}
if opts.args[1] == "help" or opts.args[1] == "--help":
print(compose_help)
return 0
elif opts.args[1] not in cmd_map:
log.error("Unknown compose command: %s", opts.args[1])
return 1
return cmd_map[opts.args[1]](opts.socket, opts.api_version, opts.args[2:], opts.json, opts.testmode, api=api)
[docs]def get_size(args):
"""Return optional --size argument, and remaining args
:param args: list of arguments
:type args: list of strings
:returns: (args, size)
:rtype: tuple
- check size argument for int
- check other args for --size in wrong place
- raise error? Or just return 0?
- no size returns 0 in size
- multiply by 1024**2 to make it easier on users to specify large sizes
"""
args, value = get_arg(args, "--size", int)
value = value * 1024**2 if value is not None else 0
return (args, value)
[docs]def get_parent(args):
"""Return optional --parent argument, and remaining args
:param args: list of arguments
:type args: list of strings
:returns: (args, parent)
:rtype: tuple
"""
args, value = get_arg(args, "--parent")
value = value if value is not None else ""
return (args, value)
[docs]def get_ref(args):
"""Return optional --ref argument, and remaining args
:param args: list of arguments
:type args: list of strings
:returns: (args, parent)
:rtype: tuple
"""
args, value = get_arg(args, "--ref")
value = value if value is not None else ""
return (args, value)
[docs]def compose_list(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Return a simple list of compose identifiers"""
states = ("running", "waiting", "finished", "failed")
which = set()
if any(a not in states for a in args):
# TODO: error about unknown state
return 1
elif not args:
which.update(states)
else:
which.update(args)
results = []
if "running" in which or "waiting" in which:
api_route = client.api_url(api_version, "/compose/queue")
r = client.get_url_json(socket_path, api_route)
if "running" in which:
results += r["run"]
if "waiting" in which:
results += r["new"]
if "finished" in which:
api_route = client.api_url(api_version, "/compose/finished")
r = client.get_url_json(socket_path, api_route)
results += r["finished"]
if "failed" in which:
api_route = client.api_url(api_version, "/compose/failed")
r = client.get_url_json(socket_path, api_route)
results += r["failed"]
if results:
if show_json:
print(json.dumps(results, indent=4))
else:
list_fmt = "{id} {queue_status} {blueprint} {version} {compose_type}"
print("\n".join(list_fmt.format(**c) for c in results))
return 0
[docs]def compose_status(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Return the status of all known composes
: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
:param testmode: unused in this function
:type testmode: int
This doesn't map directly to an API command, it combines the results from queue, finished,
and failed so raw JSON output is not available.
"""
def get_status(compose):
return {"id": compose["id"],
"blueprint": compose["blueprint"],
"version": compose["version"],
"compose_type": compose["compose_type"],
"image_size": compose["image_size"],
"status": compose["queue_status"],
"created": compose.get("job_created"),
"started": compose.get("job_started"),
"finished": compose.get("job_finished")}
# Sort the status in a specific order
def sort_status(a):
order = ["RUNNING", "WAITING", "FINISHED", "FAILED"]
return (order.index(a["status"]), a["blueprint"], a["version"], a["compose_type"])
status = []
# Get the composes currently in the queue
api_route = client.api_url(api_version, "/compose/queue")
result = client.get_url_json(socket_path, api_route)
status.extend(list(map(get_status, result["run"] + result["new"])))
# Get the list of finished composes
api_route = client.api_url(api_version, "/compose/finished")
result = client.get_url_json(socket_path, api_route)
status.extend(list(map(get_status, result["finished"])))
# Get the list of failed composes
api_route = client.api_url(api_version, "/compose/failed")
result = client.get_url_json(socket_path, api_route)
status.extend(list(map(get_status, result["failed"])))
# Sort them by status (running, waiting, finished, failed) and then by name and version.
status.sort(key=sort_status)
if show_json:
print(json.dumps(status, indent=4))
return 0
# Print them as UUID blueprint STATUS
for c in status:
if c["image_size"] > 0:
image_size = str(c["image_size"])
else:
image_size = ""
dt = datetime.fromtimestamp(c.get("finished") or c.get("started") or c.get("created"))
print("%s %-8s %s %-15s %s %-16s %s" % (c["id"], c["status"], dt.strftime("%c"), c["blueprint"],
c["version"], c["compose_type"], image_size))
[docs]def compose_types(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Return information about the supported compose types
: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
:param testmode: unused in this function
:type testmode: int
Add additional details to types that are known to composer-cli. Raw JSON output does not
include this extra information.
"""
api_route = client.api_url(api_version, "/compose/types")
result = client.get_url_json(socket_path, api_route)
if show_json:
print(json.dumps(result, indent=4))
return 0
# output a plain list of identifiers, one per line
print("\n".join(t["name"] for t in result["types"] if t["enabled"]))
[docs]def compose_start(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Start a new compose using the selected blueprint and type
: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
:param testmode: Set to 1 to simulate a failed compose, set to 2 to simulate a finished one.
:type testmode: int
:param api: Details about the API server, "version" and "backend"
:type api: dict
compose start [--size XXX] <blueprint-name> <compose-type> [<image-name> <provider> <profile> | <image-name> <profile.toml>]
"""
if api == None:
log.error("Missing api version/backend")
return 1
# Get the optional size before checking other parameters
try:
args, size = get_size(args)
except (RuntimeError, ValueError) as e:
log.error(str(e))
return 1
if len(args) == 0:
log.error("start is missing the blueprint name and output type")
return 1
if len(args) == 1:
log.error("start is missing the output type")
return 1
if len(args) == 3:
log.error("start is missing the provider and profile details")
return 1
config = {
"blueprint_name": args[0],
"compose_type": args[1],
"branch": "master"
}
if size > 0:
if api["backend"] == "lorax-composer":
log.warning("lorax-composer does not support --size, it will be ignored.")
else:
config["size"] = size
if len(args) == 4:
config["upload"] = {"image_name": args[2]}
# profile TOML file (maybe)
try:
config["upload"].update(toml.load(args[3]))
except toml.TomlDecodeError as e:
log.error(str(e))
return 1
elif len(args) == 5:
config["upload"] = {
"image_name": args[2],
"provider": args[3],
"profile": args[4]
}
if testmode:
test_url = "?test=%d" % testmode
else:
test_url = ""
api_route = client.api_url(api_version, "/compose" + test_url)
result = client.post_url_json(socket_path, api_route, json.dumps(config))
(rc, exit_now) = handle_api_result(result, show_json)
if exit_now:
return rc
print("Compose %s added to the queue" % result["build_id"])
if "upload_id" in result and result["upload_id"]:
print ("Upload %s added to the upload queue" % result["upload_id"])
return rc
[docs]def compose_ostree(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Start a new ostree compose using the selected blueprint and type
: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
:param testmode: Set to 1 to simulate a failed compose, set to 2 to simulate a finished one.
:type testmode: int
:param api: Details about the API server, "version" and "backend"
:type api: dict
compose start-ostree [--size XXXX] [--parent PARENT] [--ref REF] <BLUEPRINT> <TYPE> [<IMAGE-NAME> <PROFILE.TOML>]
"""
if api == None:
log.error("Missing api version/backend")
return 1
if api["backend"] == "lorax-composer":
log.warning("lorax-composer doesn not support start-ostree.")
return 1
# Get the optional size before checking other parameters
try:
args, size = get_size(args)
args, parent = get_parent(args)
args, ref = get_ref(args)
except (RuntimeError, ValueError) as e:
log.error(str(e))
return 1
if len(args) == 0:
log.error("start-ostree is missing the blueprint name, output type, and ostree details")
return 1
if len(args) == 1:
log.error("start-ostree is missing the output type")
return 1
if len(args) == 3:
log.error("start-ostree is missing the provider TOML file")
return 1
config = {
"blueprint_name": args[0],
"compose_type": args[1],
"branch": "master",
"ostree": {"ref": ref, "parent": parent},
}
if size > 0:
config["size"] = size
if len(args) == 4:
config["upload"] = {"image_name": args[2]}
# profile TOML file (maybe)
try:
config["upload"].update(toml.load(args[3]))
except toml.TomlDecodeError as e:
log.error(str(e))
return 1
if testmode:
test_url = "?test=%d" % testmode
else:
test_url = ""
api_route = client.api_url(api_version, "/compose" + test_url)
result = client.post_url_json(socket_path, api_route, json.dumps(config))
(rc, exit_now) = handle_api_result(result, show_json)
if exit_now:
return rc
print("Compose %s added to the queue" % result["build_id"])
if "upload_id" in result and result["upload_id"]:
print ("Upload %s added to the upload queue" % result["upload_id"])
return rc
[docs]def compose_log(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Show the last part of the compose log
: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
:param testmode: unused in this function
:type testmode: int
compose log <uuid> [<size>kB]
This will display the last 1kB of the compose's log file. Can be used to follow progress
during the build.
"""
if len(args) == 0:
log.error("log is missing the compose build id")
return 1
if len(args) == 2:
try:
log_size = int(args[1])
except ValueError:
log.error("Log size must be an integer.")
return 1
else:
log_size = 1024
api_route = client.api_url(api_version, "/compose/log/%s?size=%d" % (args[0], log_size))
try:
result = client.get_url_raw(socket_path, api_route)
except RuntimeError as e:
print(str(e))
return 1
print(result)
return 0
[docs]def compose_cancel(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Cancel a running compose
: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
:param testmode: unused in this function
:type testmode: int
compose cancel <uuid>
This will cancel a running compose. It does nothing if the compose has finished.
"""
if len(args) == 0:
log.error("cancel is missing the compose build id")
return 1
api_route = client.api_url(api_version, "/compose/cancel/%s" % args[0])
result = client.delete_url_json(socket_path, api_route)
return handle_api_result(result, show_json)[0]
[docs]def compose_delete(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Delete a finished compose's results
: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
:param testmode: unused in this function
:type testmode: int
compose delete <uuid,...>
Delete the listed compose results. It will only delete results for composes that have finished
or failed, not a running compose.
"""
if len(args) == 0:
log.error("delete is missing the compose build id")
return 1
api_route = client.api_url(api_version, "/compose/delete/%s" % (",".join(argify(args))))
result = client.delete_url_json(socket_path, api_route)
return handle_api_result(result, show_json)[0]
[docs]def compose_info(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Return detailed information about the compose
: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
:param testmode: unused in this function
:type testmode: int
compose info <uuid>
This returns information about the compose, including the blueprint and the dependencies.
"""
if len(args) == 0:
log.error("info is missing the compose build id")
return 1
api_route = client.api_url(api_version, "/compose/info/%s" % args[0])
result = client.get_url_json(socket_path, api_route)
(rc, exit_now) = handle_api_result(result, show_json)
if exit_now:
return rc
if result["image_size"] > 0:
image_size = str(result["image_size"])
else:
image_size = ""
print("%s %-8s %-15s %s %-16s %s" % (result["id"],
result["queue_status"],
result["blueprint"]["name"],
result["blueprint"]["version"],
result["compose_type"],
image_size))
print("Packages:")
for p in result["blueprint"]["packages"]:
print(" %s-%s" % (p["name"], p["version"]))
print("Modules:")
for m in result["blueprint"]["modules"]:
print(" %s-%s" % (m["name"], m["version"]))
print("Dependencies:")
for d in result["deps"]["packages"]:
print(" " + packageNEVRA(d))
return rc
[docs]def compose_results(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Download a tar file of the compose's results
: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
:param testmode: unused in this function
:type testmode: int
compose results <uuid>
The results includes the metadata, output image, and logs.
It is saved as uuid.tar
"""
if len(args) == 0:
log.error("results is missing the compose build id")
return 1
api_route = client.api_url(api_version, "/compose/results/%s" % args[0])
try:
rc = client.download_file(socket_path, api_route, sys.stdout.isatty())
except RuntimeError as e:
print(str(e))
rc = 1
return rc
[docs]def compose_logs(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Download a tar of the compose's logs
: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
:param testmode: unused in this function
:type testmode: int
compose logs <uuid>
Saves the logs as uuid-logs.tar
"""
if len(args) == 0:
log.error("logs is missing the compose build id")
return 1
api_route = client.api_url(api_version, "/compose/logs/%s" % args[0])
try:
rc = client.download_file(socket_path, api_route, sys.stdout.isatty())
except RuntimeError as e:
print(str(e))
rc = 1
return rc
[docs]def compose_image(socket_path, api_version, args, show_json=False, testmode=0, api=None):
"""Download the compose's output image
: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
:param testmode: unused in this function
:type testmode: int
compose image <uuid>
This downloads only the result image, saving it as the image name, which depends on the type
of compose that was selected.
"""
if len(args) == 0:
log.error("logs is missing the compose build id")
return 1
api_route = client.api_url(api_version, "/compose/image/%s" % args[0])
try:
rc = client.download_file(socket_path, api_route, sys.stdout.isatty())
except RuntimeError as e:
print(str(e))
rc = 1
return rc