Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / rlib2.py @ 445d7262

History | View | Annotate | Download (38 kB)

1
#
2
#
3

    
4
# Copyright (C) 2006, 2007, 2008, 2009, 2010, 2011, 2012 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 resource implementations.
23

24
PUT or POST?
25
============
26

27
According to RFC2616 the main difference between PUT and POST is that
28
POST can create new resources but PUT can only create the resource the
29
URI was pointing to on the PUT request.
30

31
In the context of this module POST on ``/2/instances`` to change an existing
32
entity is legitimate, while PUT would not be. PUT creates a new entity (e.g. a
33
new instance) with a name specified in the request.
34

35
Quoting from RFC2616, section 9.6::
36

37
  The fundamental difference between the POST and PUT requests is reflected in
38
  the different meaning of the Request-URI. The URI in a POST request
39
  identifies the resource that will handle the enclosed entity. That resource
40
  might be a data-accepting process, a gateway to some other protocol, or a
41
  separate entity that accepts annotations. In contrast, the URI in a PUT
42
  request identifies the entity enclosed with the request -- the user agent
43
  knows what URI is intended and the server MUST NOT attempt to apply the
44
  request to some other resource. If the server desires that the request be
45
  applied to a different URI, it MUST send a 301 (Moved Permanently) response;
46
  the user agent MAY then make its own decision regarding whether or not to
47
  redirect the request.
48

49
So when adding new methods, if they are operating on the URI entity itself,
50
PUT should be prefered over POST.
51

52
"""
53

    
54
# pylint: disable=C0103
55

    
56
# C0103: Invalid name, since the R_* names are not conforming
57

    
58
from ganeti import opcodes
59
from ganeti import http
60
from ganeti import constants
61
from ganeti import cli
62
from ganeti import rapi
63
from ganeti import ht
64
from ganeti import compat
65
from ganeti import ssconf
66
from ganeti.rapi import baserlib
67

    
68

    
69
_COMMON_FIELDS = ["ctime", "mtime", "uuid", "serial_no", "tags"]
70
I_FIELDS = ["name", "admin_state", "os",
71
            "pnode", "snodes",
72
            "disk_template",
73
            "nic.ips", "nic.macs", "nic.modes",
74
            "nic.links", "nic.networks", "nic.bridges",
75
            "network_port",
76
            "disk.sizes", "disk_usage",
77
            "beparams", "hvparams",
78
            "oper_state", "oper_ram", "oper_vcpus", "status",
79
            "custom_hvparams", "custom_beparams", "custom_nicparams",
80
            ] + _COMMON_FIELDS
81

    
82
N_FIELDS = ["name", "offline", "master_candidate", "drained",
83
            "dtotal", "dfree",
84
            "mtotal", "mnode", "mfree",
85
            "pinst_cnt", "sinst_cnt",
86
            "ctotal", "cnodes", "csockets",
87
            "pip", "sip", "role",
88
            "pinst_list", "sinst_list",
89
            "master_capable", "vm_capable",
90
            "ndparams",
91
            "group.uuid",
92
            ] + _COMMON_FIELDS
93

    
94
NET_FIELDS = ["name", "network", "gateway",
95
              "network6", "gateway6",
96
              "mac_prefix", "network_type",
97
              "free_count", "reserved_count",
98
              "map", "group_list", "inst_list",
99
              "external_reservations",
100
             ] 
101

    
102
G_FIELDS = [
103
  "alloc_policy",
104
  "name",
105
  "node_cnt",
106
  "node_list",
107
  "ipolicy",
108
  "custom_ipolicy",
109
  "diskparams",
110
  "custom_diskparams",
111
  "ndparams",
112
  "custom_ndparams",
113
  ] + _COMMON_FIELDS
114

    
115
J_FIELDS_BULK = [
116
  "id", "ops", "status", "summary",
117
  "opstatus",
118
  "received_ts", "start_ts", "end_ts",
119
  ]
120

    
121
J_FIELDS = J_FIELDS_BULK + [
122
  "oplog",
123
  "opresult",
124
  ]
125

    
126
_NR_DRAINED = "drained"
127
_NR_MASTER_CANDIDATE = "master-candidate"
128
_NR_MASTER = "master"
129
_NR_OFFLINE = "offline"
130
_NR_REGULAR = "regular"
131

    
132
_NR_MAP = {
133
  constants.NR_MASTER: _NR_MASTER,
134
  constants.NR_MCANDIDATE: _NR_MASTER_CANDIDATE,
135
  constants.NR_DRAINED: _NR_DRAINED,
136
  constants.NR_OFFLINE: _NR_OFFLINE,
137
  constants.NR_REGULAR: _NR_REGULAR,
138
  }
139

    
140
assert frozenset(_NR_MAP.keys()) == constants.NR_ALL
141

    
142
# Request data version field
143
_REQ_DATA_VERSION = "__version__"
144

    
145
# Feature string for instance creation request data version 1
146
_INST_CREATE_REQV1 = "instance-create-reqv1"
147

    
148
# Feature string for instance reinstall request version 1
149
_INST_REINSTALL_REQV1 = "instance-reinstall-reqv1"
150

    
151
# Feature string for node migration version 1
152
_NODE_MIGRATE_REQV1 = "node-migrate-reqv1"
153

    
154
# Feature string for node evacuation with LU-generated jobs
155
_NODE_EVAC_RES1 = "node-evac-res1"
156

    
157
ALL_FEATURES = frozenset([
158
  _INST_CREATE_REQV1,
159
  _INST_REINSTALL_REQV1,
160
  _NODE_MIGRATE_REQV1,
161
  _NODE_EVAC_RES1,
162
  ])
163

    
164
# Timeout for /2/jobs/[job_id]/wait. Gives job up to 10 seconds to change.
165
_WFJC_TIMEOUT = 10
166

    
167

    
168
# FIXME: For compatibility we update the beparams/memory field. Needs to be
169
#        removed in Ganeti 2.7
170
def _UpdateBeparams(inst):
171
  """Updates the beparams dict of inst to support the memory field.
172

173
  @param inst: Inst dict
174
  @return: Updated inst dict
175

176
  """
177
  beparams = inst["beparams"]
178
  beparams[constants.BE_MEMORY] = beparams[constants.BE_MAXMEM]
179

    
180
  return inst
181

    
182

    
183
class R_root(baserlib.ResourceBase):
184
  """/ resource.
