Add credential API
Implements the 'credentials' endpoint to Magnum API, initially supporting rotation of cluster credentials dictated by the underlying driver. Change-Id: Idaab3c0d1fdca4b15505a949182ceb1d987d3a7f Signed-off-by: Matthew Northcott <matthewnorthcott@catalystcloud.nz>
This commit is contained in:
@@ -27,6 +27,7 @@ from magnum.api.controllers import link
|
||||
from magnum.api.controllers.v1 import certificate
|
||||
from magnum.api.controllers.v1 import cluster
|
||||
from magnum.api.controllers.v1 import cluster_template
|
||||
from magnum.api.controllers.v1 import credential
|
||||
from magnum.api.controllers.v1 import federation
|
||||
from magnum.api.controllers.v1 import magnum_services
|
||||
from magnum.api.controllers.v1 import quota
|
||||
@@ -98,6 +99,9 @@ class V1(controllers_base.APIBase):
|
||||
nodegroups = [link.Link]
|
||||
"""Links to the nodegroups resource"""
|
||||
|
||||
credentials = [link.Link]
|
||||
"""Links to the credentials resource"""
|
||||
|
||||
@staticmethod
|
||||
def convert():
|
||||
v1 = V1()
|
||||
@@ -162,6 +166,12 @@ class V1(controllers_base.APIBase):
|
||||
'clusters/{cluster_id}',
|
||||
'nodegroups',
|
||||
bookmark=True)]
|
||||
v1.credentials = [link.Link.make_link('self', pecan.request.host_url,
|
||||
'credentials', ''),
|
||||
link.Link.make_link('bookmark',
|
||||
pecan.request.host_url,
|
||||
'credentials', '',
|
||||
bookmark=True)]
|
||||
|
||||
return v1
|
||||
|
||||
@@ -176,6 +186,7 @@ class Controller(controllers_base.Controller):
|
||||
mservices = magnum_services.MagnumServiceController()
|
||||
stats = stats.StatsController()
|
||||
federations = federation.FederationsController()
|
||||
credentials = credential.CredentialsController()
|
||||
|
||||
@expose.expose(V1)
|
||||
def get(self):
|
||||
|
65
magnum/api/controllers/v1/credential.py
Normal file
65
magnum/api/controllers/v1/credential.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import pecan
|
||||
from wsme import types as wtypes
|
||||
|
||||
from oslo_log import log
|
||||
|
||||
from magnum.api.controllers import base
|
||||
from magnum.api.controllers.v1 import types
|
||||
from magnum.api import expose
|
||||
from magnum.api import utils as api_utils
|
||||
from magnum.common import policy
|
||||
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
|
||||
class ClusterID(wtypes.Base):
|
||||
"""API representation of a cluster ID
|
||||
|
||||
This class enforces type checking and value constraints, and converts
|
||||
between the internal object model and the API representation of a cluster
|
||||
ID.
|
||||
"""
|
||||
|
||||
uuid = types.uuid
|
||||
"""Unique UUID for this cluster"""
|
||||
|
||||
def __init__(self, uuid):
|
||||
self.uuid = uuid
|
||||
|
||||
|
||||
class CredentialsController(base.Controller):
|
||||
"""REST controller for cluster actions."""
|
||||
def __init__(self):
|
||||
super(CredentialsController, self).__init__()
|
||||
|
||||
@base.Controller.api_version("1.12")
|
||||
@expose.expose(ClusterID, types.uuid_or_name, status_code=202)
|
||||
def patch(self, cluster_ident):
|
||||
"""Rotate the credential in use by a cluster.
|
||||
|
||||
:param cluster_ident: UUID of a cluster or logical name of the cluster.
|
||||
"""
|
||||
|
||||
context = pecan.request.context
|
||||
policy.enforce(context, 'credential:rotate',
|
||||
action='credential:rotate')
|
||||
|
||||
cluster = api_utils.get_resource('Cluster', cluster_ident)
|
||||
# NOTE(northcottmt): Perform rotation synchronously as there aren't any
|
||||
# slow apply/upgrade operations to do
|
||||
pecan.request.rpcapi.credential_rotate(cluster)
|
||||
|
||||
return ClusterID(cluster.uuid)
|
@@ -34,10 +34,11 @@ REST_API_VERSION_HISTORY = """REST API Version History:
|
||||
* 1.9 - Add nodegroup API
|
||||
* 1.10 - Allow nodegroups with 0 nodes
|
||||
* 1.11 - Remove bay and baymodel objects
|
||||
* 1.12 - Add credential API
|
||||
"""
|
||||
|
||||
BASE_VER = '1.1'
|
||||
CURRENT_MAX_VER = '1.11'
|
||||
CURRENT_MAX_VER = '1.12'
|
||||
|
||||
|
||||
class Version(object):
|
||||
|
@@ -105,7 +105,16 @@ user documentation.
|
||||
Allow the cluster to be created with node_count = 0 as well as to update
|
||||
existing nodegroups to have 0 nodes.
|
||||
|
||||
|
||||
1.11
|
||||
---
|
||||
|
||||
Drop bay and baymodels objects from magnum source code
|
||||
|
||||
|
||||
1.12
|
||||
---
|
||||
|
||||
Add credential API
|
||||
|
||||
Allow the cluster to have its associated credential rotated.
|
||||
|
@@ -29,6 +29,7 @@ from magnum.common import short_id
|
||||
from magnum.conductor.handlers import ca_conductor
|
||||
from magnum.conductor.handlers import cluster_conductor
|
||||
from magnum.conductor.handlers import conductor_listener
|
||||
from magnum.conductor.handlers import credential_conductor
|
||||
from magnum.conductor.handlers import federation_conductor
|
||||
from magnum.conductor.handlers import indirection_api
|
||||
from magnum.conductor.handlers import nodegroup_conductor
|
||||
@@ -54,6 +55,7 @@ def main():
|
||||
indirection_api.Handler(),
|
||||
cluster_conductor.Handler(),
|
||||
conductor_listener.Handler(),
|
||||
credential_conductor.Handler(),
|
||||
ca_conductor.Handler(),
|
||||
federation_conductor.Handler(),
|
||||
nodegroup_conductor.Handler(),
|
||||
|
@@ -18,6 +18,7 @@ from magnum.common.policies import base
|
||||
from magnum.common.policies import certificate
|
||||
from magnum.common.policies import cluster
|
||||
from magnum.common.policies import cluster_template
|
||||
from magnum.common.policies import credential
|
||||
from magnum.common.policies import federation
|
||||
from magnum.common.policies import magnum_service
|
||||
from magnum.common.policies import nodegroup
|
||||
@@ -31,6 +32,7 @@ def list_rules():
|
||||
certificate.list_rules(),
|
||||
cluster.list_rules(),
|
||||
cluster_template.list_rules(),
|
||||
credential.list_rules(),
|
||||
federation.list_rules(),
|
||||
magnum_service.list_rules(),
|
||||
quota.list_rules(),
|
||||
|
35
magnum/common/policies/credential.py
Normal file
35
magnum/common/policies/credential.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from oslo_policy import policy
|
||||
|
||||
from magnum.common.policies import base
|
||||
|
||||
rules = [
|
||||
policy.DocumentedRuleDefault(
|
||||
name='credential:rotate',
|
||||
check_str=base.RULE_PROJECT_MEMBER_DENY_CLUSTER_USER,
|
||||
scope_types=["project"],
|
||||
description='Rotate the credential of a cluster.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/v1/credentials/{cluster_uuid}',
|
||||
'method': 'PATCH'
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def list_rules():
|
||||
return rules
|
@@ -175,6 +175,14 @@ class API(rpc_service.API):
|
||||
def nodegroup_update_async(self, cluster, nodegroup):
|
||||
self._cast('nodegroup_update', cluster=cluster, nodegroup=nodegroup)
|
||||
|
||||
# Credential Operations
|
||||
|
||||
def credential_rotate(self, cluster):
|
||||
return self._call('credential_rotate', cluster=cluster)
|
||||
|
||||
def credential_rotate_async(self, cluster):
|
||||
return self._cast('credential_rotate', cluster=cluster)
|
||||
|
||||
|
||||
@profiler.trace_cls("rpc")
|
||||
class ListenerAPI(rpc_service.API):
|
||||
|
58
magnum/conductor/handlers/credential_conductor.py
Normal file
58
magnum/conductor/handlers/credential_conductor.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
from magnum.common import exception
|
||||
from magnum.common import profiler
|
||||
import magnum.conf
|
||||
from magnum.drivers.common import driver
|
||||
from magnum.i18n import _
|
||||
from magnum.objects import fields
|
||||
|
||||
CONF = magnum.conf.CONF
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_CLUSTER_STATES = {
|
||||
fields.ClusterStatus.CREATE_COMPLETE,
|
||||
fields.ClusterStatus.UPDATE_COMPLETE,
|
||||
fields.ClusterStatus.UPDATE_IN_PROGRESS,
|
||||
fields.ClusterStatus.UPDATE_FAILED,
|
||||
fields.ClusterStatus.RESUME_COMPLETE,
|
||||
fields.ClusterStatus.RESTORE_COMPLETE,
|
||||
fields.ClusterStatus.ROLLBACK_COMPLETE,
|
||||
fields.ClusterStatus.SNAPSHOT_COMPLETE,
|
||||
fields.ClusterStatus.CHECK_COMPLETE,
|
||||
fields.ClusterStatus.ADOPT_COMPLETE
|
||||
}
|
||||
|
||||
|
||||
@profiler.trace_cls("rpc")
|
||||
class Handler(object):
|
||||
|
||||
def __init__(self):
|
||||
super(Handler, self).__init__()
|
||||
|
||||
def credential_rotate(self, context, cluster):
|
||||
if cluster.status not in ALLOWED_CLUSTER_STATES:
|
||||
operation = _(
|
||||
f'{__name__} when cluster status is "{cluster.status}"')
|
||||
raise exception.NotSupported(operation=operation)
|
||||
|
||||
cluster_driver = driver.Driver.get_driver_for_cluster(context, cluster)
|
||||
|
||||
try:
|
||||
cluster_driver.rotate_credential(context, cluster)
|
||||
except NotImplementedError:
|
||||
raise exception.NotSupported(
|
||||
message=_("Credential rotation is not supported by driver."))
|
@@ -257,6 +257,10 @@ class Driver(object, metaclass=abc.ABCMeta):
|
||||
raise NotImplementedError("Subclasses must implement "
|
||||
"'delete_nodegroup'.")
|
||||
|
||||
def rotate_credential(self, context, cluster):
|
||||
raise NotImplementedError(
|
||||
"Driver does not support credential rotate.")
|
||||
|
||||
def get_monitor(self, context, cluster):
|
||||
"""return the monitor with container data for this driver."""
|
||||
|
||||
|
@@ -41,7 +41,7 @@ class TestRootController(api_base.FunctionalTest):
|
||||
[{u'href': u'http://localhost/v1/',
|
||||
u'rel': u'self'}],
|
||||
u'status': u'CURRENT',
|
||||
u'max_version': u'1.11',
|
||||
u'max_version': u'1.12',
|
||||
u'min_version': u'1.1'}]}
|
||||
|
||||
self.v1_expected = {
|
||||
@@ -71,6 +71,10 @@ class TestRootController(api_base.FunctionalTest):
|
||||
u'rel': u'self'},
|
||||
{u'href': u'http://localhost/clustertemplates/',
|
||||
u'rel': u'bookmark'}],
|
||||
u'credentials': [{u'href': u'http://localhost/v1/credentials/',
|
||||
u'rel': u'self'},
|
||||
{u'href': u'http://localhost/credentials/',
|
||||
u'rel': u'bookmark'}],
|
||||
u'id': u'v1',
|
||||
u'certificates': [{u'href': u'http://localhost/v1/certificates/',
|
||||
u'rel': u'self'},
|
||||
|
81
magnum/tests/unit/api/controllers/v1/test_credential.py
Normal file
81
magnum/tests/unit/api/controllers/v1/test_credential.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from magnum.conductor import api as rpcapi
|
||||
from magnum.objects import fields
|
||||
from magnum.tests.unit.api import base as api_base
|
||||
from magnum.tests.unit.objects import utils as obj_utils
|
||||
|
||||
|
||||
class CredentialControllerTest(api_base.FunctionalTest):
|
||||
headers = {"Openstack-Api-Version": "container-infra latest"}
|
||||
|
||||
def _add_headers(self, kwargs, roles=None):
|
||||
if 'headers' not in kwargs:
|
||||
kwargs['headers'] = self.headers
|
||||
if roles:
|
||||
kwargs['headers']['X-Roles'] = ",".join(roles)
|
||||
|
||||
def patch_json(self, *args, **kwargs):
|
||||
self._add_headers(kwargs, roles=['member'])
|
||||
return super(CredentialControllerTest, self).patch_json(*args,
|
||||
**kwargs)
|
||||
|
||||
|
||||
class TestPatch(CredentialControllerTest):
|
||||
def setUp(self):
|
||||
super(TestPatch, self).setUp()
|
||||
self.cluster = obj_utils.create_test_cluster(self.context)
|
||||
p = mock.patch.object(rpcapi.API, 'credential_rotate')
|
||||
self.mock_rotate = p.start()
|
||||
self.mock_rotate.side_effect = self._simulate_credential_rotate
|
||||
self.addCleanup(p.stop)
|
||||
self.url = f"/credentials/{self.cluster.uuid}"
|
||||
|
||||
def _simulate_credential_rotate(self, cluster):
|
||||
cluster.status = fields.ClusterStatus.UPDATE_IN_PROGRESS
|
||||
cluster.save()
|
||||
|
||||
cluster.status = fields.ClusterStatus.UPDATE_COMPLETE
|
||||
cluster.save()
|
||||
|
||||
def test_rotate(self):
|
||||
response = self.patch_json(self.url, params={})
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(202, response.status_code)
|
||||
|
||||
|
||||
class TestCredentialPolicyEnforcement(api_base.FunctionalTest):
|
||||
|
||||
def _common_policy_check(self, rule, func, *arg, **kwarg):
|
||||
self.policy.set_rules({rule: "project_id:non_fake"})
|
||||
response = func(*arg, **kwarg)
|
||||
self.assertEqual(403, response.status_int)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(
|
||||
"Policy doesn't allow %s to be performed." % rule,
|
||||
response.json['errors'][0]['detail'])
|
||||
|
||||
def test_policy_disallow_rotate(self):
|
||||
cluster = obj_utils.create_test_cluster(self.context)
|
||||
self._common_policy_check(
|
||||
"credential:rotate", self.patch_json,
|
||||
'/credentials/%s' % cluster.uuid,
|
||||
params={},
|
||||
expect_errors=True,
|
||||
headers={
|
||||
'OpenStack-API-Version': 'container-infra latest',
|
||||
"X-Roles": "member"
|
||||
}
|
||||
)
|
7
releasenotes/notes/credential-api-be55ad07a3f2ae22.yaml
Normal file
7
releasenotes/notes/credential-api-be55ad07a3f2ae22.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
This release introduces the 'credentials' endpoint to Magnum API which
|
||||
initially supports rotation of cluster credentials. The definition of
|
||||
"credential" and the implementation to perform the rotation, is dictated by
|
||||
the underlying driver.
|
Reference in New Issue
Block a user