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 certificate
|
||||||
from magnum.api.controllers.v1 import cluster
|
from magnum.api.controllers.v1 import cluster
|
||||||
from magnum.api.controllers.v1 import cluster_template
|
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 federation
|
||||||
from magnum.api.controllers.v1 import magnum_services
|
from magnum.api.controllers.v1 import magnum_services
|
||||||
from magnum.api.controllers.v1 import quota
|
from magnum.api.controllers.v1 import quota
|
||||||
@@ -98,6 +99,9 @@ class V1(controllers_base.APIBase):
|
|||||||
nodegroups = [link.Link]
|
nodegroups = [link.Link]
|
||||||
"""Links to the nodegroups resource"""
|
"""Links to the nodegroups resource"""
|
||||||
|
|
||||||
|
credentials = [link.Link]
|
||||||
|
"""Links to the credentials resource"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def convert():
|
def convert():
|
||||||
v1 = V1()
|
v1 = V1()
|
||||||
@@ -162,6 +166,12 @@ class V1(controllers_base.APIBase):
|
|||||||
'clusters/{cluster_id}',
|
'clusters/{cluster_id}',
|
||||||
'nodegroups',
|
'nodegroups',
|
||||||
bookmark=True)]
|
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
|
return v1
|
||||||
|
|
||||||
@@ -176,6 +186,7 @@ class Controller(controllers_base.Controller):
|
|||||||
mservices = magnum_services.MagnumServiceController()
|
mservices = magnum_services.MagnumServiceController()
|
||||||
stats = stats.StatsController()
|
stats = stats.StatsController()
|
||||||
federations = federation.FederationsController()
|
federations = federation.FederationsController()
|
||||||
|
credentials = credential.CredentialsController()
|
||||||
|
|
||||||
@expose.expose(V1)
|
@expose.expose(V1)
|
||||||
def get(self):
|
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.9 - Add nodegroup API
|
||||||
* 1.10 - Allow nodegroups with 0 nodes
|
* 1.10 - Allow nodegroups with 0 nodes
|
||||||
* 1.11 - Remove bay and baymodel objects
|
* 1.11 - Remove bay and baymodel objects
|
||||||
|
* 1.12 - Add credential API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_VER = '1.1'
|
BASE_VER = '1.1'
|
||||||
CURRENT_MAX_VER = '1.11'
|
CURRENT_MAX_VER = '1.12'
|
||||||
|
|
||||||
|
|
||||||
class Version(object):
|
class Version(object):
|
||||||
|
@@ -105,7 +105,16 @@ user documentation.
|
|||||||
Allow the cluster to be created with node_count = 0 as well as to update
|
Allow the cluster to be created with node_count = 0 as well as to update
|
||||||
existing nodegroups to have 0 nodes.
|
existing nodegroups to have 0 nodes.
|
||||||
|
|
||||||
|
|
||||||
1.11
|
1.11
|
||||||
---
|
---
|
||||||
|
|
||||||
Drop bay and baymodels objects from magnum source code
|
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 ca_conductor
|
||||||
from magnum.conductor.handlers import cluster_conductor
|
from magnum.conductor.handlers import cluster_conductor
|
||||||
from magnum.conductor.handlers import conductor_listener
|
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 federation_conductor
|
||||||
from magnum.conductor.handlers import indirection_api
|
from magnum.conductor.handlers import indirection_api
|
||||||
from magnum.conductor.handlers import nodegroup_conductor
|
from magnum.conductor.handlers import nodegroup_conductor
|
||||||
@@ -54,6 +55,7 @@ def main():
|
|||||||
indirection_api.Handler(),
|
indirection_api.Handler(),
|
||||||
cluster_conductor.Handler(),
|
cluster_conductor.Handler(),
|
||||||
conductor_listener.Handler(),
|
conductor_listener.Handler(),
|
||||||
|
credential_conductor.Handler(),
|
||||||
ca_conductor.Handler(),
|
ca_conductor.Handler(),
|
||||||
federation_conductor.Handler(),
|
federation_conductor.Handler(),
|
||||||
nodegroup_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 certificate
|
||||||
from magnum.common.policies import cluster
|
from magnum.common.policies import cluster
|
||||||
from magnum.common.policies import cluster_template
|
from magnum.common.policies import cluster_template
|
||||||
|
from magnum.common.policies import credential
|
||||||
from magnum.common.policies import federation
|
from magnum.common.policies import federation
|
||||||
from magnum.common.policies import magnum_service
|
from magnum.common.policies import magnum_service
|
||||||
from magnum.common.policies import nodegroup
|
from magnum.common.policies import nodegroup
|
||||||
@@ -31,6 +32,7 @@ def list_rules():
|
|||||||
certificate.list_rules(),
|
certificate.list_rules(),
|
||||||
cluster.list_rules(),
|
cluster.list_rules(),
|
||||||
cluster_template.list_rules(),
|
cluster_template.list_rules(),
|
||||||
|
credential.list_rules(),
|
||||||
federation.list_rules(),
|
federation.list_rules(),
|
||||||
magnum_service.list_rules(),
|
magnum_service.list_rules(),
|
||||||
quota.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):
|
def nodegroup_update_async(self, cluster, nodegroup):
|
||||||
self._cast('nodegroup_update', cluster=cluster, nodegroup=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")
|
@profiler.trace_cls("rpc")
|
||||||
class ListenerAPI(rpc_service.API):
|
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 "
|
raise NotImplementedError("Subclasses must implement "
|
||||||
"'delete_nodegroup'.")
|
"'delete_nodegroup'.")
|
||||||
|
|
||||||
|
def rotate_credential(self, context, cluster):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Driver does not support credential rotate.")
|
||||||
|
|
||||||
def get_monitor(self, context, cluster):
|
def get_monitor(self, context, cluster):
|
||||||
"""return the monitor with container data for this driver."""
|
"""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'href': u'http://localhost/v1/',
|
||||||
u'rel': u'self'}],
|
u'rel': u'self'}],
|
||||||
u'status': u'CURRENT',
|
u'status': u'CURRENT',
|
||||||
u'max_version': u'1.11',
|
u'max_version': u'1.12',
|
||||||
u'min_version': u'1.1'}]}
|
u'min_version': u'1.1'}]}
|
||||||
|
|
||||||
self.v1_expected = {
|
self.v1_expected = {
|
||||||
@@ -71,6 +71,10 @@ class TestRootController(api_base.FunctionalTest):
|
|||||||
u'rel': u'self'},
|
u'rel': u'self'},
|
||||||
{u'href': u'http://localhost/clustertemplates/',
|
{u'href': u'http://localhost/clustertemplates/',
|
||||||
u'rel': u'bookmark'}],
|
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'id': u'v1',
|
||||||
u'certificates': [{u'href': u'http://localhost/v1/certificates/',
|
u'certificates': [{u'href': u'http://localhost/v1/certificates/',
|
||||||
u'rel': u'self'},
|
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