185

186
  """
187
  @staticmethod
188
  def GET():
189
    """Supported for legacy reasons.
190

191
    """
192
    return None
193

    
194

    
195
class R_2(R_root):
196
  """/2 resource.
197

198
  """
199

    
200

    
201
class R_version(baserlib.ResourceBase):
202
  """/version resource.
203

204
  This resource should be used to determine the remote API version and
205
  to adapt clients accordingly.
206

207
  """
208
  @staticmethod
209
  def GET():
210
    """Returns the remote API version.
211

212
    """
213
    return constants.RAPI_VERSION
214

    
215

    
216
class R_2_info(baserlib.OpcodeResource):
217
  """/2/info resource.
218

219
  """
220
  GET_OPCODE = opcodes.OpClusterQuery
221

    
222
  def GET(self):
223
    """Returns cluster information.
224

225
    """
226
    client = self.GetClient()
227
    return client.QueryClusterInfo()
228

    
229

    
230
class R_2_features(baserlib.ResourceBase):
231
  """/2/features resource.
232

233
  """
234
  @staticmethod
235
  def GET():
236
    """Returns list of optional RAPI features implemented.
237

238
    """
239
    return list(ALL_FEATURES)
240

    
241

    
242
class R_2_os(baserlib.OpcodeResource):
243
  """/2/os resource.
244

245
  """
246
  GET_OPCODE = opcodes.OpOsDiagnose
247

    
248
  def GET(self):
249
    """Return a list of all OSes.
250

251
    Can return error 500 in case of a problem.
252

253
    Example: ["debian-etch"]
254

255
    """
256
    cl = self.GetClient()
257
    op = opcodes.OpOsDiagnose(output_fields=["name", "variants"], names=[])
258
    job_id = self.SubmitJob([op], cl=cl)
259
    # we use custom feedback function, instead of print we log the status
260
    result = cli.PollJob(job_id, cl, feedback_fn=baserlib.FeedbackFn)
261
    diagnose_data = result[0]
262

    
263
    if not isinstance(diagnose_data, list):
264
      raise http.HttpBadGateway(message="Can't get OS list")
265

    
266
    os_names = []
267
    for (name, variants) in diagnose_data:
268
      os_names.extend(cli.CalculateOSNames(name, variants))
269

    
270
    return os_names
271

    
272

    
273
class R_2_redist_config(baserlib.OpcodeResource):
274
  """/2/redistribute-config resource.
275

276
  """
277
  PUT_OPCODE = opcodes.OpClusterRedistConf
278

    
279

    
280
class R_2_cluster_modify(baserlib.OpcodeResource):
281
  """/2/modify resource.
282

283
  """
284
  PUT_OPCODE = opcodes.OpClusterSetParams
285

    
286

    
287
class R_2_jobs(baserlib.ResourceBase):
288
  """/2/jobs resource.
289

290
  """
291
  def GET(self):
292
    """Returns a dictionary of jobs.
293

294
    @return: a dictionary with jobs id and uri.
295

296
    """
297
    client = self.GetClient()
298

    
299
    if self.useBulk():
300
      bulkdata = client.QueryJobs(None, J_FIELDS_BULK)
301
      return baserlib.MapBulkFields(bulkdata, J_FIELDS_BULK)
302
    else:
303
      jobdata = map(compat.fst, client.QueryJobs(None, ["id"]))
304
      return baserlib.BuildUriList(jobdata, "/2/jobs/%s",
305
                                   uri_fields=("id", "uri"))
306

    
307

    
308
class R_2_jobs_id(baserlib.ResourceBase):
309
  """/2/jobs/[job_id] resource.
310

311
  """
312
  def GET(self):
313
    """Returns a job status.
314

315
    @return: a dictionary with job parameters.
316
        The result includes:
317
            - id: job ID as a number
318
            - status: current job status as a string
319
            - ops: involved OpCodes as a list of dictionaries for each
320
              opcodes in the job
321
            - opstatus: OpCodes status as a list
322
            - opresult: OpCodes results as a list of lists
323

324
    """
325
    job_id = self.items[0]
326
    result = self.GetClient().QueryJobs([job_id, ], J_FIELDS)[0]
327
    if result is None:
328
      raise http.HttpNotFound()
329
    return baserlib.MapFields(J_FIELDS, result)
330

    
331
  def DELETE(self):
332
    """Cancel not-yet-started job.
333

334
    """
335
    job_id = self.items[0]
336
    result = self.GetClient().CancelJob(job_id)
337
    return result
338

    
339

    
340
class R_2_jobs_id_wait(baserlib.ResourceBase):
341
  """/2/jobs/[job_id]/wait resource.
342

343
  """
344
  # WaitForJobChange provides access to sensitive information and blocks
345
  # machine resources (it's a blocking RAPI call), hence restricting access.
346
  GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
347

    
348
  def GET(self):
349
    """Waits for job changes.
350

351
    """
352
    job_id = self.items[0]
353

    
354
    fields = self.getBodyParameter("fields")
355
    prev_job_info = self.getBodyParameter("previous_job_info", None)
356
    prev_log_serial = self.getBodyParameter("previous_log_serial", None)
357

    
358
    if not isinstance(fields, list):
359
      raise http.HttpBadRequest("The 'fields' parameter should be a list")
360

    
361
    if not (prev_job_info is None or isinstance(prev_job_info, list)):
362
      raise http.HttpBadRequest("The 'previous_job_info' parameter should"
363
                                " be a list")
364

    
365
    if not (prev_log_serial is None or
366
            isinstance(prev_log_serial, (int, long))):
367
      raise http.HttpBadRequest("The 'previous_log_serial' parameter should"
368
                                " be a number")
369

    
370
    client = self.GetClient()
371
    result = client.WaitForJobChangeOnce(job_id, fields,
372
                                         prev_job_info, prev_log_serial,
373
                                         timeout=_WFJC_TIMEOUT)
374
    if not result:
375
      raise http.HttpNotFound()
376

    
377
    if result == constants.JOB_NOTCHANGED:
378
      # No changes
379
      return None
380

    
381
    (job_info, log_entries) = result
382

    
383
    return {
384
      "job_info": job_info,
385
      "log_entries": log_entries,
386
      }
387

    
388

    
389
class R_2_nodes(baserlib.OpcodeResource):
390
  """/2/nodes resource.
