Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / rlib2.py @ 2197b66f

History | View | Annotate | Download (20.9 kB)

1
#
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
"""Remote API version 2 baserlib.library.
23

24
"""
25

    
26
# pylint: disable-msg=C0103
27

    
28
# C0103: Invalid name, since the R_* names are not conforming
29

    
30
from ganeti import opcodes
31
from ganeti import http
32
from ganeti import constants
33
from ganeti import cli
34
from ganeti import rapi
35
from ganeti.rapi import baserlib
36

    
37

    
38
_COMMON_FIELDS = ["ctime", "mtime", "uuid", "serial_no", "tags"]
39
I_FIELDS = ["name", "admin_state", "os",
40
            "pnode", "snodes",
41
            "disk_template",
42
            "nic.ips", "nic.macs", "nic.modes", "nic.links", "nic.bridges",
43
            "network_port",
44
            "disk.sizes", "disk_usage",
45
            "beparams", "hvparams",
46
            "oper_state", "oper_ram", "status",
47
            ] + _COMMON_FIELDS
48

    
49
N_FIELDS = ["name", "offline", "master_candidate", "drained",
50
            "dtotal", "dfree",
51
            "mtotal", "mnode", "mfree",
52
            "pinst_cnt", "sinst_cnt",
53
            "ctotal", "cnodes", "csockets",
54
            "pip", "sip", "role",
55
            "pinst_list", "sinst_list",
56
            ] + _COMMON_FIELDS
57

    
58
_NR_DRAINED = "drained"
59
_NR_MASTER_CANDIATE = "master-candidate"
60
_NR_MASTER = "master"
61
_NR_OFFLINE = "offline"
62
_NR_REGULAR = "regular"
63

    
64
_NR_MAP = {
65
  "M": _NR_MASTER,
66
  "C": _NR_MASTER_CANDIATE,
67
  "D": _NR_DRAINED,
68
  "O": _NR_OFFLINE,
69
  "R": _NR_REGULAR,
70
  }
71

    
72

    
73
class R_version(baserlib.R_Generic):
74
  """/version resource.
75

76
  This resource should be used to determine the remote API version and
77
  to adapt clients accordingly.
78

79
  """
80
  @staticmethod
81
  def GET():
82
    """Returns the remote API version.
83

84
    """
85
    return constants.RAPI_VERSION
86

    
87

    
88
class R_2_info(baserlib.R_Generic):
89
  """Cluster info.
90

91
  """
92
  @staticmethod
93
  def GET():
94
    """Returns cluster information.
95

96
    """
97
    client = baserlib.GetClient()
98
    return client.QueryClusterInfo()
99

    
100

    
101
class R_2_os(baserlib.R_Generic):
102
  """/2/os resource.
103

104
  """
105
  @staticmethod
106
  def GET():
107
    """Return a list of all OSes.
108

109
    Can return error 500 in case of a problem.
110

111
    Example: ["debian-etch"]
112

113
    """
114
    cl = baserlib.GetClient()
115
    op = opcodes.OpDiagnoseOS(output_fields=["name", "valid", "variants"],
116
                              names=[])
117
    job_id = baserlib.SubmitJob([op], cl)
118
    # we use custom feedback function, instead of print we log the status
119
    result = cli.PollJob(job_id, cl, feedback_fn=baserlib.FeedbackFn)
120
    diagnose_data = result[0]
121

    
122
    if not isinstance(diagnose_data, list):
123
      raise http.HttpBadGateway(message="Can't get OS list")
124

    
125
    os_names = []
126
    for (name, valid, variants) in diagnose_data:
127
      if valid:
128
        os_names.extend(cli.CalculateOSNames(name, variants))
129

    
130
    return os_names
131

    
132

    
133
class R_2_redist_config(baserlib.R_Generic):
134
  """/2/redistribute-config resource.
135

136
  """
137
  @staticmethod
138
  def PUT():
139
    """Redistribute configuration to all nodes.
140

141
    """
142
    return baserlib.SubmitJob([opcodes.OpRedistributeConfig()])
143

    
144

    
145
class R_2_jobs(baserlib.R_Generic):
146
  """/2/jobs resource.
147

148
  """
149
  @staticmethod
150
  def GET():
151
    """Returns a dictionary of jobs.
152

153
    @return: a dictionary with jobs id and uri.
154

155
    """
156
    fields = ["id"]
157
    cl = baserlib.GetClient()
158
    # Convert the list of lists to the list of ids
159
    result = [job_id for [job_id] in cl.QueryJobs(None, fields)]
160
    return baserlib.BuildUriList(result, "/2/jobs/%s",
161
                                 uri_fields=("id", "uri"))
162

    
163

    
164
class R_2_jobs_id(baserlib.R_Generic):
165
  """/2/jobs/[job_id] resource.
166

167
  """
168
  def GET(self):
169
    """Returns a job status.
170

171
    @return: a dictionary with job parameters.
172
        The result includes:
173
            - id: job ID as a number
174
            - status: current job status as a string
175
            - ops: involved OpCodes as a list of dictionaries for each
176
              opcodes in the job
177
            - opstatus: OpCodes status as a list
178
            - opresult: OpCodes results as a list of lists
179

180
    """
181
    fields = ["id", "ops", "status", "summary",
182
              "opstatus", "opresult", "oplog",
183
              "received_ts", "start_ts", "end_ts",
184
              ]
185
    job_id = self.items[0]
186
    result = baserlib.GetClient().QueryJobs([job_id, ], fields)[0]
187
    if result is None:
188
      raise http.HttpNotFound()
189
    return baserlib.MapFields(fields, result)
190

    
191
  def DELETE(self):
192
    """Cancel not-yet-started job.
193

194
    """
195
    job_id = self.items[0]
196
    result = baserlib.GetClient().CancelJob(job_id)
197
    return result
198

    
199

    
200
class R_2_nodes(baserlib.R_Generic):
201
  """/2/nodes resource.
202

203
  """
204
  def GET(self):
205
    """Returns a list of all nodes.
206

207
    """
208
    client = baserlib.GetClient()
209

    
210
    if self.useBulk():
211
      bulkdata = client.QueryNodes([], N_FIELDS, False)
212
      return baserlib.MapBulkFields(bulkdata, N_FIELDS)
213
    else:
214
      nodesdata = client.QueryNodes([], ["name"], False)
215
      nodeslist = [row[0] for row in nodesdata]
216
      return baserlib.BuildUriList(nodeslist, "/2/nodes/%s",
217
                                   uri_fields=("id", "uri"))
218

    
219

    
220
class R_2_nodes_name(baserlib.R_Generic):
221
  """/2/nodes/[node_name] resources.
222

223
  """
224
  def GET(self):
225
    """Send information about a node.
226

227
    """
228
    node_name = self.items[0]
229
    client = baserlib.GetClient()
230
    result = client.QueryNodes(names=[node_name], fields=N_FIELDS,
231
                               use_locking=self.useLocking())
232

    
233
    return baserlib.MapFields(N_FIELDS, result[0])
234

    
235

    
236
class R_2_nodes_name_role(baserlib.R_Generic):
237
  """ /2/nodes/[node_name]/role resource.
238

239
  """
240
  def GET(self):
241
    """Returns the current node role.
242

243
    @return: Node role
244

245
    """
246
    node_name = self.items[0]
247
    client = baserlib.GetClient()
248
    result = client.QueryNodes(names=[node_name], fields=["role"],
249
                               use_locking=self.useLocking())
250

    
251
    return _NR_MAP[result[0][0]]
252

    
253
  def PUT(self):
254
    """Sets the node role.
255

256
    @return: a job id
257

258
    """
259
    if not isinstance(self.req.request_body, basestring):
260
      raise http.HttpBadRequest("Invalid body contents, not a string")
261

    
262
    node_name = self.items[0]
263
    role = self.req.request_body
264

    
265
    if role == _NR_REGULAR:
266
      candidate = False
267
      offline = False
268
      drained = False
269

    
270
    elif role == _NR_MASTER_CANDIATE:
271
      candidate = True
272
      offline = drained = None
273

    
274
    elif role == _NR_DRAINED:
275
      drained = True
276
      candidate = offline = None
277

    
278
    elif role == _NR_OFFLINE:
279
      offline = True
280
      candidate = drained = None
281

    
282
    else:
283
      raise http.HttpBadRequest("Can't set '%s' role" % role)
284

    
285
    op = opcodes.OpSetNodeParams(node_name=node_name,
286
                                 master_candidate=candidate,
287
                                 offline=offline,
288
                                 drained=drained,
289
                                 force=bool(self.useForce()))
290

    
291
    return baserlib.SubmitJob([op])
292

    
293

    
294
class R_2_nodes_name_evacuate(baserlib.R_Generic):
295
  """/2/nodes/[node_name]/evacuate resource.
296

297
  """
298
  def POST(self):
299
    """Evacuate all secondary instances off a node.
300

301
    """
302
    node_name = self.items[0]
303
    remote_node = self._checkStringVariable("remote_node", default=None)
304
    iallocator = self._checkStringVariable("iallocator", default=None)
305

    
306
    op = opcodes.OpEvacuateNode(node_name=node_name,
307
                                remote_node=remote_node,
308
                                iallocator=iallocator)
309

    
310
    return baserlib.SubmitJob([op])
311

    
312

    
313
class R_2_nodes_name_migrate(baserlib.R_Generic):
314
  """/2/nodes/[node_name]/migrate resource.
315

316
  """
317
  def POST(self):
318
    """Migrate all primary instances from a node.
319

320
    """
321
    node_name = self.items[0]
322
    live = bool(self._checkIntVariable("live", default=1))
323

    
324
    op = opcodes.OpMigrateNode(node_name=node_name, live=live)
325

    
326
    return baserlib.SubmitJob([op])
327

    
328

    
329
class R_2_nodes_name_storage(baserlib.R_Generic):
330
  """/2/nodes/[node_name]/storage ressource.
331

332
  """
333
  # LUQueryNodeStorage acquires locks, hence restricting access to GET
334
  GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
335

    
336
  def GET(self):
337
    node_name = self.items[0]
338

    
339
    storage_type = self._checkStringVariable("storage_type", None)
340
    if not storage_type:
341
      raise http.HttpBadRequest("Missing the required 'storage_type'"
342
                                " parameter")
343

    
344
    output_fields = self._checkStringVariable("output_fields", None)
345
    if not output_fields:
346
      raise http.HttpBadRequest("Missing the required 'output_fields'"
347
                                " parameter")
348

    
349
    op = opcodes.OpQueryNodeStorage(nodes=[node_name],
350
                                    storage_type=storage_type,
351
                                    output_fields=output_fields.split(","))
352
    return baserlib.SubmitJob([op])
353

    
354

    
355
class R_2_nodes_name_storage_modify(baserlib.R_Generic):
356
  """/2/nodes/[node_name]/storage/modify ressource.
357

358
  """
359
  def PUT(self):
360
    node_name = self.items[0]
361

    
362
    storage_type = self._checkStringVariable("storage_type", None)
363
    if not storage_type:
364
      raise http.HttpBadRequest("Missing the required 'storage_type'"
365
                                " parameter")
366

    
367
    name = self._checkStringVariable("name", None)
368
    if not name:
369
      raise http.HttpBadRequest("Missing the required 'name'"
370
                                " parameter")
371

    
372
    changes = {}
373

    
374
    if "allocatable" in self.queryargs:
375
      changes[constants.SF_ALLOCATABLE] = \
376
        bool(self._checkIntVariable("allocatable", default=1))
377

    
378
    op = opcodes.OpModifyNodeStorage(node_name=node_name,
379
                                     storage_type=storage_type,
380
                                     name=name,
381
                                     changes=changes)
382
    return baserlib.SubmitJob([op])
383

    
384

    
385
class R_2_nodes_name_storage_repair(baserlib.R_Generic):
386
  """/2/nodes/[node_name]/storage/repair ressource.
387

388
  """
389
  def PUT(self):
390
    node_name = self.items[0]
391

    
392
    storage_type = self._checkStringVariable("storage_type", None)
393
    if not storage_type:
394
      raise http.HttpBadRequest("Missing the required 'storage_type'"
395
                                " parameter")
396

    
397
    name = self._checkStringVariable("name", None)
398
    if not name:
399
      raise http.HttpBadRequest("Missing the required 'name'"
400
                                " parameter")
