Merge pull request #195 from mesosphere/dcos-514-deployment-list

DCOS-514: use readable tables in the output of many commands
This commit is contained in:
mgummelt
2015-06-04 12:53:17 -07:00
28 changed files with 979 additions and 338 deletions

View File

@@ -13,25 +13,31 @@ Usage:
dcos marathon app stop [--force] <app-id>
dcos marathon app update [--force] <app-id> [<properties>...]
dcos marathon app version list [--max-count=<max-count>] <app-id>
dcos marathon deployment list [<app-id>]
dcos marathon deployment list [--json <app-id>]
dcos marathon deployment rollback <deployment-id>
dcos marathon deployment stop <deployment-id>
dcos marathon deployment watch [--max-count=<max-count>]
[--interval=<interval>] <deployment-id>
dcos marathon task list [<app-id>]
dcos marathon task list [--json <app-id>]
dcos marathon task show <task-id>
dcos marathon group add [<group-resource>]
dcos marathon group list
dcos marathon group list [--json]
dcos marathon group show [--group-version=<group-version>] <group-id>
dcos marathon group remove [--force] <group-id>
Options:
-h, --help Show this screen
--info Show a short description of this
subcommand
--json Print json-formatted tasks
--version Show version
--force This flag disable checks in Marathon
during update operations
--app-version=<app-version> This flag specifies the application
version to use for the command. The
application version (<app-version>) can be
@@ -42,6 +48,7 @@ Options:
integer and they represent the version
from the currently deployed application
definition
--group-version=<group-version> This flag specifies the group version to
use for the command. The group version
(<group-version>) can be specified as an
@@ -51,38 +58,48 @@ Options:
specified as a negative integer and they
represent the version from the currently
deployed group definition
--config-schema Show the configuration schema for the
Marathon subcommand
--max-count=<max-count> Maximum number of entries to try to fetch
and return
--interval=<interval> Number of seconds to wait between actions
Positional Arguments:
<app-id> The application id
<app-resource> The application resource; for a detailed
description see (https://mesosphere.github.io/
marathon/docs/rest-api.html#post-/v2/apps)
<deployment-id> The deployment id
<group-id> The group id
<group-resource> The group resource; for a detailed description
see (https://mesosphere.github.io/marathon/docs
/rest-api.html#post-/v2/groups)
<instances> The number of instances to start
<properties> Optional key-value pairs to be included in the
command. The separator between the key and
value must be the '=' character. E.g. cpus=2.0
<task-id> The task id
"""
import json
import sys
import time
from collections import OrderedDict
import dcoscli
import docopt
import pkg_resources
from dcos import cmds, emitting, jsonitem, marathon, options, util
from dcos.errors import DCOSException
from dcoscli import tables
logger = util.get_logger(__name__)
emitter = emitting.FlatEmitter()
@@ -120,7 +137,7 @@ def _cmds():
cmds.Command(
hierarchy=['marathon', 'deployment', 'list'],
arg_keys=['<app-id>'],
arg_keys=['<app-id>', '--json'],
function=_deployment_list),
cmds.Command(
@@ -140,7 +157,7 @@ def _cmds():
cmds.Command(
hierarchy=['marathon', 'task', 'list'],
arg_keys=['<app-id>'],
arg_keys=['<app-id>', '--json'],
function=_task_list),
cmds.Command(
@@ -195,7 +212,7 @@ def _cmds():
cmds.Command(
hierarchy=['marathon', 'group', 'list'],
arg_keys=[],
arg_keys=['--json'],
function=_group_list),
cmds.Command(
@@ -319,37 +336,6 @@ def _add(app_resource):
return 0
def _app_table(apps):
def get_cmd(app):
if app["cmd"] is not None:
return app["cmd"]
else:
return app["args"]
def get_container(app):
if app["container"] is not None:
return app["container"]["type"]
else:
return "null"
fields = OrderedDict([
("id", lambda a: a["id"]),
("mem", lambda a: a["mem"]),
("cpus", lambda a: a["cpus"]),
("deployments", lambda a: len(a["deployments"])),
("instances", lambda a: "{}/{}".format(a["tasksRunning"],
a["instances"])),
("container", get_container),
("cmd", get_cmd)
])
tb = util.table(fields, apps)
tb.align["CMD"] = "l"
tb.align["ID"] = "l"
return tb
def _list(json_):
"""
:param json_: output json if True
@@ -361,18 +347,14 @@ def _list(json_):
client = marathon.create_client()
apps = client.get_apps()
if json_:
emitter.publish(apps)
else:
table = _app_table(apps)
output = str(table)
if output:
emitter.publish(output)
emitting.publish_table(emitter, apps, tables.app_table, json_)
return 0
def _group_list():
def _group_list(json_):
"""
:param json_: output json if True
:type json_: bool
:returns: process status
:rtype: int
"""
@@ -380,7 +362,7 @@ def _group_list():
client = marathon.create_client()
groups = client.get_groups()
emitter.publish(groups)
emitting.publish_table(emitter, groups, tables.group_table, json_)
return 0
@@ -665,10 +647,12 @@ def _version_list(app_id, max_count):
return 0
def _deployment_list(app_id):
def _deployment_list(app_id, json_):
"""
:param app_id: the application id
:type app_id: str
:param json_: output json if True
:type json_: bool
:returns: process status
:rtype: int
"""
@@ -677,7 +661,10 @@ def _deployment_list(app_id):
deployments = client.get_deployments(app_id)
emitter.publish(deployments)
emitting.publish_table(emitter,
deployments,
tables.deployment_table,
json_)
return 0
@@ -743,10 +730,12 @@ def _deployment_watch(deployment_id, max_count, interval):
return 0
def _task_list(app_id):
def _task_list(app_id, json_):
"""
:param app_id: the id of the application
:type app_id: str
:param json_: output json if True
:type json_: bool
:returns: process status
:rtype: int
"""
@@ -754,7 +743,7 @@ def _task_list(app_id):
client = marathon.create_client()
tasks = client.get_tasks(app_id)
emitter.publish(tasks)
emitting.publish_table(emitter, tasks, tables.app_task_table, json_)
return 0

View File

@@ -7,8 +7,8 @@ Usage:
dcos package info
dcos package install [--cli | [--app --app-id=<app_id>]]
[--options=<file> --yes] <package_name>
dcos package list [--endpoints --app-id=<app-id> <package_name>]
dcos package search [<query>]
dcos package list [--json --endpoints --app-id=<app-id> <package_name>]
dcos package search [--json <query>]
dcos package sources
dcos package uninstall [--cli | [--app --app-id=<app-id> --all]]
<package_name>
@@ -53,6 +53,7 @@ import docopt
import pkg_resources
from dcos import cmds, emitting, marathon, options, package, subcommand, util
from dcos.errors import DCOSException
from dcoscli import tables
logger = util.get_logger(__name__)
@@ -107,12 +108,12 @@ def _cmds():
cmds.Command(
hierarchy=['package', 'list'],
arg_keys=['--endpoints', '--app-id', '<package_name>'],
arg_keys=['--json', '--endpoints', '--app-id', '<package_name>'],
function=_list),
cmds.Command(
hierarchy=['package', 'search'],
arg_keys=['<query>'],
arg_keys=['--json', '<query>'],
function=_search),
cmds.Command(
@@ -358,9 +359,11 @@ def _install(package_name, options_path, app_id, cli, app, yes):
return 0
def _list(endpoints, app_id, package_name):
"""Show installed apps
def _list(json_, endpoints, app_id, package_name):
"""List installed apps
:param json_: output json if True
:type json_: bool
:param endpoints: Whether to include a list of
endpoints as port-host pairs
:type endpoints: boolean
@@ -391,8 +394,7 @@ def _list(endpoints, app_id, package_name):
results.append(pkg_info)
emitter.publish(results)
emitting.publish_table(emitter, results, tables.package_table, json_)
return 0
@@ -424,9 +426,11 @@ def _matches_app_id(app_id, pkg_info):
return app_id is None or app_id in pkg_info.get('apps')
def _search(query):
def _search(json_, query):
"""Search for matching packages.
:param json_: output json if True
:type json_: bool
:param query: The search term
:type query: str
:returns: Process status
@@ -436,10 +440,13 @@ def _search(query):
query = ''
config = util.get_config()
results = package.search(query, config)
emitter.publish([r.as_dict() for r in results])
results = [index_entry.as_dict()
for index_entry in package.search(query, config)]
emitting.publish_table(emitter,
results,
tables.package_search_table,
json_)
return 0

View File

@@ -22,15 +22,11 @@ Positional Arguments:
<service-id> The ID for the DCOS Service
"""
from collections import OrderedDict
import blessings
import dcoscli
import docopt
import prettytable
from dcos import cmds, emitting, mesos, util
from dcos.errors import DCOSException
from dcoscli import tables
logger = util.get_logger(__name__)
emitter = emitting.FlatEmitter()
@@ -89,44 +85,6 @@ def _info():
return 0
def _service_table(services):
"""Returns a PrettyTable representation of the provided services.
:param services: services to render
:type services: [Framework]
:rtype: TaskTable
"""
term = blessings.Terminal()
table_generator = OrderedDict([
("name", lambda s: s['name']),
("host", lambda s: s['hostname']),
("active", lambda s: s['active']),
("tasks", lambda s: len(s['tasks'])),
("cpu", lambda s: s['resources']['cpus']),
("mem", lambda s: s['resources']['mem']),
("disk", lambda s: s['resources']['disk']),
("ID", lambda s: s['id']),
])
tb = prettytable.PrettyTable(
[k.upper() for k in table_generator.keys()],
border=False,
max_table_width=term.width,
hrules=prettytable.NONE,
vrules=prettytable.NONE,
left_padding_width=0,
right_padding_width=1
)
for service in services:
row = [fn(service) for fn in table_generator.values()]
tb.add_row(row)
return tb
# TODO (mgummelt): support listing completed services as well.
# blocked on framework shutdown.
def _service(inactive, is_json):
@@ -146,7 +104,7 @@ def _service(inactive, is_json):
if is_json:
emitter.publish([service.dict() for service in services])
else:
table = _service_table(services)
table = tables.service_table(services)
output = str(table)
if output:
emitter.publish(output)

276
cli/dcoscli/tables.py Normal file
View File

@@ -0,0 +1,276 @@
import copy
from collections import OrderedDict
from dcos import util
def task_table(tasks):
"""Returns a PrettyTable representation of the provided mesos tasks.
:param tasks: tasks to render
:type tasks: [Task]
:rtype: PrettyTable
"""
fields = OrderedDict([
("NAME", lambda t: t["name"]),
("USER", lambda t: t.user()),
("STATE", lambda t: t["state"].split("_")[-1][0]),
("ID", lambda t: t["id"]),
])
tb = util.table(fields, tasks, sortby="NAME")
tb.align["NAME"] = "l"
tb.align["ID"] = "l"
return tb
def app_table(apps):
"""Returns a PrettyTable representation of the provided apps.
:param tasks: apps to render
:type tasks: [dict]
:rtype: PrettyTable
"""
def get_cmd(app):
if app["cmd"] is not None:
return app["cmd"]
else:
return app["args"]
def get_container(app):
if app["container"] is not None:
return app["container"]["type"]
else:
return "mesos"
fields = OrderedDict([
("ID", lambda a: a["id"]),
("MEM", lambda a: a["mem"]),
("CPUS", lambda a: a["cpus"]),
("DEPLOYMENTS", lambda a: len(a["deployments"])),
("TASKS", lambda a: "{}/{}".format(a["tasksRunning"],
a["instances"])),
("CONTAINER", get_container),
("CMD", get_cmd)
])
tb = util.table(fields, apps, sortby="ID")
tb.align["CMD"] = "l"
tb.align["ID"] = "l"
return tb
def app_task_table(tasks):
"""Returns a PrettyTable representation of the provided marathon tasks.
:param tasks: tasks to render
:type tasks: [dict]
:rtype: PrettyTable
"""
fields = OrderedDict([
("APP", lambda t: t["appId"]),
("HEALTHY", lambda t:
all(check['alive'] for check in t.get('healthCheckResults', []))),
("STARTED", lambda t: t["startedAt"]),
("HOST", lambda t: t["host"]),
("ID", lambda t: t["id"])
])
tb = util.table(fields, tasks, sortby="APP")
tb.align["APP"] = "l"
tb.align["ID"] = "l"
return tb
def deployment_table(deployments):
"""Returns a PrettyTable representation of the provided marathon
deployments.
:param deployments: deployments to render
:type deployments: [dict]
:rtype: PrettyTable
"""
def get_action(deployment):
action_map = {'ResolveArtifacts': 'artifacts',
'ScaleApplication': 'scale',
'StartApplication': 'start',
'StopApplication': 'stop',
'RestartApplication': 'restart',
'KillAllOldTasksOf': 'kill-tasks'}
multiple_apps = len({action['app']
for action in deployment['currentActions']}) > 1
ret = []
for action in deployment['currentActions']:
try:
action_display = action_map[action['action']]
except KeyError:
raise ValueError(
'Unknown Marathon action: {}'.format(action['action']))
if multiple_apps:
ret.append('{0} {1}'.format(action_display, action['app']))
else:
ret.append(action_display)
return '\n'.join(ret)
fields = OrderedDict([
('APP', lambda d: '\n'.join(d['affectedApps'])),
('ACTION', get_action),
('PROGRESS', lambda d: '{0}/{1}'.format(d['currentStep']-1,
d['totalSteps'])),
('ID', lambda d: d['id'])
])
tb = util.table(fields, deployments, sortby="APP")
tb.align['APP'] = 'l'
tb.align['ACTION'] = 'l'
tb.align['ID'] = 'l'
return tb
def service_table(services):
"""Returns a PrettyTable representation of the provided DCOS services.
:param services: services to render
:type services: [Framework]
:rtype: PrettyTable
"""
fields = OrderedDict([
("NAME", lambda s: s['name']),
("HOST", lambda s: s['hostname']),
("ACTIVE", lambda s: s['active']),
("TASKS", lambda s: len(s['tasks'])),
("CPU", lambda s: s['resources']['cpus']),
("MEM", lambda s: s['resources']['mem']),
("DISK", lambda s: s['resources']['disk']),
("ID", lambda s: s['id']),
])
tb = util.table(fields, services, sortby="NAME")
tb.align["ID"] = 'l'
tb.align["NAME"] = 'l'
return tb
def _count_apps(group, group_dict):
"""Counts how many apps are registered for each group. Recursively
populates the profided `group_dict`, which maps group_id ->
(group, count).
:param group: nested group dictionary
:type group: dict
:param group_dict: group map that maps group_id -> (group, count)
:type group_dict: dict
:rtype: dict
"""
for child_group in group['groups']:
_count_apps(child_group, group_dict)
count = (len(group['apps']) +
sum(group_dict[child_group['id']][1]
for child_group in group['groups']))
group_dict[group['id']] = (group, count)
def group_table(groups):
"""Returns a PrettyTable representation of the provided marathon
groups
:param groups: groups to render
:type groups: [dict]
:rtype: PrettyTable
"""
group_dict = {}
for group in groups:
_count_apps(group, group_dict)
fields = OrderedDict([
('ID', lambda g: g[0]['id']),
('APPS', lambda g: g[1]),
])
tb = util.table(fields, group_dict.values(), sortby="ID")
tb.align['ID'] = 'l'
return tb
def package_table(packages):
"""Returns a PrettyTable representation of the provided DCOS packages
:param packages: packages to render
:type packages: [dict]
:rtype: PrettyTable
"""
fields = OrderedDict([
('NAME', lambda p: p['name']),
('APP', lambda p: p['app']['appId'] if 'app' in p else 'null'),
('COMMAND',
lambda p: p['command']['name'] if 'command' in p else 'null'),
('DESCRIPTION', lambda p: p['description'])
])
tb = util.table(fields, packages, sortby="NAME")
tb.align['NAME'] = 'l'
tb.align['APP'] = 'l'
tb.align['COMMAND'] = 'l'
tb.align['DESCRIPTION'] = 'l'
return tb
def package_search_table(search_results):
"""Returns a PrettyTable representation of the provided DCOS package
search results
:param search_results: search_results, in the format of
dcos.package.IndexEntries::as_dict()
:type search_results: [dict]
:rtype: PrettyTable
"""
fields = OrderedDict([
('NAME', lambda p: p['name']),
('VERSION', lambda p: p['currentVersion']),
('FRAMEWORK', lambda p: p['framework']),
('SOURCE', lambda p: p['source']),
('DESCRIPTION', lambda p: p['description'])
])
packages = []
for result in search_results:
for package in result['packages']:
package_ = copy.deepcopy(package)
package_['source'] = result['source']
packages.append(package_)
tb = util.table(fields, packages, sortby="NAME")
tb.align['NAME'] = 'l'
tb.align['VERSION'] = 'l'
tb.align['FRAMEWORK'] = 'l'
tb.align['SOURCE'] = 'l'
tb.align['DESCRIPTION'] = 'l'
return tb

View File

@@ -17,13 +17,11 @@ Positional Arguments:
a substring of the ID, or a unix glob pattern.
"""
from collections import OrderedDict
import dcoscli
import docopt
from dcos import cmds, emitting, mesos, util
from dcos.errors import DCOSException
from dcoscli import tables
logger = util.get_logger(__name__)
emitter = emitting.FlatEmitter()
@@ -77,39 +75,18 @@ def _info():
return 0
def _task_table(tasks):
"""Returns a PrettyTable representation of the provided tasks.
:param tasks: tasks to render
:type tasks: [Task]
:rtype: TaskTable
"""
fields = OrderedDict([
("name", lambda t: t["name"]),
("user", lambda t: t.user()),
("state", lambda t: t["state"].split("_")[-1][0]),
("id", lambda t: t["id"]),
])
tb = util.table(fields, tasks)
tb.align["NAME"] = "l"
tb.align["ID"] = "l"
return tb
def _task(fltr, completed, is_json):
""" List DCOS tasks
def _task(fltr, completed, json_):
"""List DCOS tasks
:param fltr: task id filter
:type fltr: str
:param completed: If True, include completed tasks
:type completed: bool
:param is_json: If True, output json. Otherwise, output a human readable
table.
:type is_json: bool
:param json_: If True, output json. Otherwise, output a human
readable table.
:type json_: bool
:returns: process return code
"""
if fltr is None:
@@ -118,10 +95,12 @@ def _task(fltr, completed, is_json):
tasks = sorted(mesos.get_master().tasks(completed=completed, fltr=fltr),
key=lambda task: task['name'])
if is_json:
if json_:
emitter.publish([task.dict() for task in tasks])
else:
table = _task_table(tasks)
table = tables.task_table(tasks)
output = str(table)
if output:
emitter.publish(output)
return 0

View File

@@ -73,7 +73,6 @@ setup(
'rollbar>=0.9, <1.0',
'futures>=3.0, <4.0',
'oauth2client>=1.4, <2.0',
'blessings>=1.6, <2.0',
],
# If there are data files included in your packages that need to be

View File

@@ -1,41 +0,0 @@
def app_fixture():
return {
"acceptedResourceRoles": None,
"args": None,
"backoffFactor": 1.15,
"backoffSeconds": 1,
"cmd": "sleep 1000",
"constraints": [],
"container": None,
"cpus": 0.1,
"dependencies": [],
"deployments": [],
"disk": 0.0,
"env": {},
"executor": "",
"healthChecks": [],
"id": "/test-app",
"instances": 1,
"labels": {
"PACKAGE_ID": "test-app",
"PACKAGE_VERSION": "1.2.3"
},
"maxLaunchDelaySeconds": 3600,
"mem": 16.0,
"ports": [
10000
],
"requirePorts": False,
"storeUrls": [],
"tasksHealthy": 0,
"tasksRunning": 1,
"tasksStaged": 0,
"tasksUnhealthy": 0,
"upgradeStrategy": {
"maximumOverCapacity": 1.0,
"minimumHealthCapacity": 1.0
},
"uris": [],
"user": None,
"version": "2015-05-28T21:21:05.064Z"
}

161
cli/tests/fixtures/marathon.py vendored Normal file
View File

@@ -0,0 +1,161 @@
def app_fixture():
""" Marathon app fixture.
:rtype: dict
"""
return {
"acceptedResourceRoles": None,
"args": None,
"backoffFactor": 1.15,
"backoffSeconds": 1,
"cmd": "sleep 1000",
"constraints": [],
"container": None,
"cpus": 0.1,
"dependencies": [],
"deployments": [],
"disk": 0.0,
"env": {},
"executor": "",
"healthChecks": [],
"id": "/test-app",
"instances": 1,
"labels": {
"PACKAGE_ID": "test-app",
"PACKAGE_VERSION": "1.2.3"
},
"maxLaunchDelaySeconds": 3600,
"mem": 16.0,
"ports": [
10000
],
"requirePorts": False,
"storeUrls": [],
"tasksHealthy": 0,
"tasksRunning": 1,
"tasksStaged": 0,
"tasksUnhealthy": 0,
"upgradeStrategy": {
"maximumOverCapacity": 1.0,
"minimumHealthCapacity": 1.0
},
"uris": [],
"user": None,
"version": "2015-05-28T21:21:05.064Z"
}
def deployment_fixture():
""" Marathon deployment fixture.
:rtype: dict
"""
return {
"affectedApps": [
"/cassandra/dcos"
],
"currentActions": [
{
"action": "ScaleApplication",
"app": "/cassandra/dcos"
}
],
"currentStep": 2,
"id": "bebb8ffd-118e-4067-8fcb-d19e44126911",
"steps": [
[
{
"action": "StartApplication",
"app": "/cassandra/dcos"
}
],
[
{
"action": "ScaleApplication",
"app": "/cassandra/dcos"
}
]
],
"totalSteps": 2,
"version": "2015-05-29T01:13:47.694Z"
}
def app_task_fixture():
""" Marathon task fixture.
:rtype: dict
"""
return {
"appId": "/zero-instance-app",
"host": "dcos-01",
"id": "zero-instance-app.027b3a83-063d-11e5-84a3-56847afe9799",
"ports": [
8165
],
"servicePorts": [
10001
],
"stagedAt": "2015-05-29T19:58:00.907Z",
"startedAt": "2015-05-29T19:58:01.114Z",
"version": "2015-05-29T18:50:58.941Z"
}
def group_fixture():
""" Marathon group fixture.
:rtype: dict
"""
return {
"apps": [],
"dependencies": [],
"groups": [
{
"apps": [
{
"acceptedResourceRoles": None,
"args": None,
"backoffFactor": 1.15,
"backoffSeconds": 1,
"cmd": "sleep 1",
"constraints": [],
"container": None,
"cpus": 1.0,
"dependencies": [],
"disk": 0.0,
"env": {},
"executor": "",
"healthChecks": [],
"id": "/test-group/sleep/goodnight",
"instances": 0,
"labels": {},
"maxLaunchDelaySeconds": 3600,
"mem": 128.0,
"ports": [
10000
],
"requirePorts": False,
"storeUrls": [],
"upgradeStrategy": {
"maximumOverCapacity": 1.0,
"minimumHealthCapacity": 1.0
},
"uris": [],
"user": None,
"version": "2015-05-29T23:12:46.187Z"
}
],
"dependencies": [],
"groups": [],
"id": "/test-group/sleep",
"version": "2015-05-29T23:12:46.187Z"
}
],
"id": "/test-group",
"version": "2015-05-29T23:12:46.187Z"
}

145
cli/tests/fixtures/package.py vendored Normal file
View File

@@ -0,0 +1,145 @@
from dcos.package import HttpSource, IndexEntries
def package_fixture():
""" DCOS package fixture.
:rtype: dict
"""
return {
"app": {
"appId": "/helloworld"
},
"command": {
"name": "helloworld"
},
"description": "Example DCOS application package",
"maintainer": "support@mesosphere.io",
"name": "helloworld",
"packageSource":
"https://github.com/mesosphere/universe/archive/master.zip",
"postInstallNotes": "A sample post-installation message",
"preInstallNotes": "A sample pre-installation message",
"releaseVersion": "0",
"tags": [
"mesosphere",
"example",
"subcommand"
],
"version": "0.1.0",
"website": "https://github.com/mesosphere/dcos-helloworld"
}
def search_result_fixture():
""" DCOS package search result fixture.
:rtype: dict
"""
return IndexEntries(
HttpSource(
"https://github.com/mesosphere/universe/archive/master.zip"),
[
{
"currentVersion": "0.1.0-SNAPSHOT-447-master-3ad1bbf8f7",
"description": "Apache Cassandra running on Apache Mesos",
"framework": True,
"name": "cassandra",
"tags": [
"mesosphere",
"framework"
],
"versions": [
"0.1.0-SNAPSHOT-447-master-3ad1bbf8f7"
]
},
{
"currentVersion": "2.3.4",
"description": ("A fault tolerant job scheduler for Mesos " +
"which handles dependencies and ISO8601 " +
"based schedules."),
"framework": True,
"name": "chronos",
"tags": [
"mesosphere",
"framework"
],
"versions": [
"2.3.4"
]
},
{
"currentVersion": "0.1.1",
"description": ("Hadoop Distributed File System (HDFS), " +
"Highly Available"),
"framework": True,
"name": "hdfs",
"tags": [
"mesosphere",
"framework",
"filesystem"
],
"versions": [
"0.1.1"
]
},
{
"currentVersion": "0.1.0",
"description": "Example DCOS application package",
"framework": False,
"name": "helloworld",
"tags": [
"mesosphere",
"example",
"subcommand"
],
"versions": [
"0.1.0"
]
},
{
"currentVersion": "0.9.0-beta",
"description": "Apache Kafka running on top of Apache Mesos",
"framework": True,
"name": "kafka",
"tags": [
"mesosphere",
"framework",
"bigdata"
],
"versions": [
"0.9.0-beta"
]
},
{
"currentVersion": "0.8.1",
"description": ("A cluster-wide init and control system for " +
"services in cgroups or Docker containers."),
"framework": True,
"name": "marathon",
"tags": [
"mesosphere",
"framework"
],
"versions": [
"0.8.1"
]
},
{
"currentVersion": "1.4.0-SNAPSHOT",
"description": ("Spark is a fast and general cluster " +
"computing system for Big Data"),
"framework": True,
"name": "spark",
"tags": [
"mesosphere",
"framework",
"bigdata"
],
"versions": [
"1.4.0-SNAPSHOT"
]
}
]).as_dict()

46
cli/tests/fixtures/service.py vendored Normal file
View File

@@ -0,0 +1,46 @@
from dcos.mesos import Framework
def framework_fixture():
""" Framework fixture
:rtype: Framework
"""
return Framework({
"active": True,
"checkpoint": True,
"completed_tasks": [],
"failover_timeout": 604800,
"hostname": "mesos.vm",
"id": "20150502-231327-16842879-5050-3889-0000",
"name": "marathon",
"offered_resources": {
"cpus": 0.0,
"disk": 0.0,
"mem": 0.0,
"ports": "[1379-1379, 10000-10000]"
},
"offers": [],
"pid":
"scheduler-a58cd5ba-f566-42e0-a283-b5f39cb66e88@172.17.8.101:55130",
"registered_time": 1431543498.31955,
"reregistered_time": 1431543498.31959,
"resources": {
"cpus": 0.2,
"disk": 0,
"mem": 32,
"ports": "[1379-1379, 10000-10000]"
},
"role": "*",
"tasks": [],
"unregistered_time": 0,
"used_resources": {
"cpus": 0.2,
"disk": 0,
"mem": 32,
"ports": "[1379-1379, 10000-10000]"
},
"user": "root",
"webui_url": "http://mesos:8080"
})

View File

@@ -4,6 +4,11 @@ import mock
def task_fixture():
""" Task fixture
:rtype: Task
"""
task = Task({
"executor_id": "",
"framework_id": "20150502-231327-16842879-5050-3889-0000",

View File

@@ -12,9 +12,9 @@ def exec_command(cmd, env=None, stdin=None):
"""Execute CLI command
:param cmd: Program and arguments
:type cmd: list of str
:type cmd: [str]
:param env: Environment variables
:type env: dict of str to str
:type env: dict
:param stdin: File to use for stdin
:type stdin: file
:returns: A tuple with the returncode, stdout and stderr
@@ -115,7 +115,7 @@ def watch_deployment(deployment_id, count):
assert stderr == b''
def watch_all_deployments(count=60):
def watch_all_deployments(count=300):
""" Wait for all deployments to complete.
:param count: max number of seconds to wait
@@ -140,7 +140,7 @@ def list_deployments(expected_count=None, app_id=None):
:rtype: [dict]
"""
cmd = ['dcos', 'marathon', 'deployment', 'list']
cmd = ['dcos', 'marathon', 'deployment', 'list', '--json']
if app_id is not None:
cmd.append(app_id)
@@ -208,3 +208,19 @@ def delete_zk_nodes():
base_path.format(znode))
requests.delete(znode_url)
def assert_lines(cmd, num_lines):
""" Assert stdout contains the expected number of lines
:param cmd: program and arguments
:type cmd: [str]
:param num_lines: expected number of lines for stdout
:type num_lines: int
:rtype: None
"""
returncode, stdout, stderr = exec_command(cmd)
assert returncode == 0
assert stderr == b''
assert len(stdout.decode('utf-8').split('\n')) - 1 == num_lines

View File

@@ -5,8 +5,8 @@ from dcos import constants
import pytest
from .common import (assert_command, exec_command, list_deployments,
watch_deployment)
from .common import (assert_command, assert_lines, exec_command,
list_deployments, watch_all_deployments, watch_deployment)
def test_help():
@@ -25,25 +25,31 @@ Usage:
dcos marathon app stop [--force] <app-id>
dcos marathon app update [--force] <app-id> [<properties>...]
dcos marathon app version list [--max-count=<max-count>] <app-id>
dcos marathon deployment list [<app-id>]
dcos marathon deployment list [--json <app-id>]
dcos marathon deployment rollback <deployment-id>
dcos marathon deployment stop <deployment-id>
dcos marathon deployment watch [--max-count=<max-count>]
[--interval=<interval>] <deployment-id>
dcos marathon task list [<app-id>]
dcos marathon task list [--json <app-id>]
dcos marathon task show <task-id>
dcos marathon group add [<group-resource>]
dcos marathon group list
dcos marathon group list [--json]
dcos marathon group show [--group-version=<group-version>] <group-id>
dcos marathon group remove [--force] <group-id>
Options:
-h, --help Show this screen
--info Show a short description of this
subcommand
--json Print json-formatted tasks
--version Show version
--force This flag disable checks in Marathon
during update operations
--app-version=<app-version> This flag specifies the application
version to use for the command. The
application version (<app-version>) can be
@@ -54,6 +60,7 @@ Options:
integer and they represent the version
from the currently deployed application
definition
--group-version=<group-version> This flag specifies the group version to
use for the command. The group version
(<group-version>) can be specified as an
@@ -63,26 +70,36 @@ Options:
specified as a negative integer and they
represent the version from the currently
deployed group definition
--config-schema Show the configuration schema for the
Marathon subcommand
--max-count=<max-count> Maximum number of entries to try to fetch
and return
--interval=<interval> Number of seconds to wait between actions
Positional Arguments:
<app-id> The application id
<app-resource> The application resource; for a detailed
description see (https://mesosphere.github.io/
marathon/docs/rest-api.html#post-/v2/apps)
<deployment-id> The deployment id
<group-id> The group id
<group-resource> The group resource; for a detailed description
see (https://mesosphere.github.io/marathon/docs
/rest-api.html#post-/v2/groups)
<instances> The number of instances to start
<properties> Optional key-value pairs to be included in the
command. The separator between the key and
value must be the '=' character. E.g. cpus=2.0
<task-id> The task id
"""
assert_command(['dcos', 'marathon', '--help'],
@@ -470,6 +487,17 @@ def test_list_deployment():
_remove_app('zero-instance-app')
def test_list_deployment_table():
"""Simple sanity check for listing deployments with a table output.
The more specific testing is done in unit tests.
"""
_add_app('tests/data/marathon/apps/zero_instance_sleep.json')
_start_app('zero-instance-app', 3)
assert_lines(['dcos', 'marathon', 'deployment', 'list'], 2)
_remove_app('zero-instance-app')
def test_list_deployment_missing_app():
_add_app('tests/data/marathon/apps/zero_instance_sleep.json')
_start_app('zero-instance-app')
@@ -555,6 +583,14 @@ def test_list_tasks():
_remove_app('zero-instance-app')
def test_list_tasks_table():
_add_app('tests/data/marathon/apps/zero_instance_sleep.json')
_start_app('zero-instance-app', 3)
watch_all_deployments()
assert_lines(['dcos', 'marathon', 'task', 'list'], 4)
_remove_app('zero-instance-app')
def test_list_app_tasks():
_add_app('tests/data/marathon/apps/zero_instance_sleep.json')
_start_app('zero-instance-app', 3)
@@ -711,7 +747,7 @@ def _list_versions(app_id, expected_count, max_count=None):
def _list_tasks(expected_count, app_id=None):
cmd = ['dcos', 'marathon', 'task', 'list']
cmd = ['dcos', 'marathon', 'task', 'list', '--json']
if app_id is not None:
cmd.append(app_id)

View File

@@ -1,7 +1,7 @@
import json
from .common import (assert_command, exec_command, list_deployments,
watch_deployment)
from .common import (assert_command, assert_lines, exec_command,
list_deployments, watch_all_deployments, watch_deployment)
def test_add_group():
@@ -13,6 +13,13 @@ def test_add_group():
_remove_group('test-group')
def test_group_list_table():
_add_group('tests/data/marathon/groups/good.json')
watch_all_deployments()
assert_lines(['dcos', 'marathon', 'group', 'list'], 3)
_remove_group('test-group')
def test_validate_complicated_group_and_app():
_add_group('tests/data/marathon/groups/complicated.json')
result = list_deployments(None, 'test-group/moregroups/moregroups/sleep1')
@@ -102,7 +109,7 @@ def test_add_bad_complicated_group():
def _list_groups(group_id=None):
returncode, stdout, stderr = exec_command(
['dcos', 'marathon', 'group', 'list'])
['dcos', 'marathon', 'group', 'list', '--json'])
result = json.loads(stdout.decode('utf-8'))

View File

@@ -1,3 +1,4 @@
import contextlib
import json
import os
@@ -6,8 +7,9 @@ from dcos import subcommand
import pytest
from .common import (assert_command, delete_zk_nodes, exec_command,
get_services, service_shutdown, watch_all_deployments)
from .common import (assert_command, assert_lines, delete_zk_nodes,
exec_command, get_services, service_shutdown,
watch_all_deployments)
@pytest.fixture(scope="module")
@@ -82,8 +84,8 @@ Usage:
dcos package info
dcos package install [--cli | [--app --app-id=<app_id>]]
[--options=<file> --yes] <package_name>
dcos package list [--endpoints --app-id=<app-id> <package_name>]
dcos package search [<query>]
dcos package list [--json --endpoints --app-id=<app-id> <package_name>]
dcos package search [--json <query>]
dcos package sources
dcos package uninstall [--cli | [--app --app-id=<app-id> --all]]
<package_name>
@@ -497,8 +499,7 @@ def test_uninstall_missing():
def test_uninstall_subcommand():
_install_helloworld()
_uninstall_helloworld()
assert_command(['dcos', 'package', 'list'], stdout=b'[]\n')
_list()
def test_uninstall_cli():
@@ -528,48 +529,34 @@ version-1.x.zip",
}
]
"""
assert_command(['dcos', 'package', 'list'],
stdout=stdout)
_list(stdout=stdout)
_uninstall_helloworld()
def test_list_installed(zk_znode):
assert_command(['dcos', 'package', 'list'],
stdout=b'[]\n')
assert_command(['dcos', 'package', 'list', 'xyzzy'],
stdout=b'[]\n')
assert_command(['dcos', 'package', 'list', '--app-id=/xyzzy'],
stdout=b'[]\n')
def test_list(zk_znode):
_list()
_list(args=['xyzzy', '--json'])
_list(args=['--app-id=/xyzzy', '--json'])
_install_chronos()
expected_output = _chronos_description(['/chronos'])
assert_command(['dcos', 'package', 'list'],
stdout=expected_output)
assert_command(['dcos', 'package', 'list', 'chronos'],
stdout=expected_output)
assert_command(
['dcos', 'package', 'list', '--app-id=/chronos'],
stdout=expected_output)
assert_command(
['dcos', 'package', 'list', 'ceci-nest-pas-une-package'],
stdout=b'[]\n')
assert_command(
['dcos', 'package', 'list',
'--app-id=/ceci-nest-pas-une-package'],
stdout=b'[]\n')
_list(stdout=expected_output)
_list(args=['--json', 'chronos'],
stdout=expected_output)
_list(args=['--json', '--app-id=/chronos'],
stdout=expected_output)
_list(args=['--json', 'ceci-nest-pas-une-package'])
_list(args=['--json', '--app-id=/ceci-nest-pas-une-package'])
_uninstall_chronos()
def test_list_table():
with _helloworld():
assert_lines(['dcos', 'package', 'list'], 2)
def test_install_yes():
with open('tests/data/package/assume_yes.txt') as yes_file:
_install_helloworld(
@@ -592,7 +579,7 @@ def test_install_no():
b'Continue installing? [yes/no] Exiting installation.\n')
def test_list_installed_cli():
def test_list_cli():
_install_helloworld()
stdout = b"""\
@@ -622,9 +609,7 @@ version-1.x.zip",
}
]
"""
assert_command(['dcos', 'package', 'list'],
stdout=stdout)
_list(stdout=stdout)
_uninstall_helloworld()
stdout = (b"A sample pre-installation message\n"
@@ -656,9 +641,7 @@ version-1.x.zip",
}
]
"""
assert_command(['dcos', 'package', 'list'],
stdout=stdout)
_list(stdout=stdout)
_uninstall_helloworld()
@@ -673,20 +656,12 @@ def test_uninstall_multiple_frameworknames(zk_znode):
expected_output = _chronos_description(
['/chronos-user-1', '/chronos-user-2'])
assert_command(['dcos', 'package', 'list'],
stdout=expected_output)
assert_command(['dcos', 'package', 'list', 'chronos'],
stdout=expected_output)
assert_command(
['dcos', 'package', 'list', '--app-id=/chronos-user-1'],
stdout=_chronos_description(['/chronos-user-1']))
assert_command(
['dcos', 'package', 'list', '--app-id=/chronos-user-2'],
stdout=_chronos_description(['/chronos-user-2']))
_list(stdout=expected_output)
_list(args=['--json', 'chronos'], stdout=expected_output)
_list(args=['--json', '--app-id=/chronos-user-1'],
stdout=_chronos_description(['/chronos-user-1']))
_list(args=['--json', '--app-id=/chronos-user-2'],
stdout=_chronos_description(['/chronos-user-2']))
_uninstall_chronos(
args=['--app-id=chronos-user-1'],
returncode=1,
@@ -705,20 +680,14 @@ def test_uninstall_multiple_frameworknames(zk_znode):
def test_search():
returncode, stdout, stderr = exec_command(
['dcos',
'package',
'search',
'framework'])
['dcos', 'package', 'search', 'framework', '--json'])
assert returncode == 0
assert b'chronos' in stdout
assert stderr == b''
returncode, stdout, stderr = exec_command(
['dcos',
'package',
'search',
'xyzzy'])
['dcos', 'package', 'search', 'xyzzy', '--json'])
assert returncode == 0
assert b'"packages": []' in stdout
@@ -727,9 +696,7 @@ version-1.x.zip"' in stdout
assert stderr == b''
returncode, stdout, stderr = exec_command(
['dcos',
'package',
'search'])
['dcos', 'package', 'search', '--json'])
registries = json.loads(stdout.decode('utf-8'))
for registry in registries:
@@ -741,6 +708,16 @@ version-1.x.zip"' in stdout
assert stderr == b''
def test_search_table():
returncode, stdout, stderr = exec_command(
['dcos', 'package', 'search'])
assert returncode == 0
assert b'chronos' in stdout
assert len(stdout.decode('utf-8').split('\n')) > 5
assert stderr == b''
def _get_app_labels(app_id):
returncode, stdout, stderr = exec_command(
['dcos', 'marathon', 'app', 'show', app_id])
@@ -800,3 +777,40 @@ def _install_chronos(
preInstallNotes + stdout + postInstallNotes,
stderr,
stdin=stdin)
def _list(args=['--json'],
stdout=b'[]\n'):
assert_command(['dcos', 'package', 'list'] + args,
stdout=stdout)
def _helloworld():
stdout = b'''A sample pre-installation message
Installing package [helloworld] version [0.1.0]
Installing CLI subcommand for package [helloworld]
A sample post-installation message
'''
return _package('helloworld',
stdout=stdout)
@contextlib.contextmanager
def _package(name,
stdout=b''):
"""Context manager that deploys an app on entrance, and removes it on
exit.
:param path: path to app's json definition:
:type path: str
:param app_id: app id
:type app_id: str
:rtype: None
"""
assert_command(['dcos', 'package', 'install', name, '--yes'],
stdout=stdout)
try:
yield
finally:
assert_command(['dcos', 'package', 'uninstall', name])

