cli: Pass options in {Add,Remove}Tags
[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 field list for L{ListVolumes}
50 _LIST_VOL_DEF_FIELDS = ["node", "phys", "vg", "name", "size", "instance"]
51
52
53 #: default list of field for L{ListStorage}
54 _LIST_STOR_DEF_FIELDS = [
55   constants.SF_NODE,
56   constants.SF_TYPE,
57   constants.SF_NAME,
58   constants.SF_SIZE,
59   constants.SF_USED,
60   constants.SF_FREE,
61   constants.SF_ALLOCATABLE,
62   ]
63
64
65 #: headers (and full field list for L{ListNodes}
66 _LIST_HEADERS = {
67   "name": "Node", "pinst_cnt": "Pinst", "sinst_cnt": "Sinst",
68   "pinst_list": "PriInstances", "sinst_list": "SecInstances",
69   "pip": "PrimaryIP", "sip": "SecondaryIP",
70   "dtotal": "DTotal", "dfree": "DFree",
71   "mtotal": "MTotal", "mnode": "MNode", "mfree": "MFree",
72   "bootid": "BootID",
73   "ctotal": "CTotal", "cnodes": "CNodes", "csockets": "CSockets",
74   "tags": "Tags",
75   "serial_no": "SerialNo",
76   "master_candidate": "MasterC",
77   "master": "IsMaster",
78   "offline": "Offline", "drained": "Drained",
79   "role": "Role",
80   "ctime": "CTime", "mtime": "MTime", "uuid": "UUID"
81   }
82
83
84 #: headers (and full field list for L{ListStorage}
85 _LIST_STOR_HEADERS = {
86   constants.SF_NODE: "Node",
87   constants.SF_TYPE: "Type",
88   constants.SF_NAME: "Name",
89   constants.SF_SIZE: "Size",
90   constants.SF_USED: "Used",
91   constants.SF_FREE: "Free",
92   constants.SF_ALLOCATABLE: "Allocatable",
93   }
94
95
96 #: User-facing storage unit types
97 _USER_STORAGE_TYPE = {
98   constants.ST_FILE: "file",
99   constants.ST_LVM_PV: "lvm-pv",
100   constants.ST_LVM_VG: "lvm-vg",
101   }
102
103 _STORAGE_TYPE_OPT = \
104   cli_option("-t", "--storage-type",
105              dest="user_storage_type",
106              choices=_USER_STORAGE_TYPE.keys(),
107              default=None,
108              metavar="STORAGE_TYPE",
109              help=("Storage type (%s)" %
110                    utils.CommaJoin(_USER_STORAGE_TYPE.keys())))
111
112 _REPAIRABLE_STORAGE_TYPES = \
113   [st for st, so in constants.VALID_STORAGE_OPERATIONS.iteritems()
114    if constants.SO_FIX_CONSISTENCY in so]
115
116 _MODIFIABLE_STORAGE_TYPES = constants.MODIFIABLE_STORAGE_FIELDS.keys()
117
118
119 def ConvertStorageType(user_storage_type):
120   """Converts a user storage type to its internal name.
121
122   """
123   try:
124     return _USER_STORAGE_TYPE[user_storage_type]
125   except KeyError:
126     raise errors.OpPrereqError("Unknown storage type: %s" % user_storage_type,
127                                errors.ECODE_INVAL)
128
129
130 @UsesRPC
131 def AddNode(opts, args):
132   """Add a node to the cluster.
133
134   @param opts: the command line options selected by the user
135   @type args: list
136   @param args: should contain only one element, the new node name
137   @rtype: int
138   @return: the desired exit code
139
140   """
141   cl = GetClient()
142   dns_data = netutils.GetHostInfo(netutils.HostInfo.NormalizeName(args[0]))
143   node = dns_data.name
144   readd = opts.readd
145
146   try:
147     output = cl.QueryNodes(names=[node], fields=['name', 'sip'],
148                            use_locking=False)
149     node_exists, sip = output[0]
150   except (errors.OpPrereqError, errors.OpExecError):
151     node_exists = ""
152     sip = None
153
154   if readd:
155     if not node_exists:
156       ToStderr("Node %s not in the cluster"
157                " - please retry without '--readd'", node)
158       return 1
159   else:
160     if node_exists:
161       ToStderr("Node %s already in the cluster (as %s)"
162                " - please retry with '--readd'", node, node_exists)
163       return 1
164     sip = opts.secondary_ip
165
166   # read the cluster name from the master
167   output = cl.QueryConfigValues(['cluster_name'])
168   cluster_name = output[0]
169
170   if not readd:
171     ToStderr("-- WARNING -- \n"
172              "Performing this operation is going to replace the ssh daemon"
173              " keypair\n"
174              "on the target machine (%s) with the ones of the"
175              " current one\n"
176              "and grant full intra-cluster ssh root access to/from it\n", node)
177
178   bootstrap.SetupNodeDaemon(cluster_name, node, opts.ssh_key_check)
179
180   op = opcodes.OpAddNode(node_name=args[0], secondary_ip=sip,
181                          readd=opts.readd)
182   SubmitOpCode(op, opts=opts)
183
184
185 def ListNodes(opts, args):
186   """List nodes and their properties.
187
188   @param opts: the command line options selected by the user
189   @type args: list
190   @param args: should be an empty list
191   @rtype: int
192   @return: the desired exit code
193
194   """
195   selected_fields = ParseFields(opts.output, _LIST_DEF_FIELDS)
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   if result:
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   selected_fields = ParseFields(opts.output, _LIST_VOL_DEF_FIELDS)
469
470   op = opcodes.OpQueryNodeVolumes(nodes=args, output_fields=selected_fields)
471   output = SubmitOpCode(op, opts=opts)
472
473   if not opts.no_headers:
474     headers = {"node": "Node", "phys": "PhysDev",
475                "vg": "VG", "name": "Name",
476                "size": "Size", "instance": "Instance"}
477   else:
478     headers = None
479
480   unitfields = ["size"]
481
482   numfields = ["size"]
483
484   data = GenerateTable(separator=opts.separator, headers=headers,
485                        fields=selected_fields, unitfields=unitfields,
486                        numfields=numfields, data=output, units=opts.units)
487
488   for line in data:
489     ToStdout(line)
490
491   return 0
492
493
494 def ListStorage(opts, args):
495   """List physical volumes on node(s).
496
497   @param opts: the command line options selected by the user
498   @type args: list
499   @param args: should either be an empty list, in which case
500       we list data for all nodes, or contain a list of nodes
501       to display data only for those
502   @rtype: int
503   @return: the desired exit code
504
505   """
506   # TODO: Default to ST_FILE if LVM is disabled on the cluster
507   if opts.user_storage_type is None:
508     opts.user_storage_type = constants.ST_LVM_PV
509
510   storage_type = ConvertStorageType(opts.user_storage_type)
511
512   selected_fields = ParseFields(opts.output, _LIST_STOR_DEF_FIELDS)
513
514   op = opcodes.OpQueryNodeStorage(nodes=args,
515                                   storage_type=storage_type,
516                                   output_fields=selected_fields)
517   output = SubmitOpCode(op, opts=opts)
518
519   if not opts.no_headers:
520     headers = {
521       constants.SF_NODE: "Node",
522       constants.SF_TYPE: "Type",
523       constants.SF_NAME: "Name",
524       constants.SF_SIZE: "Size",
525       constants.SF_USED: "Used",
526       constants.SF_FREE: "Free",
527       constants.SF_ALLOCATABLE: "Allocatable",
528       }
529   else:
530     headers = None
531
532   unitfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
533   numfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
534
535   # change raw values to nicer strings
536   for row in output:
537     for idx, field in enumerate(selected_fields):
538       val = row[idx]
539       if field == constants.SF_ALLOCATABLE:
540         if val:
541           val = "Y"
542         else:
543           val = "N"
544       row[idx] = str(val)
545
546   data = GenerateTable(separator=opts.separator, headers=headers,
547                        fields=selected_fields, unitfields=unitfields,
548                        numfields=numfields, data=output, units=opts.units)
549
550   for line in data:
551     ToStdout(line)
552
553   return 0
554
555
556 def ModifyStorage(opts, args):
557   """Modify storage volume on a node.
558
559   @param opts: the command line options selected by the user
560   @type args: list
561   @param args: should contain 3 items: node name, storage type and volume name
562   @rtype: int
563   @return: the desired exit code
564
565   """
566   (node_name, user_storage_type, volume_name) = args
567
568   storage_type = ConvertStorageType(user_storage_type)
569
570   changes = {}
571
572   if opts.allocatable is not None:
573     changes[constants.SF_ALLOCATABLE] = opts.allocatable
574
575   if changes:
576     op = opcodes.OpModifyNodeStorage(node_name=node_name,
577                                      storage_type=storage_type,
578                                      name=volume_name,
579                                      changes=changes)
580     SubmitOpCode(op, opts=opts)
581   else:
582     ToStderr("No changes to perform, exiting.")
583
584
585 def RepairStorage(opts, args):
586   """Repairs a storage volume on a node.
587
588   @param opts: the command line options selected by the user
589   @type args: list
590   @param args: should contain 3 items: node name, storage type and volume name
591   @rtype: int
592   @return: the desired exit code
593
594   """
595   (node_name, user_storage_type, volume_name) = args
596
597   storage_type = ConvertStorageType(user_storage_type)
598
599   op = opcodes.OpRepairNodeStorage(node_name=node_name,
600                                    storage_type=storage_type,
601                                    name=volume_name,
602                                    ignore_consistency=opts.ignore_consistency)
603   SubmitOpCode(op, opts=opts)
604
605
606 def SetNodeParams(opts, args):
607   """Modifies a node.
608
609   @param opts: the command line options selected by the user
610   @type args: list
611   @param args: should contain only one element, the node name
612   @rtype: int
613   @return: the desired exit code
614
615   """
616   if [opts.master_candidate, opts.drained, opts.offline].count(None) == 3:
617     ToStderr("Please give at least one of the parameters.")
618     return 1
619
620   op = opcodes.OpSetNodeParams(node_name=args[0],
621                                master_candidate=opts.master_candidate,
622                                offline=opts.offline,
623                                drained=opts.drained,
624                                force=opts.force,
625                                auto_promote=opts.auto_promote)
626
627   # even if here we process the result, we allow submit only
628   result = SubmitOrSend(op, opts)
629
630   if result:
631     ToStdout("Modified node %s", args[0])
632     for param, data in result:
633       ToStdout(" - %-5s -> %s", param, data)
634   return 0
635
636
637 commands = {
638   'add': (
639     AddNode, [ArgHost(min=1, max=1)],
640     [SECONDARY_IP_OPT, READD_OPT, NOSSH_KEYCHECK_OPT],
641     "[-s ip] [--readd] [--no-ssh-key-check] <node_name>",
642     "Add a node to the cluster"),
643   'evacuate': (
644     EvacuateNode, [ArgNode(min=1)],
645     [FORCE_OPT, IALLOCATOR_OPT, NEW_SECONDARY_OPT, EARLY_RELEASE_OPT],
646     "[-f] {-I <iallocator> | -n <dst>} <node>",
647     "Relocate the secondary instances from a node"
648     " to other nodes (only for instances with drbd disk template)"),
649   'failover': (
650     FailoverNode, ARGS_ONE_NODE, [FORCE_OPT, IGNORE_CONSIST_OPT],
651     "[-f] <node>",
652     "Stops the primary instances on a node and start them on their"
653     " secondary node (only for instances with drbd disk template)"),
654   'migrate': (
655     MigrateNode, ARGS_ONE_NODE, [FORCE_OPT, NONLIVE_OPT, MIGRATION_MODE_OPT],
656     "[-f] <node>",
657     "Migrate all the primary instance on a node away from it"
658     " (only for instances of type drbd)"),
659   'info': (
660     ShowNodeConfig, ARGS_MANY_NODES, [],
661     "[<node_name>...]", "Show information about the node(s)"),
662   'list': (
663     ListNodes, ARGS_MANY_NODES,
664     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, SYNC_OPT, ROMAN_OPT],
665     "[nodes...]",
666     "Lists the nodes in the cluster. The available fields are (see the man"
667     " page for details): %s. The default field list is (in order): %s." %
668     (utils.CommaJoin(_LIST_HEADERS), utils.CommaJoin(_LIST_DEF_FIELDS))),
669   'modify': (
670     SetNodeParams, ARGS_ONE_NODE,
671     [FORCE_OPT, SUBMIT_OPT, MC_OPT, DRAINED_OPT, OFFLINE_OPT,
672      AUTO_PROMOTE_OPT, DRY_RUN_OPT],
673     "<node_name>", "Alters the parameters of a node"),
674   'powercycle': (
675     PowercycleNode, ARGS_ONE_NODE,
676     [FORCE_OPT, CONFIRM_OPT, DRY_RUN_OPT],
677     "<node_name>", "Tries to forcefully powercycle a node"),
678   'remove': (
679     RemoveNode, ARGS_ONE_NODE, [DRY_RUN_OPT],
680     "<node_name>", "Removes a node from the cluster"),
681   'volumes': (
682     ListVolumes, [ArgNode()],
683     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT],
684     "[<node_name>...]", "List logical volumes on node(s)"),
685   'list-storage': (
686     ListStorage, ARGS_MANY_NODES,
687     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, _STORAGE_TYPE_OPT],
688     "[<node_name>...]", "List physical volumes on node(s). The available"
689     " fields are (see the man page for details): %s." %
690     (utils.CommaJoin(_LIST_STOR_HEADERS))),
691   'modify-storage': (
692     ModifyStorage,
693     [ArgNode(min=1, max=1),
694      ArgChoice(min=1, max=1, choices=_MODIFIABLE_STORAGE_TYPES),
695      ArgFile(min=1, max=1)],
696     [ALLOCATABLE_OPT, DRY_RUN_OPT],
697     "<node_name> <storage_type> <name>", "Modify storage volume on a node"),
698   'repair-storage': (
699     RepairStorage,
700     [ArgNode(min=1, max=1),
701      ArgChoice(min=1, max=1, choices=_REPAIRABLE_STORAGE_TYPES),
702      ArgFile(min=1, max=1)],
703     [IGNORE_CONSIST_OPT, DRY_RUN_OPT],
704     "<node_name> <storage_type> <name>",
705     "Repairs a storage volume on a node"),
706   'list-tags': (
707     ListTags, ARGS_ONE_NODE, [],
708     "<node_name>", "List the tags of the given node"),
709   'add-tags': (
710     AddTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
711     "<node_name> tag...", "Add tags to the given node"),
712   'remove-tags': (
713     RemoveTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
714     "<node_name> tag...", "Remove tags from the given node"),
715   }
716
717
718 if __name__ == '__main__':
719   sys.exit(GenericMain(commands, override={"tag_type": constants.TAG_NODE}))