Add function to format all job log messages
[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 from ganeti import netutils
39
40
41 #: default list of field for L{ListNodes}
42 _LIST_DEF_FIELDS = [
43   "name", "dtotal", "dfree",
44   "mtotal", "mnode", "mfree",
45   "pinst_cnt", "sinst_cnt",
46   ]
47
48
49 #: default list of field for L{ListStorage}
50 _LIST_STOR_DEF_FIELDS = [
51   constants.SF_NODE,
52   constants.SF_TYPE,
53   constants.SF_NAME,
54   constants.SF_SIZE,
55   constants.SF_USED,
56   constants.SF_FREE,
57   constants.SF_ALLOCATABLE,
58   ]
59
60
61 #: headers (and full field list for L{ListNodes}
62 _LIST_HEADERS = {
63   "name": "Node", "pinst_cnt": "Pinst", "sinst_cnt": "Sinst",
64   "pinst_list": "PriInstances", "sinst_list": "SecInstances",
65   "pip": "PrimaryIP", "sip": "SecondaryIP",
66   "dtotal": "DTotal", "dfree": "DFree",
67   "mtotal": "MTotal", "mnode": "MNode", "mfree": "MFree",
68   "bootid": "BootID",
69   "ctotal": "CTotal", "cnodes": "CNodes", "csockets": "CSockets",
70   "tags": "Tags",
71   "serial_no": "SerialNo",
72   "master_candidate": "MasterC",
73   "master": "IsMaster",
74   "offline": "Offline", "drained": "Drained",
75   "role": "Role",
76   "ctime": "CTime", "mtime": "MTime", "uuid": "UUID"
77   }
78
79
80 #: headers (and full field list for L{ListStorage}
81 _LIST_STOR_HEADERS = {
82   constants.SF_NODE: "Node",
83   constants.SF_TYPE: "Type",
84   constants.SF_NAME: "Name",
85   constants.SF_SIZE: "Size",
86   constants.SF_USED: "Used",
87   constants.SF_FREE: "Free",
88   constants.SF_ALLOCATABLE: "Allocatable",
89   }
90
91
92 #: User-facing storage unit types
93 _USER_STORAGE_TYPE = {
94   constants.ST_FILE: "file",
95   constants.ST_LVM_PV: "lvm-pv",
96   constants.ST_LVM_VG: "lvm-vg",
97   }
98
99 _STORAGE_TYPE_OPT = \
100   cli_option("-t", "--storage-type",
101              dest="user_storage_type",
102              choices=_USER_STORAGE_TYPE.keys(),
103              default=None,
104              metavar="STORAGE_TYPE",
105              help=("Storage type (%s)" %
106                    utils.CommaJoin(_USER_STORAGE_TYPE.keys())))
107
108 _REPAIRABLE_STORAGE_TYPES = \
109   [st for st, so in constants.VALID_STORAGE_OPERATIONS.iteritems()
110    if constants.SO_FIX_CONSISTENCY in so]
111
112 _MODIFIABLE_STORAGE_TYPES = constants.MODIFIABLE_STORAGE_FIELDS.keys()
113
114
115 def ConvertStorageType(user_storage_type):
116   """Converts a user storage type to its internal name.
117
118   """
119   try:
120     return _USER_STORAGE_TYPE[user_storage_type]
121   except KeyError:
122     raise errors.OpPrereqError("Unknown storage type: %s" % user_storage_type,
123                                errors.ECODE_INVAL)
124
125
126 @UsesRPC
127 def AddNode(opts, args):
128   """Add a node to the cluster.
129
130   @param opts: the command line options selected by the user
131   @type args: list
132   @param args: should contain only one element, the new node name
133   @rtype: int
134   @return: the desired exit code
135
136   """
137   cl = GetClient()
138   dns_data = netutils.GetHostInfo(netutils.HostInfo.NormalizeName(args[0]))
139   node = dns_data.name
140   readd = opts.readd
141
142   try:
143     output = cl.QueryNodes(names=[node], fields=['name', 'sip'],
144                            use_locking=False)
145     node_exists, sip = output[0]
146   except (errors.OpPrereqError, errors.OpExecError):
147     node_exists = ""
148     sip = None
149
150   if readd:
151     if not node_exists:
152       ToStderr("Node %s not in the cluster"
153                " - please retry without '--readd'", node)
154       return 1
155   else:
156     if node_exists:
157       ToStderr("Node %s already in the cluster (as %s)"
158                " - please retry with '--readd'", node, node_exists)
159       return 1
160     sip = opts.secondary_ip
161
162   # read the cluster name from the master
163   output = cl.QueryConfigValues(['cluster_name'])
164   cluster_name = output[0]
165
166   if not readd:
167     ToStderr("-- WARNING -- \n"
168              "Performing this operation is going to replace the ssh daemon"
169              " keypair\n"
170              "on the target machine (%s) with the ones of the"
171              " current one\n"
172              "and grant full intra-cluster ssh root access to/from it\n", node)
173
174   bootstrap.SetupNodeDaemon(cluster_name, node, opts.ssh_key_check)
175
176   op = opcodes.OpAddNode(node_name=args[0], secondary_ip=sip,
177                          readd=opts.readd)
178   SubmitOpCode(op, opts=opts)
179
180
181 def ListNodes(opts, args):
182   """List nodes and their properties.
183
184   @param opts: the command line options selected by the user
185   @type args: list
186   @param args: should be an empty list
187   @rtype: int
188   @return: the desired exit code
189
190   """
191   if opts.output is None:
192     selected_fields = _LIST_DEF_FIELDS
193   elif opts.output.startswith("+"):
194     selected_fields = _LIST_DEF_FIELDS + opts.output[1:].split(",")
195   else:
196     selected_fields = opts.output.split(",")
197
198   output = GetClient().QueryNodes(args, selected_fields, opts.do_locking)
199
200   if not opts.no_headers:
201     headers = _LIST_HEADERS
202   else:
203     headers = None
204
205   unitfields = ["dtotal", "dfree", "mtotal", "mnode", "mfree"]
206
207   numfields = ["dtotal", "dfree",
208                "mtotal", "mnode", "mfree",
209                "pinst_cnt", "sinst_cnt",
210                "ctotal", "serial_no"]
211
212   list_type_fields = ("pinst_list", "sinst_list", "tags")
213   # change raw values to nicer strings
214   for row in output:
215     for idx, field in enumerate(selected_fields):
216       val = row[idx]
217       if field in list_type_fields:
218         val = ",".join(val)
219       elif field in ('master', 'master_candidate', 'offline', 'drained'):
220         if val:
221           val = 'Y'
222         else:
223           val = 'N'
224       elif field == "ctime" or field == "mtime":
225         val = utils.FormatTime(val)
226       elif val is None:
227         val = "?"
228       elif opts.roman_integers and isinstance(val, int):
229         val = compat.TryToRoman(val)
230       row[idx] = str(val)
231
232   data = GenerateTable(separator=opts.separator, headers=headers,
233                        fields=selected_fields, unitfields=unitfields,
234                        numfields=numfields, data=output, units=opts.units)
235   for line in data:
236     ToStdout(line)
237
238   return 0
239
240
241 def EvacuateNode(opts, args):
242   """Relocate all secondary instance from a node.
243
244   @param opts: the command line options selected by the user
245   @type args: list
246   @param args: should be an empty list
247   @rtype: int
248   @return: the desired exit code
249
250   """
251   cl = GetClient()
252   force = opts.force
253
254   dst_node = opts.dst_node
255   iallocator = opts.iallocator
256
257   op = opcodes.OpNodeEvacuationStrategy(nodes=args,
258                                         iallocator=iallocator,
259                                         remote_node=dst_node)
260
261   result = SubmitOpCode(op, cl=cl, opts=opts)
262   if not result:
263     # no instances to migrate
264     ToStderr("No secondary instances on node(s) %s, exiting.",
265              utils.CommaJoin(args))
266     return constants.EXIT_SUCCESS
267
268   if not force and not AskUser("Relocate instance(s) %s from node(s) %s?" %
269                                (",".join("'%s'" % name[0] for name in result),
270                                utils.CommaJoin(args))):
271     return constants.EXIT_CONFIRMATION
272
273   jex = JobExecutor(cl=cl, opts=opts)
274   for row in result:
275     iname = row[0]
276     node = row[1]
277     ToStdout("Will relocate instance %s to node %s", iname, node)
278     op = opcodes.OpReplaceDisks(instance_name=iname,
279                                 remote_node=node, disks=[],
280                                 mode=constants.REPLACE_DISK_CHG,
281                                 early_release=opts.early_release)
282     jex.QueueJob(iname, op)
283   results = jex.GetResults()
284   bad_cnt = len([row for row in results if not row[0]])
285   if bad_cnt == 0:
286     ToStdout("All %d instance(s) failed over successfully.", len(results))
287     rcode = constants.EXIT_SUCCESS
288   else:
289     ToStdout("There were errors during the failover:\n"
290              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
291     rcode = constants.EXIT_FAILURE
292   return rcode
293
294
295 def FailoverNode(opts, args):
296   """Failover all primary instance on a node.
297
298   @param opts: the command line options selected by the user
299   @type args: list
300   @param args: should be an empty list
301   @rtype: int
302   @return: the desired exit code
303
304   """
305   cl = GetClient()
306   force = opts.force
307   selected_fields = ["name", "pinst_list"]
308
309   # these fields are static data anyway, so it doesn't matter, but
310   # locking=True should be safer
311   result = cl.QueryNodes(names=args, fields=selected_fields,
312                          use_locking=False)
313   node, pinst = result[0]
314
315   if not pinst:
316     ToStderr("No primary instances on node %s, exiting.", node)
317     return 0
318
319   pinst = utils.NiceSort(pinst)
320
321   retcode = 0
322
323   if not force and not AskUser("Fail over instance(s) %s?" %
324                                (",".join("'%s'" % name for name in pinst))):
325     return 2
326
327   jex = JobExecutor(cl=cl, opts=opts)
328   for iname in pinst:
329     op = opcodes.OpFailoverInstance(instance_name=iname,
330                                     ignore_consistency=opts.ignore_consistency)
331     jex.QueueJob(iname, op)
332   results = jex.GetResults()
333   bad_cnt = len([row for row in results if not row[0]])
334   if bad_cnt == 0:
335     ToStdout("All %d instance(s) failed over successfully.", len(results))
336   else:
337     ToStdout("There were errors during the failover:\n"
338              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
339   return retcode
340
341
342 def MigrateNode(opts, args):
343   """Migrate all primary instance on a node.
344
345   """
346   cl = GetClient()
347   force = opts.force
348   selected_fields = ["name", "pinst_list"]
349
350   result = cl.QueryNodes(names=args, fields=selected_fields, use_locking=False)
351   node, pinst = result[0]
352
353   if not pinst:
354     ToStdout("No primary instances on node %s, exiting." % node)
355     return 0
356
357   pinst = utils.NiceSort(pinst)
358
359   if not force and not AskUser("Migrate instance(s) %s?" %
360                                (",".join("'%s'" % name for name in pinst))):
361     return 2
362
363   op = opcodes.OpMigrateNode(node_name=args[0], live=opts.live)
364   SubmitOpCode(op, cl=cl, opts=opts)
365
366
367 def ShowNodeConfig(opts, args):
368   """Show node information.
369
370   @param opts: the command line options selected by the user
371   @type args: list
372   @param args: should either be an empty list, in which case
373       we show information about all nodes, or should contain
374       a list of nodes to be queried for information
375   @rtype: int
376   @return: the desired exit code
377
378   """
379   cl = GetClient()
380   result = cl.QueryNodes(fields=["name", "pip", "sip",
381                                  "pinst_list", "sinst_list",
382                                  "master_candidate", "drained", "offline"],
383                          names=args, use_locking=False)
384
385   for (name, primary_ip, secondary_ip, pinst, sinst,
386        is_mc, drained, offline) in result:
387     ToStdout("Node name: %s", name)
388     ToStdout("  primary ip: %s", primary_ip)
389     ToStdout("  secondary ip: %s", secondary_ip)
390     ToStdout("  master candidate: %s", is_mc)
391     ToStdout("  drained: %s", drained)
392     ToStdout("  offline: %s", offline)
393     if pinst:
394       ToStdout("  primary for instances:")
395       for iname in utils.NiceSort(pinst):
396         ToStdout("    - %s", iname)
397     else:
398       ToStdout("  primary for no instances")
399     if sinst:
400       ToStdout("  secondary for instances:")
401       for iname in utils.NiceSort(sinst):
402         ToStdout("    - %s", iname)
403     else:
404       ToStdout("  secondary for no instances")
405
406   return 0
407
408
409 def RemoveNode(opts, args):
410   """Remove a node from the cluster.
411
412   @param opts: the command line options selected by the user
413   @type args: list
414   @param args: should contain only one element, the name of
415       the node to be removed
416   @rtype: int
417   @return: the desired exit code
418
419   """
420   op = opcodes.OpRemoveNode(node_name=args[0])
421   SubmitOpCode(op, opts=opts)
422   return 0
423
424
425 def PowercycleNode(opts, args):
426   """Remove a node from the cluster.
427
428   @param opts: the command line options selected by the user
429   @type args: list
430   @param args: should contain only one element, the name of
431       the node to be removed
432   @rtype: int
433   @return: the desired exit code
434
435   """
436   node = args[0]
437   if (not opts.confirm and
438       not AskUser("Are you sure you want to hard powercycle node %s?" % node)):
439     return 2
440
441   op = opcodes.OpPowercycleNode(node_name=node, force=opts.force)
442   result = SubmitOpCode(op, opts=opts)
443   ToStderr(result)
444   return 0
445
446
447 def ListVolumes(opts, args):
448   """List logical volumes on node(s).
449
450   @param opts: the command line options selected by the user
451   @type args: list
452   @param args: should either be an empty list, in which case
453       we list data for all nodes, or contain a list of nodes
454       to display data only for those
455   @rtype: int
456   @return: the desired exit code
457
458   """
459   if opts.output is None:
460     selected_fields = ["node", "phys", "vg",
461                        "name", "size", "instance"]
462   else:
463     selected_fields = opts.output.split(",")
464
465   op = opcodes.OpQueryNodeVolumes(nodes=args, output_fields=selected_fields)
466   output = SubmitOpCode(op, opts=opts)
467
468   if not opts.no_headers:
469     headers = {"node": "Node", "phys": "PhysDev",
470                "vg": "VG", "name": "Name",
471                "size": "Size", "instance": "Instance"}
472   else:
473     headers = None
474
475   unitfields = ["size"]
476
477   numfields = ["size"]
478
479   data = GenerateTable(separator=opts.separator, headers=headers,
480                        fields=selected_fields, unitfields=unitfields,
481                        numfields=numfields, data=output, units=opts.units)
482
483   for line in data:
484     ToStdout(line)
485
486   return 0
487
488
489 def ListStorage(opts, args):
490   """List physical volumes on node(s).
491
492   @param opts: the command line options selected by the user
493   @type args: list
494   @param args: should either be an empty list, in which case
495       we list data for all nodes, or contain a list of nodes
496       to display data only for those
497   @rtype: int
498   @return: the desired exit code
499
500   """
501   # TODO: Default to ST_FILE if LVM is disabled on the cluster
502   if opts.user_storage_type is None:
503     opts.user_storage_type = constants.ST_LVM_PV
504
505   storage_type = ConvertStorageType(opts.user_storage_type)
506
507   if opts.output is None:
508     selected_fields = _LIST_STOR_DEF_FIELDS
509   elif opts.output.startswith("+"):
510     selected_fields = _LIST_STOR_DEF_FIELDS + opts.output[1:].split(",")
511   else:
512     selected_fields = opts.output.split(",")
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],
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],
673     "<node_name>", "Alters the parameters of a node"),
674   'powercycle': (
675     PowercycleNode, ARGS_ONE_NODE,
676     [FORCE_OPT, CONFIRM_OPT],
677     "<node_name>", "Tries to forcefully powercycle a node"),
678   'remove': (
679     RemoveNode, ARGS_ONE_NODE, [],
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],
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],
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}))