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