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