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:
Dmitriy Chubinidze
2026-06-16 16:26:49 +00:00
co-authored by Sergiu Miclea
parent 6529dd7e60
commit 14ba479c17
11 changed files with 273 additions and 0 deletions
+14
View File
@@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+6
View File
@@ -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,
@@ -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 %}
@@ -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'
+6
View File
@@ -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.