1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 """Node related commands"""
22
23
24
25
26
27
28
29 from ganeti.cli import *
30 from ganeti import cli
31 from ganeti import bootstrap
32 from ganeti import opcodes
33 from ganeti import utils
34 from ganeti import constants
35 from ganeti import errors
36 from ganeti import netutils
37 from cStringIO import StringIO
38
39
40
41 _LIST_DEF_FIELDS = [
42 "name", "dtotal", "dfree",
43 "mtotal", "mnode", "mfree",
44 "pinst_cnt", "sinst_cnt",
45 ]
46
47
48
49 _LIST_VOL_DEF_FIELDS = ["node", "phys", "vg", "name", "size", "instance"]
50
51
52
53 _LIST_STOR_DEF_FIELDS = [
54 constants.SF_NODE,
55 constants.SF_TYPE,
56 constants.SF_NAME,
57 constants.SF_SIZE,
58 constants.SF_USED,
59 constants.SF_FREE,
60 constants.SF_ALLOCATABLE,
61 ]
62
63
64
65 _LIST_POWER_COMMANDS = ["on", "off", "cycle", "status"]
66
67
68
69 _LIST_STOR_HEADERS = {
70 constants.SF_NODE: "Node",
71 constants.SF_TYPE: "Type",
72 constants.SF_NAME: "Name",
73 constants.SF_SIZE: "Size",
74 constants.SF_USED: "Used",
75 constants.SF_FREE: "Free",
76 constants.SF_ALLOCATABLE: "Allocatable",
77 }
78
79
80
81 _USER_STORAGE_TYPE = {
82 constants.ST_FILE: "file",
83 constants.ST_LVM_PV: "lvm-pv",
84 constants.ST_LVM_VG: "lvm-vg",
85 }
86
87 _STORAGE_TYPE_OPT = \
88 cli_option("-t", "--storage-type",
89 dest="user_storage_type",
90 choices=_USER_STORAGE_TYPE.keys(),
91 default=None,
92 metavar="STORAGE_TYPE",
93 help=("Storage type (%s)" %
94 utils.CommaJoin(_USER_STORAGE_TYPE.keys())))
95
96 _REPAIRABLE_STORAGE_TYPES = \
97 [st for st, so in constants.VALID_STORAGE_OPERATIONS.iteritems()
98 if constants.SO_FIX_CONSISTENCY in so]
99
100 _MODIFIABLE_STORAGE_TYPES = constants.MODIFIABLE_STORAGE_FIELDS.keys()
101
102
103 NONODE_SETUP_OPT = cli_option("--no-node-setup", default=True,
104 action="store_false", dest="node_setup",
105 help=("Do not make initial SSH setup on remote"
106 " node (needs to be done manually)"))
118
121 """Wrapper around utils.RunCmd to call setup-ssh
122
123 @param options: The command line options
124 @param nodes: The nodes to setup
125
126 """
127 cmd = [constants.SETUP_SSH]
128
129
130
131 if options.debug:
132 cmd.append("--debug")
133 elif options.verbose:
134 cmd.append("--verbose")
135 if not options.ssh_key_check:
136 cmd.append("--no-ssh-key-check")
137 if options.force_join:
138 cmd.append("--force-join")
139
140 cmd.extend(nodes)
141
142 result = utils.RunCmd(cmd, interactive=True)
143
144 if result.failed:
145 errmsg = ("Command '%s' failed with exit code %s; output %r" %
146 (result.cmd, result.exit_code, result.output))
147 raise errors.OpExecError(errmsg)
148
149
150 @UsesRPC
151 -def AddNode(opts, args):
152 """Add a node to the cluster.
153
154 @param opts: the command line options selected by the user
155 @type args: list
156 @param args: should contain only one element, the new node name
157 @rtype: int
158 @return: the desired exit code
159
160 """
161 cl = GetClient()
162 node = netutils.GetHostname(name=args[0]).name
163 readd = opts.readd
164
165 try:
166 output = cl.QueryNodes(names=[node], fields=['name', 'sip', 'master'],
167 use_locking=False)
168 node_exists, sip, is_master = output[0]
169 except (errors.OpPrereqError, errors.OpExecError):
170 node_exists = ""
171 sip = None
172
173 if readd:
174 if not node_exists:
175 ToStderr("Node %s not in the cluster"
176 " - please retry without '--readd'", node)
177 return 1
178 if is_master:
179 ToStderr("Node %s is the master, cannot readd", node)
180 return 1
181 else:
182 if node_exists:
183 ToStderr("Node %s already in the cluster (as %s)"
184 " - please retry with '--readd'", node, node_exists)
185 return 1
186 sip = opts.secondary_ip
187
188
189 output = cl.QueryConfigValues(['cluster_name'])
190 cluster_name = output[0]
191
192 if not readd and opts.node_setup:
193 ToStderr("-- WARNING -- \n"
194 "Performing this operation is going to replace the ssh daemon"
195 " keypair\n"
196 "on the target machine (%s) with the ones of the"
197 " current one\n"
198 "and grant full intra-cluster ssh root access to/from it\n", node)
199
200 if opts.node_setup:
201 _RunSetupSSH(opts, [node])
202
203 bootstrap.SetupNodeDaemon(cluster_name, node, opts.ssh_key_check)
204
205 op = opcodes.OpNodeAdd(node_name=args[0], secondary_ip=sip,
206 readd=opts.readd, group=opts.nodegroup,
207 vm_capable=opts.vm_capable, ndparams=opts.ndparams,
208 master_capable=opts.master_capable)
209 SubmitOpCode(op, opts=opts)
210
213 """List nodes and their properties.
214
215 @param opts: the command line options selected by the user
216 @type args: list
217 @param args: nodes to list, or empty for all
218 @rtype: int
219 @return: the desired exit code
220
221 """
222 selected_fields = ParseFields(opts.output, _LIST_DEF_FIELDS)
223
224 fmtoverride = dict.fromkeys(["pinst_list", "sinst_list", "tags"],
225 (",".join, False))
226
227 return GenericList(constants.QR_NODE, selected_fields, args, opts.units,
228 opts.separator, not opts.no_headers,
229 format_override=fmtoverride, verbose=opts.verbose)
230
233 """List node fields.
234
235 @param opts: the command line options selected by the user
236 @type args: list
237 @param args: fields to list, or empty for all
238 @rtype: int
239 @return: the desired exit code
240
241 """
242 return GenericListFields(constants.QR_NODE, args, opts.separator,
243 not opts.no_headers)
244
247 """Relocate all secondary instance from a node.
248
249 @param opts: the command line options selected by the user
250 @type args: list
251 @param args: should be an empty list
252 @rtype: int
253 @return: the desired exit code
254
255 """
256 cl = GetClient()
257 force = opts.force
258
259 dst_node = opts.dst_node
260 iallocator = opts.iallocator
261
262 op = opcodes.OpNodeEvacStrategy(nodes=args,
263 iallocator=iallocator,
264 remote_node=dst_node)
265
266 result = SubmitOpCode(op, cl=cl, opts=opts)
267 if not result:
268
269 ToStderr("No secondary instances on node(s) %s, exiting.",
270 utils.CommaJoin(args))
271 return constants.EXIT_SUCCESS
272
273 if not force and not AskUser("Relocate instance(s) %s from node(s) %s?" %
274 (",".join("'%s'" % name[0] for name in result),
275 utils.CommaJoin(args))):
276 return constants.EXIT_CONFIRMATION
277
278 jex = JobExecutor(cl=cl, opts=opts)
279 for row in result:
280 iname = row[0]
281 node = row[1]
282 ToStdout("Will relocate instance %s to node %s", iname, node)
283 op = opcodes.OpInstanceReplaceDisks(instance_name=iname,
284 remote_node=node, disks=[],
285 mode=constants.REPLACE_DISK_CHG,
286 early_release=opts.early_release)
287 jex.QueueJob(iname, op)
288 results = jex.GetResults()
289 bad_cnt = len([row for row in results if not row[0]])
290 if bad_cnt == 0:
291 ToStdout("All %d instance(s) failed over successfully.", len(results))
292 rcode = constants.EXIT_SUCCESS
293 else:
294 ToStdout("There were errors during the failover:\n"
295 "%d error(s) out of %d instance(s).", bad_cnt, len(results))
296 rcode = constants.EXIT_FAILURE
297 return rcode
298
301 """Failover all primary instance on a node.
302
303 @param opts: the command line options selected by the user
304 @type args: list
305 @param args: should be an empty list
306 @rtype: int
307 @return: the desired exit code
308
309 """
310 cl = GetClient()
311 force = opts.force
312 selected_fields = ["name", "pinst_list"]
313
314
315
316 result = cl.QueryNodes(names=args, fields=selected_fields,
317 use_locking=False)
318 node, pinst = result[0]
319
320 if not pinst:
321 ToStderr("No primary instances on node %s, exiting.", node)
322 return 0
323
324 pinst = utils.NiceSort(pinst)
325
326 retcode = 0
327
328 if not force and not AskUser("Fail over instance(s) %s?" %
329 (",".join("'%s'" % name for name in pinst))):
330 return 2
331
332 jex = JobExecutor(cl=cl, opts=opts)
333 for iname in pinst:
334 op = opcodes.OpInstanceFailover(instance_name=iname,
335 ignore_consistency=opts.ignore_consistency)
336 jex.QueueJob(iname, op)
337 results = jex.GetResults()
338 bad_cnt = len([row for row in results if not row[0]])
339 if bad_cnt == 0:
340 ToStdout("All %d instance(s) failed over successfully.", len(results))
341 else:
342 ToStdout("There were errors during the failover:\n"
343 "%d error(s) out of %d instance(s).", bad_cnt, len(results))
344 return retcode
345
348 """Migrate all primary instance on a node.
349
350 """
351 cl = GetClient()
352 force = opts.force
353 selected_fields = ["name", "pinst_list"]
354
355 result = cl.QueryNodes(names=args, fields=selected_fields, use_locking=False)
356 node, pinst = result[0]
357
358 if not pinst:
359 ToStdout("No primary instances on node %s, exiting." % node)
360 return 0
361
362 pinst = utils.NiceSort(pinst)
363
364 if not force and not AskUser("Migrate instance(s) %s?" %
365 (",".join("'%s'" % name for name in pinst))):
366 return 2
367
368
369 if not opts.live and opts.migration_mode is not None:
370 raise errors.OpPrereqError("Only one of the --non-live and "
371 "--migration-mode options can be passed",
372 errors.ECODE_INVAL)
373 if not opts.live:
374 mode = constants.HT_MIGRATION_NONLIVE
375 else:
376 mode = opts.migration_mode
377 op = opcodes.OpNodeMigrate(node_name=args[0], mode=mode)
378 SubmitOpCode(op, cl=cl, opts=opts)
379
382 """Show node information.
383
384 @param opts: the command line options selected by the user
385 @type args: list
386 @param args: should either be an empty list, in which case
387 we show information about all nodes, or should contain
388 a list of nodes to be queried for information
389 @rtype: int
390 @return: the desired exit code
391
392 """
393 cl = GetClient()
394 result = cl.QueryNodes(fields=["name", "pip", "sip",
395 "pinst_list", "sinst_list",
396 "master_candidate", "drained", "offline",
397 "master_capable", "vm_capable", "powered",
398 "ndparams", "custom_ndparams"],
399 names=args, use_locking=False)
400
401 for (name, primary_ip, secondary_ip, pinst, sinst, is_mc, drained, offline,
402 master_capable, vm_capable, powered, ndparams,
403 ndparams_custom) in result:
404 ToStdout("Node name: %s", name)
405 ToStdout(" primary ip: %s", primary_ip)
406 ToStdout(" secondary ip: %s", secondary_ip)
407 ToStdout(" master candidate: %s", is_mc)
408 ToStdout(" drained: %s", drained)
409 ToStdout(" offline: %s", offline)
410 if powered is not None:
411 ToStdout(" powered: %s", powered)
412 ToStdout(" master_capable: %s", master_capable)
413 ToStdout(" vm_capable: %s", vm_capable)
414 if vm_capable:
415 if pinst:
416 ToStdout(" primary for instances:")
417 for iname in utils.NiceSort(pinst):
418 ToStdout(" - %s", iname)
419 else:
420 ToStdout(" primary for no instances")
421 if sinst:
422 ToStdout(" secondary for instances:")
423 for iname in utils.NiceSort(sinst):
424 ToStdout(" - %s", iname)
425 else:
426 ToStdout(" secondary for no instances")
427 ToStdout(" node parameters:")
428 buf = StringIO()
429 FormatParameterDict(buf, ndparams_custom, ndparams, level=2)
430 ToStdout(buf.getvalue().rstrip("\n"))
431
432 return 0
433
436 """Remove a node from the cluster.
437
438 @param opts: the command line options selected by the user
439 @type args: list
440 @param args: should contain only one element, the name of
441 the node to be removed
442 @rtype: int
443 @return: the desired exit code
444
445 """
446 op = opcodes.OpNodeRemove(node_name=args[0])
447 SubmitOpCode(op, opts=opts)
448 return 0
449
452 """Remove a node from the cluster.
453
454 @param opts: the command line options selected by the user
455 @type args: list
456 @param args: should contain only one element, the name of
457 the node to be removed
458 @rtype: int
459 @return: the desired exit code
460
461 """
462 node = args[0]
463 if (not opts.confirm and
464 not AskUser("Are you sure you want to hard powercycle node %s?" % node)):
465 return 2
466
467 op = opcodes.OpNodePowercycle(node_name=node, force=opts.force)
468 result = SubmitOpCode(op, opts=opts)
469 if result:
470 ToStderr(result)
471 return 0
472
529
532 """List logical volumes on node(s).
533
534 @param opts: the command line options selected by the user
535 @type args: list
536 @param args: should either be an empty list, in which case
537 we list data for all nodes, or contain a list of nodes
538 to display data only for those
539 @rtype: int
540 @return: the desired exit code
541
542 """
543 selected_fields = ParseFields(opts.output, _LIST_VOL_DEF_FIELDS)
544
545 op = opcodes.OpNodeQueryvols(nodes=args, output_fields=selected_fields)
546 output = SubmitOpCode(op, opts=opts)
547
548 if not opts.no_headers:
549 headers = {"node": "Node", "phys": "PhysDev",
550 "vg": "VG", "name": "Name",
551 "size": "Size", "instance": "Instance"}
552 else:
553 headers = None
554
555 unitfields = ["size"]
556
557 numfields = ["size"]
558
559 data = GenerateTable(separator=opts.separator, headers=headers,
560 fields=selected_fields, unitfields=unitfields,
561 numfields=numfields, data=output, units=opts.units)
562
563 for line in data:
564 ToStdout(line)
565
566 return 0
567
570 """List physical volumes on node(s).
571
572 @param opts: the command line options selected by the user
573 @type args: list
574 @param args: should either be an empty list, in which case
575 we list data for all nodes, or contain a list of nodes
576 to display data only for those
577 @rtype: int
578 @return: the desired exit code
579
580 """
581
582 if opts.user_storage_type is None:
583 opts.user_storage_type = constants.ST_LVM_PV
584
585 storage_type = ConvertStorageType(opts.user_storage_type)
586
587 selected_fields = ParseFields(opts.output, _LIST_STOR_DEF_FIELDS)
588
589 op = opcodes.OpNodeQueryStorage(nodes=args,
590 storage_type=storage_type,
591 output_fields=selected_fields)
592 output = SubmitOpCode(op, opts=opts)
593
594 if not opts.no_headers:
595 headers = {
596 constants.SF_NODE: "Node",
597 constants.SF_TYPE: "Type",
598 constants.SF_NAME: "Name",
599 constants.SF_SIZE: "Size",
600 constants.SF_USED: "Used",
601 constants.SF_FREE: "Free",
602 constants.SF_ALLOCATABLE: "Allocatable",
603 }
604 else:
605 headers = None
606
607 unitfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
608 numfields = [constants.SF_SIZE, constants.SF_USED, constants.SF_FREE]
609
610
611 for row in output:
612 for idx, field in enumerate(selected_fields):
613 val = row[idx]
614 if field == constants.SF_ALLOCATABLE:
615 if val:
616 val = "Y"
617 else:
618 val = "N"
619 row[idx] = str(val)
620
621 data = GenerateTable(separator=opts.separator, headers=headers,
622 fields=selected_fields, unitfields=unitfields,
623 numfields=numfields, data=output, units=opts.units)
624
625 for line in data:
626 ToStdout(line)
627
628 return 0
629
632 """Modify storage volume on a node.
633
634 @param opts: the command line options selected by the user
635 @type args: list
636 @param args: should contain 3 items: node name, storage type and volume name
637 @rtype: int
638 @return: the desired exit code
639
640 """
641 (node_name, user_storage_type, volume_name) = args
642
643 storage_type = ConvertStorageType(user_storage_type)
644
645 changes = {}
646
647 if opts.allocatable is not None:
648 changes[constants.SF_ALLOCATABLE] = opts.allocatable
649
650 if changes:
651 op = opcodes.OpNodeModifyStorage(node_name=node_name,
652 storage_type=storage_type,
653 name=volume_name,
654 changes=changes)
655 SubmitOpCode(op, opts=opts)
656 else:
657 ToStderr("No changes to perform, exiting.")
658
661 """Repairs a storage volume on a node.
662
663 @param opts: the command line options selected by the user
664 @type args: list
665 @param args: should contain 3 items: node name, storage type and volume name
666 @rtype: int
667 @return: the desired exit code
668
669 """
670 (node_name, user_storage_type, volume_name) = args
671
672 storage_type = ConvertStorageType(user_storage_type)
673
674 op = opcodes.OpRepairNodeStorage(node_name=node_name,
675 storage_type=storage_type,
676 name=volume_name,
677 ignore_consistency=opts.ignore_consistency)
678 SubmitOpCode(op, opts=opts)
679
682 """Modifies a node.
683
684 @param opts: the command line options selected by the user
685 @type args: list
686 @param args: should contain only one element, the node name
687 @rtype: int
688 @return: the desired exit code
689
690 """
691 all_changes = [opts.master_candidate, opts.drained, opts.offline,
692 opts.master_capable, opts.vm_capable, opts.secondary_ip,
693 opts.ndparams]
694 if all_changes.count(None) == len(all_changes):
695 ToStderr("Please give at least one of the parameters.")
696 return 1
697
698 op = opcodes.OpNodeSetParams(node_name=args[0],
699 master_candidate=opts.master_candidate,
700 offline=opts.offline,
701 drained=opts.drained,
702 master_capable=opts.master_capable,
703 vm_capable=opts.vm_capable,
704 secondary_ip=opts.secondary_ip,
705 force=opts.force,
706 ndparams=opts.ndparams,
707 auto_promote=opts.auto_promote,
708 powered=opts.node_powered)
709
710
711 result = SubmitOrSend(op, opts)
712
713 if result:
714 ToStdout("Modified node %s", args[0])
715 for param, data in result:
716 ToStdout(" - %-5s -> %s", param, data)
717 return 0
718
719
720 commands = {
721 'add': (
722 AddNode, [ArgHost(min=1, max=1)],
723 [SECONDARY_IP_OPT, READD_OPT, NOSSH_KEYCHECK_OPT, NODE_FORCE_JOIN_OPT,
724 NONODE_SETUP_OPT, VERBOSE_OPT, NODEGROUP_OPT, PRIORITY_OPT,
725 CAPAB_MASTER_OPT, CAPAB_VM_OPT, NODE_PARAMS_OPT],
726 "[-s ip] [--readd] [--no-ssh-key-check] [--force-join]"
727 " [--no-node-setup] [--verbose]"
728 " <node_name>",
729 "Add a node to the cluster"),
730 'evacuate': (
731 EvacuateNode, [ArgNode(min=1)],
732 [FORCE_OPT, IALLOCATOR_OPT, NEW_SECONDARY_OPT, EARLY_RELEASE_OPT,
733 PRIORITY_OPT],
734 "[-f] {-I <iallocator> | -n <dst>} <node>",
735 "Relocate the secondary instances from a node"
736 " to other nodes (only for instances with drbd disk template)"),
737 'failover': (
738 FailoverNode, ARGS_ONE_NODE, [FORCE_OPT, IGNORE_CONSIST_OPT, PRIORITY_OPT],
739 "[-f] <node>",
740 "Stops the primary instances on a node and start them on their"
741 " secondary node (only for instances with drbd disk template)"),
742 'migrate': (
743 MigrateNode, ARGS_ONE_NODE,
744 [FORCE_OPT, NONLIVE_OPT, MIGRATION_MODE_OPT, PRIORITY_OPT],
745 "[-f] <node>",
746 "Migrate all the primary instance on a node away from it"
747 " (only for instances of type drbd)"),
748 'info': (
749 ShowNodeConfig, ARGS_MANY_NODES, [],
750 "[<node_name>...]", "Show information about the node(s)"),
751 'list': (
752 ListNodes, ARGS_MANY_NODES,
753 [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, VERBOSE_OPT],
754 "[nodes...]",
755 "Lists the nodes in the cluster. The available fields can be shown using"
756 " the \"list-fields\" command (see the man page for details)."
757 " The default field list is (in order): %s." %
758 utils.CommaJoin(_LIST_DEF_FIELDS)),
759 "list-fields": (
760 ListNodeFields, [ArgUnknown()],
761 [NOHDR_OPT, SEP_OPT],
762 "[fields...]",
763 "Lists all available fields for nodes"),
764 'modify': (
765 SetNodeParams, ARGS_ONE_NODE,
766 [FORCE_OPT, SUBMIT_OPT, MC_OPT, DRAINED_OPT, OFFLINE_OPT,
767 CAPAB_MASTER_OPT, CAPAB_VM_OPT, SECONDARY_IP_OPT,
768 AUTO_PROMOTE_OPT, DRY_RUN_OPT, PRIORITY_OPT, NODE_PARAMS_OPT,
769 NODE_POWERED_OPT],
770 "<node_name>", "Alters the parameters of a node"),
771 'powercycle': (
772 PowercycleNode, ARGS_ONE_NODE,
773 [FORCE_OPT, CONFIRM_OPT, DRY_RUN_OPT, PRIORITY_OPT],
774 "<node_name>", "Tries to forcefully powercycle a node"),
775 'power': (
776 PowerNode,
777 [ArgChoice(min=1, max=1, choices=_LIST_POWER_COMMANDS),
778 ArgNode(min=1, max=1)],
779 [SUBMIT_OPT, AUTO_PROMOTE_OPT, PRIORITY_OPT],
780 "on|off|cycle|status <node>",
781 "Change power state of node by calling out-of-band helper."),
782 'remove': (
783 RemoveNode, ARGS_ONE_NODE, [DRY_RUN_OPT, PRIORITY_OPT],
784 "<node_name>", "Removes a node from the cluster"),
785 'volumes': (
786 ListVolumes, [ArgNode()],
787 [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, PRIORITY_OPT],
788 "[<node_name>...]", "List logical volumes on node(s)"),
789 'list-storage': (
790 ListStorage, ARGS_MANY_NODES,
791 [NOHDR_OPT, SEP_OPT, USEUNITS_OPT, FIELDS_OPT, _STORAGE_TYPE_OPT,
792 PRIORITY_OPT],
793 "[<node_name>...]", "List physical volumes on node(s). The available"
794 " fields are (see the man page for details): %s." %
795 (utils.CommaJoin(_LIST_STOR_HEADERS))),
796 'modify-storage': (
797 ModifyStorage,
798 [ArgNode(min=1, max=1),
799 ArgChoice(min=1, max=1, choices=_MODIFIABLE_STORAGE_TYPES),
800 ArgFile(min=1, max=1)],
801 [ALLOCATABLE_OPT, DRY_RUN_OPT, PRIORITY_OPT],
802 "<node_name> <storage_type> <name>", "Modify storage volume on a node"),
803 'repair-storage': (
804 RepairStorage,
805 [ArgNode(min=1, max=1),
806 ArgChoice(min=1, max=1, choices=_REPAIRABLE_STORAGE_TYPES),
807 ArgFile(min=1, max=1)],
808 [IGNORE_CONSIST_OPT, DRY_RUN_OPT, PRIORITY_OPT],
809 "<node_name> <storage_type> <name>",
810 "Repairs a storage volume on a node"),
811 'list-tags': (
812 ListTags, ARGS_ONE_NODE, [],
813 "<node_name>", "List the tags of the given node"),
814 'add-tags': (
815 AddTags, [ArgNode(min=1, max=1), ArgUnknown()], [TAG_SRC_OPT, PRIORITY_OPT],
816 "<node_name> tag...", "Add tags to the given node"),
817 'remove-tags': (
818 RemoveTags, [ArgNode(min=1, max=1), ArgUnknown()],
819 [TAG_SRC_OPT, PRIORITY_OPT],
820 "<node_name> tag...", "Remove tags from the given node"),
821 }
825 return GenericMain(commands, override={"tag_type": constants.TAG_NODE})
826