391

392
  """
393
  GET_OPCODE = opcodes.OpNodeQuery
394

    
395
  def GET(self):
396
    """Returns a list of all nodes.
397

398
    """
399
    client = self.GetClient()
400

    
401
    if self.useBulk():
402
      bulkdata = client.QueryNodes([], N_FIELDS, False)
403
      return baserlib.MapBulkFields(bulkdata, N_FIELDS)
404
    else:
405
      nodesdata = client.QueryNodes([], ["name"], False)
406
      nodeslist = [row[0] for row in nodesdata]
407
      return baserlib.BuildUriList(nodeslist, "/2/nodes/%s",
408
                                   uri_fields=("id", "uri"))
409

    
410

    
411
class R_2_nodes_name(baserlib.OpcodeResource):
412
  """/2/nodes/[node_name] resource.
413

414
  """
415
  GET_OPCODE = opcodes.OpNodeQuery
416

    
417
  def GET(self):
418
    """Send information about a node.
419

420
    """
421
    node_name = self.items[0]
422
    client = self.GetClient()
423

    
424
    result = baserlib.HandleItemQueryErrors(client.QueryNodes,
425
                                            names=[node_name], fields=N_FIELDS,
426
                                            use_locking=self.useLocking())
427

    
428
    return baserlib.MapFields(N_FIELDS, result[0])
429

    
430

    
431
class R_2_nodes_name_powercycle(baserlib.OpcodeResource):
432
  """/2/nodes/[node_name]/powercycle resource.
433

434
  """
435
  POST_OPCODE = opcodes.OpNodePowercycle
436

    
437
  def GetPostOpInput(self):
438
    """Tries to powercycle a node.
439

440
    """
441
    return (self.request_body, {
442
      "node_name": self.items[0],
443
      "force": self.useForce(),
444
      })
445

    
446

    
447
class R_2_nodes_name_role(baserlib.OpcodeResource):
448
  """/2/nodes/[node_name]/role resource.
449

450
  """
451
  PUT_OPCODE = opcodes.OpNodeSetParams
452

    
453
  def GET(self):
454
    """Returns the current node role.
455

456
    @return: Node role
457

458
    """
459
    node_name = self.items[0]
460
    client = self.GetClient()
461
    result = client.QueryNodes(names=[node_name], fields=["role"],
462
                               use_locking=self.useLocking())
463

    
464
    return _NR_MAP[result[0][0]]
465

    
466
  def GetPutOpInput(self):
467
    """Sets the node role.
468

469
    """
470
    baserlib.CheckType(self.request_body, basestring, "Body contents")
471

    
472
    role = self.request_body
473

    
474
    if role == _NR_REGULAR:
475
      candidate = False
476
      offline = False
477
      drained = False
478

    
479
    elif role == _NR_MASTER_CANDIDATE:
480
      candidate = True
481
      offline = drained = None
482

    
483
    elif role == _NR_DRAINED:
484
      drained = True
485
      candidate = offline = None
486

    
487
    elif role == _NR_OFFLINE:
488
      offline = True
489
      candidate = drained = None
490

    
491
    else:
492
      raise http.HttpBadRequest("Can't set '%s' role" % role)
493

    
494
    assert len(self.items) == 1
495

    
496
    return ({}, {
497
      "node_name": self.items[0],
498
      "master_candidate": candidate,
499
      "offline": offline,
500
      "drained": drained,
501
      "force": self.useForce(),
502
      "auto_promote": bool(self._checkIntVariable("auto-promote", default=0)),
503
      })
504

    
505

    
506
class R_2_nodes_name_evacuate(baserlib.OpcodeResource):
507
  """/2/nodes/[node_name]/evacuate resource.
508

509
  """
510
  POST_OPCODE = opcodes.OpNodeEvacuate
511

    
512
  def GetPostOpInput(self):
513
    """Evacuate all instances off a node.
514

515
    """
516
    return (self.request_body, {
517
      "node_name": self.items[0],
518
      "dry_run": self.dryRun(),
519
      })
520

    
521

    
522
class R_2_nodes_name_migrate(baserlib.OpcodeResource):
523
  """/2/nodes/[node_name]/migrate resource.
524

525
  """
526
  POST_OPCODE = opcodes.OpNodeMigrate
527

    
528
  def GetPostOpInput(self):
529
    """Migrate all primary instances from a node.
530

531
    """
532
    if self.queryargs:
533
      # Support old-style requests
534
      if "live" in self.queryargs and "mode" in self.queryargs:
535
        raise http.HttpBadRequest("Only one of 'live' and 'mode' should"
536
                                  " be passed")
537

    
538
      if "live" in self.queryargs:
539
        if self._checkIntVariable("live", default=1):
540
          mode = constants.HT_MIGRATION_LIVE
541
        else:
542
          mode = constants.HT_MIGRATION_NONLIVE
543
      else:
544
        mode = self._checkStringVariable("mode", default=None)
545

    
546
      data = {
547
        "mode": mode,
548
        }
549
    else:
550
      data = self.request_body
551

    
552
    return (data, {
553
      "node_name": self.items[0],
554
      })
555

    
556

    
557
class R_2_nodes_name_modify(baserlib.OpcodeResource):
558
  """/2/nodes/[node_name]/modify resource.
559

560
  """
561
  POST_OPCODE = opcodes.OpNodeSetParams
562

    
563
  def GetPostOpInput(self):
564
    """Changes parameters of a node.
565

566
    """
567
    assert len(self.items) == 1
568

    
569
    return (self.request_body, {
570
      "node_name": self.items[0],
571
      })
572

    
573

    
574
class R_2_nodes_name_storage(baserlib.OpcodeResource):
575
  """/2/nodes/[node_name]/storage resource.
576

577
  """
578
  # LUNodeQueryStorage acquires locks, hence restricting access to GET
579
  GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
580
  GET_OPCODE = opcodes.OpNodeQueryStorage
581

    
582
  def GetGetOpInput(self):
583
    """List storage available on a node.
584

