Add changing instance password support
This brings the CLI password changing command (nova set-password <instance_id>) into Horizon. The action is allowed if the instance is in one of the active states or is shuttof and is enabled in settings. A modal is presented for entering the new password and after confirmation, the nova client is called. Based on MR: I550403490094ab040801bd7de9f7cd1f20518adb Change-Id: I039d273cb10d532dfb2d18ef8e8943946ded9da6 Co-authored-by: Sergiu Miclea <smiclea@cloudbasesolutions.com> Signed-off-by: Dmitriy Chubinidze <dcu995@gmail.com>
This commit is contained in:
co-authored by
Sergiu Miclea
parent
6529dd7e60
commit
14ba479c17
@@ -2473,6 +2473,20 @@ Default: ``"False"``
|
||||
When set, enables the instance action "Retrieve password" allowing password
|
||||
retrieval from metadata service.
|
||||
|
||||
OPENSTACK_ENABLE_INSTANCE_PASSWORD_CHANGE
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. versionadded:: 2026.2(Hibiscus)
|
||||
|
||||
Default: ``"False"``
|
||||
|
||||
When set, enables the instance action "Change password" allowing to change
|
||||
password for root user. The action is allowed if the instance is in one of
|
||||
the active states or is shutt off and is enabled in settings.
|
||||
This function requires the QEMU Guest Agent to be running inside the guest
|
||||
instance. Without it, password changes cannot be applied from the
|
||||
interface.
|
||||
|
||||
OPENSTACK_HYPERVISOR_FEATURES
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -596,6 +596,12 @@ def server_rebuild(request, instance_id, image_id, password=None,
|
||||
**kwargs)
|
||||
|
||||
|
||||
@profiler.trace
|
||||
def server_change_password(request, instance_id, password=None):
|
||||
nc = _nova.get_novaclient_with_instance_desc(request)
|
||||
return nc.servers.change_password(instance_id, password)
|
||||
|
||||
|
||||
@profiler.trace
|
||||
def server_update(request, instance_id, name, description=None):
|
||||
nc = _nova.get_novaclient_with_instance_desc(request)
|
||||
|
||||
@@ -170,6 +170,51 @@ class DecryptPasswordInstanceForm(forms.SelfHandlingForm):
|
||||
return True
|
||||
|
||||
|
||||
class ChangePasswordInstanceForm(forms.SelfHandlingForm):
|
||||
instance_id = forms.CharField(widget=forms.HiddenInput())
|
||||
password = forms.RegexField(
|
||||
label=_("New Password"),
|
||||
required=True,
|
||||
widget=forms.PasswordInput(render_value=False),
|
||||
regex=validators.password_validator(),
|
||||
error_messages={'invalid': validators.password_validator_msg()})
|
||||
confirm_password = forms.CharField(
|
||||
label=_("Confirm Password"),
|
||||
required=True,
|
||||
widget=forms.PasswordInput(render_value=False))
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(request,
|
||||
*args,
|
||||
**kwargs)
|
||||
instance_id = kwargs.get('initial', {}).get('instance_id')
|
||||
self.fields['instance_id'].initial = instance_id
|
||||
|
||||
@sensitive_variables('data', 'password')
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
instance = data.get('instance_id')
|
||||
password = data.get('password') or None
|
||||
api.nova.server_change_password(request, instance, password)
|
||||
messages.success(request, _(
|
||||
'Successfully changed password for instance %s.') % instance)
|
||||
except Exception:
|
||||
redirect = reverse('horizon:project:instances:index')
|
||||
exceptions.handle(request,
|
||||
_("Unable to change instance password."),
|
||||
redirect=redirect)
|
||||
return True
|
||||
|
||||
def clean(self):
|
||||
'''Check to make sure password fields match.'''
|
||||
cleaned_data = super().clean()
|
||||
if 'password' in cleaned_data:
|
||||
if cleaned_data['password'] != cleaned_data.get(
|
||||
'confirm_password', None):
|
||||
raise forms.ValidationError(_('Passwords do not match.'))
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class AttachVolume(forms.SelfHandlingForm):
|
||||
volume = forms.ChoiceField(label=_("Volume ID"),
|
||||
widget=forms.ThemableSelectWidget(),
|
||||
|
||||
@@ -677,6 +677,19 @@ class DecryptInstancePassword(tables.LinkAction):
|
||||
keypair_name])
|
||||
|
||||
|
||||
class ChangeInstancePassword(tables.LinkAction):
|
||||
name = "changepassword"
|
||||
verbose_name = _("Change Password")
|
||||
classes = ("btn-change", "ajax-modal")
|
||||
url = "horizon:project:instances:changepassword"
|
||||
|
||||
def allowed(self, request, instance):
|
||||
return (settings.OPENSTACK_ENABLE_INSTANCE_PASSWORD_CHANGE and
|
||||
(instance.status in ACTIVE_STATES or
|
||||
instance.status == 'SHUTOFF') and
|
||||
not is_deleting(instance))
|
||||
|
||||
|
||||
class AssociateIP(policy.PolicyTargetMixin, tables.LinkAction):
|
||||
name = "associate"
|
||||
verbose_name = _("Associate Floating IP")
|
||||
@@ -1329,6 +1342,7 @@ class InstancesTable(tables.DataTable):
|
||||
AttachInterface, DetachInterface, EditInstance,
|
||||
AttachVolume, DetachVolume,
|
||||
UpdateMetadata, DecryptInstancePassword,
|
||||
ChangeInstancePassword,
|
||||
EditInstanceSecurityGroups,
|
||||
EditPortSecurityGroups,
|
||||
ConsoleLink, LogLink,
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block form_id %}change_instance_password_form{% endblock %}
|
||||
|
||||
{% block form_action %}{% url "horizon:project:instances:changepassword" instance_id %}{% endblock %}
|
||||
|
||||
{% block modal_id %}change_instance_password_modal{% endblock %}
|
||||
{% block modal-header %}{% trans "Change Instance Password" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<fieldset>
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</fieldset>
|
||||
{% endblock %}
|
||||
{% block modal-footer %}
|
||||
<a href="{% url "horizon:project:instances:index" %}" class="btn btn-default cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<input class="btn btn-primary" type="submit" id="changepassword_button"
|
||||
value="{% trans "Change Password" %}" />
|
||||
{% endblock %}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Change Instance Password" %}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% include "project/instances/_changepassword.html" %}
|
||||
{% endblock %}
|
||||
@@ -1906,6 +1906,77 @@ class InstanceTests(InstanceTestBase):
|
||||
self.mock_get_password.assert_called_once_with(
|
||||
helpers.IsHttpRequest(), server.id)
|
||||
|
||||
@django.test.utils.override_settings(
|
||||
OPENSTACK_ENABLE_INSTANCE_PASSWORD_CHANGE=False)
|
||||
def test_instances_index_change_password_action_disabled(self):
|
||||
self._test_instances_index_change_password_action()
|
||||
|
||||
@django.test.utils.override_settings(
|
||||
OPENSTACK_ENABLE_INSTANCE_PASSWORD_CHANGE=True)
|
||||
def test_instances_index_change_password_action_enabled(self):
|
||||
self._test_instances_index_change_password_action()
|
||||
|
||||
@helpers.create_mocks({
|
||||
api.nova: ('flavor_list',
|
||||
'server_list_paged',
|
||||
'tenant_absolute_limits',
|
||||
'is_feature_available',),
|
||||
api.glance: ('image_list_detailed',),
|
||||
api.neutron: ('floating_ip_simple_associate_supported',
|
||||
'floating_ip_supported',),
|
||||
api.network: ('servers_update_addresses',),
|
||||
api.cinder: ('volume_list',),
|
||||
})
|
||||
def _test_instances_index_change_password_action(self):
|
||||
servers = self.servers.list()
|
||||
|
||||
self.mock_is_feature_available.return_value = True
|
||||
self.mock_flavor_list.return_value = self.flavors.list()
|
||||
self.mock_image_list_detailed.return_value = (self.images.list(),
|
||||
False, False)
|
||||
self.mock_server_list_paged.return_value = [servers, False, False]
|
||||
self.mock_servers_update_addresses.return_value = None
|
||||
self.mock_tenant_absolute_limits.return_value = self.limits['absolute']
|
||||
self.mock_floating_ip_supported.return_value = True
|
||||
self.mock_floating_ip_simple_associate_supported.return_value = True
|
||||
|
||||
url = reverse('horizon:project:instances:index')
|
||||
res = self.client.get(url)
|
||||
for server in servers:
|
||||
_action_id = ''.join(["instances__row_",
|
||||
server.id,
|
||||
"__action_changepassword"])
|
||||
if settings.OPENSTACK_ENABLE_INSTANCE_PASSWORD_CHANGE and \
|
||||
(server.status in tables.ACTIVE_STATES or
|
||||
server.status == 'SHUTOFF') and \
|
||||
not tables.is_deleting(server):
|
||||
self.assertContains(res, _action_id)
|
||||
else:
|
||||
self.assertNotContains(res, _action_id)
|
||||
|
||||
self.assert_mock_multiple_calls_with_same_arguments(
|
||||
self.mock_is_feature_available, 10,
|
||||
mock.call(helpers.IsHttpRequest(), 'locked_attribute'))
|
||||
self.mock_flavor_list.assert_called_once_with(helpers.IsHttpRequest())
|
||||
self._assert_mock_image_list_detailed_calls()
|
||||
|
||||
search_opts = {'marker': None, 'paginate': True}
|
||||
self.mock_server_list_paged.assert_called_once_with(
|
||||
helpers.IsHttpRequest(),
|
||||
sort_dir='desc',
|
||||
search_opts=search_opts)
|
||||
self.mock_servers_update_addresses.assert_called_once_with(
|
||||
helpers.IsHttpRequest(), servers)
|
||||
self.assert_mock_multiple_calls_with_same_arguments(
|
||||
self.mock_tenant_absolute_limits, 2,
|
||||
mock.call(helpers.IsHttpRequest(), reserved=True))
|
||||
self.assert_mock_multiple_calls_with_same_arguments(
|
||||
self.mock_floating_ip_supported, 10,
|
||||
mock.call(helpers.IsHttpRequest()))
|
||||
self.assert_mock_multiple_calls_with_same_arguments(
|
||||
self.mock_floating_ip_simple_associate_supported, 5,
|
||||
mock.call(helpers.IsHttpRequest()))
|
||||
|
||||
instance_update_get_stubs = {
|
||||
api.nova: ('server_get', 'is_feature_available'),
|
||||
api.neutron: ('security_group_list',
|
||||
@@ -1996,6 +2067,71 @@ class InstanceTests(InstanceTestBase):
|
||||
helpers.IsHttpRequest(), "instance_description"
|
||||
)
|
||||
|
||||
def test_change_password_instance_get(self):
|
||||
server = self.servers.first()
|
||||
|
||||
url = reverse('horizon:project:instances:changepassword',
|
||||
args=[server.id])
|
||||
res = self.client.get(url)
|
||||
|
||||
self.assertTemplateUsed(res, 'project/instances/changepassword.html')
|
||||
self.assertContains(res, 'New Password')
|
||||
self.assertContains(res, 'Confirm Password')
|
||||
|
||||
def _instance_change_password_post(self, server_id,
|
||||
password=None, confirm_password=None):
|
||||
form_data = {'instance_id': server_id}
|
||||
if password is not None:
|
||||
form_data.update(password=password)
|
||||
if confirm_password is not None:
|
||||
form_data.update(confirm_password=confirm_password)
|
||||
url = reverse('horizon:project:instances:changepassword',
|
||||
args=[server_id])
|
||||
return self.client.post(url, form_data)
|
||||
|
||||
@helpers.create_mocks({api.nova: ('server_change_password',)})
|
||||
def test_change_password_instance_post(self):
|
||||
server = self.servers.first()
|
||||
password = 'testpass'
|
||||
|
||||
self.mock_server_change_password.return_value = None
|
||||
|
||||
res = self._instance_change_password_post(server.id,
|
||||
password=password,
|
||||
confirm_password=password)
|
||||
self.assertNoFormErrors(res)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
|
||||
self.mock_server_change_password.assert_called_once_with(
|
||||
helpers.IsHttpRequest(), server.id, password)
|
||||
|
||||
def test_change_password_instance_post_password_do_not_match(self):
|
||||
server = self.servers.first()
|
||||
pass1 = 'somepass'
|
||||
pass2 = 'notsomepass'
|
||||
|
||||
res = self._instance_change_password_post(server.id,
|
||||
password=pass1,
|
||||
confirm_password=pass2)
|
||||
|
||||
self.assertEqual(res.context['form'].errors['__all__'],
|
||||
["Passwords do not match."])
|
||||
|
||||
@helpers.create_mocks({api.nova: ('server_change_password',)})
|
||||
def test_change_password_instance_post_api_exception(self):
|
||||
server = self.servers.first()
|
||||
password = 'testpass'
|
||||
|
||||
self.mock_server_change_password.side_effect = self.exceptions.nova
|
||||
|
||||
res = self._instance_change_password_post(server.id,
|
||||
password=password,
|
||||
confirm_password=password)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
|
||||
self.mock_server_change_password.assert_called_once_with(
|
||||
helpers.IsHttpRequest(), server.id, password)
|
||||
|
||||
@helpers.create_mocks(instance_update_post_stubs)
|
||||
def test_instance_update_post_with_desc(self):
|
||||
server = self.servers.first()
|
||||
|
||||
@@ -41,6 +41,8 @@ urlpatterns = [
|
||||
re_path(INSTANCES % 'resize', views.ResizeView.as_view(), name='resize'),
|
||||
re_path(INSTANCES_KEYPAIR % 'decryptpassword',
|
||||
views.DecryptPasswordView.as_view(), name='decryptpassword'),
|
||||
re_path(INSTANCES % 'changepassword',
|
||||
views.ChangePasswordView.as_view(), name='changepassword'),
|
||||
re_path(INSTANCES % 'disassociate',
|
||||
views.DisassociateView.as_view(), name='disassociate'),
|
||||
re_path(INSTANCES % 'attach_interface',
|
||||
|
||||
@@ -442,6 +442,21 @@ class DecryptPasswordView(forms.ModalFormView):
|
||||
'keypair_name': self.kwargs['keypair_name']}
|
||||
|
||||
|
||||
class ChangePasswordView(forms.ModalFormView):
|
||||
form_class = project_forms.ChangePasswordInstanceForm
|
||||
template_name = 'project/instances/changepassword.html'
|
||||
success_url = reverse_lazy('horizon:project:instances:index')
|
||||
page_title = _("Change Instance Password")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['instance_id'] = self.kwargs['instance_id']
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
return {'instance_id': self.kwargs['instance_id']}
|
||||
|
||||
|
||||
class DisassociateView(forms.ModalFormView):
|
||||
form_class = project_forms.Disassociate
|
||||
template_name = 'project/instances/disassociate.html'
|
||||
|
||||
@@ -496,6 +496,12 @@ OPENSTACK_HYPERVISOR_FEATURES = {
|
||||
# allowing Admin session password retrieval/decryption.
|
||||
OPENSTACK_ENABLE_PASSWORD_RETRIEVE = False
|
||||
|
||||
# Setting this to True, will add a new "Change Password" action on instance,
|
||||
# allowing Admin session password changing. This function requires the QEMU
|
||||
# Guest Agent to be running inside the instance. Without it, password changes
|
||||
# cannot be applied from the interface.
|
||||
OPENSTACK_ENABLE_INSTANCE_PASSWORD_CHANGE = False
|
||||
|
||||
# The OPENSTACK_IMAGE_BACKEND settings can be used to customize features
|
||||
# in the OpenStack Dashboard related to the Image service, such as the list
|
||||
# of supported image formats.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Added changing instance password directly from the dashboard.
|
||||
This requires the QEMU Guest Agent to be installed and running inside the
|
||||
guest operating system.
|
||||
Reference in New Issue
Block a user