Files
deb-python-dcos/cli/dcoscli/task/main.py
Maksym Naboka 33c44b4a9f switch to dcos-log logging backend (#817)
with DC/OS 1.9 release all system and container logs will go to sd journal on each
host. This PR adds dcos-log support to CLI.

Before using newest API , the CLI will try to get the cosmos capability "LOGGING".
If the cluster supports dcos-log, it will use it. If the requests fails or
dcos-log is missing the LOGGING capability, we will default to mesos files API.
2017-01-03 15:00:15 -08:00

543 lines
17 KiB
Python

import os
import posixpath
import sys
import docopt
import six
import dcoscli
from dcos import cmds, config, emitting, mesos, util
from dcos.errors import (DCOSAuthenticationException,
DCOSAuthorizationException,
DCOSException, DCOSHTTPException, DefaultError)
from dcoscli import log, tables
from dcoscli.subcommand import default_command_info, default_doc
from dcoscli.util import decorate_docopt_usage
logger = util.get_logger(__name__)
emitter = emitting.FlatEmitter()
def main(argv):
try:
return _main(argv)
except DCOSException as e:
emitter.publish(e)
return 1
def docopt_wrapper(usage, real_usage, **keywords):
""" A wrapper around the real docopt parser.
Redirects a failed command to /dev/null and prints the proper
real_usage message, instead of just the usage string from usage.
:param usage: The simplified usage string to parse
:type usage: str
:param real_usage: The original usage string to parse
:type real_usage: str
:param keywords: The keyword arguments to pass to docopt
:type keywords: dict
:returns: The parsed arguments
:rtype: dict
"""
base_subcommand = keywords.pop('base_subcommand')
subcommand = keywords.pop('subcommand')
try:
stdout = sys.stdout
# We run docopt twice (once with the real usage string and
# once with the modified usage string) in order to populate
# the 'real' arguments properly.
with open(os.devnull, 'w') as nullfile:
sys.stdout = nullfile
real_arguments = docopt.docopt(
real_usage,
argv=[base_subcommand])
arguments = docopt.docopt(
usage,
**keywords)
sys.stdout = stdout
real_arguments.update(arguments)
real_arguments[subcommand] = True
return real_arguments
except docopt.DocoptExit:
sys.stdout = stdout
print(real_usage.strip(), file=sys.stderr)
sys.exit(1)
except SystemExit:
sys.stdout = stdout
if "argv" in keywords and any(h in ("-h", "--help")
for h in keywords["argv"]):
print(real_usage.strip())
elif "version" in keywords and any(v in ("--version")
for v in keywords["argv"]):
print(keywords["version"].strip())
sys.exit()
@decorate_docopt_usage
def _main(argv):
"""The main function for the 'task' subcommand"""
# We must special case the 'dcos task exec' subcommand in order to
# allow us to pass arguments to docopt in a more user-friendly
# manner. Specifically, we need to be able to to pass arguments
# beginning with "-" to the command we are trying to launch with
# 'exec' without docopt trying to match them to a named parameter
# for the 'dcos' command itself.
if len(argv) > 1 and argv[1] == "exec":
args = docopt_wrapper(
default_doc("task_exec"),
default_doc("task"),
argv=argv[2:],
version="dcos-task version {}".format(dcoscli.version),
options_first=True,
base_subcommand="task",
subcommand="exec")
else:
args = docopt.docopt(
default_doc("task"),
argv=argv,
version="dcos-task version {}".format(dcoscli.version))
return cmds.execute(_cmds(), args)
def _cmds():
"""
:returns: All of the supported commands
:rtype: [Command]
"""
return [
cmds.Command(
hierarchy=['task', '--info'],
arg_keys=[],
function=_info),
cmds.Command(
hierarchy=['task', 'log'],
arg_keys=['--follow', '--completed', '--lines', '<task>',
'<file>'],
function=_log),
cmds.Command(
hierarchy=['task', 'ls'],
arg_keys=['<task>', '<path>', '--long', '--completed'],
function=_ls),
cmds.Command(
hierarchy=['task', 'exec'],
arg_keys=['<task>', '<cmd>', '<args>', '--interactive', '--tty'],
function=_exec),
cmds.Command(
hierarchy=['task'],
arg_keys=['<task>', '--completed', '--json'],
function=_task),
]
def _info():
"""Print task cli information.
:returns: process return code
:rtype: int
"""
emitter.publish(default_command_info("task"))
return 0
def _task(task, completed, json_):
"""List DCOS tasks
:param task: task id filter
:type task: str
:param completed: If True, include completed tasks
:type completed: bool
:param json_: If True, output json. Otherwise, output a human
readable table.
:type json_: bool
:returns: process return code
"""
tasks = sorted(mesos.get_master().tasks(completed=completed, fltr=task),
key=lambda t: t['name'])
if json_:
emitter.publish([t.dict() for t in tasks])
else:
table = tables.task_table(tasks)
output = six.text_type(table)
if output:
emitter.publish(output)
return 0
def _log(follow, completed, lines, task, file_):
""" Tail a file in the task's sandbox.
:param follow: same as unix tail's -f
:type follow: bool
:param completed: whether to include completed tasks
:type completed: bool
:param lines: number of lines to print
:type lines: int
:param task: task pattern to match
:type task: str
:param file_: file path to read
:type file_: str
:returns: process return code
:rtype: int
"""
fltr = task
if file_ is None:
file_ = 'stdout'
if lines is None:
lines = 10
lines = util.parse_int(lines)
# get tasks
client = mesos.DCOSClient()
master = mesos.Master(client.get_master_state())
tasks = master.tasks(completed=completed, fltr=fltr)
if not tasks:
if not fltr:
raise DCOSException("No tasks found. Exiting.")
elif not completed:
completed_tasks = master.tasks(completed=True, fltr=fltr)
if completed_tasks:
msg = 'No running tasks match ID [{}]; however, there '.format(
fltr)
if len(completed_tasks) > 1:
msg += 'are {} matching completed tasks. '.format(
len(completed_tasks))
else:
msg += 'is 1 matching completed task. '
msg += 'Run with --completed to see these logs.'
raise DCOSException(msg)
raise DCOSException('No matching tasks. Exiting.')
if file_ in ('stdout', 'stderr') and log.dcos_log_enabled():
try:
_dcos_log(follow, tasks, lines, file_, completed)
return 0
except (DCOSAuthenticationException,
DCOSAuthorizationException):
raise
except DCOSException as e:
emitter.publish(DefaultError(e))
emitter.publish(DefaultError('Falling back to files API...'))
mesos_files = _mesos_files(tasks, file_, client)
if not mesos_files:
if fltr is None:
msg = "No tasks found. Exiting."
else:
msg = "No matching tasks. Exiting."
raise DCOSException(msg)
log.log_files(mesos_files, follow, lines)
return 0
def get_nested_container_id(task):
""" Get the nested container id from mesos state.
:param task: task definition
:type task: dict
:return: comma separated string of nested containers
:rtype: string
"""
# get current task state
task_state = task.get('state')
if not task_state:
logger.debug('Full task state: {}'.format(task))
raise DCOSException('Invalid executor info. '
'Missing field `state`')
container_ids = []
statuses = task.get('statuses')
if not statuses:
logger.debug('Full task state: {}'.format(task))
raise DCOSException('Invalid executor info. Missing field `statuses`')
for status in statuses:
if 'state' not in status:
logger.debug('Full task state: {}'.format(task))
raise DCOSException('Invalid executor info. Missing field `state`')
if status['state'] != task_state:
continue
container_status = status.get('container_status')
if not container_status:
logger.debug('Full task state: {}'.format(task))
raise DCOSException('Invalid executor info. '
'Missing field `container_status`')
container_id = container_status.get('container_id')
if not container_id:
logger.debug('Full task state: {}'.format(task))
raise DCOSException('Invalid executor info. '
'Missing field `container_id`')
# traverse nested container_id field
while True:
value = container_id.get('value')
if not value:
logger.debug('Full task state: {}'.format(task))
raise DCOSException('Invalid executor info. Missing field'
'`value` for nested container ids')
container_ids.append(value)
if 'parent' not in container_id:
break
container_id = container_id['parent']
return '.'.join(reversed(container_ids))
def _dcos_log(follow, tasks, lines, file_, completed):
""" a client to dcos-log
:param follow: same as unix tail's -f
:type follow: bool
:param task: task pattern to match
:type task: str
:param lines: number of lines to print
:type lines: int
:param file_: file path to read
:type file_: str
:param completed: whether to include completed tasks
:type completed: bool
"""
# only stdout and stderr is supported
if file_ not in ('stdout', 'stderr'):
raise DCOSException('Expect file stdout or stderr. '
'Got {}'.format(file_))
# state json may container tasks and completed_tasks fields. Based on
# user request we should traverse the appropriate field.
tasks_field = 'tasks'
if completed:
tasks_field = 'completed_tasks'
for task in tasks:
executor_info = task.executor()
if not executor_info:
continue
if (tasks_field not in executor_info and
not isinstance(executor_info[tasks_field], list)):
logger.debug('Executor info: {}'.format(executor_info))
raise DCOSException('Invalid executor info. '
'Missing field {}'.format(tasks_field))
for t in executor_info[tasks_field]:
container_id = get_nested_container_id(t)
if not container_id:
logger.debug('Executor info: {}'.format(executor_info))
raise DCOSException(
'Invalid executor info. Missing container id')
# get slave_id field
slave_id = t.get('slave_id')
if not slave_id:
logger.debug('Executor info: {}'.format(executor_info))
raise DCOSException(
'Invalid executor info. Missing field `slave_id`')
framework_id = t.get('framework_id')
if not framework_id:
logger.debug('Executor info: {}'.format(executor_info))
raise DCOSException(
'Invalid executor info. Missing field `framework_id`')
# try `executor_id` first.
executor_id = t.get('executor_id')
if not executor_id:
# if `executor_id` is an empty string, default to `id`.
executor_id = t.get('id')
if not executor_id:
logger.debug('Executor info: {}'.format(executor_info))
raise DCOSException(
'Invalid executor info. Missing executor id')
dcos_url = config.get_config_val('core.dcos_url').rstrip('/')
if not dcos_url:
raise config.missing_config_exception(['core.dcos_url'])
# dcos-log provides 2 base endpoints /range/ and /stream/
# for range and streaming requests.
endpoint_type = 'range'
if follow:
endpoint_type = 'stream'
endpoint = ('/system/v1/agent/{}/logs/v1/{}/framework/{}'
'/executor/{}/container/{}'.format(slave_id,
endpoint_type,
framework_id,
executor_id,
container_id))
# append request parameters.
# `skip_prev` will move the cursor to -n lines.
# `filter=STREAM:{STDOUT,STDERR}` will filter logs by label.
url = (dcos_url + endpoint +
'?skip_prev={}&filter=STREAM:{}'.format(lines,
file_.upper()))
if follow:
return log.follow_logs(url)
return log.print_logs_range(url)
def _ls(task, path, long_, completed):
""" List files in a task's sandbox.
:param task: task pattern to match
:type task: str
:param path: file path to read
:type path: str
:param long_: whether to use a long listing format
:type long_: bool
:param completed: If True, include completed tasks
:type completed: bool
:returns: process return code
:rtype: int
"""
if path is None:
path = '.'
if path.startswith('/'):
path = path[1:]
dcos_client = mesos.DCOSClient()
task_objects = mesos.get_master(dcos_client).tasks(
fltr=task,
completed=completed)
if len(task_objects) == 0:
if task is None:
raise DCOSException("No tasks found")
else:
raise DCOSException(
'Cannot find a task with ID containing "{}"'.format(task))
try:
all_files = []
for task_obj in task_objects:
dir_ = posixpath.join(task_obj.directory(), path)
all_files += [
(task_obj['id'], dcos_client.browse(task_obj.slave(), dir_))]
except DCOSHTTPException as e:
if e.response.status_code == 404:
raise DCOSException(
'Cannot access [{}]: No such file or directory'.format(path))
else:
raise
add_header = len(all_files) > 1
for (task_id, files) in all_files:
if add_header:
emitter.publish('===> {} <==='.format(task_id))
if long_:
emitter.publish(tables.ls_long_table(files))
else:
emitter.publish(
' '.join(posixpath.basename(file_['path'])
for file_ in files))
def _exec(task, cmd, args=None, interactive=False, tty=False):
""" Launch a process inside a container with the given <task_id>
:param task: task ID pattern to match
:type task: str
:param cmd: The command to launch inside the task's container
:type args: cmd
:param args: Additional arguments for the command
:type args: list
:param interactive: attach stdin
:type interactive: bool
:param tty: attach a tty
:type tty: bool
:returns: process return code
:rtype int
"""
task_io = mesos.TaskIO(task, cmd, args, interactive, tty)
task_io.run()
return 0
def _mesos_files(tasks, file_, client):
"""Return MesosFile objects for the specified tasks and file name.
Only include files that satisfy all of the following:
a) belong to an available slave
b) have an executor entry on the slave
:param tasks: tasks on which files reside
:type tasks: [mesos.Task]
:param file_: file path to read
:type file_: str
:param client: DC/OS client
:type client: mesos.DCOSClient
:returns: MesosFile objects
:rtype: [mesos.MesosFile]
"""
# load slave state in parallel
slaves = _load_slaves_state([task.slave() for task in tasks])
# some completed tasks may have entries on the master, but none on
# the slave. since we need the slave entry to get the executor
# sandbox, we only include files with an executor entry.
available_tasks = [task for task in tasks
if task.slave() in slaves and task.executor()]
# create files.
return [mesos.MesosFile(file_, task=task, dcos_client=client)
for task in available_tasks]
def _load_slaves_state(slaves):
"""Fetch each slave's state.json in parallel, and return the reachable
slaves.
:param slaves: slaves to fetch
:type slaves: [MesosSlave]
:returns: MesosSlave objects that were successfully reached
:rtype: [MesosSlave]
"""
reachable_slaves = []
for job, slave in util.stream(lambda slave: slave.state(), slaves):
try:
job.result()
reachable_slaves.append(slave)
except DCOSException as e:
emitter.publish(
DefaultError('Error accessing slave: {0}'.format(e)))
return reachable_slaves