Support credential API

Adds a 'Rotate Credential' cluster action as a drop down menu item for
clusters. This invokes PATCH /v1/credential/{cluster-uuid} against the
credential API in Magnum.

Change-Id: I44c667dbb34df1bd5835720a1c39337d6b11a34e
Signed-off-by: Matthew Northcott <matthewnorthcott@catalystcloud.nz>
This commit is contained in:
Matthew Northcott
2025-08-05 14:05:23 +12:00
parent b3d46128ed
commit 9744bb2cfe
10 changed files with 285 additions and 2 deletions

View File

@@ -35,7 +35,7 @@ CLUSTER_CREATE_ATTRS = clusters.CREATION_ATTRIBUTES
CERTIFICATE_CREATE_ATTRS = certificates.CREATION_ATTRIBUTES
QUOTA_CREATION_ATTRIBUTES = quotas.CREATION_ATTRIBUTES
CLUSTER_UPDATE_ALLOWED_PROPERTIES = set(['/node_count'])
DEFAULT_API_VERSION = '1.10'
DEFAULT_API_VERSION = '1.12'
def _cleanup_params(attrs, create, **params):
@@ -264,6 +264,10 @@ def certificate_rotate(request, id):
return magnumclient(request).certificates.rotate_ca(**args)
def credential_rotate(request, id):
return magnumclient(request).credentials.update(id)
def stats_list(request, project_id=None):
return magnumclient(request).stats.list(project_id=project_id)

View File

@@ -375,6 +375,17 @@ class Certificates(generic.View):
new_cert.to_dict())
@urls.register
class RotateCredential(generic.View):
"""API for rotating a cluster credential"""
url_regex = r'container_infra/credentials/(?P<cluster_id>[^/]+)$'
@rest_utils.ajax()
def patch(self, request, cluster_id):
"""Rotate the existing credential"""
return magnum.credential_rotate(request, cluster_id).to_dict()
@urls.register
class Stats(generic.View):
"""API for Magnum Stats"""

View File

