Remove quotes from CommaJoin and convert to it
[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)" %
102                    utils.CommaJoin(_USER_STORAGE_TYPE.keys())))
103
104 _REPAIRABLE_STORAGE_TYPES = \
105   [st for st, so in constants.VALID_STORAGE_OPERATIONS.iteritems()
106    if constants.SO_FIX_CONSISTENCY in so]
107
108 _MODIFIABLE_STORAGE_TYPES = constants.MODIFIABLE_STORAGE_FIELDS.keys()
109
110
111 def ConvertStorageType(user_storage_type):
112   """Converts a user storage type to its internal name.
113
114   """
115   try:
116     return _USER_STORAGE_TYPE[user_storage_type]
117   except KeyError:
118     raise errors.OpPrereqError("Unknown storage type: %s" % user_storage_type,
119                                errors.ECODE_INVAL)
120
121
122 @UsesRPC
123 def AddNode(opts, args):
124   """Add a node to the cluster.
125
126   @param opts: the command line options selected by the user
127   @type args: list
128   @param args: should contain only one element, the new node name
129   @rtype: int
130   @return: the desired exit code
131
132   """
133   cl = GetClient()
134   dns_data = utils.GetHostInfo(args[0])
135   node = dns_data.name
136   readd = opts.readd
137
138   try:
139     output = cl.QueryNodes(names=[node], fields=['name', 'sip'],
140                            use_locking=False)
141     node_exists, sip = output[0]
142   except (errors.OpPrereqError, errors.OpExecError):
143     node_exists = ""
144     sip = None
145
146   if readd:
147     if not node_exists:
148       ToStderr("Node %s not in the cluster"
149                " - please retry without '--readd'", node)
150       return 1
151   else:
152     if node_exists:
153       ToStderr("Node %s already in the cluster (as %s)"
154                " - please retry with '--readd'", node, node_exists)
155       return 1
156     sip = opts.secondary_ip
157
158   # read the cluster name from the master
159   output = cl.QueryConfigValues(['cluster_name'])
160   cluster_name = output[0]
161
162   if not readd:
163     ToStderr("-- WARNING -- \n"
164              "Performing this operation is going to replace the ssh daemon"
165              " keypair\n"
166              "on the target machine (%s) with the ones of the"
167              " current one\n"
168              "and grant full intra-cluster ssh root access to/from it\n", node)
169
170   bootstrap.SetupNodeDaemon(cluster_name, node, opts.ssh_key_check)
171
172   op = opcodes.OpAddNode(node_name=args[0], secondary_ip=sip,
173                          readd=opts.readd)
174   SubmitOpCode(op)
175
176
177 def ListNodes(opts, args):
178   """List nodes and their properties.
179
180   @param opts: the command line options selected by the user
181   @type args: list
182   @param args: should be an empty list
183   @rtype: int
184   @return: the desired exit code
185
186   """
187   if opts.output is None:
188     selected_fields = _LIST_DEF_FIELDS
189   elif opts.output.startswith("+"):
190     selected_fields = _LIST_DEF_FIELDS + opts.output[1:].split(",")
191   else:
192     selected_fields = opts.output.split(",")
193
194   output = GetClient().QueryNodes(args, selected_fields, opts.do_locking)
195
196   if not opts.no_headers:
197     headers = _LIST_HEADERS
198   else:
199     headers = None
200
201   unitfields = ["dtotal", "dfree", "mtotal", "mnode", "mfree"]
202
203   numfields = ["dtotal", "dfree",
204                "mtotal", "mnode", "mfree",
205                "pinst_cnt", "sinst_cnt",
206                "ctotal", "serial_no"]
207
208   list_type_fields = ("pinst_list", "sinst_list", "tags")
209   # change raw values to nicer strings
210   for row in output:
211     for idx, field in enumerate(selected_fields):
212       val = row[idx]
213       if field in list_type_fields:
214         val = ",".join(val)
215       elif field in ('master', 'master_candidate', 'offline', 'drained'):
216         if val:
217           val = 'Y'
218         else:
219           val = 'N'
220       elif field == "ctime" or field == "mtime":
221         val = utils.FormatTime(val)
222       elif val is None:
223         val = "?"
224       row[idx] = str(val)
225
226   data = GenerateTable(separator=opts.separator, headers=headers,
227                        fields=selected_fields, unitfields=unitfields,
228                        numfields=numfields, data=output, units=opts.units)
229   for line in data:
230     ToStdout(line)
231
232   return 0
233
234
235 def EvacuateNode(opts, args):
236   """Relocate all secondary instance from a node.
237
238   @param opts: the command line options selected by the user
239   @type args: list
240   @param args: should be an empty list
241   @rtype: int
242   @return: the desired exit code
243
244   """
245   cl = GetClient()
246   force = opts.force
247
248   dst_node = opts.dst_node
249   iallocator = opts.iallocator
250
251   cnt = [dst_node, iallocator].count(None)
252   if cnt != 1:
253     raise errors.OpPrereqError("One and only one of the -n and -I"
254                                " options must be passed", errors.ECODE_INVAL)
255
256   selected_fields = ["name", "sinst_list"]
257   src_node = args[0]
258
259   result = cl.QueryNodes(names=[src_node], fields=selected_fields,
260                          use_locking=False)
261   src_node, sinst = result[0]
262
263   if not sinst:
264     ToStderr("No secondary instances on node %s, exiting.", src_node)
265     return constants.EXIT_SUCCESS
266
267   if dst_node is not None:
268     result = cl.QueryNodes(names=[dst_node], fields=["name"],
269                            use_locking=False)
270     dst_node = result[0][0]
271
272     if src_node == dst_node:
273       raise errors.OpPrereqError("Evacuate node needs different source and"
274                                  " target nodes (node %s given twice)" %
275                                  src_node, errors.ECODE_INVAL)
276     txt_msg = "to node %s" % dst_node
277   else:
278     txt_msg = "using iallocator %s" % iallocator
279
280   sinst = utils.NiceSort(sinst)
281
282   if not force and not AskUser("Relocate instance(s) %s from node\n"
283                                " %s %s?" %
284                                (",".join("'%s'" % name for name in sinst),
285                                src_node, txt_msg)):
286     return constants.EXIT_CONFIRMATION
287
288   op = opcodes.OpEvacuateNode(node_name=args[0], remote_node=dst_node,
289                               iallocator=iallocator)
290   SubmitOpCode(op, cl=cl)
291
292
293 def FailoverNode(opts, args):
294   """Failover all primary instance on a node.
295
296   @param opts: the command line options selected by the user
297   @type args: list
298   @param args: should be an empty list
299   @rtype: int
300   @return: the desired exit code
301
302   """
303   cl = GetClient()
304   force = opts.force
305   selected_fields = ["name", "pinst_list"]
306
307   # these fields are static data anyway, so it doesn't matter, but
308   # locking=True should be safer
309   result = cl.QueryNodes(names=args, fields=selected_fields,
310                          use_locking=False)
311   node, pinst = result[0]
312
313   if not pinst:
314     ToStderr("No primary instances on node %s, exiting.", node)
315     return 0
316
317   pinst = utils.NiceSort(pinst)
318
319   retcode = 0
320
321   if not force and not AskUser("Fail over instance(s) %s?" %
322                                (",".join("'%s'" % name for name in pinst))):
323     return 2
324
325   jex = JobExecutor(cl=cl)
326   for iname in pinst:
327     op = opcodes.OpFailoverInstance(instance_name=iname,
328                                     ignore_consistency=opts.ignore_consistency)
329     jex.QueueJob(iname, op)
330   results = jex.GetResults()
331   bad_cnt = len([row for row in results if not row[0]])
332   if bad_cnt == 0:
333     ToStdout("All %d instance(s) failed over successfully.", len(results))
334   else:
335     ToStdout("There were errors during the failover:\n"
336              "%d error(s) out of %d instance(s).", bad_cnt, len(results))
337   return retcode
338
339
340 def MigrateNode(opts, args):
341   """Migrate all primary instance on a node.
342
343   """
344   cl = GetClient()
345   force = opts.force
346   selected_fields = ["name", "pinst_list"]
347
348   result = cl.QueryNodes(names=args, fields=selected_fields, use_locking=False)
349   node, pinst = result[0]
350
351   if not pinst:
352     ToStdout("No primary instances on node %s, exiting." % node)
353     return 0
354
355   pinst = utils.NiceSort(pinst)
356
357   retcode = 0
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)
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)
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)
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)
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)
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 == "yes")
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)
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)
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   if opts.master_candidate is not None:
621     candidate = opts.master_candidate == 'yes'
622   else:
623     candidate = None
624   if opts.offline is not None:
625     offline = opts.offline == 'yes'
626   else:
627     offline = None
628
629   if opts.drained is not None:
630     drained = opts.drained == 'yes'
631   else:
632     drained = None
633   op = opcodes.OpSetNodeParams(node_name=args[0],
634                                master_candidate=candidate,
635                                offline=offline,
636                                drained=drained,
637                                force=opts.force)
638
639   # even if here we process the result, we allow submit only
640   result = SubmitOrSend(op, opts)
641
642   if result:
643     ToStdout("Modified node %s", args[0])
644     for param, data in result:
645       ToStdout(" - %-5s -> %s", param, data)
646   return 0
647
648
649 commands = {
650   'add': (
651     AddNode, [ArgHost(min=1, max=1)],
652     [SECONDARY_IP_OPT, READD_OPT, NOSSH_KEYCHECK_OPT],
653     "[-s ip] [--readd] [--no-ssh-key-check] <node_name>",
654     "Add a node to the cluster"),
655   'evacuate': (
656     EvacuateNode, ARGS_ONE_NODE,
657     [FORCE_OPT, IALLOCATOR_OPT, NEW_SECONDARY_OPT],
658     "[-f] {-I <iallocator> | -n <dst>} <node>",
659     "Relocate the secondary instances from a node"
660     " to other nodes (only for instances with drbd disk template)"),
661   'failover': (
662     FailoverNode, ARGS_ONE_NODE, [FORCE_OPT, IGNORE_CONSIST_OPT],
663     "[-f] <node>",
664     "Stops the primary instances on a node and start them on their"
665     " secondary node (only for instances with drbd disk template)"),
666   'migrate': (
667     MigrateNode, ARGS_ONE_NODE, [FORCE_OPT, NONLIVE_OPT],
668     "[-f] <node>",
669     "Migrate all the primary instance on a node away from it"
670     " (only for instances of type drbd)"),
671   'info': (
672     ShowNodeConfig, ARGS_MANY_NODES, [],
673     "[<node_name>...]", "Show information about the node(s)"),
674   'list': (
675     ListNodes, ARGS_MANY_NODES,
676     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, SYNC_OPT],
677     "[nodes...]",
678     "Lists the nodes in the cluster. The available fields are (see the man"
679     " page for details): %s. The default field list is (in order): %s." %
680     (utils.CommaJoin(_LIST_HEADERS), utils.CommaJoin(_LIST_DEF_FIELDS))),
681   'modify': (
682     SetNodeParams, ARGS_ONE_NODE,
683     [FORCE_OPT, SUBMIT_OPT, MC_OPT, DRAINED_OPT, OFFLINE_OPT],
684     "<node_name>", "Alters the parameters of a node"),
685   'powercycle': (
686     PowercycleNode, ARGS_ONE_NODE,
687     [FORCE_OPT, CONFIRM_OPT],
688     "<node_name>", "Tries to forcefully powercycle a node"),
689   'remove': (
690     RemoveNode, ARGS_ONE_NODE, [],
691     "<node_name>", "Removes a node from the cluster"),
692   'volumes': (
693     ListVolumes, [ArgNode()],
694     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT],
695     "[<node_name>...]", "List logical volumes on node(s)"),
696   'list-storage': (
697     ListStorage, ARGS_MANY_NODES,
698     [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, _STORAGE_TYPE_OPT],
699     "[<node_name>...]", "List physical volumes on node(s). The available"
700     " fields are (see the man page for details): %s." %
701     (utils.CommaJoin(_LIST_STOR_HEADERS))),
702   'modify-storage': (
703     ModifyStorage,
704     [ArgNode(min=1, max=1),
705      ArgChoice(min=1, max=1, choices=_MODIFIABLE_STORAGE_TYPES),
706      ArgFile(min=1, max=1)],
707     [ALLOCATABLE_OPT],
708     "<node_name> <storage_type> <name>", "Modify storage volume on a node"),
709   'repair-storage': (
710     RepairStorage,
711     [ArgNode(min=1, max=1),
712      ArgChoice(min=1, max=1, choices=_REPAIRABLE_STORAGE_TYPES),
713      ArgFile(min=1, max=1)],
714     [IGNORE_CONSIST_OPT],
715     "<node_name> <storage_type> <name>",
716     "Repairs a storage volume on a node"),
717   'list-tags': (
718     ListTags, ARGS_ONE_NODE, [],
719     "<node_name>", "List the tags of the given node"),
720   'add-tags': (
721     AddTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
722     "<node_name> tag...", "Add tags to the given node"),
723   'remove-tags': (
724     RemoveTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT],
725     "<node_name> tag...", "Remove tags from the given node"),
726   }
727
728
729 if __name__ == '__main__':
730   sys.exit(GenericMain(commands, override={"tag_type": constants.TAG_NODE}))