585
    """
586
    storage_type = self._checkStringVariable("storage_type", None)
587
    output_fields = self._checkStringVariable("output_fields", None)
588

    
589
    if not output_fields:
590
      raise http.HttpBadRequest("Missing the required 'output_fields'"
591
                                " parameter")
592

    
593
    return ({}, {
594
      "nodes": [self.items[0]],
595
      "storage_type": storage_type,
596
      "output_fields": output_fields.split(","),
597
      })
598

    
599

    
600
class R_2_nodes_name_storage_modify(baserlib.OpcodeResource):
601
  """/2/nodes/[node_name]/storage/modify resource.
602

603
  """
604
  PUT_OPCODE = opcodes.OpNodeModifyStorage
605

    
606
  def GetPutOpInput(self):
607
    """Modifies a storage volume on a node.
608

609
    """
610
    storage_type = self._checkStringVariable("storage_type", None)
611
    name = self._checkStringVariable("name", None)
612

    
613
    if not name:
614
      raise http.HttpBadRequest("Missing the required 'name'"
615
                                " parameter")
616

    
617
    changes = {}
618

    
619
    if "allocatable" in self.queryargs:
620
      changes[constants.SF_ALLOCATABLE] = \
621
        bool(self._checkIntVariable("allocatable", default=1))
622

    
623
    return ({}, {
624
      "node_name": self.items[0],
625
      "storage_type": storage_type,
626
      "name": name,
627
      "changes": changes,
628
      })
629

    
630

    
631
class R_2_nodes_name_storage_repair(baserlib.OpcodeResource):
632
  """/2/nodes/[node_name]/storage/repair resource.
633

634
  """
635
  PUT_OPCODE = opcodes.OpRepairNodeStorage
636

    
637
  def GetPutOpInput(self):
638
    """Repairs a storage volume on a node.
639

640
    """
641
    storage_type = self._checkStringVariable("storage_type", None)
642
    name = self._checkStringVariable("name", None)
643
    if not name:
644
      raise http.HttpBadRequest("Missing the required 'name'"
645
                                " parameter")
646

    
647
    return ({}, {
648
      "node_name": self.items[0],
649
      "storage_type": storage_type,
650
      "name": name,
651
      })
652

    
653

    
654
class R_2_networks(baserlib.OpcodeResource):
655
  """/2/networks resource.
656

657
  """
658
  GET_OPCODE = opcodes.OpNetworkQuery
659
  POST_OPCODE = opcodes.OpNetworkAdd
660
  POST_RENAME = {
661
    "name": "network_name",
662
    }
663

    
664
  def GetPostOpInput(self):
665
    """Create a network.
666

667
    """
668
    assert not self.items
669
    return (self.request_body, {
670
      "dry_run": self.dryRun(),
671
      })
672

    
673
  def GET(self):
674
    """Returns a list of all networks.
675

676
    """
677
    client = self.GetClient()
678

    
679
    if self.useBulk():
680
      bulkdata = client.QueryNetworks([], NET_FIELDS, False)
681
      return baserlib.MapBulkFields(bulkdata, NET_FIELDS)
682
    else:
683
      data = client.QueryNetworks([], ["name"], False)
684
      networknames = [row[0] for row in data]
685
      return baserlib.BuildUriList(networknames, "/2/networks/%s",
686
                                   uri_fields=("name", "uri"))
687

    
688

    
689
class R_2_networks_name(baserlib.OpcodeResource):
690
  """/2/network/[network_name] resource.
691

692
  """
693
  DELETE_OPCODE = opcodes.OpNetworkRemove
694

    
695
  def GET(self):
696
    """Send information about a network.
697

698
    """
699
    network_name = self.items[0]
700
    client = self.GetClient()
701

    
702
    result = baserlib.HandleItemQueryErrors(client.QueryNetworks,
703
                                            names=[network_name],
704
                                            fields=NET_FIELDS,
705
                                            use_locking=self.useLocking())
706

    
707
    return baserlib.MapFields(NET_FIELDS, result[0])
708

    
709
  def GetDeleteOpInput(self):
710
    """Delete a network.
711

712
    """
713
    assert len(self.items) == 1
714
    return (self.request_body, {
715
      "network_name": self.items[0],
716
      "dry_run": self.dryRun(),
717
      })
718

    
719
class R_2_networks_name_connect(baserlib.OpcodeResource):
720
  """/2/network/[network_name]/connect.
721

722
  """
723
  PUT_OPCODE = opcodes.OpNetworkConnect
724

    
725
  def GetPutOpInput(self):
726
    """Changes some parameters of node group.
727

728
    """
729
    assert self.items
730
    return (self.request_body, {
731
      "network_name": self.items[0],
732
      })
733

    
734
class R_2_networks_name_connectall(baserlib.OpcodeResource):
735
  """/2/network/[network_name]/connectall.
736

737
  """
738
  PUT_OPCODE = opcodes.OpNetworkConnectAll
739

    
740
  def GetPutOpInput(self):
741
    """Changes some parameters of node group.
742

743
    """
744
    assert self.items
745
    return (self.request_body, {
746
      "network_name": self.items[0]
747
      })
748

    
749
class R_2_networks_name_disconnect(baserlib.OpcodeResource):
750
  """/2/network/[network_name]/disconnect.
751

752
  """
753
  PUT_OPCODE = opcodes.OpNetworkDisconnect
754

    
755
  def GetPutOpInput(self):
756
    """Changes some parameters of node group.
757

758
    """
759
    assert self.items
760
    return (self.request_body, {
761
      "network_name": self.items[0],
762
      })
763

    
764
class R_2_networks_name_disconnectall(baserlib.OpcodeResource):
765
  """/2/network/[network_name]/disconnectall.
766

767
  """
768
  PUT_OPCODE = opcodes.OpNetworkDisconnectAll
769

    
770
  def GetPutOpInput(self):
771
    """Changes some parameters of node group.
772

773
    """
774
    assert self.items
775
    return (self.request_body, {
776
      "network_name": self.items[0],
777
      })
778

    
779
class R_2_groups(baserlib.OpcodeResource):
780
  """/2/groups resource.
781

782
  """
783
  GET_OPCODE = opcodes.OpGroupQuery
784
  POST_OPCODE = opcodes.OpGroupAdd
785
  POST_RENAME = {
786
    "name": "group_name",
787
    }
788

    
789
  def GetPostOpInput(self):
790
    """Create a node group.
791

792

793
    """
794
    assert not self.items
795
    return (self.request_body, {
796
      "dry_run": self.dryRun(),
797
      })
798

    
799
  def GET(self):
800
    """Returns a list of all node groups.
