gnt-debug: Extend job queue tests
[ganeti-local] / scripts / gnt-node
1 #!/usr/bin/python
2 #
3
4 # Copyright (C) 2006, 2007, 2008, 2009, 2010 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21 """Node related commands"""
22
23 # pylint: disable-msg=W0401,W0613,W0614,C0103
24 # W0401: Wildcard import ganeti.cli
25 # W0613: Unused argument, since all functions follow the same API
26 # W0614: Unused import %s from wildcard import (since we need cli)
27 # C0103: Invalid name gnt-node
28
29 import sys
30
31 from ganeti.cli import *
32 from ganeti import opcodes
33 from ganeti import utils
34 from ganeti import constants
35 from ganeti import compat
36 from ganeti import errors
37 from ganeti import bootstrap
38 from ganeti import netutils
39
40
41 #: default list of field for L{ListNodes}
42 _LIST_DEF_FIELDS = [
43   "name", "dtotal", "dfree",
44   "mtotal", "mnode", "mfree",
45   "pinst_cnt", "sinst_cnt",
46   ]
47
48
49 #: default list of field for L{ListStorage}
50 _LIST_STOR_DEF_FIELDS = [
51   constants.SF_NODE,
52   constants.SF_TYPE,
53   constants.SF_NAME,
54   constants.SF_SIZE,
55   constants.SF_USED,
56   constants.SF_FREE,
57   constants.SF_ALLOCATABLE,
58   ]
59
60
61 #: headers (and full field list for L{ListNodes}
62 _LIST_HEADERS = {
63   "name": "Node", "pinst_cnt": "Pinst", "sinst_cnt": "Sinst",
64   "pinst_list": "PriInstances", "sinst_list": "SecInstances",
65   "pip": "PrimaryIP", "sip": "SecondaryIP",
66   "dtotal": "DTotal", "dfree": "DFree",
67   "mtotal": "MTotal", "mnode": "MNode", "mfree": "MFree",
68   "bootid": "BootID",
69   "ctotal": "CTotal", "cnodes": "CNodes", "csockets": "CSockets",
70   "tags": "Tags",
71   "serial_no": "SerialNo",
72   "master_candidate": "MasterC",
73   "master": "IsMaster",
74   "offline": "Offline", "drained": "Drained",
75   "role": "Role",
76   "ctime": "CTime", "mtime": "MTime", "uuid": "UUID"
77   }
78
79
80 #: headers (and full field list for L{ListStorage}
81 _LIST_STOR_HEADERS = {
82   constants.SF_NODE: "Node",
83   constants.SF_TYPE: "Type",
84   constants.SF_NAME: "Name",
85   constants.SF_SIZE: "Size",
86   constants.SF_USED: "Used",
87   constants.SF_FREE: "Free",
88   constants.SF_ALLOCATABLE: "Allocatable",
89   }
90
91
92 #: User-facing storage unit types
93 _USER_STORAGE_TYPE = {
94   constants.ST_FILE: "file",
95   constants.ST_LVM_PV: "lvm-pv",
96   constants.ST_LVM_VG: "lvm-vg",
97   }
98
99 _STORAGE_TYPE_OPT = \
100   cli_option("-t", "--storage-type",
101              dest="user_storage_type",
102              choices=_USER_STORAGE_TYPE.keys(),
103              default=None,
104              metavar="STORAGE_TYPE",
105              help=("Storage type (%s)" %
106                    utils.CommaJoin(_USER_STORAGE_TYPE.keys())))
107
108 _REPAIRABLE_STORAGE_TYPES = \
109   [st for st, so in constants.VALID_STORAGE_OPERATIONS.iteritems()
110    if constants.SO_FIX_CONSISTENCY in so]
111
112 _MODIFIABLE_STORAGE_TYPES = constants.MODIFIABLE_STORAGE_FIELDS.keys()
113
114
115 def ConvertStorageType(user_storage_type):
116   """Converts a user storage type to its internal name.
117
118   """
119   try:
120     return _USER_STORAGE_TYPE[user_storage_type]
121   except KeyError:
122     raise errors.OpPrereqError("Unknown storage type: %s" % user_storage_type,
123                                errors.ECODE_INVAL)
124
125
126 @UsesRPC
127 def AddNode(opts, args):
128   """Add a node to the cluster.
129
130   @param opts: the command line options selected by the user
131   @type args: list
132   @param args: should contain only one element, the new node name
133   @rtype: int
134   @return: the desired exit code
135
136   """
137   cl = GetClient()
138   dns_data = netutils.GetHostInfo(netutils.HostInfo.NormalizeName(args[0]))
139   node = dns_data.name
140   readd = opts.readd
141
142   try:
143     output = cl.QueryNodes(names=[node], fields=['name', 'sip'],
144                            use_locking=False)
145     node_exists, sip = output[0]
146   except (errors.OpPrereqError, errors.OpExecError):
147     node_exists = ""
148     sip = None
149
150   if readd:
151     if not node_exists:
152       ToStderr("Node %s not in the cluster"
153                " - please retry without '--readd'", node)
154       return 1
155   else:
156     if node_exists:
157       ToStderr("Node %s already in the cluster (as %s)"
158                " - please retry with '--readd'", node, node_exists)
159       return 1
160     sip = opts.secondary_ip
161
162   # read the cluster name from the master
163   output = cl.QueryConfigValues(['cluster_name'])
164   cluster_name = output[0]
165
166   if not readd:
167     ToStderr("-- WARNING -- \n"
168              "Performing this operation is going to replace the ssh daemon"
169              " keypair\n"
170              "on the target machine (%s) with the ones of the"
171              " current one\n"
172              "and grant full intra-cluster ssh root access to/from it\n", node)
173
174   bootstrap.SetupNodeDaemon(cluster_name, node, opts.ssh_key_check)
175
176   op = opcodes.OpAddNode(node_name=args[0], secondary_ip=sip,
177                          readd=opts.readd)
178   SubmitOpCode(op, opts=opts)
179
180
181 def ListNodes(opts, args):
182   """List nodes and their properties.
183
184   @param opts: the command line options selected by the user
185   @type args: list
186   @param args: should be an empty list
187   @rtype: int
188   @return: the desired exit code
189
190   """
191   if opts.output is None:
192     selected_fields = _LIST_DEF_FIELDS
193   elif opts.output.startswith("+"):
194     selected_fields = _LIST_DEF_FIELDS + opts.output[1:].split(",")
195   else:
196     selected_fields = opts.output.split(",")
197
198   output = GetClient().QueryNodes(args, selected_fields, opts.do_locking)
199
200   if not opts.no_headers:
201     headers = _LIST_HEADERS
202   else:
203     headers = None
204
205   unitfields = ["dtotal", "dfree", "mtotal", "mnode", "mfree"]
206
207   numfields = ["dtotal", "dfree",
208                "mtotal", "mnode", "mfree",
209                "pinst_cnt", "sinst_cnt",
210                "ctotal", "serial_no"]
211
212   list_type_fields = ("pinst_list", "sinst_list", "tags")
213   # change raw values to nicer strings
214   for row in output:
215     for idx, field in enumerate(selected_fields):
216       val = row[idx]
217       if field in list_type_fields:
218         val = ",".join(val)
219       elif field in ('master', 'master_candidate', 'offline', 'drained'):
220         if val:
221           val = 'Y'
222         else:
223           val = 'N'
224       elif field == "ctime" or field == "mtime":
225         val = utils.FormatTime(val)
226       elif val is None:
227         val = "?"
228       elif opts.roman_integers and isinstance(val, int):
229         val = compat.TryToRoman(val)
230       row[idx] = str(val)
231
232   data = GenerateTable(separator=opts.separator, headers=headers,
233                        fields=selected_fields, unitfields=unitfields,
234                        numfields=numfields, data=output, units=opts.units)
235   for line in data:
236     ToStdout(line)
237
238   return 0
239
240
241 def EvacuateNode(opts, args):
242   """Relocate all secondary instance from a node.
243
244   @param opts: the command line options selected by the user
245   @type args: list
246   @param args: should be an empty list
247   @rtype: int
248   @return: the desired exit code
249
250   """
251   cl = GetClient()
252   force = opts.force
253
254   dst_node = opts.dst_node
255   iallocator = opts.iallocator
256
257   op = opcodes.OpNodeEvacuationStrategy(nodes=args,
258                                         iallocator=iallocator,
259                                         remote_node=dst_node)
260
261   result = SubmitOpCode(op, cl=cl, opts=opts)
262   if not result:
263     # no instances to migrate
264     ToStderr("No secondary instances on node(s) %s, exiting.",
265              utils.CommaJoin(args))
266     return constants.EXIT_SUCCESS
267
268   if not force and not AskUser("Relocate instance(s) %s from node(s) %s?" %
269                                (",".join("'%s'" % name[0] for name in result),
270                                utils.CommaJoin(args))):
271     return constants.EXIT_CONFIRMATION
272
273   jex = JobExecutor(cl=cl, opts=opts)
274   for row in result:
275     iname = row[0]
276     node = row[1]
277     ToStdout("Will relocate instance %s to node %s", iname, node)
278     op = opcodes.OpReplaceDisks(instance_name=iname,
279                                 remote_node=node, disks=[],
280                                 mode=constants.REPLACE_DISK_CHG,
281                                 early_release=opts.early_release)
282     jex.QueueJob(iname, op)
283   results = jex.GetResults()
284   bad_cnt = len([row for row in results if not row[0]])
285   if bad_cnt == 0:
286     ToStdout("All %d instance(s) failed over successfully.", len(results))
287     rcode = constants.EXIT_SUCCESS
288   else:
289     ToStdout("There were errors during the failover:\n"
290              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
291     rcode = constants.EXIT_FAILURE
292   return rcode
293
294
295 def FailoverNode(opts, args):
296   """Failover all primary instance on a node.
297
298   @param opts: the command line options selected by the user
299   @type args: list
300   @param args: should be an empty list
301   @rtype: int
302   @return: the desired exit code
303
304   """
305   cl = GetClient()
306   force = opts.force
307   selected_fields = ["name", "pinst_list"]
308
309   # these fields are static data anyway, so it doesn't matter, but
310   # locking=True should be safer
311   result = cl.QueryNodes(names=args, fields=selected_fields,
312                          use_locking=False)
313   node, pinst = result[0]
314
315   if not pinst:
316     ToStderr("No primary instances on node %s, exiting.", node)
317     return 0
318
319   pinst = utils.NiceSort(pinst)
320
321   retcode = 0
322
323   if not force and not AskUser("Fail over instance(s) %s?" %
324                                (",".join("'%s'" % name for name in pinst))):
325     return 2
326
327   jex = JobExecutor(cl=cl, opts=opts)
328   for iname in pinst:
329     op = opcodes.OpFailoverInstance(instance_name=iname,
330                                     ignore_consistency=opts.ignore_consistency)
331     jex.QueueJob(iname, op)
332   results = jex.GetResults()
333   bad_cnt = len([row for row in results if not row[0]])
334   if bad_cnt == 0:
335     ToStdout("All %d instance(s) failed over successfully.", len(results))
336   else:
337     ToStdout("There were errors during the failover:\n"
338              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
339   return retcode
340
341
342 def MigrateNode(opts, args):
343   """Migrate all primary instance on a node.
344
345   """
346   cl = GetClient()
347   force = opts.force
348   selected_fields = ["name", "pinst_list"]
349
350   result = cl.QueryNodes(names=args, fields=selected_fields, use_locking=False)
351   node, pinst = result[0]
352
353   if not pinst:
354     ToStdout("No primary instances on node %s, exiting." % node)
355     return 0
356
357   pinst = utils.NiceSort(pinst)
358
359   if not force and not AskUser("Migrate instance(s) %s?" %
360                                (",".join("'%s'" % name for name in pinst))):
361     return 2
362
363   # this should be removed once --non-live is deprecated
364   if not opts.live and opts.migration_mode is not None:
365     raise errors.OpPrereqError("Only one of the --non-live and "
366                                "--migration-mode options can be passed",
367                                errors.ECODE_INVAL)
368   if not opts.live: # --non-live passed
369     mode = constants.HT_MIGRATION_NONLIVE
370   else:
371     mode = opts.migration_mode
372   op = opcodes.OpMigrateNode(node_name=args[0], mode=mode)
373   SubmitOpCode(op, cl=cl, opts=opts)
374
375
376 def ShowNodeConfig(opts, args):
377   """Show node information.
378
379   @param opts: the command line options selected by the user
380   @type args: list
381   @param args: should either be an empty list, in which case
382       we show information about all nodes, or should contain
383       a list of nodes to be queried for information
384   @rtype: int
385   @return: the desired exit code
386
387   """
388   cl = GetClient()
389   result = cl.QueryNodes(fields=["name", "pip", "sip",
390                                  "pinst_list", "sinst_list",
391                                  "master_candidate", "drained", "offline"],
392                          names=args, use_locking=False)
393
394   for (name, primary_ip, secondary_ip, pinst, sinst,
395        is_mc, drained, offline) in result:
396     ToStdout("Node name: %s", name)
397     ToStdout("  primary ip: %s", primary_ip)
398     ToStdout("  secondary ip: %s", secondary_ip)
399     ToStdout("  master candidate: %s", is_mc)
400     ToStdout("  drained: %s", drained)
401     ToStdout("  offline: %s", offline)
402     if pinst:
403       ToStdout("  primary for instances:")
404       for iname in utils.NiceSort(pinst):
405         ToStdout("    - %s", iname)
406     else:
407       ToStdout("  primary for no instances")
408     if sinst:
409       ToStdout("  secondary for instances:")
410       for iname in utils.NiceSort(sinst):
411         ToStdout("    - %s", iname)
412     else:
413       ToStdout("  secondary for no instances")
414
415   return 0
416
417
418 def RemoveNode(opts, args):
419   """Remove a node from the cluster.
420
421   @param opts: the command line options selected by the user
422   @type args: list
423   @param args: should contain only one element, the name of
424       the node to be removed
425   @rtype: int
426   @return: the desired exit code
427
428   """
429   op = opcodes.OpRemoveNode(node_name=args[0])
430   SubmitOpCode(op, opts=opts)
431   return 0
432
433
434 def PowercycleNode(opts, args):
435   """Remove a node from the cluster.
436
437   @param opts: the command line options selected by the user
438   @type args: list
439   @param args: should contain only one element, the name of
440       the node to be removed
441   @rtype: int
442   @return: the desired exit code
443
444   """
445   node = args[0]
446   if (not opts.confirm and
447       not AskUser("Are you sure you want to hard powercycle node %s?" % node)):
448     return 2
449
450   op = opcodes.OpPowercycleNode(node_name=node, force=opts.force)
451   result = SubmitOpCode(op, opts=opts)
452   ToStderr(result)
453   return 0
454
455
456 def ListVolumes(opts, args):
457   """List logical volumes on node(s).
458
459   @param opts: the command line options selected by the user
460   @type args: list
461   @param args: should either be an empty list, in which case
462       we list data for all nodes, or contain a list of nodes
463       to display data only for those
464   @rtype: int
465   @return: the desired exit code
466
467   """
468   if opts.output is None:
469     selected_fields = ["node", "phys", "vg",
470                        "name", "size", "instance"]
471   else:
472     selected_fields = opts.output.split(",")
473
474   op = opcodes.OpQueryNodeVolumes(nodes=args, output_fields=selected_fields)
475   output = SubmitOpCode(op, opts=opts)
476
477   if not opts.no_headers:
478     headers = {"node": "Node", "phys": "PhysDev",
479                "vg": "VG", "name": "Name",
480                "size": "Size", "instance": "Instance"}
481   else:
482     headers = None
483
484   unitfields = ["size"]
485
486   numfields = ["size"]
487
488   data = GenerateTable(separator=opts.separator, headers=headers,
489                        fields=selected_fields, unitfields=unitfields,
490                        numfields=numfields, data=output, units=opts.units)
491
492   for line in data:
493     ToStdout(line)
494
495   return 0
496
497
498 def ListStorage(opts, args):
499   """List physical volumes on node(s).
500
501   @param opts: the command line options selected by the user
502   @type args: list
503   @param args: should either be an empty list, in which case
504       we list data for all nodes, or contain a list of nodes
505       to display data only for those
506   @rtype: int
507   @return: the desired exit code
508
509   """
510   # TODO: Default to ST_FILE if LVM is disabled on the cluster
511   if opts.user_storage_type is None:
512     opts.user_storage_type = constants.ST_LVM_PV
513
514   storage_type = ConvertStorageType(opts.user_storage_type)
515
516   if opts.output is None:
517     selected_fields = _LIST_STOR_DEF_FIELDS
518   elif opts.output.startswith("+"):
519     selected_fields = _LIST_STOR_DEF_FIELDS + opts.output[1:].split(",")
520   else:
521     selected_fields = opts.output.split(",")
522
523   op = opcodes.OpQueryNodeStorage(nodes=args,
524                                   storage_type=storage_type,
525                                   output_fields=selected_fields)
526   output = SubmitOpCode(op, opts=opts)
527
528   if not opts.no_headers:
529     headers = {
530       constants.SF_NODE: "Node",
531       constants.SF_TYPE: "Type",
532       constants.SF_NAME: "Name",
533       constants.SF_SIZE: "Size",
534       constants.SF_USED: "Used",
535       constants.SF_FREE: "Free",
536       constants.SF_ALLOCATABLE: "Allocatable",
537       }
538   else:
539     headers = None
540
541   unitfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
542   numfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
543
544   # change raw values to nicer strings
545   for row in output:
546     for idx, field in enumerate(selected_fields):
547       val = row[idx]
548       if field == constants.SF_ALLOCATABLE:
549         if val:
550           val = "Y"
551         else:
552           val = "N"
553       row[idx] = str(val)
554
555   data = GenerateTable(separator=opts.separator, headers=headers,
556                        fields=selected_fields, unitfields=unitfields,
557                        numfields=numfields, data=output, units=opts.units)
558
559   for line in data:
560     ToStdout(line)
561
562   return 0
563
564
565 def ModifyStorage(opts, args):
566   """Modify storage volume on a node.
567
568   @param opts: the command line options selected by the user
569   @type args: list
570   @param args: should contain 3 items: node name, storage type and volume name
571   @rtype: int
572   @return: the desired exit code
573
574   """
575   (node_name, user_storage_type, volume_name) = args
576
577   storage_type = ConvertStorageType(user_storage_type)
578
579   changes = {}
580
581   if opts.allocatable is not None:
582     changes[constants.SF_ALLOCATABLE] = opts.allocatable
583
584   if changes:
585     op = opcodes.OpModifyNodeStorage(node_name=node_name,
586                                      storage_type=storage_type,
587                                      name=volume_name,
588                                      changes=changes)
589     SubmitOpCode(op, opts=opts)
590   else:
591     ToStderr("No changes to perform, exiting.")
592
593
594 def RepairStorage(opts, args):
595   """Repairs a storage volume on a node.
596
597   @param opts: the command line options selected by the user
598   @type args: list
599   @param args: should contain 3 items: node name, storage type and volume name
600   @rtype: int
601   @return: the desired exit code
602
603   """
604   (node_name, user_storage_type, volume_name) = args
605
606   storage_type = ConvertStorageType(user_storage_type)
607
608   op = opcodes.OpRepairNodeStorage(node_name=node_name,
609                                    storage_type=storage_type,
610                                    name=volume_name,
611                                    ignore_consistency=opts.ignore_consistency)
612   SubmitOpCode(op, opts=opts)
613
614
615 def SetNodeParams(opts, args):
616   """Modifies a node.
617
618   @param opts: the command line options selected by the user
619   @type args: list
620   @param args: should contain only one element, the node name
621   @rtype: int
622   @return: the desired exit code
623
624   """
625   if [opts.master_candidate, opts.drained, opts.offline].count(None) == 3:
626     ToStderr("Please give at least one of the parameters.")
627     return 1
628
629   op = opcodes.OpSetNodeParams(node_name=args[0],
630                                master_candidate=opts.master_candidate,
631                                offline=opts.offline,
632                                drained=opts.drained,
633                                force=opts.force,
634                                auto_promote=opts.auto_promote)
635
636   # even if here we process the result, we allow submit only
637   result = SubmitOrSend(op, opts)
638
639   if result:
640     ToStdout("Modified node %s", args[0])
641     for param, data in result:
642       ToStdout(" - %-5s -> %s", param, data)
643   return 0
644
645
646 commands = {
647   'add': (
648     AddNode, [ArgHost(min=1, max=1)],
649     [SECONDARY_IP_OPT, READD_OPT, NOSSH_KEYCHECK_OPT],
650     "[-s ip] [--readd] [--no-ssh-key-check] <node_name>",
651     "Add a node to the cluster"),
652   'evacuate': (
653     EvacuateNode, [ArgNode(min=1)],
654     [FORCE_OPT, IALLOCATOR_OPT, NEW_SECONDARY_OPT, EARLY_RELEASE_OPT],
655     "[-f] {-I <iallocator> | -n <dst>} <node>",
656     "Relocate the secondary instances from a node"
657     " to other nodes (only for instances with drbd disk template)"),
658   'failover': (
659     FailoverNode, ARGS_ONE_NODE, [FORCE_OPT, IGNORE_CONSIST_OPT],
660     "[-f] <node>",
661     "Stops the primary instances on a node and start them on their"
662     " secondary node (only for instances with drbd disk template)"),
663   'migrate': (
664     MigrateNode, ARGS_ONE_NODE, [FORCE_OPT, NONLIVE_OPT, MIGRATION_MODE_OPT],
665     "[-f] <node>",
666     "Migrate all the primary instance on a node away from it"
667     " (only for instances of type drbd)"),
668   'info': (
669     ShowNodeConfig, ARGS_MANY_NODES, [],
670     "[<node_name>...]", "Show information about the node(s)"),
671   'list': (
672     ListNodes, ARGS_MANY_NODES,
673     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, SYNC_OPT, ROMAN_OPT],
674     "[nodes...]",
675     "Lists the nodes in the cluster. The available fields are (see the man"
676     " page for details): %s. The default field list is (in order): %s." %
677     (utils.CommaJoin(_LIST_HEADERS), utils.CommaJoin(_LIST_DEF_FIELDS))),
678   'modify': (
679     SetNodeParams, ARGS_ONE_NODE,
680     [FORCE_OPT, SUBMIT_OPT, MC_OPT, DRAINED_OPT, OFFLINE_OPT,
681      AUTO_PROMOTE_OPT],
682     "<node_name>", "Alters the parameters of a node"),
683   'powercycle': (
684     PowercycleNode, ARGS_ONE_NODE,
685     [FORCE_OPT, CONFIRM_OPT],
686     "<node_name>", "Tries to forcefully powercycle a node"),
687   'remove': (
688     RemoveNode, ARGS_ONE_NODE, [],
689     "<node_name>", "Removes a node from the cluster"),
690   'volumes': (
691     ListVolumes, [ArgNode()],
692     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT],
693     "[<node_name>...]", "List logical volumes on node(s)"),
694   'list-storage': (
695     ListStorage, ARGS_MANY_NODES,
696     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, _STORAGE_TYPE_OPT],
697     "[<node_name>...]", "List physical volumes on node(s). The available"
698     " fields are (see the man page for details): %s." %
699     (utils.CommaJoin(_LIST_STOR_HEADERS))),
700   'modify-storage': (
701     ModifyStorage,
702     [ArgNode(min=1, max=1),
703      ArgChoice(min=1, max=1, choices=_MODIFIABLE_STORAGE_TYPES),
704      ArgFile(min=1, max=1)],
705     [ALLOCATABLE_OPT],
706     "<node_name> <storage_type> <name>", "Modify storage volume on a node"),
707   'repair-storage': (
708     RepairStorage,
709     [ArgNode(min=1, max=1),
710      ArgChoice(min=1, max=1, choices=_REPAIRABLE_STORAGE_TYPES),
711      ArgFile(min=1, max=1)],
712     [IGNORE_CONSIST_OPT],
713     "<node_name> <storage_type> <name>",
714     "Repairs a storage volume on a node"),
715   'list-tags': (
716     ListTags, ARGS_ONE_NODE, [],
717     "<node_name>", "List the tags of the given node"),
718   'add-tags': (
719     AddTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
720     "<node_name> tag...", "Add tags to the given node"),
721   'remove-tags': (
722     RemoveTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
723     "<node_name> tag...", "Remove tags from the given node"),
724   }
725
726
727 if __name__ == '__main__':
728   sys.exit(GenericMain(commands, override={"tag_type": constants.TAG_NODE}))