@@ -39,6 +39,7 @@
'horizon.dashboard.container-infra.clusters.show-certificate.service',
'horizon.dashboard.container-infra.clusters.sign-certificate.service',
'horizon.dashboard.container-infra.clusters.rotate-certificate.service',
'horizon.dashboard.container-infra.clusters.rotate-credential.service',
'horizon.dashboard.container-infra.clusters.config.service',
'horizon.dashboard.container-infra.clusters.resourceType'
];
@@ -53,6 +54,7 @@
showCertificateService,
signCertificateService,
rotateCertificateService,
rotateCredentialService,
getClusterConfigService,
resourceType) {
@@ -120,6 +122,14 @@
text: gettext('Rolling Cluster Upgrade')
}
})
.append({
id: 'rotateCredentialAction',
service: rotateCredentialService,
template: {
type: 'danger',
text: gettext('Rotate Cluster Credentials')
}
})
.append({
id: 'deleteClusterAction',
service: deleteClusterService,

View File

@@ -50,6 +50,11 @@
expect(actionHasId(actions, 'rotateCertificateAction')).toBe(true);
});
it('registers Rotate Credential as an item action', function() {
var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions;
expect(actionHasId(actions, 'rotateCredentialAction')).toBe(true);
});
it('registers Resize Cluster as an item action', function() {
var actions = registry.getResourceType('OS::Magnum::Cluster').itemActions;
expect(actionHasId(actions, 'resizeClusterAction')).toBe(true);

View File

@@ -0,0 +1,119 @@
/**
* 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.
*/
(function() {
'use strict';
/**
* @ngdoc overview
* @name horizon.dashboard.container-infra.clusters.rotate-credential.service
* @description Service for the container-infra cluster rotate certificate
*/
angular
.module('horizon.dashboard.container-infra.clusters')
.factory(
'horizon.dashboard.container-infra.clusters.rotate-credential.service',
rotateCredentialService);
rotateCredentialService.$inject = [
'horizon.app.core.openstack-service-api.magnum',
'horizon.dashboard.container-infra.clusters.resourceType',
'horizon.framework.util.actions.action-result.service',
'horizon.framework.util.i18n.gettext',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.modal.simple-modal.service',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.framework.widgets.toast.service'
];
function rotateCredentialService(
magnum, resourceType, actionResult, gettext, $qExtensions, modal, spinnerModal, toast
) {
var cluster;
var labels = {
title: gettext('Confirm Credential Rotation'),
/* eslint-disable max-len */
message: gettext('You have chosen to rotate the credentials for cluster "%(name)s" (%(id)s). If you are not already the owner of this cluster, the cluster ownership will transfer to you.'),
submit: gettext('Rotate Cluster Credentials'),
};
var message = {
success: gettext('Credentials successfully rotated for cluster "%(name)s" (%(id)s).'),
error: gettext('Unable to rotate credentials for cluster "%(name)s" (%(id)s).'),
errorDetail: gettext('Unable to rotate credentials for cluster "%(name)s" (%(id)s): %(reason)s.')
};
var service = {
initAction: initAction,
perform: perform,
allowed: allowed
};
return service;
//////////////
function initAction() {
}
function perform(selected) {
cluster = { id: selected.id, name: selected.name };
var options = {
title: labels.title,
body: getMessage(labels.message),
submit: labels.submit
};
modal.modal(options).result.then(onSubmit);
}
function allowed() {
// NOTE(northcottmt): Consider hiding this if the user is unable to rotate credentials.
return $qExtensions.booleanAsPromise(true);
}
function onSubmit() {
spinnerModal.showModalSpinner(gettext('Loading'));
return magnum.rotateCredential(cluster.id)
.then(handleResponse)
.catch(onError)
.finally(spinnerModal.hideModalSpinner);
}
function handleResponse(response) {
var result = { created: [], deleted: [], failed: [], updated: [] };
if (!response) {
return result;
}
toast.add('success', getMessage(message.success));
result = actionResult.getActionResult().updated(resourceType, cluster.id).result;
return result;
}
function onError(response) {
var msg;
if (response && response.data) {
msg = getMessage(message.errorDetail, { reason: response.data });
} else {
msg = getMessage(message.error);
}
toast.add('error', msg);
}
function getMessage(message, params) {
return interpolate(message, Object.assign(cluster, params), true);
}
}
})();

View File

@@ -0,0 +1,102 @@
/**
* 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.
*/
(function() {
'use strict';
describe('horizon.dashboard.container-infra.clusters.rotate-credential.service', function() {
var $q, service, magnum, deferred, spinnerModal, modal, modalConfig, toast, toastState;
var selected = { id: '1', name: 'mycluster' };
///////////////////
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.dashboard.container-infra.clusters'));
beforeEach(inject(function($injector, _$q_) {
$q = _$q_;
service = $injector.get(
'horizon.dashboard.container-infra.clusters.rotate-credential.service');
magnum = $injector.get('horizon.app.core.openstack-service-api.magnum');
spinnerModal = $injector.get('horizon.framework.widgets.modal-wait-spinner.service');
modal = $injector.get('horizon.framework.widgets.modal.simple-modal.service');
toast = $injector.get('horizon.framework.widgets.toast.service');
spyOn(spinnerModal, 'showModalSpinner').and.callFake(function() {});
spyOn(spinnerModal, 'hideModalSpinner').and.callFake(function() {});
spyOn(toast, 'add').and.callFake(function(state) {
toastState = state;
});
spyOn(modal, 'modal').and.callFake(function(config) {
deferred = $q.defer();
deferred.resolve(config);
modalConfig = config;
return { result: deferred.promise };
});
}));
it('should check the policy', function() {
var allowed = service.allowed();
expect(allowed).toBeTruthy();
});
it('should open the modal, hide the loading spinner, and rotate credentials',
inject(function($timeout) {
deferred = $q.defer();
deferred.resolve({});
spyOn(magnum, 'rotateCredential').and.returnValue(deferred.promise);
service.perform(selected);
$timeout(function() {
expect(toast.add).toHaveBeenCalledTimes(1);
expect(modal.modal).toHaveBeenCalled();
expect(spinnerModal.showModalSpinner).toHaveBeenCalled();
expect(spinnerModal.hideModalSpinner).toHaveBeenCalled();
expect(magnum.rotateCredential).toHaveBeenCalled();
expect(modalConfig.title).toBeDefined();
expect(modalConfig.body).toBeDefined();
expect(modalConfig.submit).toBeDefined();
expect(toastState).toEqual('success');
}, 0);
$timeout.flush();
})
);
it('should open the modal, hide the loading spinner, and handle errors on failed rotation',
inject(function($timeout) {
deferred = $q.defer();
deferred.reject();
spyOn(magnum, 'rotateCredential').and.returnValue(deferred.promise);
service.perform(selected);
$timeout(function () {
expect(toast.add).toHaveBeenCalledTimes(1);
expect(modal.modal).toHaveBeenCalled();
expect(spinnerModal.showModalSpinner).toHaveBeenCalled();
expect(spinnerModal.hideModalSpinner).toHaveBeenCalled();
expect(magnum.rotateCredential).toHaveBeenCalled();
expect(modalConfig.title).toBeDefined();
expect(modalConfig.body).toBeDefined();
expect(modalConfig.submit).toBeDefined();
expect(toastState).toEqual('error');
}, 0);
$timeout.flush();
})
);
});
})();

View File

@@ -48,6 +48,7 @@
showCertificate: showCertificate,
signCertificate: signCertificate,
rotateCertificate: rotateCertificate,
rotateCredential: rotateCredential,
getStats: getStats,
getIngressControllers: getIngressControllers,
getAddons: getAddons,
@@ -212,6 +213,25 @@
});
}
/////////////////
// Credentials //
/////////////////
function rotateCredential(id) {
return apiService.patch('/api/container_infra/credentials/' + id)
.catch(function onError(response) {
var msg, params;
if (response && response.data) {
msg = gettext('Unable to rotate credentials for cluster %(id)s: %(reason)s.');
params = { id: id, reason: response.data };
} else {
msg = gettext('Unable to rotate credentials for cluster %(id)s.');
params = { id: id };
}
toastService.add('error', interpolate(msg, params, true));
});
}
///////////
// Stats //
///////////

View File

@@ -219,6 +219,13 @@
"error": "Unable to rotate the certificate.",
"testInput": [123, [123]]
},
{
"func": "rotateCredential",
"method": "patch",
"path": "/api/container_infra/credentials/123",
"error": "Unable to rotate credentials for cluster 123.",
"testInput": [123]
},
{
"func": "getQuotas",
"method": "get",

View File

@@ -0,0 +1,5 @@
---
features:
- |
Support credential rotation. This adds
"Rotate Credential" action for cluster as item action.

View File

@@ -2,7 +2,7 @@
# date but we do not test them so no guarantee of having them all correct. If
# you find any incorrect lower bounds, let us know or propose a fix.
pbr!=2.1.0,>=2.0.0 # Apache-2.0
python-magnumclient>=4.2.0 # Apache-2.0
python-magnumclient>=4.9.0 # Apache-2.0
python-heatclient>=1.18.0
horizon>=17.1.0 # Apache-2.0