801

802
    """
803
    client = self.GetClient()
804

    
805
    if self.useBulk():
806
      bulkdata = client.QueryGroups([], G_FIELDS, False)
807
      return baserlib.MapBulkFields(bulkdata, G_FIELDS)
808
    else:
809
      data = client.QueryGroups([], ["name"], False)
810
      groupnames = [row[0] for row in data]
811
      return baserlib.BuildUriList(groupnames, "/2/groups/%s",
812
                                   uri_fields=("name", "uri"))
813

    
814

    
815
class R_2_groups_name(baserlib.OpcodeResource):
816
  """/2/groups/[group_name] resource.
817

818
  """
819
  DELETE_OPCODE = opcodes.OpGroupRemove
820

    
821
  def GET(self):
822
    """Send information about a node group.
823

824
    """
825
    group_name = self.items[0]
826
    client = self.GetClient()
827

    
828
    result = baserlib.HandleItemQueryErrors(client.QueryGroups,
829
                                            names=[group_name], fields=G_FIELDS,
830
                                            use_locking=self.useLocking())
831

    
832
    return baserlib.MapFields(G_FIELDS, result[0])
833

    
834
  def GetDeleteOpInput(self):
835
    """Delete a node group.
836

837
    """
838
    assert len(self.items) == 1
839
    return ({}, {
840
      "group_name": self.items[0],
841
      "dry_run": self.dryRun(),
842
      })
843

    
844

    
845
class R_2_groups_name_modify(baserlib.OpcodeResource):
846
  """/2/groups/[group_name]/modify resource.
847

848
  """
849
  PUT_OPCODE = opcodes.OpGroupSetParams
850

    
851
  def GetPutOpInput(self):
852
    """Changes some parameters of node group.
853

854
    """
855
    assert self.items
856
    return (self.request_body, {
857
      "group_name": self.items[0],
858
      })
859

    
860

    
861
class R_2_groups_name_rename(baserlib.OpcodeResource):
862
  """/2/groups/[group_name]/rename resource.
863

864
  """
865
  PUT_OPCODE = opcodes.OpGroupRename
866

    
867
  def GetPutOpInput(self):
868
    """Changes the name of a node group.
869

870
    """
871
    assert len(self.items) == 1
872
    return (self.request_body, {
873
      "group_name": self.items[0],
874
      "dry_run": self.dryRun(),
875
      })
876

    
877

    
878
class R_2_groups_name_assign_nodes(baserlib.OpcodeResource):
879
  """/2/groups/[group_name]/assign-nodes resource.
880

881
  """
882
  PUT_OPCODE = opcodes.OpGroupAssignNodes
883

    
884
  def GetPutOpInput(self):
885
    """Assigns nodes to a group.
886

887
    """
888
    assert len(self.items) == 1
889
    return (self.request_body, {
890
      "group_name": self.items[0],
891
      "dry_run": self.dryRun(),
892
      "force": self.useForce(),
893
      })
894

    
895

    
896
class R_2_instances(baserlib.OpcodeResource):
897
  """/2/instances resource.
898

899
  """
900
  GET_OPCODE = opcodes.OpInstanceQuery
901
  POST_OPCODE = opcodes.OpInstanceCreate
902
  POST_RENAME = {
903
    "os": "os_type",
904
    "name": "instance_name",
905
    }
906

    
907
  def GET(self):
908
    """Returns a list of all available instances.
909

910
    """
911
    client = self.GetClient()
912

    
913
    use_locking = self.useLocking()
914
    if self.useBulk():
915
      bulkdata = client.QueryInstances([], I_FIELDS, use_locking)
916
      return map(_UpdateBeparams, baserlib.MapBulkFields(bulkdata, I_FIELDS))
917
    else:
918
      instancesdata = client.QueryInstances([], ["name"], use_locking)
919
      instanceslist = [row[0] for row in instancesdata]
920
      return baserlib.BuildUriList(instanceslist, "/2/instances/%s",
921
                                   uri_fields=("id", "uri"))
922

    
923
  def GetPostOpInput(self):
924
    """Create an instance.
925

926
    @return: a job id
927

928
    """
929
    baserlib.CheckType(self.request_body, dict, "Body contents")
930

    
931
    # Default to request data version 0
932
    data_version = self.getBodyParameter(_REQ_DATA_VERSION, 0)
933

    
934
    if data_version == 0:
935
      raise http.HttpBadRequest("Instance creation request version 0 is no"
936
                                " longer supported")
937
    elif data_version != 1:
938
      raise http.HttpBadRequest("Unsupported request data version %s" %
939
                                data_version)
940

    
941
    data = self.request_body.copy()
942
    # Remove "__version__"
943
    data.pop(_REQ_DATA_VERSION, None)
944

    
945
    return (data, {
946
      "dry_run": self.dryRun(),
947
      })
948

    
949

    
950
class R_2_instances_name(baserlib.OpcodeResource):
951
  """/2/instances/[instance_name] resource.
952

953
  """
954
  GET_OPCODE = opcodes.OpInstanceQuery
955
  DELETE_OPCODE = opcodes.OpInstanceRemove
956

    
957
  def GET(self):
958
    """Send information about an instance.
959

960
    """
961
    client = self.GetClient()
962
    instance_name = self.items[0]
963

    
964
    result = baserlib.HandleItemQueryErrors(client.QueryInstances,
965
                                            names=[instance_name],
966
                                            fields=I_FIELDS,
967
                                            use_locking=self.useLocking())
968

    
969
    return _UpdateBeparams(baserlib.MapFields(I_FIELDS, result[0]))
970

    
971
  def GetDeleteOpInput(self):
972
    """Delete an instance.
973

974
    """
975
    assert len(self.items) == 1
976
    return ({}, {
977
      "instance_name": self.items[0],
978
      "ignore_failures": False,
979
      "dry_run": self.dryRun(),
980
      })
981

    
982

    
983
class R_2_instances_name_info(baserlib.OpcodeResource):
984
  """/2/instances/[instance_name]/info resource.
985

986
  """
987
  GET_OPCODE = opcodes.OpInstanceQueryData
988

    
989
  def GetGetOpInput(self):
990
    """Request detailed instance information.
991