View File

@@ -1,14 +1,14 @@
import time
import dcos.util as util
from dcos.mesos import Framework
from dcos.util import create_schema
from dcoscli.service.main import _service_table
import pytest
from .common import (assert_command, delete_zk_nodes, exec_command,
get_services, service_shutdown, watch_all_deployments)
from ..fixtures.service import framework_fixture
from .common import (assert_command, assert_lines, delete_zk_nodes,
exec_command, get_services, service_shutdown,
watch_all_deployments)
@pytest.fixture(scope="module")
@@ -17,49 +17,6 @@ def zk_znode(request):
return request
@pytest.fixture
def service():
service = Framework({
"active": True,
"checkpoint": True,
"completed_tasks": [],
"failover_timeout": 604800,
"hostname": "mesos.vm",
"id": "20150502-231327-16842879-5050-3889-0000",
"name": "marathon",
"offered_resources": {
"cpus": 0.0,
"disk": 0.0,
"mem": 0.0,
"ports": "[1379-1379, 10000-10000]"
},
"offers": [],
"pid":
"scheduler-a58cd5ba-f566-42e0-a283-b5f39cb66e88@172.17.8.101:55130",
"registered_time": 1431543498.31955,
"reregistered_time": 1431543498.31959,
"resources": {
"cpus": 0.2,
"disk": 0,
"mem": 32,
"ports": "[1379-1379, 10000-10000]"
},
"role": "*",
"tasks": [],
"unregistered_time": 0,
"used_resources": {
"cpus": 0.2,
"disk": 0,
"mem": 32,
"ports": "[1379-1379, 10000-10000]"
},
"user": "root",
"webui_url": "http://mesos:8080"
})
return service
def test_help():
stdout = b"""Get the status of DCOS services
@@ -92,16 +49,20 @@ def test_info():
assert_command(['dcos', 'service', '--info'], stdout=stdout)
def test_service(service):
def test_service():
returncode, stdout, stderr = exec_command(['dcos', 'service', '--json'])
services = get_services(1)
schema = _get_schema(service)
schema = _get_schema(framework_fixture())
for srv in services:
assert not util.validate_json(srv, schema)
def test_service_table():
assert_lines(['dcos', 'service'], 2)
def _get_schema(service):
schema = create_schema(service.dict())
schema['required'].remove('reregistered_time')
@@ -161,15 +122,3 @@ Thank you for installing the Apache Cassandra DCOS Service.
# assert marathon is only listed with --inactive
get_services(1, ['--inactive'])
# not an integration test
def test_task_table(service):
table = _service_table([service])
stdout = """\
NAME HOST ACTIVE TASKS CPU MEM DISK ID\
\n\
marathon mesos.vm True 0 0.2 32 0 \
20150502-231327-16842879-5050-3889-0000 """
assert str(table) == stdout

View File

@@ -5,7 +5,8 @@ import dcos.util as util
from dcos.util import create_schema
from ..fixtures.task import task_fixture
from .common import assert_command, exec_command, watch_all_deployments
from .common import (assert_command, assert_lines, exec_command,
watch_all_deployments)
SLEEP1 = 'tests/data/marathon/apps/sleep.json'
SLEEP2 = 'tests/data/marathon/apps/sleep2.json'
@@ -58,6 +59,12 @@ def test_task():
_uninstall_sleep()
def test_task_table():
_install_sleep_task()
assert_lines(['dcos', 'task'], 2)
_uninstall_sleep()
def test_task_completed():
_install_sleep_task()
_uninstall_sleep()

View File

@@ -1,2 +1,2 @@
ID MEM CPUS DEPLOYMENTS INSTANCES CONTAINER CMD
/test-app 16.0 0.1 0 1/1 null sleep 1000
ID MEM CPUS DEPLOYMENTS TASKS CONTAINER CMD
/test-app 16.0 0.1 0 1/1 mesos sleep 1000

View File

@@ -0,0 +1,2 @@
APP HEALTHY STARTED HOST ID
/zero-instance-app True 2015-05-29T19:58:01.114Z dcos-01 zero-instance-app.027b3a83-063d-11e5-84a3-56847afe9799

View File

@@ -0,0 +1,2 @@
APP ACTION PROGRESS ID
/cassandra/dcos scale 1/2 bebb8ffd-118e-4067-8fcb-d19e44126911

View File

@@ -0,0 +1,3 @@
ID APPS
/test-group 1
/test-group/sleep 1

View File

@@ -0,0 +1,2 @@
NAME APP COMMAND DESCRIPTION
helloworld /helloworld helloworld Example DCOS application package

View File

@@ -0,0 +1,8 @@
NAME VERSION FRAMEWORK SOURCE DESCRIPTION
cassandra 0.1.0-SNAPSHOT-447-master-3ad1bbf8f7 True https://github.com/mesosphere/universe/archive/master.zip Apache Cassandra running on Apache Mesos
chronos 2.3.4 True https://github.com/mesosphere/universe/archive/master.zip A fault tolerant job scheduler for Mesos which handles dependencies and ISO8601 based schedules.
hdfs 0.1.1 True https://github.com/mesosphere/universe/archive/master.zip Hadoop Distributed File System (HDFS), Highly Available
helloworld 0.1.0 False https://github.com/mesosphere/universe/archive/master.zip Example DCOS application package
kafka 0.9.0-beta True https://github.com/mesosphere/universe/archive/master.zip Apache Kafka running on top of Apache Mesos
marathon 0.8.1 True https://github.com/mesosphere/universe/archive/master.zip A cluster-wide init and control system for services in cgroups or Docker containers.
spark 1.4.0-SNAPSHOT True https://github.com/mesosphere/universe/archive/master.zip Spark is a fast and general cluster computing system for Big Data

View File

@@ -0,0 +1,2 @@
NAME HOST ACTIVE TASKS CPU MEM DISK ID
marathon mesos.vm True 0 0.2 32 0 20150502-231327-16842879-5050-3889-0000

View File

@@ -1,17 +1,61 @@
from dcoscli.marathon.main import _app_table
from dcoscli.task.main import _task_table
from dcoscli import tables
from ..fixtures.app import app_fixture
from ..fixtures.marathon import (app_fixture, app_task_fixture,
deployment_fixture, group_fixture)
from ..fixtures.package import package_fixture, search_result_fixture
from ..fixtures.service import framework_fixture
from ..fixtures.task import task_fixture
def test_task_table():
table = _task_table([task_fixture()])
with open('tests/unit/data/task.txt') as f:
assert str(table) == f.read()
_test_table(tables.task_table,
task_fixture,
'tests/unit/data/task.txt')
def test_app_table():
table = _app_table([app_fixture()])
with open('tests/unit/data/app.txt') as f:
_test_table(tables.app_table,
app_fixture,
'tests/unit/data/app.txt')
def test_deployment_table():
_test_table(tables.deployment_table,
deployment_fixture,
'tests/unit/data/deployment.txt')
def test_app_task_table():
_test_table(tables.app_task_table,
app_task_fixture,
'tests/unit/data/app_task.txt')
def test_service_table():
_test_table(tables.service_table,
framework_fixture,
'tests/unit/data/service.txt')
def test_group_table():
_test_table(tables.group_table,
group_fixture,
'tests/unit/data/group.txt')
def test_package_table():
_test_table(tables.package_table,
package_fixture,
'tests/unit/data/package.txt')
def test_package_search_table():
_test_table(tables.package_search_table,
search_result_fixture,
'tests/unit/data/package_search.txt')
def _test_table(table_fn, fixture_fn, path):
table = table_fn([fixture_fn()])
with open(path) as f:
assert str(table) == f.read()

View File

@@ -97,6 +97,30 @@ def print_handler(event):
_page(event, pager_command)
def publish_table(emitter, objs, table_fn, json_):
"""Publishes a json representation of `objs` if `json_` is True,
otherwise, publishes a table representation.
:param emitter: emitter to use for publishing
:type emitter: Emitter
:param objs: objects to print
:type objs: [object]
:param table_fn: function used to generate a PrettyTable from `objs`
:type table_fn: objs -> PrettyTable
:param json_: whether or not to publish a json representation
:type json_: bool
:rtype: None
"""
if json_:
emitter.publish(objs)
else:
table = table_fn(objs)
output = str(table)
if output:
emitter.publish(output)
def _process_json(event, pager_command):
"""Conditionally highlights the supplied JSON value.

View File

@@ -1372,7 +1372,7 @@ class IndexEntries():
:param source: The source of these index entries
:type source: Source
:param packages: The index entries
:type packages: list of dict
:type packages: [dict]
"""
def __init__(self, source, packages):

View File

@@ -478,7 +478,7 @@ def humanize_bytes(b):
return "{0:.2f} {1}".format(b/float(factor), suffix)
def table(fields, objs):
def table(fields, objs, sortby=None):
"""Returns a PrettyTable. `fields` represents the header schema of
the table. `objs` represents the objects to be rendered into
rows.
@@ -498,7 +498,8 @@ def table(fields, objs):
hrules=prettytable.NONE,
vrules=prettytable.NONE,
left_padding_width=0,
right_padding_width=1
right_padding_width=1,
sortby=sortby
)
for obj in objs: