From e1c701e78e7dcb5e567abcde60c448524ff084fb Mon Sep 17 00:00:00 2001 From: Michael Hanselmann <hansmi@google.com> Date: Fri, 12 Oct 2012 11:10:13 +0200 Subject: [PATCH] gnt-job cancel: Confirmation and selection of jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New parameters, β--pendingβ, β--queuedβ and β--waitingβ, are added to select all jobs in the respective state. If one of those options is used and β--forceβ is not given, the user is asked to confirm the operation. Unit tests are included. Signed-off-by: Michael Hanselmann <hansmi@google.com> Reviewed-by: Iustin Pop <iustin@google.com> --- Makefile.am | 1 + lib/client/gnt_job.py | 86 ++++++++++---- man/gnt-job.rst | 9 +- test/ganeti.client.gnt_job_unittest.py | 156 +++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 24 deletions(-) create mode 100755 test/ganeti.client.gnt_job_unittest.py diff --git a/Makefile.am b/Makefile.am index 00a272f3b..5977b3a84 100644 --- a/Makefile.am +++ b/Makefile.am @@ -881,6 +881,7 @@ python_tests = \ test/ganeti.cli_unittest.py \ test/ganeti.client.gnt_cluster_unittest.py \ test/ganeti.client.gnt_instance_unittest.py \ + test/ganeti.client.gnt_job_unittest.py \ test/ganeti.cmdlib_unittest.py \ test/ganeti.compat_unittest.py \ test/ganeti.confd.client_unittest.py \ diff --git a/lib/client/gnt_job.py b/lib/client/gnt_job.py index 3bdbd0d6f..93a9ba778 100644 --- a/lib/client/gnt_job.py +++ b/lib/client/gnt_job.py @@ -60,6 +60,16 @@ def _FormatStatus(value): raise errors.ProgrammerError("Unknown job status code '%s'" % value) +_JOB_LIST_FORMAT = { + "status": (_FormatStatus, False), + "summary": (lambda value: ",".join(str(item) for item in value), False), + } +_JOB_LIST_FORMAT.update(dict.fromkeys(["opstart", "opexec", "opend"], + (lambda value: map(FormatTimestamp, + value), + None))) + + def _ParseJobIds(args): """Parses a list of string job IDs into integers. @@ -90,19 +100,11 @@ def ListJobs(opts, args): if opts.archived and "archived" not in selected_fields: selected_fields.append("archived") - fmtoverride = { - "status": (_FormatStatus, False), - "summary": (lambda value: ",".join(str(item) for item in value), False), - } - fmtoverride.update(dict.fromkeys(["opstart", "opexec", "opend"], - (lambda value: map(FormatTimestamp, value), - None))) - qfilter = qlang.MakeSimpleFilter("status", opts.status_filter) return GenericList(constants.QR_JOB, selected_fields, args, None, opts.separator, not opts.no_headers, - format_override=fmtoverride, verbose=opts.verbose, + format_override=_JOB_LIST_FORMAT, verbose=opts.verbose, force_filter=opts.force_filter, namefield="id", qfilter=qfilter, isnumeric=True) @@ -172,7 +174,7 @@ def AutoArchiveJobs(opts, args): return 0 -def CancelJobs(opts, args): +def CancelJobs(opts, args, cl=None, _stdout_fn=ToStdout, _ask_fn=AskUser): """Cancel not-yet-started jobs. @param opts: the command line options selected by the user @@ -182,16 +184,42 @@ def CancelJobs(opts, args): @return: the desired exit code """ - client = GetClient() + if cl is None: + cl = GetClient() + result = constants.EXIT_SUCCESS - for job_id in args: - (success, msg) = client.CancelJob(job_id) + if bool(args) ^ (opts.status_filter is None): + raise errors.OpPrereqError("Either a status filter or job ID(s) must be" + " specified and never both", errors.ECODE_INVAL) + + if opts.status_filter is not None: + response = cl.Query(constants.QR_JOB, ["id", "status", "summary"], + qlang.MakeSimpleFilter("status", opts.status_filter)) + + jobs = [i for ((_, i), _, _) in response.data] + if not jobs: + raise errors.OpPrereqError("No jobs with the requested status have been" + " found", errors.ECODE_STATE) + + if not opts.force: + (_, table) = FormatQueryResult(response, header=True, + format_override=_JOB_LIST_FORMAT) + for line in table: + _stdout_fn(line) + + if not _ask_fn("Cancel job(s) listed above?"): + return constants.EXIT_CONFIRMATION + else: + jobs = args + + for job_id in jobs: + (success, msg) = cl.CancelJob(job_id) if not success: result = constants.EXIT_FAILURE - ToStdout(msg) + _stdout_fn(msg) return result @@ -362,11 +390,8 @@ def WatchJob(opts, args): _PENDING_OPT = \ cli_option("--pending", default=None, action="store_const", dest="status_filter", - const=frozenset([ - constants.JOB_STATUS_QUEUED, - constants.JOB_STATUS_WAITING, - ]), - help="Show only jobs pending execution") + const=constants.JOBS_PENDING, + help="Select jobs pending execution or being cancelled") _RUNNING_OPT = \ cli_option("--running", default=None, @@ -395,6 +420,22 @@ _ARCHIVED_OPT = \ action="store_true", dest="archived", help="Include archived jobs in list (slow and expensive)") +_QUEUED_OPT = \ + cli_option("--queued", default=None, + action="store_const", dest="status_filter", + const=frozenset([ + constants.JOB_STATUS_QUEUED, + ]), + help="Select queued jobs only") + +_WAITING_OPT = \ + cli_option("--waiting", default=None, + action="store_const", dest="status_filter", + const=frozenset([ + constants.JOB_STATUS_WAITING, + ]), + help="Select waiting jobs only") + commands = { "list": ( @@ -420,8 +461,11 @@ commands = { [], "<age>", "Auto archive jobs older than the given age"), "cancel": ( - CancelJobs, [ArgJobId(min=1)], [], - "<job-id> [<job-id> ...]", "Cancel specified jobs"), + CancelJobs, [ArgJobId()], + [FORCE_OPT, _PENDING_OPT, _QUEUED_OPT, _WAITING_OPT], + "{[--force] {--pending | --queued | --waiting} |" + " <job-id> [<job-id> ...]}", + "Cancel jobs"), "info": ( ShowJobs, [ArgJobId(min=1)], [], "<job-id> [<job-id> ...]", diff --git a/man/gnt-job.rst b/man/gnt-job.rst index 56d381746..773d8a375 100644 --- a/man/gnt-job.rst +++ b/man/gnt-job.rst @@ -41,11 +41,14 @@ if the string all is passed. CANCEL ~~~~~~ -**cancel** {*id*} +| **cancel** +| {[\--force] {\--pending | \--queued | \--waiting} | *job-id* ...} -Cancel the job identified by the given *id*. Only jobs that have +Cancel the job(s) identified by the given *job id*. Only jobs that have not yet started to run can be canceled; that is, jobs in either the -*queued* or *waiting* state. +*queued* or *waiting* state. To skip a confirmation, pass ``--force``. +``--queued`` and ``waiting`` can be used to cancel all jobs in the +respective state, ``--pending`` includes both. INFO ~~~~ diff --git a/test/ganeti.client.gnt_job_unittest.py b/test/ganeti.client.gnt_job_unittest.py new file mode 100755 index 000000000..b7a86cc48 --- /dev/null +++ b/test/ganeti.client.gnt_job_unittest.py @@ -0,0 +1,156 @@ +#!/usr/bin/python +# + +# Copyright (C) 2012 Google 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + + +"""Script for testing ganeti.client.gnt_job""" + +import unittest +import optparse + +from ganeti.client import gnt_job +from ganeti import utils +from ganeti import errors +from ganeti import query +from ganeti import qlang +from ganeti import objects +from ganeti import compat +from ganeti import constants + +import testutils + + +class _ClientForCancelJob: + def __init__(self, cancel_cb, query_cb): + self.cancelled = [] + self._cancel_cb = cancel_cb + self._query_cb = query_cb + + def CancelJob(self, job_id): + self.cancelled.append(job_id) + return self._cancel_cb(job_id) + + def Query(self, kind, selected, qfilter): + assert kind == constants.QR_JOB + assert selected == ["id", "status", "summary"] + + fields = query.GetAllFields(query._GetQueryFields(query.JOB_FIELDS, + selected)) + + return objects.QueryResponse(data=self._query_cb(qfilter), + fields=fields) + + +class TestCancelJob(unittest.TestCase): + def setUp(self): + unittest.TestCase.setUp(self) + self.stdout = [] + + def _ToStdout(self, line): + self.stdout.append(line) + + def _Ask(self, answer, question): + self.assertTrue(question.endswith("?")) + return answer + + def testStatusFilterAndArguments(self): + opts = optparse.Values(dict(status_filter=frozenset())) + try: + gnt_job.CancelJobs(opts, ["a"], cl=NotImplemented, + _stdout_fn=NotImplemented, _ask_fn=NotImplemented) + except errors.OpPrereqError, err: + self.assertEqual(err.args[1], errors.ECODE_INVAL) + else: + self.fail("Did not raise exception") + + def _TestArguments(self, force): + opts = optparse.Values(dict(status_filter=None, force=force)) + + def _CancelCb(job_id): + self.assertTrue(job_id in ("24185", "3252")) + return (True, "%s will be cancelled" % job_id) + + cl = _ClientForCancelJob(_CancelCb, NotImplemented) + self.assertEqual(gnt_job.CancelJobs(opts, ["24185", "3252"], cl=cl, + _stdout_fn=self._ToStdout, + _ask_fn=NotImplemented), + constants.EXIT_SUCCESS) + self.assertEqual(cl.cancelled, ["24185", "3252"]) + self.assertEqual(self.stdout, [ + "24185 will be cancelled", + "3252 will be cancelled", + ]) + + def testArgumentsWithForce(self): + self._TestArguments(True) + + def testArgumentsNoForce(self): + self._TestArguments(False) + + def testArgumentsWithError(self): + opts = optparse.Values(dict(status_filter=None, force=True)) + + def _CancelCb(job_id): + if job_id == "10788": + return (False, "error %s" % job_id) + else: + return (True, "%s will be cancelled" % job_id) + + cl = _ClientForCancelJob(_CancelCb, NotImplemented) + self.assertEqual(gnt_job.CancelJobs(opts, ["203", "10788", "30801"], cl=cl, + _stdout_fn=self._ToStdout, + _ask_fn=NotImplemented), + constants.EXIT_FAILURE) + self.assertEqual(cl.cancelled, ["203", "10788", "30801"]) + self.assertEqual(self.stdout, [ + "203 will be cancelled", + "error 10788", + "30801 will be cancelled", + ]) + + def testFilterPending(self): + opts = optparse.Values(dict(status_filter=constants.JOBS_PENDING, + force=False)) + + def _Query(qfilter): + # Need to sort as constants.JOBS_PENDING has no stable order + assert isinstance(constants.JOBS_PENDING, frozenset) + self.assertEqual(sorted(qfilter), + sorted(qlang.MakeSimpleFilter("status", + constants.JOBS_PENDING))) + + return [ + [(constants.RS_UNAVAIL, None), + (constants.RS_UNAVAIL, None), + (constants.RS_UNAVAIL, None)], + [(constants.RS_NORMAL, 32532), + (constants.RS_NORMAL, constants.JOB_STATUS_QUEUED), + (constants.RS_NORMAL, ["op1", "op2", "op3"])], + ] + + cl = _ClientForCancelJob(NotImplemented, _Query) + + result = gnt_job.CancelJobs(opts, [], cl=cl, + _stdout_fn=self._ToStdout, + _ask_fn=compat.partial(self._Ask, False)) + self.assertEqual(result, constants.EXIT_CONFIRMATION) + + +if __name__ == "__main__": + testutils.GanetiTestProgram() -- GitLab