992
    """
993
    assert len(self.items) == 1
994
    return ({}, {
995
      "instances": [self.items[0]],
996
      "static": bool(self._checkIntVariable("static", default=0)),
997
      })
998

    
999

    
1000
class R_2_instances_name_reboot(baserlib.OpcodeResource):
1001
  """/2/instances/[instance_name]/reboot resource.
1002

1003
  Implements an instance reboot.
1004

1005
  """
1006
  POST_OPCODE = opcodes.OpInstanceReboot
1007

    
1008
  def GetPostOpInput(self):
1009
    """Reboot an instance.
1010

1011
    The URI takes type=[hard|soft|full] and
1012
    ignore_secondaries=[False|True] parameters.
1013

1014
    """
1015
    return ({}, {
1016
      "instance_name": self.items[0],
1017
      "reboot_type":
1018
        self.queryargs.get("type", [constants.INSTANCE_REBOOT_HARD])[0],
1019
      "ignore_secondaries": bool(self._checkIntVariable("ignore_secondaries")),
1020
      "dry_run": self.dryRun(),
1021
      })
1022

    
1023

    
1024
class R_2_instances_name_startup(baserlib.OpcodeResource):
1025
  """/2/instances/[instance_name]/startup resource.
1026

1027
  Implements an instance startup.
1028

1029
  """
1030
  PUT_OPCODE = opcodes.OpInstanceStartup
1031

    
1032
  def GetPutOpInput(self):
1033
    """Startup an instance.
1034

1035
    The URI takes force=[False|True] parameter to start the instance
1036
    if even if secondary disks are failing.
1037

1038
    """
1039
    return ({}, {
1040
      "instance_name": self.items[0],
1041
      "force": self.useForce(),
1042
      "dry_run": self.dryRun(),
1043
      "no_remember": bool(self._checkIntVariable("no_remember")),
1044
      })
1045

    
1046

    
1047
class R_2_instances_name_shutdown(baserlib.OpcodeResource):
1048
  """/2/instances/[instance_name]/shutdown resource.
1049

1050
  Implements an instance shutdown.
1051

1052
  """
1053
  PUT_OPCODE = opcodes.OpInstanceShutdown
1054

    
1055
  def GetPutOpInput(self):
1056
    """Shutdown an instance.
1057

1058
    """
1059
    return (self.request_body, {
1060
      "instance_name": self.items[0],
1061
      "no_remember": bool(self._checkIntVariable("no_remember")),
1062
      "dry_run": self.dryRun(),
1063
      })
1064

    
1065

    
1066
def _ParseInstanceReinstallRequest(name, data):
1067
  """Parses a request for reinstalling an instance.
1068

1069
  """
1070
  if not isinstance(data, dict):
1071
    raise http.HttpBadRequest("Invalid body contents, not a dictionary")
1072

    
1073
  ostype = baserlib.CheckParameter(data, "os", default=None)
1074
  start = baserlib.CheckParameter(data, "start", exptype=bool,
1075
                                  default=True)
1076
  osparams = baserlib.CheckParameter(data, "osparams", default=None)
1077

    
1078
  ops = [
1079
    opcodes.OpInstanceShutdown(instance_name=name),
1080
    opcodes.OpInstanceReinstall(instance_name=name, os_type=ostype,
1081
                                osparams=osparams),
1082
    ]
1083

    
1084
  if start:
1085
    ops.append(opcodes.OpInstanceStartup(instance_name=name, force=False))
1086

    
1087
  return ops
1088

    
1089

    
1090
class R_2_instances_name_reinstall(baserlib.OpcodeResource):
1091
  """/2/instances/[instance_name]/reinstall resource.
1092

1093
  Implements an instance reinstall.
1094

1095
  """
1096
  POST_OPCODE = opcodes.OpInstanceReinstall
1097

    
1098
  def POST(self):
1099
    """Reinstall an instance.
1100

1101
    The URI takes os=name and nostartup=[0|1] optional
1102
    parameters. By default, the instance will be started
1103
    automatically.
1104

1105
    """
1106
    if self.request_body:
1107
      if self.queryargs:
1108
        raise http.HttpBadRequest("Can't combine query and body parameters")
1109

    
1110
      body = self.request_body
1111
    elif self.queryargs:
1112
      # Legacy interface, do not modify/extend
1113
      body = {
1114
        "os": self._checkStringVariable("os"),
1115
        "start": not self._checkIntVariable("nostartup"),
1116
        }
1117
    else:
1118
      body = {}
1119

    
1120
    ops = _ParseInstanceReinstallRequest(self.items[0], body)
1121

    
1122
    return self.SubmitJob(ops)
1123

    
1124

    
1125
class R_2_instances_name_replace_disks(baserlib.OpcodeResource):
1126
  """/2/instances/[instance_name]/replace-disks resource.
1127

1128
  """
1129
  POST_OPCODE = opcodes.OpInstanceReplaceDisks
1130

    
1131
  def GetPostOpInput(self):
1132
    """Replaces disks on an instance.
1133

1134
    """
1135
    static = {
1136
      "instance_name": self.items[0],
1137
      }
1138

    
1139
    if self.request_body:
1140
      data = self.request_body
1141
    elif self.queryargs:
1142
      # Legacy interface, do not modify/extend
1143
      data = {
1144
        "remote_node": self._checkStringVariable("remote_node", default=None),
1145
        "mode": self._checkStringVariable("mode", default=None),
1146
        "disks": self._checkStringVariable("disks", default=None),
1147
        "iallocator": self._checkStringVariable("iallocator", default=None),
1148
        }
1149
    else:
1150
      data = {}
1151

    
1152
    # Parse disks
1153
    try:
1154
      raw_disks = data.pop("disks")
1155
    except KeyError:
1156
      pass
1157
    else:
1158
      if raw_disks:
1159
        if ht.TListOf(ht.TInt)(raw_disks): # pylint: disable=E1102
1160
          data["disks"] = raw_disks
1161
        else:
1162
          # Backwards compatibility for strings of the format "1, 2, 3"
1163
          try:
1164
            data["disks"] = [int(part) for part in raw_disks.split(",")]
1165
          except (TypeError, ValueError), err:
1166
            raise http.HttpBadRequest("Invalid disk index passed: %s" % err)
1167

    
1168
    return (data, static)
1169

    
1170

    
1171
class R_2_instances_name_activate_disks(baserlib.OpcodeResource):
1172
  """/2/instances/[instance_name]/activate-disks resource.
1173

1174
  """
1175
  PUT_OPCODE = opcodes.OpInstanceActivateDisks
1176

    
1177
  def GetPutOpInput(self):
1178
    """Activate disks for an instance.
1179

1180
    The URI might contain ignore_size to ignore current recorded size.
1181

1182
    """
1183
    return ({}, {
1184
      "instance_name": self.items[0],
1185
      "ignore_size": bool(self._checkIntVariable("ignore_size")),
1186
      })
1187

    
1188

    
1189
class R_2_instances_name_deactivate_disks(baserlib.OpcodeResource):
1190
  """/2/instances/[instance_name]/deactivate-disks resource.
1191

1192
  """
1193
  PUT_OPCODE = opcodes.OpInstanceDeactivateDisks
1194

    
1195
  def GetPutOpInput(self):
1196
    """Deactivate disks for an instance.
1197

1198
    """
1199
    return ({}, {
1200
      "instance_name": self.items[0],
1201
      })
1202

    
1203

    
1204
class R_2_instances_name_recreate_disks(baserlib.OpcodeResource):
1205
  """/2/instances/[instance_name]/recreate-disks resource.
1206

1207
  """
1208
  POST_OPCODE = opcodes.OpInstanceRecreateDisks
1209

    
1210
  def GetPostOpInput(self):
1211
    """Recreate disks for an instance.
1212

1213
    """
1214
    return ({}, {
1215
      "instance_name": self.items[0],
1216
      })
1217

    
1218

    
1219
class R_2_instances_name_prepare_export(baserlib.OpcodeResource):
1220
  """/2/instances/[instance_name]/prepare-export resource.
1221

1222
  """
1223
  PUT_OPCODE = opcodes.OpBackupPrepare
1224

    
1225
  def GetPutOpInput(self):
1226
    """Prepares an export for an instance.
1227

1228
    """
1229
    return ({}, {
1230
      "instance_name": self.items[0],
1231
      "mode": self._checkStringVariable("mode"),
1232
      })
1233

    
1234

    
1235
class R_2_instances_name_export(baserlib.OpcodeResource):
1236
  """/2/instances/[instance_name]/export resource.
1237

1238
  """
1239
  PUT_OPCODE = opcodes.OpBackupExport
1240
  PUT_RENAME = {
1241
    "destination": "target_node",
1242
    }
1243

    
1244
  def GetPutOpInput(self):
1245
    """Exports an instance.
1246

1247
    """
1248
    return (self.request_body, {
1249
      "instance_name": self.items[0],
1250
      })
1251

    
1252

    
1253
class R_2_instances_name_migrate(baserlib.OpcodeResource):
1254
  """/2/instances/[instance_name]/migrate resource.
1255

1256
  """
1257
  PUT_OPCODE = opcodes.OpInstanceMigrate
1258

    
1259
  def GetPutOpInput(self):
1260
    """Migrates an instance.
1261

1262
    """
1263
    return (self.request_body, {
1264
      "instance_name": self.items[0],
1265
      })
1266

    
1267

    
1268
class R_2_instances_name_failover(baserlib.OpcodeResource):
1269
  """/2/instances/[instance_name]/failover resource.
1270

1271
  """
1272
  PUT_OPCODE = opcodes.OpInstanceFailover
1273

    
1274
  def GetPutOpInput(self):
1275
    """Does a failover of an instance.
1276

1277
    """
1278
    return (self.request_body, {
1279
      "instance_name": self.items[0],
1280
      })
1281

    
1282

    
1283
class R_2_instances_name_rename(baserlib.OpcodeResource):
1284
  """/2/instances/[instance_name]/rename resource.
1285

1286
  """
1287
  PUT_OPCODE = opcodes.OpInstanceRename
1288

    
1289
  def GetPutOpInput(self):
1290
    """Changes the name of an instance.
1291

1292
    """
1293
    return (self.request_body, {
1294
      "instance_name": self.items[0],
1295
      })
1296

    
1297

    
1298
class R_2_instances_name_modify(baserlib.OpcodeResource):
1299
  """/2/instances/[instance_name]/modify resource.
1300

1301
  """
1302
  PUT_OPCODE = opcodes.OpInstanceSetParams
1303

    
1304
  def GetPutOpInput(self):
1305
    """Changes parameters of an instance.
1306

1307
    """
1308
    return (self.request_body, {
1309
      "instance_name": self.items[0],
1310
      })
1311

    
1312

    
1313
class R_2_instances_name_disk_grow(baserlib.OpcodeResource):
1314
  """/2/instances/[instance_name]/disk/[disk_index]/grow resource.
1315

1316
  """
1317
  POST_OPCODE = opcodes.OpInstanceGrowDisk
1318

    
1319
  def GetPostOpInput(self):
1320
    """Increases the size of an instance disk.
1321

1322
    """
1323
    return (self.request_body, {
1324
      "instance_name": self.items[0],
1325
      "disk": int(self.items[1]),
1326
      })
1327

    
1328

    
1329
class R_2_instances_name_console(baserlib.ResourceBase):
1330
  """/2/instances/[instance_name]/console resource.
1331

1332
  """
1333
  GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
1334
  GET_OPCODE = opcodes.OpInstanceConsole
1335

    
1336
  def GET(self):
1337
    """Request information for connecting to instance's console.
1338

1339
    @return: Serialized instance console description, see
1340
             L{objects.InstanceConsole}
1341

1342
    """
1343
    client = self.GetClient()
1344

    
1345
    ((console, ), ) = client.QueryInstances([self.items[0]], ["console"], False)
1346

    
1347
    if console is None:
1348
      raise http.HttpServiceUnavailable("Instance console unavailable")
1349

    
1350
    assert isinstance(console, dict)
1351
    return console
1352

    
1353

    
1354
def _GetQueryFields(args):
1355
  """
1356

1357
  """
1358
  try:
1359
    fields = args["fields"]
1360
  except KeyError:
1361
    raise http.HttpBadRequest("Missing 'fields' query argument")
1362

    
1363
  return _SplitQueryFields(fields[0])
1364

    
1365

    
1366
def _SplitQueryFields(fields):
1367
  """
1368

