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:
Matthew Northcott
2025-07-24 15:54:30 +12:00
parent 10e87db1dd
commit c85b9554d3
13 changed files with 289 additions and 2 deletions

View File

@@ -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):

View 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)

View File

@@ -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):

View File

@@ -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.

View File

@@ -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(),

View File

@@ -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(),

View 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

View File

@@ -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):

View 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."))

View File

@@ -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."""

View File

@@ -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'},

View 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"
}
)

View 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.