401

    
402
    op = opcodes.OpRepairNodeStorage(node_name=node_name,
403
                                     storage_type=storage_type,
404
                                     name=name)
405
    return baserlib.SubmitJob([op])
406

    
407

    
408
class R_2_instances(baserlib.R_Generic):
409
  """/2/instances resource.
410

411
  """
412
  def GET(self):
413
    """Returns a list of all available instances.
414

415
    """
416
    client = baserlib.GetClient()
417

    
418
    use_locking = self.useLocking()
419
    if self.useBulk():
420
      bulkdata = client.QueryInstances([], I_FIELDS, use_locking)
421
      return baserlib.MapBulkFields(bulkdata, I_FIELDS)
422
    else:
423
      instancesdata = client.QueryInstances([], ["name"], use_locking)
424
      instanceslist = [row[0] for row in instancesdata]
425
      return baserlib.BuildUriList(instanceslist, "/2/instances/%s",
426
                                   uri_fields=("id", "uri"))
427

    
428
  def POST(self):
429
    """Create an instance.
430

431
    @return: a job id
432

433
    """
434
    if not isinstance(self.req.request_body, dict):
435
      raise http.HttpBadRequest("Invalid body contents, not a dictionary")
436

    
437
    beparams = baserlib.MakeParamsDict(self.req.request_body,
438
                                       constants.BES_PARAMETERS)
439
    hvparams = baserlib.MakeParamsDict(self.req.request_body,
440
                                       constants.HVS_PARAMETERS)
441
    fn = self.getBodyParameter
442

    
443
    # disk processing
444
    disk_data = fn('disks')
445
    if not isinstance(disk_data, list):
446
      raise http.HttpBadRequest("The 'disks' parameter should be a list")
447
    disks = []
448
    for idx, d in enumerate(disk_data):
449
      if not isinstance(d, int):
450
        raise http.HttpBadRequest("Disk %d specification wrong: should"
451
                                  " be an integer" % idx)
452
      disks.append({"size": d})
453
    # nic processing (one nic only)
454
    nics = [{"mac": fn("mac", constants.VALUE_AUTO)}]
455
    if fn("ip", None) is not None:
456
      nics[0]["ip"] = fn("ip")
457
    if fn("mode", None) is not None:
458
      nics[0]["mode"] = fn("mode")
459
    if fn("link", None) is not None:
460
      nics[0]["link"] = fn("link")
461
    if fn("bridge", None) is not None:
462
      nics[0]["bridge"] = fn("bridge")
463

    
464
    op = opcodes.OpCreateInstance(
465
      mode=constants.INSTANCE_CREATE,
466
      instance_name=fn('name'),
467
      disks=disks,
468
      disk_template=fn('disk_template'),
469
      os_type=fn('os'),
470
      pnode=fn('pnode', None),
471
      snode=fn('snode', None),
472
      iallocator=fn('iallocator', None),
473
      nics=nics,
474
      start=fn('start', True),
475
      ip_check=fn('ip_check', True),
476
      name_check=fn('name_check', True),
477
      wait_for_sync=True,
478
      hypervisor=fn('hypervisor', None),
479
      hvparams=hvparams,
480
      beparams=beparams,
481
      file_storage_dir=fn('file_storage_dir', None),
482
      file_driver=fn('file_driver', 'loop'),
483
      dry_run=bool(self.dryRun()),
484
      )
485

    
486
    return baserlib.SubmitJob([op])
487

    
488

    
489
class R_2_instances_name(baserlib.R_Generic):
490
  """/2/instances/[instance_name] resources.
491

492
  """
493
  def GET(self):
494
    """Send information about an instance.
495

496
    """
497
    client = baserlib.GetClient()
498
    instance_name = self.items[0]
499
    result = client.QueryInstances(names=[instance_name], fields=I_FIELDS,
500
                                   use_locking=self.useLocking())
501

    
502
    return baserlib.MapFields(I_FIELDS, result[0])
503

    
504
  def DELETE(self):
505
    """Delete an instance.
506

507
    """
508
    op = opcodes.OpRemoveInstance(instance_name=self.items[0],
509
                                  ignore_failures=False,
510
                                  dry_run=bool(self.dryRun()))
511
    return baserlib.SubmitJob([op])
