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