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