512

    
513

    
514
class R_2_instances_name_info(baserlib.R_Generic):
515
  """/2/instances/[instance_name]/info resource.
516

517
  """
518
  def GET(self):
519
    """Request detailed instance information.
520

521
    """
522
    instance_name = self.items[0]
523
    static = bool(self._checkIntVariable("static", default=0))
524

    
525
    op = opcodes.OpQueryInstanceData(instances=[instance_name],
526
                                     static=static)
527
    return baserlib.SubmitJob([op])
528

    
529

    
530
class R_2_instances_name_reboot(baserlib.R_Generic):
531
  """/2/instances/[instance_name]/reboot resource.
532

533
  Implements an instance reboot.
534

535
  """
536
  def POST(self):
537
    """Reboot an instance.
538

539
    The URI takes type=[hard|soft|full] and
540
    ignore_secondaries=[False|True] parameters.
541

542
    """
543
    instance_name = self.items[0]
544
    reboot_type = self.queryargs.get('type',
545
                                     [constants.INSTANCE_REBOOT_HARD])[0]
546
    ignore_secondaries = bool(self._checkIntVariable('ignore_secondaries'))
547
    op = opcodes.OpRebootInstance(instance_name=instance_name,
548
                                  reboot_type=reboot_type,
549
                                  ignore_secondaries=ignore_secondaries,
550
                                  dry_run=bool(self.dryRun()))
551

    
552
    return baserlib.SubmitJob([op])
553

    
554

    
555
class R_2_instances_name_startup(baserlib.R_Generic):
556
  """/2/instances/[instance_name]/startup resource.
557

558
  Implements an instance startup.
559

560
  """
561
  def PUT(self):
562
    """Startup an instance.
563

564
    The URI takes force=[False|True] parameter to start the instance
565
    if even if secondary disks are failing.
566

567
    """
568
    instance_name = self.items[0]
569
    force_startup = bool(self._checkIntVariable('force'))
570
    op = opcodes.OpStartupInstance(instance_name=instance_name,
571
                                   force=force_startup,
572
                                   dry_run=bool(self.dryRun()))
573

    
574
    return baserlib.SubmitJob([op])
575

    
576

    
577
class R_2_instances_name_shutdown(baserlib.R_Generic):
578
  """/2/instances/[instance_name]/shutdown resource.
579

580
  Implements an instance shutdown.
581

582
  """
583
  def PUT(self):
584
    """Shutdown an instance.
585

586
    """
587
    instance_name = self.items[0]
588
    op = opcodes.OpShutdownInstance(instance_name=instance_name,
589
                                    dry_run=bool(self.dryRun()))
590

    
591
    return baserlib.SubmitJob([op])
592

    
593

    
594
class R_2_instances_name_reinstall(baserlib.R_Generic):
595
  """/2/instances/[instance_name]/reinstall resource.
596

597
  Implements an instance reinstall.
598

599
  """
600
  def POST(self):
601
    """Reinstall an instance.
602

603
    The URI takes os=name and nostartup=[0|1] optional
604
    parameters. By default, the instance will be started
605
    automatically.
606

607
    """
608
    instance_name = self.items[0]
609
    ostype = self._checkStringVariable('os')
610
    nostartup = self._checkIntVariable('nostartup')
611
    ops = [
612
      opcodes.OpShutdownInstance(instance_name=instance_name),
613
      opcodes.OpReinstallInstance(instance_name=instance_name, os_type=ostype),
614
      ]
615
    if not nostartup:
616
      ops.append(opcodes.OpStartupInstance(instance_name=instance_name,
617
                                           force=False))
618
    return baserlib.SubmitJob(ops)
619

    
620

    
621
class R_2_instances_name_replace_disks(baserlib.R_Generic):
622
  """/2/instances/[instance_name]/replace-disks resource.
623

624
  """
625
  def POST(self):
626
    """Replaces disks on an instance.
627

628
    """
629
    instance_name = self.items[0]
630
    remote_node = self._checkStringVariable("remote_node", default=None)
631
    mode = self._checkStringVariable("mode", default=None)
632
    raw_disks = self._checkStringVariable("disks", default=None)
633
    iallocator = self._checkStringVariable("iallocator", default=None)
