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