1369
  """
1370
  return [i.strip() for i in fields.split(",")]
1371

    
1372

    
1373
class R_2_query(baserlib.ResourceBase):
1374
  """/2/query/[resource] resource.
1375

1376
  """
1377
  # Results might contain sensitive information
1378
  GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
1379
  GET_OPCODE = opcodes.OpQuery
1380
  PUT_OPCODE = opcodes.OpQuery
1381

    
1382
  def _Query(self, fields, qfilter):
1383
    return self.GetClient().Query(self.items[0], fields, qfilter).ToDict()
1384

    
1385
  def GET(self):
1386
    """Returns resource information.
1387

1388
    @return: Query result, see L{objects.QueryResponse}
1389

1390
    """
1391
    return self._Query(_GetQueryFields(self.queryargs), None)
1392

    
1393
  def PUT(self):
1394
    """Submits job querying for resources.
1395

1396
    @return: Query result, see L{objects.QueryResponse}
1397

1398
    """
1399
    body = self.request_body
1400

    
1401
    baserlib.CheckType(body, dict, "Body contents")
1402

    
1403
    try:
1404
      fields = body["fields"]
1405
    except KeyError:
1406
      fields = _GetQueryFields(self.queryargs)
1407

    
1408
    qfilter = body.get("qfilter", None)
1409
    # TODO: remove this after 2.7
1410
    if qfilter is None:
1411
      qfilter = body.get("filter", None)
1412

    
1413
    return self._Query(fields, qfilter)
1414

    
1415

    
1416
class R_2_query_fields(baserlib.ResourceBase):
1417
  """/2/query/[resource]/fields resource.
1418

1419
  """
1420
  GET_OPCODE = opcodes.OpQueryFields
1421

    
1422
  def GET(self):
1423
    """Retrieves list of available fields for a resource.
1424

1425
    @return: List of serialized L{objects.QueryFieldDefinition}
1426

1427
    """
1428
    try:
1429
      raw_fields = self.queryargs["fields"]
1430
    except KeyError:
1431
      fields = None
1432
    else:
1433
      fields = _SplitQueryFields(raw_fields[0])
1434

    
1435
    return self.GetClient().QueryFields(self.items[0], fields).ToDict()
1436

    
1437

    
1438
class _R_Tags(baserlib.OpcodeResource):
1439
  """Quasiclass for tagging resources.
1440

1441
  Manages tags. When inheriting this class you must define the
1442
  TAG_LEVEL for it.
1443

1444
  """
1445
  TAG_LEVEL = None
1446
  GET_OPCODE = opcodes.OpTagsGet
1447
  PUT_OPCODE = opcodes.OpTagsSet
1448
  DELETE_OPCODE = opcodes.OpTagsDel
1449

    
1450
  def __init__(self, items, queryargs, req, **kwargs):
1451
    """A tag resource constructor.
1452

1453
    We have to override the default to sort out cluster naming case.
1454

1455
    """
1456
    baserlib.OpcodeResource.__init__(self, items, queryargs, req, **kwargs)
1457

    
1458
    if self.TAG_LEVEL == constants.TAG_CLUSTER:
1459
      self.name = None
1460
    else:
1461
      self.name = items[0]
1462

    
1463
  def GET(self):
1464
    """Returns a list of tags.
1465

1466
    Example: ["tag1", "tag2", "tag3"]
1467

1468
    """
1469
    kind = self.TAG_LEVEL
1470

    
1471
    if kind in (constants.TAG_INSTANCE,
1472
                constants.TAG_NODEGROUP,
1473
                constants.TAG_NODE):
1474
      if not self.name:
1475
        raise http.HttpBadRequest("Missing name on tag request")
1476

    
1477
      cl = self.GetClient()
1478
      if kind == constants.TAG_INSTANCE:
1479
        fn = cl.QueryInstances
1480
      elif kind == constants.TAG_NODEGROUP:
1481
        fn = cl.QueryGroups
1482
      else:
1483
        fn = cl.QueryNodes
1484
      result = fn(names=[self.name], fields=["tags"], use_locking=False)
1485
      if not result or not result[0]:
1486
        raise http.HttpBadGateway("Invalid response from tag query")
1487
      tags = result[0][0]
1488

    
1489
    elif kind == constants.TAG_CLUSTER:
1490
      assert not self.name
1491
      # TODO: Use query API?
1492
      ssc = ssconf.SimpleStore()
1493
      tags = ssc.GetClusterTags()
1494

    
1495
    return list(tags)
1496

    
1497
  def GetPutOpInput(self):
1498
    """Add a set of tags.
1499

1500
    The request as a list of strings should be PUT to this URI. And
1501
    you'll have back a job id.
1502

1503
    """
1504
    return ({}, {
1505
      "kind": self.TAG_LEVEL,
1506
      "name": self.name,
1507
      "tags": self.queryargs.get("tag", []),
1508
      "dry_run": self.dryRun(),
1509
      })
1510

    
1511
  def GetDeleteOpInput(self):
1512
    """Delete a tag.
1513

1514
    In order to delete a set of tags, the DELETE
1515
    request should be addressed to URI like:
1516
    /tags?tag=[tag]&tag=[tag]
1517

1518
    """
1519
    # Re-use code
1520
    return self.GetPutOpInput()
1521

    
1522

    
1523
class R_2_instances_name_tags(_R_Tags):
1524
  """ /2/instances/[instance_name]/tags resource.
1525

1526
  Manages per-instance tags.
1527

1528
  """
1529
  TAG_LEVEL = constants.TAG_INSTANCE
1530

    
1531

    
1532
class R_2_nodes_name_tags(_R_Tags):
1533
  """ /2/nodes/[node_name]/tags resource.
1534

1535
  Manages per-node tags.
1536

1537
  """
1538
  TAG_LEVEL = constants.TAG_NODE
1539

    
1540

    
1541
class R_2_groups_name_tags(_R_Tags):
1542
  """ /2/groups/[group_name]/tags resource.
1543

1544
  Manages per-nodegroup tags.
1545

1546
  """
1547
  TAG_LEVEL = constants.TAG_NODEGROUP
1548

    
1549

    
1550
class R_2_tags(_R_Tags):
1551
  """ /2/tags resource.
1552

1553
  Manages cluster tags.
1554

1555
  """
1556
  TAG_LEVEL = constants.TAG_CLUSTER