634

    
635
    if raw_disks:
636
      try:
637
        disks = [int(part) for part in raw_disks.split(",")]
638
      except ValueError, err:
639
        raise http.HttpBadRequest("Invalid disk index passed: %s" % str(err))
640
    else:
641
      disks = []
642

    
643
    op = opcodes.OpReplaceDisks(instance_name=instance_name,
644
                                remote_node=remote_node,
645
                                mode=mode,
646
                                disks=disks,
647
                                iallocator=iallocator)
648

    
649
    return baserlib.SubmitJob([op])
650

    
651

    
652
class R_2_instances_name_activate_disks(baserlib.R_Generic):
653
  """/2/instances/[instance_name]/activate-disks resource.
654

655
  """
656
  def PUT(self):
657
    """Activate disks for an instance.
658

659
    The URI might contain ignore_size to ignore current recorded size.
660

661
    """
662
    instance_name = self.items[0]
663
    ignore_size = bool(self._checkIntVariable('ignore_size'))
664

    
665
    op = opcodes.OpActivateInstanceDisks(instance_name=instance_name,
666
                                         ignore_size=ignore_size)
667

    
668
    return baserlib.SubmitJob([op])
669

    
670

    
671
class _R_Tags(baserlib.R_Generic):
672
  """ Quasiclass for tagging resources
673

674
  Manages tags. When inheriting this class you must define the
675
  TAG_LEVEL for it.
676

677
  """
678
  TAG_LEVEL = None
679

    
680
  def __init__(self, items, queryargs, req):
681
    """A tag resource constructor.
682

683
    We have to override the default to sort out cluster naming case.
684

685
    """
686
    baserlib.R_Generic.__init__(self, items, queryargs, req)
687

    
688
    if self.TAG_LEVEL != constants.TAG_CLUSTER:
689
      self.name = items[0]
690
    else:
691
      self.name = ""
692

    
693
  def GET(self):
694
    """Returns a list of tags.
695

696
    Example: ["tag1", "tag2", "tag3"]
697

698
    """
699
    # pylint: disable-msg=W0212
700
    return baserlib._Tags_GET(self.TAG_LEVEL, name=self.name)
701

    
702
  def PUT(self):
703
    """Add a set of tags.
704

705
    The request as a list of strings should be PUT to this URI. And
706
    you'll have back a job id.
707

708
    """
709
    # pylint: disable-msg=W0212
710
    if 'tag' not in self.queryargs:
711
      raise http.HttpBadRequest("Please specify tag(s) to add using the"
712
                                " the 'tag' parameter")
713
    return baserlib._Tags_PUT(self.TAG_LEVEL,
714
                              self.queryargs['tag'], name=self.name,
715
                              dry_run=bool(self.dryRun()))
716

    
717
  def DELETE(self):
718
    """Delete a tag.
719

720
    In order to delete a set of tags, the DELETE
721
    request should be addressed to URI like:
722
    /tags?tag=[tag]&tag=[tag]
723

724
    """
725
    # pylint: disable-msg=W0212
726
    if 'tag' not in self.queryargs:
727
      # no we not gonna delete all tags
728
      raise http.HttpBadRequest("Cannot delete all tags - please specify"
729
                                " tag(s) using the 'tag' parameter")
730
    return baserlib._Tags_DELETE(self.TAG_LEVEL,
731
                                 self.queryargs['tag'],
732
                                 name=self.name,
733
                                 dry_run=bool(self.dryRun()))
734

    
735

    
736
class R_2_instances_name_tags(_R_Tags):
737
  """ /2/instances/[instance_name]/tags resource.
738

739
  Manages per-instance tags.
740

741
  """
742
  TAG_LEVEL = constants.TAG_INSTANCE
743

    
744

    
745
class R_2_nodes_name_tags(_R_Tags):
746
  """ /2/nodes/[node_name]/tags resource.
747

748
  Manages per-node tags.
749

750
  """
751
  TAG_LEVEL = constants.TAG_NODE
752

    
753

    
754
class R_2_tags(_R_Tags):
755
  """ /2/instances/tags resource.
756

757
  Manages cluster tags.
758

759
  """
760
  TAG_LEVEL = constants.TAG_CLUSTER