Support IPv6 cluster init
[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   node = netutils.GetHostname(name=args[0]).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   op = opcodes.OpNodeEvacuationStrategy(nodes=args,
257                                         iallocator=iallocator,
258                                         remote_node=dst_node)
259
260   result = SubmitOpCode(op, cl=cl, opts=opts)
261   if not result:
262     # no instances to migrate
263     ToStderr("No secondary instances on node(s) %s, exiting.",
264              utils.CommaJoin(args))
265     return constants.EXIT_SUCCESS
266
267   if not force and not AskUser("Relocate instance(s) %s from node(s) %s?" %
268                                (",".join("'%s'" % name[0] for name in result),
269                                utils.CommaJoin(args))):
270     return constants.EXIT_CONFIRMATION
271
272   jex = JobExecutor(cl=cl, opts=opts)
273   for row in result:
274     iname = row[0]
275     node = row[1]
276     ToStdout("Will relocate instance %s to node %s", iname, node)
277     op = opcodes.OpReplaceDisks(instance_name=iname,
278                                 remote_node=node, disks=[],
279                                 mode=constants.REPLACE_DISK_CHG,
280                                 early_release=opts.early_release)
281     jex.QueueJob(iname, op)
282   results = jex.GetResults()
283   bad_cnt = len([row for row in results if not row[0]])
284   if bad_cnt == 0:
285     ToStdout("All %d instance(s) failed over successfully.", len(results))
286     rcode = constants.EXIT_SUCCESS
287   else:
288     ToStdout("There were errors during the failover:\n"
289              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
290     rcode = constants.EXIT_FAILURE
291   return rcode
292
293
294 def FailoverNode(opts, args):
295   """Failover all primary instance on a node.
296
297   @param opts: the command line options selected by the user
298   @type args: list
299   @param args: should be an empty list
300   @rtype: int
301   @return: the desired exit code
302
303   """
304   cl = GetClient()
305   force = opts.force
306   selected_fields = ["name", "pinst_list"]
307
308   # these fields are static data anyway, so it doesn't matter, but
309   # locking=True should be safer
310   result = cl.QueryNodes(names=args, fields=selected_fields,
311                          use_locking=False)
312   node, pinst = result[0]
313
314   if not pinst:
315     ToStderr("No primary instances on node %s, exiting.", node)
316     return 0
317
318   pinst = utils.NiceSort(pinst)
319
320   retcode = 0
321
322   if not force and not AskUser("Fail over instance(s) %s?" %
323                                (",".join("'%s'" % name for name in pinst))):
324     return 2
325
326   jex = JobExecutor(cl=cl, opts=opts)
327   for iname in pinst:
328     op = opcodes.OpFailoverInstance(instance_name=iname,
329                                     ignore_consistency=opts.ignore_consistency)
330     jex.QueueJob(iname, op)
331   results = jex.GetResults()
332   bad_cnt = len([row for row in results if not row[0]])
333   if bad_cnt == 0:
334     ToStdout("All %d instance(s) failed over successfully.", len(results))
335   else:
336     ToStdout("There were errors during the failover:\n"
337              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
338   return retcode
339
340
341 def MigrateNode(opts, args):
342   """Migrate all primary instance on a node.
343
344   """
345   cl = GetClient()
346   force = opts.force
347   selected_fields = ["name", "pinst_list"]
348
349   result = cl.QueryNodes(names=args, fields=selected_fields, use_locking=False)
350   node, pinst = result[0]
351
352   if not pinst:
353     ToStdout("No primary instances on node %s, exiting." % node)
354     return 0
355
356   pinst = utils.NiceSort(pinst)
357
358   if not force and not AskUser("Migrate instance(s) %s?" %
359                                (",".join("'%s'" % name for name in pinst))):
360     return 2
361
362   # this should be removed once --non-live is deprecated
363   if not opts.live and opts.migration_mode is not None:
364     raise errors.OpPrereqError("Only one of the --non-live and "
365                                "--migration-mode options can be passed",
366                                errors.ECODE_INVAL)
367   if not opts.live: # --non-live passed
368     mode = constants.HT_MIGRATION_NONLIVE
369   else:
370     mode = opts.migration_mode
371   op = opcodes.OpMigrateNode(node_name=args[0], mode=mode)
372   SubmitOpCode(op, cl=cl, opts=opts)
373
374
375 def ShowNodeConfig(opts, args):
376   """Show node information.
377
378   @param opts: the command line options selected by the user
379   @type args: list
380   @param args: should either be an empty list, in which case
381       we show information about all nodes, or should contain
382       a list of nodes to be queried for information
383   @rtype: int
384   @return: the desired exit code
385
386   """
387   cl = GetClient()
388   result = cl.QueryNodes(fields=["name", "pip", "sip",
389                                  "pinst_list", "sinst_list",
390                                  "master_candidate", "drained", "offline"],
391                          names=args, use_locking=False)
392
393   for (name, primary_ip, secondary_ip, pinst, sinst,
394        is_mc, drained, offline) in result:
395     ToStdout("Node name: %s", name)
396     ToStdout("  primary ip: %s", primary_ip)
397     ToStdout("  secondary ip: %s", secondary_ip)
398     ToStdout("  master candidate: %s", is_mc)
399     ToStdout("  drained: %s", drained)
400     ToStdout("  offline: %s", offline)
401     if pinst:
402       ToStdout("  primary for instances:")
403       for iname in utils.NiceSort(pinst):
404         ToStdout("    - %s", iname)
405     else:
406       ToStdout("  primary for no instances")
407     if sinst:
408       ToStdout("  secondary for instances:")
409       for iname in utils.NiceSort(sinst):
410         ToStdout("    - %s", iname)
411     else:
412       ToStdout("  secondary for no instances")
413
414   return 0
415
416
417 def RemoveNode(opts, args):
418   """Remove a node from the cluster.
419
420   @param opts: the command line options selected by the user
421   @type args: list
422   @param args: should contain only one element, the name of
423       the node to be removed
424   @rtype: int
425   @return: the desired exit code
426
427   """
428   op = opcodes.OpRemoveNode(node_name=args[0])
429   SubmitOpCode(op, opts=opts)
430   return 0
431
432
433 def PowercycleNode(opts, args):
434   """Remove a node from the cluster.
435
436   @param opts: the command line options selected by the user
437   @type args: list
438   @param args: should contain only one element, the name of
439       the node to be removed
440   @rtype: int
441   @return: the desired exit code
442
443   """
444   node = args[0]
445   if (not opts.confirm and
446       not AskUser("Are you sure you want to hard powercycle node %s?" % node)):
447     return 2
448
449   op = opcodes.OpPowercycleNode(node_name=node, force=opts.force)
450   result = SubmitOpCode(op, opts=opts)
451   ToStderr(result)
452   return 0
453
454
455 def ListVolumes(opts, args):
456   """List logical volumes on node(s).
457
458   @param opts: the command line options selected by the user
459   @type args: list
460   @param args: should either be an empty list, in which case
461       we list data for all nodes, or contain a list of nodes
462       to display data only for those
463   @rtype: int
464   @return: the desired exit code
465
466   """
467   if opts.output is None:
468     selected_fields = ["node", "phys", "vg",
469                        "name", "size", "instance"]
470   else:
471     selected_fields = opts.output.split(",")
472
473   op = opcodes.OpQueryNodeVolumes(nodes=args, output_fields=selected_fields)
474   output = SubmitOpCode(op, opts=opts)
475
476   if not opts.no_headers:
477     headers = {"node": "Node", "phys": "PhysDev",
478                "vg": "VG", "name": "Name",
479                "size": "Size", "instance": "Instance"}
480   else:
481     headers = None
482
483   unitfields = ["size"]
484
485   numfields = ["size"]
486
487   data = GenerateTable(separator=opts.separator, headers=headers,
488                        fields=selected_fields, unitfields=unitfields,
489                        numfields=numfields, data=output, units=opts.units)
490
491   for line in data:
492     ToStdout(line)
493
494   return 0
495
496
497 def ListStorage(opts, args):
498   """List physical volumes on node(s).
499
500   @param opts: the command line options selected by the user
501   @type args: list
502   @param args: should either be an empty list, in which case
503       we list data for all nodes, or contain a list of nodes
504       to display data only for those
505   @rtype: int
506   @return: the desired exit code
507
508   """
509   # TODO: Default to ST_FILE if LVM is disabled on the cluster
510   if opts.user_storage_type is None:
511     opts.user_storage_type = constants.ST_LVM_PV
512
513   storage_type = ConvertStorageType(opts.user_storage_type)
514
515   if opts.output is None:
516     selected_fields = _LIST_STOR_DEF_FIELDS
517   elif opts.output.startswith("+"):
518     selected_fields = _LIST_STOR_DEF_FIELDS + opts.output[1:].split(",")
519   else:
520     selected_fields = opts.output.split(",")
521
522   op = opcodes.OpQueryNodeStorage(nodes=args,
523                                   storage_type=storage_type,
524                                   output_fields=selected_fields)
525   output = SubmitOpCode(op, opts=opts)
526
527   if not opts.no_headers:
528     headers = {
529       constants.SF_NODE: "Node",
530       constants.SF_TYPE: "Type",
531       constants.SF_NAME: "Name",
532       constants.SF_SIZE: "Size",
533       constants.SF_USED: "Used",
534       constants.SF_FREE: "Free",
535       constants.SF_ALLOCATABLE: "Allocatable",
536       }
537   else:
538     headers = None
539
540   unitfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
541   numfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
542
543   # change raw values to nicer strings
544   for row in output:
545     for idx, field in enumerate(selected_fields):
546       val = row[idx]
547       if field == constants.SF_ALLOCATABLE:
548         if val:
549           val = "Y"
550         else:
551           val = "N"
552       row[idx] = str(val)
553
554   data = GenerateTable(separator=opts.separator, headers=headers,
555                        fields=selected_fields, unitfields=unitfields,
556                        numfields=numfields, data=output, units=opts.units)
557
558   for line in data:
559     ToStdout(line)
560
561   return 0
562
563
564 def ModifyStorage(opts, args):
565   """Modify storage volume on a node.
566
567   @param opts: the command line options selected by the user
568   @type args: list
569   @param args: should contain 3 items: node name, storage type and volume name
570   @rtype: int
571   @return: the desired exit code
572
573   """
574   (node_name, user_storage_type, volume_name) = args
575
576   storage_type = ConvertStorageType(user_storage_type)
577
578   changes = {}
579
580   if opts.allocatable is not None:
581     changes[constants.SF_ALLOCATABLE] = opts.allocatable
582
583   if changes:
584     op = opcodes.OpModifyNodeStorage(node_name=node_name,
585                                      storage_type=storage_type,
586                                      name=volume_name,
587                                      changes=changes)
588     SubmitOpCode(op, opts=opts)
589   else:
590     ToStderr("No changes to perform, exiting.")
591
592
593 def RepairStorage(opts, args):
594   """Repairs a storage volume on a node.
595
596   @param opts: the command line options selected by the user
597   @type args: list
598   @param args: should contain 3 items: node name, storage type and volume name
599   @rtype: int
600   @return: the desired exit code
601
602   """
603   (node_name, user_storage_type, volume_name) = args
604
605   storage_type = ConvertStorageType(user_storage_type)
606
607   op = opcodes.OpRepairNodeStorage(node_name=node_name,
608                                    storage_type=storage_type,
609                                    name=volume_name,
610                                    ignore_consistency=opts.ignore_consistency)
611   SubmitOpCode(op, opts=opts)
612
613
614 def SetNodeParams(opts, args):
615   """Modifies a node.
616
617   @param opts: the command line options selected by the user
618   @type args: list
619   @param args: should contain only one element, the node name
620   @rtype: int
621   @return: the desired exit code
622
623   """
624   if [opts.master_candidate, opts.drained, opts.offline].count(None) == 3:
625     ToStderr("Please give at least one of the parameters.")
626     return 1
627
628   op = opcodes.OpSetNodeParams(node_name=args[0],
629                                master_candidate=opts.master_candidate,
630                                offline=opts.offline,
631                                drained=opts.drained,
632                                force=opts.force,
633                                auto_promote=opts.auto_promote)
634
635   # even if here we process the result, we allow submit only
636   result = SubmitOrSend(op, opts)
637
638   if result:
639     ToStdout("Modified node %s", args[0])
640     for param, data in result:
641       ToStdout(" - %-5s -> %s", param, data)
642   return 0
643
644
645 commands = {
646   'add': (
647     AddNode, [ArgHost(min=1, max=1)],
648     [SECONDARY_IP_OPT, READD_OPT, NOSSH_KEYCHECK_OPT],
649     "[-s ip] [--readd] [--no-ssh-key-check] <node_name>",
650     "Add a node to the cluster"),
651   'evacuate': (
652     EvacuateNode, [ArgNode(min=1)],
653     [FORCE_OPT, IALLOCATOR_OPT, NEW_SECONDARY_OPT, EARLY_RELEASE_OPT],
654     "[-f] {-I <iallocator> | -n <dst>} <node>",
655     "Relocate the secondary instances from a node"
656     " to other nodes (only for instances with drbd disk template)"),
657   'failover': (
658     FailoverNode, ARGS_ONE_NODE, [FORCE_OPT, IGNORE_CONSIST_OPT],
659     "[-f] <node>",
660     "Stops the primary instances on a node and start them on their"
661     " secondary node (only for instances with drbd disk template)"),
662   'migrate': (
663     MigrateNode, ARGS_ONE_NODE, [FORCE_OPT, NONLIVE_OPT, MIGRATION_MODE_OPT],
664     "[-f] <node>",
665     "Migrate all the primary instance on a node away from it"
666     " (only for instances of type drbd)"),
667   'info': (
668     ShowNodeConfig, ARGS_MANY_NODES, [],
669     "[<node_name>...]", "Show information about the node(s)"),
670   'list': (
671     ListNodes, ARGS_MANY_NODES,
672     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, SYNC_OPT, ROMAN_OPT],
673     "[nodes...]",
674     "Lists the nodes in the cluster. The available fields are (see the man"
675     " page for details): %s. The default field list is (in order): %s." %
676     (utils.CommaJoin(_LIST_HEADERS), utils.CommaJoin(_LIST_DEF_FIELDS))),
677   'modify': (
678     SetNodeParams, ARGS_ONE_NODE,
679     [FORCE_OPT, SUBMIT_OPT, MC_OPT, DRAINED_OPT, OFFLINE_OPT,
680      AUTO_PROMOTE_OPT],
681     "<node_name>", "Alters the parameters of a node"),
682   'powercycle': (
683     PowercycleNode, ARGS_ONE_NODE,
684     [FORCE_OPT, CONFIRM_OPT],
685     "<node_name>", "Tries to forcefully powercycle a node"),
686   'remove': (
687     RemoveNode, ARGS_ONE_NODE, [],
688     "<node_name>", "Removes a node from the cluster"),
689   'volumes': (
690     ListVolumes, [ArgNode()],
691     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT],
692     "[<node_name>...]", "List logical volumes on node(s)"),
693   'list-storage': (
694     ListStorage, ARGS_MANY_NODES,
695     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, _STORAGE_TYPE_OPT],
696     "[<node_name>...]", "List physical volumes on node(s). The available"
697     " fields are (see the man page for details): %s." %
698     (utils.CommaJoin(_LIST_STOR_HEADERS))),
699   'modify-storage': (
700     ModifyStorage,
701     [ArgNode(min=1, max=1),
702      ArgChoice(min=1, max=1, choices=_MODIFIABLE_STORAGE_TYPES),
703      ArgFile(min=1, max=1)],
704     [ALLOCATABLE_OPT],
705     "<node_name> <storage_type> <name>", "Modify storage volume on a node"),
706   'repair-storage': (
707     RepairStorage,
708     [ArgNode(min=1, max=1),
709      ArgChoice(min=1, max=1, choices=_REPAIRABLE_STORAGE_TYPES),
710      ArgFile(min=1, max=1)],
711     [IGNORE_CONSIST_OPT],
712     "<node_name> <storage_type> <name>",
713     "Repairs a storage volume on a node"),
714   'list-tags': (
715     ListTags, ARGS_ONE_NODE, [],
716     "<node_name>", "List the tags of the given node"),
717   'add-tags': (
718     AddTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
719     "<node_name> tag...", "Add tags to the given node"),
720   'remove-tags': (
721     RemoveTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
722     "<node_name> tag...", "Remove tags from the given node"),
723   }
724
725
726 if __name__ == '__main__':
727   sys.exit(GenericMain(commands, override={"tag_type": constants.TAG_NODE}))