Drop Django based implementation of launch instance

horizon already deprecated launch instance Django based implementation
in the wallaby cycle [1]. This patch remove code for launch instance
Django based implementation as angular based implementation is the
default one from long and all features gaps between angular and Django
implementation is closed.

It also moves SetAdvanced step code to ``resize_instance.py`` as
``workflows/create_instance.py`` file is deleted and remove server_group
option from Advanced Options of resizing instance action because
"server_group" is not required while resizing an instance as per
nova-api reference [2].

Closes-Bug: #1869222

[1] https://review.opendev.org/c/openstack/horizon/+/779125
[2] https://docs.openstack.org/api-ref/compute/?expanded=resize-server-resize-action-detail#resize-server-resize-action

Change-Id: I5e01cd81f309491f1a58ea93911030366a86e3c7
This commit is contained in:
manchandavishal 2021-11-02 12:10:22 +05:30
parent 9d1bb3626b
commit 6ac31e0ba8
22 changed files with 96 additions and 3211 deletions

View File

@ -2245,47 +2245,6 @@ specified in this setting is not found in the availability zone list,
the setting will be ignored and the behavior will be same as when ``Any``
is specified.
LAUNCH_INSTANCE_LEGACY_ENABLED
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. versionadded:: 8.0.0(Liberty)
.. versionchanged:: 9.0.0(Mitaka)
The default value for this setting has been changed to ``False``
.. deprecated:: 19.1.0(Wallaby)
The Python Launch Instance workflow is deprecated.
Consider switching to the AngujarJS workflow instead.
Default: ``False``
This setting enables the Python Launch Instance workflow.
.. note::
It is possible to run both the AngularJS and Python workflows simultaneously,
so the other may be need to be toggled with `LAUNCH_INSTANCE_NG_ENABLED`_
LAUNCH_INSTANCE_NG_ENABLED
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. versionadded:: 8.0.0(Liberty)
.. versionchanged:: 9.0.0(Mitaka)
The default value for this setting has been changed to ``True``
Default: ``True``
This setting enables the AngularJS Launch Instance workflow.
.. note::
It is possible to run both the AngularJS and Python workflows simultaneously,
so the other may be need to be toggled with `LAUNCH_INSTANCE_LEGACY_ENABLED`_
OPENSTACK_ENABLE_PASSWORD_RETRIEVE
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -346,11 +346,7 @@ class ImagesTable(tables.DataTable):
status_columns = ["status"]
verbose_name = _("Images")
table_actions = (OwnerFilter, CreateImage, DeleteImage,)
launch_actions = ()
if settings.LAUNCH_INSTANCE_LEGACY_ENABLED:
launch_actions = (LaunchImage,) + launch_actions
if settings.LAUNCH_INSTANCE_NG_ENABLED:
launch_actions = (LaunchImageNG,) + launch_actions
launch_actions = (LaunchImageNG,)
row_actions = launch_actions + (CreateVolumeFromImage,
EditImage, UpdateMetadata,
DeleteImage,)

View File

@ -413,14 +413,14 @@ class ToggleShelve(tables.BatchAction):
self.current_past_action = SHELVE
class LaunchLink(tables.LinkAction):
name = "launch"
class LaunchLinkNG(tables.LinkAction):
name = "launch-ng"
verbose_name = _("Launch Instance")
url = "horizon:project:instances:launch"
classes = ("ajax-modal", "btn-launch")
url = "horizon:project:instances:index"
ajax = False
classes = ("btn-launch", )
icon = "cloud-upload"
policy_rules = (("compute", "os_compute_api:servers:create"),)
ajax = True
def __init__(self, attrs=None, **kwargs):
kwargs['preempt'] = True
@ -458,13 +458,6 @@ class LaunchLink(tables.LinkAction):
self.allowed(request, None)
return HttpResponse(self.render(is_table_action=True))
class LaunchLinkNG(LaunchLink):
name = "launch-ng"
url = "horizon:project:instances:index"
ajax = False
classes = ("btn-launch", )
def get_default_attrs(self):
url = urls.reverse(self.url)
ngclick = "modal.openLaunchInstanceWizard(" \
@ -1295,11 +1288,7 @@ class InstancesTable(tables.DataTable):
status_columns = ["status", "task"]
row_class = UpdateRow
table_actions_menu = (StartInstance, StopInstance, SoftRebootInstance)
launch_actions = ()
if settings.LAUNCH_INSTANCE_LEGACY_ENABLED:
launch_actions = (LaunchLink,) + launch_actions
if settings.LAUNCH_INSTANCE_NG_ENABLED:
launch_actions = (LaunchLinkNG,) + launch_actions
launch_actions = (LaunchLinkNG,)
table_actions = launch_actions + (DeleteInstance,
InstancesFilterAction)
row_actions = (StartInstance, ConfirmResize, RevertResize,

View File

@ -1,3 +0,0 @@
{% load i18n %}
<p>{% blocktrans %}You can customize your instance after it has launched using the options available here.{% endblocktrans %}</p>
<p>{% blocktrans %}"Customization Script" is analogous to "User Data" in other systems.{% endblocktrans %}</p>

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,6 @@ INSTANCES_KEYPAIR = r'^(?P<instance_id>[^/]+)/(?P<keypair_name>[^/]+)/%s$'
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^launch$', views.LaunchInstanceView.as_view(), name='launch'),
url(r'^(?P<instance_id>[^/]+)/$',
views.DetailView.as_view(), name='detail'),
url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'),

View File

@ -40,7 +40,6 @@ from horizon import workflows
from openstack_dashboard import api
from openstack_dashboard.utils import filters
from openstack_dashboard.utils import settings as setting_utils
from openstack_dashboard.dashboards.project.instances \
import console as project_console
@ -260,26 +259,6 @@ def _swap_filter(resources, search_opts, fake_field, real_field):
return True
class LaunchInstanceView(workflows.WorkflowView):
workflow_class = project_workflows.LaunchInstance
def __init__(self):
super().__init__()
LOG.warning('Django version of the launch instance form is '
'deprecated since Wallaby release. Switch to '
'the AngularJS version of the form by setting '
'LAUNCH_INSTANCE_NG_ENABLED to True and '
'LAUNCH_INSTANCE_LEGACY_ENABLED to False.')
def get_initial(self):
initial = super().get_initial()
initial['project_id'] = self.request.user.tenant_id
initial['user_id'] = self.request.user.id
initial['config_drive'] = setting_utils.get_dict_config(
'LAUNCH_INSTANCE_DEFAULTS', 'config_drive')
return initial
# TODO(stephenfin): Migrate to CBV
def console(request, instance_id):
data = _('Unable to get log for instance "%s".') % instance_id

View File

@ -10,8 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from openstack_dashboard.dashboards.project.instances.workflows.\
create_instance import LaunchInstance
from openstack_dashboard.dashboards.project.instances.workflows.\
resize_instance import ResizeInstance
from openstack_dashboard.dashboards.project.instances.workflows.\
@ -20,7 +18,6 @@ from openstack_dashboard.dashboards.project.instances.workflows.\
update_port import UpdatePort
__all__ = [
'LaunchInstance',
'ResizeInstance',
'UpdateInstance',
'UpdatePort',

View File

@ -1,959 +0,0 @@
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright 2012 Nebula, Inc.
#
# 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 json
import logging
import operator
from oslo_utils import units
from django.template.defaultfilters import filesizeformat
from django.utils.text import normalize_newlines
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.debug import sensitive_variables
from horizon import exceptions
from horizon import forms
from horizon.utils import functions
from horizon.utils import memoized
from horizon.utils import validators
from horizon import workflows
from openstack_dashboard import api
from openstack_dashboard.api import base
from openstack_dashboard.api import cinder
from openstack_dashboard.api import nova
from openstack_dashboard.usage import quotas
from openstack_dashboard.dashboards.project.images.images \
import tables as image_tables
from openstack_dashboard.dashboards.project.images \
import utils as image_utils
from openstack_dashboard.dashboards.project.instances \
import utils as instance_utils
LOG = logging.getLogger(__name__)
class SelectProjectUserAction(workflows.Action):
project_id = forms.ThemableChoiceField(label=_("Project"))
user_id = forms.ThemableChoiceField(label=_("User"))
def __init__(self, request, *args, **kwargs):
super().__init__(request, *args, **kwargs)
# Set our project choices
projects = [(tenant.id, tenant.name)
for tenant in request.user.authorized_tenants]
self.fields['project_id'].choices = projects
# Set our user options
users = [(request.user.id, request.user.username)]
self.fields['user_id'].choices = users
class Meta(object):
name = _("Project & User")
# Unusable permission so this is always hidden. However, we
# keep this step in the workflow for validation/verification purposes.
permissions = ("!",)
class SelectProjectUser(workflows.Step):
action_class = SelectProjectUserAction
contributes = ("project_id", "user_id")
class SetInstanceDetailsAction(workflows.Action):
availability_zone = forms.ThemableChoiceField(label=_("Availability Zone"),
required=False)
name = forms.CharField(label=_("Instance Name"),
max_length=255)
flavor = forms.ThemableChoiceField(label=_("Flavor"),
help_text=_("Size of image to launch."))
count = forms.IntegerField(label=_("Number of Instances"),
min_value=1,
initial=1)
source_type = forms.ThemableChoiceField(
label=_("Instance Boot Source"),
help_text=_("Choose Your Boot Source "
"Type."))
instance_snapshot_id = forms.ThemableChoiceField(
label=_("Instance Snapshot"),
required=False)
volume_id = forms.ThemableChoiceField(label=_("Volume"), required=False)
volume_snapshot_id = forms.ThemableChoiceField(label=_("Volume Snapshot"),
required=False)
image_id = forms.ChoiceField(
label=_("Image Name"),
required=False,
widget=forms.ThemableSelectWidget(
data_attrs=('volume_size',),
transform=lambda x: ("%s (%s)" % (x.name,
filesizeformat(x.bytes)))))
volume_size = forms.IntegerField(label=_("Device size (GB)"),
initial=1,
min_value=0,
required=False,
help_text=_("Volume size in gigabytes "
"(integer value)."))
device_name = forms.CharField(label=_("Device Name"),
required=False,
initial="vda",
help_text=_("Volume mount point (e.g. 'vda' "
"mounts at '/dev/vda'). Leave "
"this field blank to let the "
"system choose a device name "
"for you."))
vol_delete_on_instance_delete = forms.BooleanField(
label=_("Delete Volume on Instance Delete"),
initial=False,
required=False,
help_text=_("Delete volume when the instance is deleted"))
class Meta(object):
name = _("Details")
help_text_template = ("project/instances/"
"_launch_details_help.html")
def __init__(self, request, context, *args, **kwargs):
self._init_images_cache()
self.request = request
self.context = context
super().__init__(request, context, *args, **kwargs)
# Hide the device field if the hypervisor doesn't support it.
if not nova.can_set_mount_point():
self.fields['device_name'].widget = forms.widgets.HiddenInput()
source_type_choices = [
('', _("Select source")),
("image_id", _("Boot from image")),
("instance_snapshot_id", _("Boot from snapshot")),
]
if cinder.is_volume_service_enabled(request):
source_type_choices += [
("volume_id", _("Boot from volume")),
("volume_image_id",
_("Boot from image (creates a new volume)")),
("volume_snapshot_id",
_("Boot from volume snapshot (creates a new volume)")),
]
self.fields['source_type'].choices = source_type_choices
@memoized.memoized_method
def _get_flavor(self, flavor_id):
try:
# We want to retrieve details for a given flavor,
# however flavor_list uses a memoized decorator
# so it is used instead of flavor_get to reduce the number
# of API calls.
flavors = instance_utils.flavor_list(self.request)
flavor = [x for x in flavors if x.id == flavor_id][0]
except IndexError:
flavor = None
return flavor
@memoized.memoized_method
def _get_image(self, image_id):
try:
# We want to retrieve details for a given image,
# however get_available_images uses a cache of image list,
# so it is used instead of image_get to reduce the number
# of API calls.
images = image_utils.get_available_images(
self.request,
self.context.get('project_id'),
self._images_cache)
image = [x for x in images if x.id == image_id][0]
except IndexError:
image = None
return image
def _check_quotas(self, cleaned_data):
count = cleaned_data.get('count', 1)
# Prevent launching more instances than the quota allows
usages = quotas.tenant_quota_usages(
self.request,
targets=('instances', 'cores', 'ram', 'volumes', ))
available_count = usages['instances']['available']
if available_count < count:
msg = (_('The requested instance(s) cannot be launched '
'as your quota will be exceeded: Available: '
'%(avail)s, Requested: %(req)s.')
% {'avail': available_count, 'req': count})
raise forms.ValidationError(msg)
source_type = cleaned_data.get('source_type')
if source_type in ('volume_image_id', 'volume_snapshot_id'):
available_volume = usages['volumes']['available']
if available_volume < count:
msg = (_('The requested instance cannot be launched. '
'Requested volume exceeds quota: Available: '
'%(avail)s, Requested: %(req)s.')
% {'avail': available_volume, 'req': count})
raise forms.ValidationError(msg)
flavor_id = cleaned_data.get('flavor')
flavor = self._get_flavor(flavor_id)
count_error = []
# Validate cores and ram.
available_cores = usages['cores']['available']
if flavor and available_cores < count * flavor.vcpus:
count_error.append(_("Cores(Available: %(avail)s, "
"Requested: %(req)s)")
% {'avail': available_cores,
'req': count * flavor.vcpus})
available_ram = usages['ram']['available']
if flavor and available_ram < count * flavor.ram:
count_error.append(_("RAM(Available: %(avail)s, "
"Requested: %(req)s)")
% {'avail': available_ram,
'req': count * flavor.ram})
if count_error:
value_str = ", ".join(count_error)
msg = (_('The requested instance cannot be launched. '
'The following requested resource(s) exceed '
'quota(s): %s.') % value_str)
if count == 1:
self._errors['flavor'] = self.error_class([msg])
else:
self._errors['count'] = self.error_class([msg])
def _check_flavor_for_image(self, cleaned_data):
# Prevents trying to launch an image needing more resources.
image_id = cleaned_data.get('image_id')
image = self._get_image(image_id)
flavor_id = cleaned_data.get('flavor')
flavor = self._get_flavor(flavor_id)
if not image or not flavor:
return
props_mapping = (("min_ram", "ram"), ("min_disk", "disk"))
for iprop, fprop in props_mapping:
if (getattr(image, iprop) > 0 and
getattr(flavor, fprop) > 0 and
getattr(image, iprop) > getattr(flavor, fprop)):
msg = (_("The flavor '%(flavor)s' is too small "
"for requested image.\n"
"Minimum requirements: "
"%(min_ram)s MB of RAM and "
"%(min_disk)s GB of Root Disk.") %
{'flavor': flavor.name,
'min_ram': image.min_ram,
'min_disk': image.min_disk})
self._errors['image_id'] = self.error_class([msg])
break # Not necessary to continue the tests.
def _check_volume_for_image(self, cleaned_data):
image_id = cleaned_data.get('image_id')
image = self._get_image(image_id)
volume_size = cleaned_data.get('volume_size')
if not image or not volume_size:
return
volume_size = int(volume_size)
img_gigs = functions.bytes_to_gigabytes(image.size)
smallest_size = max(img_gigs, image.min_disk)
if volume_size < smallest_size:
msg = (_("The Volume size is too small for the"
" '%(image_name)s' image and has to be"
" greater than or equal to "
"'%(smallest_size)d' GB.") %
{'image_name': image.name,
'smallest_size': smallest_size})
self._errors['volume_size'] = self.error_class([msg])
def _check_source_image(self, cleaned_data):
if not cleaned_data.get('image_id'):
msg = _("You must select an image.")
self._errors['image_id'] = self.error_class([msg])
else:
self._check_flavor_for_image(cleaned_data)
def _check_source_volume_image(self, cleaned_data):
volume_size = self.data.get('volume_size', None)
if not volume_size:
msg = _("You must set volume size")
self._errors['volume_size'] = self.error_class([msg])
if float(volume_size) <= 0:
msg = _("Volume size must be greater than 0")
self._errors['volume_size'] = self.error_class([msg])
if not cleaned_data.get('image_id'):
msg = _("You must select an image.")
self._errors['image_id'] = self.error_class([msg])
return
self._check_flavor_for_image(cleaned_data)
self._check_volume_for_image(cleaned_data)
def _check_source_instance_snapshot(self, cleaned_data):
# using the array form of get blows up with KeyError
# if instance_snapshot_id is nil
if not cleaned_data.get('instance_snapshot_id'):
msg = _("You must select a snapshot.")
self._errors['instance_snapshot_id'] = self.error_class([msg])
def _check_source_volume(self, cleaned_data):
if not cleaned_data.get('volume_id'):
msg = _("You must select a volume.")
self._errors['volume_id'] = self.error_class([msg])
# Prevent launching multiple instances with the same volume.
# TODO(gabriel): is it safe to launch multiple instances with
# a snapshot since it should be cloned to new volumes?
count = cleaned_data.get('count', 1)
if count > 1:
msg = _('Launching multiple instances is only supported for '
'images and instance snapshots.')
raise forms.ValidationError(msg)
def _check_source_volume_snapshot(self, cleaned_data):
if not cleaned_data.get('volume_snapshot_id'):
msg = _("You must select a snapshot.")
self._errors['volume_snapshot_id'] = self.error_class([msg])
def _check_source(self, cleaned_data):
# Validate our instance source.
source_type = self.data.get('source_type', None)
source_check_methods = {
'image_id': self._check_source_image,
'volume_image_id': self._check_source_volume_image,
'instance_snapshot_id': self._check_source_instance_snapshot,
'volume_id': self._check_source_volume,
'volume_snapshot_id': self._check_source_volume_snapshot
}
check_method = source_check_methods.get(source_type)
if check_method:
check_method(cleaned_data)
def clean(self):
cleaned_data = super().clean()
self._check_quotas(cleaned_data)
self._check_source(cleaned_data)
return cleaned_data
def populate_flavor_choices(self, request, context):
return instance_utils.flavor_field_data(request, False)
def populate_availability_zone_choices(self, request, context):
try:
zones = api.nova.availability_zone_list(request)
except Exception:
zones = []
exceptions.handle(request,
_('Unable to retrieve availability zones.'))
zone_list = [(zone.zoneName, zone.zoneName)
for zone in zones if zone.zoneState['available']]
zone_list.sort()
if not zone_list:
zone_list.insert(0, ("", _("No availability zones found")))
elif len(zone_list) > 1:
zone_list.insert(0, ("", _("Any Availability Zone")))
return zone_list
def get_help_text(self, extra_context=None):
extra = {} if extra_context is None else dict(extra_context)
try:
extra['usages'] = quotas.tenant_quota_usages(
self.request,
targets=('instances', 'cores', 'ram', 'volumes', 'gigabytes'))
extra['usages_json'] = json.dumps(extra['usages'])
extra['cinder_enabled'] = \
base.is_service_enabled(self.request, 'volume')
flavors = json.dumps([f._info for f in
instance_utils.flavor_list(self.request)])
extra['flavors'] = flavors
images = image_utils.get_available_images(
self.request, self.initial['project_id'], self._images_cache)
if images is not None:
attrs = [{'id': i.id,
'min_disk': getattr(i, 'min_disk', 0),
'min_ram': getattr(i, 'min_ram', 0),
'size': functions.bytes_to_gigabytes(i.size)}
for i in images]
extra['images'] = json.dumps(attrs)
except Exception:
exceptions.handle(self.request,
_("Unable to retrieve quota information."))
return super().get_help_text(extra)
def _init_images_cache(self):
if not hasattr(self, '_images_cache'):
self._images_cache = {}
def _get_volume_display_name(self, volume):
if hasattr(volume, "volume_id"):
vol_type = "snap"
visible_label = _("Snapshot")
else:
vol_type = "vol"
visible_label = _("Volume")
return (("%s:%s" % (volume.id, vol_type)),
(_("%(name)s - %(size)s GB (%(label)s)") %
{'name': volume.name,
'size': volume.size,
'label': visible_label}))
def populate_image_id_choices(self, request, context):
choices = []
images = image_utils.get_available_images(request,
context.get('project_id'),
self._images_cache)
for image in images:
if image_tables.get_image_type(image) != "snapshot":
image.bytes = getattr(
image, 'virtual_size', None) or image.size
image.volume_size = max(
image.min_disk, functions.bytes_to_gigabytes(image.bytes))
choices.append((image.id, image))
if context.get('image_id') == image.id and \
'volume_size' not in context:
context['volume_size'] = image.volume_size
if choices:
choices.sort(key=lambda c: c[1].name or '')
choices.insert(0, ("", _("Select Image")))
else:
choices.insert(0, ("", _("No images available")))
return choices
def populate_instance_snapshot_id_choices(self, request, context):
images = image_utils.get_available_images(request,
context.get('project_id'),
self._images_cache)
choices = [(image.id, image.name)
for image in images
if image_tables.get_image_type(image) == "snapshot"]
if choices:
choices.sort(key=operator.itemgetter(1))
choices.insert(0, ("", _("Select Instance Snapshot")))
else:
choices.insert(0, ("", _("No snapshots available")))
return choices
def populate_volume_id_choices(self, request, context):
volumes = []
try:
if cinder.is_volume_service_enabled(request):
available = api.cinder.VOLUME_STATE_AVAILABLE
volumes = [self._get_volume_display_name(v)
for v in cinder.volume_list(self.request,
search_opts=dict(status=available, bootable=True))]
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve list of volumes.'))
if volumes:
volumes.insert(0, ("", _("Select Volume")))
else:
volumes.insert(0, ("", _("No volumes available")))
return volumes
def populate_volume_snapshot_id_choices(self, request, context):
snapshots = []
try:
if cinder.is_volume_service_enabled(request):
available = api.cinder.VOLUME_STATE_AVAILABLE
volumes = [v.id for v in cinder.volume_list(
self.request, search_opts=dict(bootable=True))]
snapshots = [self._get_volume_display_name(s)
for s in cinder.volume_snapshot_list(
self.request, search_opts=dict(status=available))
if s.volume_id in volumes]
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve list of volume '
'snapshots.'))
if snapshots:
snapshots.insert(0, ("", _("Select Volume Snapshot")))
else:
snapshots.insert(0, ("", _("No volume snapshots available")))
return snapshots
class SetInstanceDetails(workflows.Step):
action_class = SetInstanceDetailsAction
depends_on = ("project_id", "user_id")
contributes = ("source_type", "source_id",
"availability_zone", "name", "count", "flavor",
"device_name", # Can be None for an image.
"vol_delete_on_instance_delete")
def prepare_action_context(self, request, context):
if 'source_type' in context and 'source_id' in context:
context[context['source_type']] = context['source_id']
return context
def contribute(self, data, context):
context = super().contribute(data, context)
# Allow setting the source dynamically.
if ("source_type" in context and
"source_id" in context and
context["source_type"] not in context):
context[context["source_type"]] = context["source_id"]
# Translate form input to context for source values.
if "source_type" in data:
if data["source_type"] in ["image_id", "volume_image_id"]:
context["source_id"] = data.get("image_id", None)
else:
context["source_id"] = data.get(data["source_type"], None)
if "volume_size" in data:
context["volume_size"] = data["volume_size"]
return context
KEYPAIR_IMPORT_URL = "horizon:project:key_pairs:import"
class SetAccessControlsAction(workflows.Action):
keypair = forms.ThemableDynamicChoiceField(
label=_("Key Pair"),
help_text=_("Key pair to use for "
"authentication."),
add_item_link=KEYPAIR_IMPORT_URL)
admin_pass = forms.RegexField(
label=_("Admin Password"),
required=False,
widget=forms.PasswordInput(render_value=False),
regex=validators.password_validator(),
error_messages={'invalid': validators.password_validator_msg()})
confirm_admin_pass = forms.CharField(
label=_("Confirm Admin Password"),
strip=False,
required=False,
widget=forms.PasswordInput(render_value=False))
groups = forms.MultipleChoiceField(
label=_("Security Groups"),
required=False,
initial=["default"],
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Launch instance in these "
"security groups."))
class Meta(object):
name = _("Access & Security")
help_text = _("Control access to your instance via key pairs, "
"security groups, and other mechanisms.")
def __init__(self, request, *args, **kwargs):
super().__init__(request, *args, **kwargs)
if not api.nova.can_set_server_password():
del self.fields['admin_pass']
del self.fields['confirm_admin_pass']
self.fields['keypair'].required = api.nova.requires_keypair()
def populate_keypair_choices(self, request, context):
keypairs = instance_utils.keypair_field_data(request, True)
if len(keypairs) == 2:
self.fields['keypair'].initial = keypairs[1][0]
return keypairs
def populate_groups_choices(self, request, context):
try:
groups = api.neutron.security_group_list(request)
security_group_list = [(sg.id, sg.name) for sg in groups]
except Exception:
exceptions.handle(request,
_('Unable to retrieve list of security groups'))
security_group_list = []
return security_group_list
def clean(self):
'''Check to make sure password fields match.'''
cleaned_data = super().clean()
if 'admin_pass' in cleaned_data:
if cleaned_data['admin_pass'] != cleaned_data.get(
'confirm_admin_pass', None):
raise forms.ValidationError(_('Passwords do not match.'))
return cleaned_data
class SetAccessControls(workflows.Step):
action_class = SetAccessControlsAction
depends_on = ("project_id", "user_id")
contributes = ("keypair_id", "security_group_ids",
"admin_pass", "confirm_admin_pass")
def contribute(self, data, context):
if data:
post = self.workflow.request.POST
context['security_group_ids'] = post.getlist("groups")
context['keypair_id'] = data.get("keypair", "")
context['admin_pass'] = data.get("admin_pass", "")
context['confirm_admin_pass'] = data.get("confirm_admin_pass", "")
return context
class CustomizeAction(workflows.Action):
class Meta(object):
name = _("Post-Creation")
help_text_template = ("project/instances/"
"_launch_customize_help.html")
source_choices = [('', _('Select Script Source')),
('raw', _('Direct Input')),
('file', _('File'))]
attributes = {'class': 'switchable', 'data-slug': 'scriptsource'}
script_source = forms.ChoiceField(
label=_('Customization Script Source'),
choices=source_choices,
widget=forms.ThemableSelectWidget(attrs=attributes),
required=False)
script_help = _("A script or set of commands to be executed after the "
"instance has been built (max 16kb).")
script_upload = forms.FileField(
label=_('Script File'),
help_text=script_help,
widget=forms.FileInput(attrs={
'class': 'switched',
'data-switch-on': 'scriptsource',
'data-scriptsource-file': _('Script File')}),
required=False)
script_data = forms.CharField(
label=_('Script Data'),
help_text=script_help,
widget=forms.widgets.Textarea(attrs={
'class': 'switched',
'data-switch-on': 'scriptsource',
'data-scriptsource-raw': _('Script Data')}),
required=False)
def clean(self):
cleaned = super().clean()
files = self.request.FILES
script = self.clean_uploaded_files('script', files)
if script is not None:
cleaned['script_data'] = script
return cleaned
def clean_uploaded_files(self, prefix, files):
upload_str = prefix + "_upload"
if upload_str not in files:
return None
upload_file = files[upload_str]
log_script_name = upload_file.name
LOG.info('got upload %s', log_script_name)
if upload_file._size > 16 * units.Ki: # 16kb
msg = _('File exceeds maximum size (16kb)')
raise forms.ValidationError(msg)
script = upload_file.read()
if script != "":
try:
if not isinstance(script, str):
script = script.decode()
normalize_newlines(script)
except Exception as e:
msg = _('There was a problem parsing the'
' %(prefix)s: %(error)s')
msg = msg % {'prefix': prefix,
'error': e}
raise forms.ValidationError(msg)
return script
class PostCreationStep(workflows.Step):
action_class = CustomizeAction
contributes = ("script_data",)
class SetNetworkAction(workflows.Action):
network = forms.MultipleChoiceField(
label=_("Networks"),
widget=forms.ThemableCheckboxSelectMultiple(),
error_messages={
'required': _(
"At least one network must"
" be specified.")},
help_text=_("Launch instance with"
" these networks"))
def __init__(self, request, *args, **kwargs):
super().__init__(request, *args, **kwargs)
# NOTE(e0ne): we don't need 'required attribute for networks
# checkboxes to be able to select only one network
# NOTE(e0ne): we need it for compatibility with different
# Django versions (prior to 1.11)
self.use_required_attribute = False
network_list = self.fields["network"].choices
if len(network_list) == 1:
self.fields['network'].initial = [network_list[0][0]]
class Meta(object):
name = _("Networking")
permissions = ('openstack.services.network',)
help_text = _("Select networks for your instance.")
def populate_network_choices(self, request, context):
return instance_utils.network_field_data(request, for_launch=True)
class SetNetwork(workflows.Step):
action_class = SetNetworkAction
template_name = "project/instances/_update_networks.html"
contributes = ("network_id",)
def contribute(self, data, context):
if data:
networks = self.workflow.request.POST.getlist("network")
# If no networks are explicitly specified, network list
# contains an empty string, so remove it.
networks = [n for n in networks if n != '']
if networks:
context['network_id'] = networks
return context
class SetNetworkPortsAction(workflows.Action):
ports = forms.MultipleChoiceField(label=_("Ports"),
widget=forms.CheckboxSelectMultiple(),
required=False,
help_text=_("Launch instance with"
" these ports"))
class Meta(object):
name = _("Network Ports")
permissions = ('openstack.services.network',)
help_text_template = ("project/instances/"
"_launch_network_ports_help.html")
def populate_ports_choices(self, request, context):
ports = instance_utils.port_field_data(request)
if not ports:
self.fields['ports'].label = _("No ports available")
self.fields['ports'].help_text = _("No ports available")
return ports
class SetNetworkPorts(workflows.Step):
action_class = SetNetworkPortsAction
contributes = ("ports",)
def contribute(self, data, context):
if data:
ports = self.workflow.request.POST.getlist("ports")
if ports:
context['ports'] = ports
return context
class SetAdvancedAction(workflows.Action):
disk_config = forms.ThemableChoiceField(
label=_("Disk Partition"), required=False,
help_text=_("Automatic: The entire disk is a single partition and "
"automatically resizes. Manual: Results in faster build "
"times but requires manual partitioning."))
config_drive = forms.BooleanField(
label=_("Configuration Drive"),
required=False, help_text=_("Configure OpenStack to write metadata to "
"a special configuration drive that "
"attaches to the instance when it boots."))
server_group = forms.ThemableChoiceField(
label=_("Server Group"), required=False,
help_text=_("Server group to associate with this instance."))
def __init__(self, request, context, *args, **kwargs):
super().__init__(request, context, *args, **kwargs)
try:
config_choices = [("AUTO", _("Automatic")),
("MANUAL", _("Manual"))]
self.fields['disk_config'].choices = config_choices
# Only show the Config Drive option for the Launch Instance
# is supported.
if context.get('workflow_slug') != 'launch_instance':
del self.fields['config_drive']
server_group_choices = instance_utils.server_group_field_data(
request)
self.fields['server_group'].choices = server_group_choices
except Exception:
exceptions.handle(request, _('Unable to retrieve extensions '
'information.'))
class Meta(object):
name = _("Advanced Options")
help_text_template = ("project/instances/"
"_launch_advanced_help.html")
class SetAdvanced(workflows.Step):
action_class = SetAdvancedAction
contributes = ("disk_config", "config_drive", "server_group",)
def prepare_action_context(self, request, context):
context = super().prepare_action_context(request, context)
# Add the workflow slug to the context so that we can tell which
# workflow is being used when creating the action. This step is
# used by both the Launch Instance and Resize Instance workflows.
context['workflow_slug'] = self.workflow.slug
return context
class LaunchInstance(workflows.Workflow):
slug = "launch_instance"
name = _("Launch Instance")
finalize_button_name = _("Launch")
success_message = _('Request for launching %(count)s named "%(name)s" '
'has been submitted.')
failure_message = _('Unable to launch %(count)s named "%(name)s".')
success_url = "horizon:project:instances:index"
multipart = True
default_steps = (SelectProjectUser,
SetInstanceDetails,
SetAccessControls,
SetNetwork,
SetNetworkPorts,
PostCreationStep,
SetAdvanced)
def format_status_message(self, message):
name = self.context.get('name', 'unknown instance')
count = self.context.get('count', 1)
if int(count) > 1:
return message % {"count": _("%s instances") % count,
"name": name}
return message % {"count": _("instance"), "name": name}
@sensitive_variables('context')
def handle(self, request, context):
custom_script = context.get('script_data', '')
dev_mapping_1 = None
dev_mapping_2 = None
image_id = ''
# Determine volume mapping options
source_type = context.get('source_type', None)
if source_type in ['image_id', 'instance_snapshot_id']:
image_id = context['source_id']
elif source_type in ['volume_id', 'volume_snapshot_id']:
# Volume source id is extracted from the source
volume_source_id = context['source_id'].split(':')[0]
device_name = context.get('device_name', '').strip() or None
dev_source_type_mapping = {
'volume_id': 'volume',
'volume_snapshot_id': 'snapshot'
}
dev_mapping_2 = [
{'device_name': device_name,
'source_type': dev_source_type_mapping[source_type],
'destination_type': 'volume',
'delete_on_termination':
bool(context['vol_delete_on_instance_delete']),
'uuid': volume_source_id,
'boot_index': '0',
'volume_size': context['volume_size']
}
]
elif source_type == 'volume_image_id':
device_name = context.get('device_name', '').strip() or None
dev_mapping_2 = [
{'device_name': device_name, # None auto-selects device
'source_type': 'image',
'destination_type': 'volume',
'delete_on_termination':
bool(context['vol_delete_on_instance_delete']),
'uuid': context['source_id'],
'boot_index': '0',
'volume_size': context['volume_size']
}
]
netids = context.get('network_id', None)
if netids:
nics = [{"net-id": netid, "v4-fixed-ip": ""}
for netid in netids]
else:
nics = None
avail_zone = context.get('availability_zone', None)
scheduler_hints = {}
server_group = context.get('server_group', None)
if server_group:
scheduler_hints['group'] = server_group
ports = context.get('ports')
if ports:
if nics is None:
nics = []
nics.extend([{'port-id': port} for port in ports])
try:
api.nova.server_create(request,
context['name'],
image_id,
context['flavor'],
context['keypair_id'],
normalize_newlines(custom_script),
context['security_group_ids'],
block_device_mapping=dev_mapping_1,
block_device_mapping_v2=dev_mapping_2,
nics=nics,
availability_zone=avail_zone,
instance_count=int(context['count']),
admin_pass=context['admin_pass'],
disk_config=context.get('disk_config'),
config_drive=context.get('config_drive'),
scheduler_hints=scheduler_hints)
return True
except Exception:
exceptions.handle(request)
return False
def _cleanup_ports_on_failed_vm_launch(request, nics):
ports_failing_deletes = []
LOG.debug('Cleaning up stale VM ports.')
for nic in nics:
try:
LOG.debug('Deleting port with id: %s', nic['port-id'])
api.neutron.port_delete(request, nic['port-id'])
except Exception:
ports_failing_deletes.append(nic['port-id'])
return ports_failing_deletes

View File

@ -25,8 +25,53 @@ from horizon import workflows
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.instances \
import utils as instance_utils
from openstack_dashboard.dashboards.project.instances.workflows \
import create_instance
class SetAdvancedAction(workflows.Action):
disk_config = forms.ThemableChoiceField(
label=_("Disk Partition"), required=False,
help_text=_("Automatic: The entire disk is a single partition and "
"automatically resizes. Manual: Results in faster build "
"times but requires manual partitioning."))
config_drive = forms.BooleanField(
label=_("Configuration Drive"),
required=False, help_text=_("Configure OpenStack to write metadata to "
"a special configuration drive that "
"attaches to the instance when it boots."))
def __init__(self, request, context, *args, **kwargs):
super().__init__(request, context, *args, **kwargs)
try:
config_choices = [("AUTO", _("Automatic")),
("MANUAL", _("Manual"))]
self.fields['disk_config'].choices = config_choices
# Only show the Config Drive option for the Launch Instance
# is supported.
if context.get('workflow_slug') != 'launch_instance':
del self.fields['config_drive']
except Exception:
exceptions.handle(request, _('Unable to retrieve extensions '
'information.'))
class Meta(object):
name = _("Advanced Options")
help_text_template = ("project/instances/"
"_launch_advanced_help.html")
class SetAdvanced(workflows.Step):
action_class = SetAdvancedAction
contributes = ("disk_config", "config_drive",)
def prepare_action_context(self, request, context):
context = super().prepare_action_context(request, context)
# Add the workflow slug to the context so that we can tell which
# workflow is being used when creating the action. This step is
# used by both the Launch Instance and Resize Instance workflows.
context['workflow_slug'] = self.workflow.slug
return context
class SetFlavorChoiceAction(workflows.Action):
@ -94,7 +139,7 @@ class ResizeInstance(workflows.Workflow):
'has been submitted.')
failure_message = _('Unable to resize instance "%s".')
success_url = "horizon:project:instances:index"
default_steps = (SetFlavorChoice, create_instance.SetAdvanced)
default_steps = (SetFlavorChoice, SetAdvanced,)
def format_status_message(self, message):
if "%s" in message:

View File

@ -2,31 +2,17 @@
<div class="launchButtons pull-right">
{% if launch_instance_allowed %}
{% if show_ng_launch %}
{% url 'horizon:project:network_topology:index' as networkUrl %}
<a href="javascript:void(0);" ng-controller="LaunchInstanceModalController as modal"
ng-click="modal.openLaunchInstanceWizard({successUrl: '{{networkUrl}}'})"
id="instances__action_launch-ng" class="btn btn-default btn-launch
{% if instance_quota_exceeded %}disabled{% endif %}">
<span class="fa fa-cloud-upload"></span>
{% if instance_quota_exceeded %}
{% trans "Launch Instance (Quota exceeded)" %}
{% else %}
{% trans "Launch Instance" %}
{% endif %}
</a>
{% endif %}
{% if show_legacy_launch %}
<a href="{% url 'horizon:project:network_topology:launchinstance' %}"
id="instances__action_launch" class="btn btn-default btn-launch ajax-modal {% if instance_quota_exceeded %}disabled{% endif %}">
<span class="fa fa-cloud-upload"></span>
{% if instance_quota_exceeded %}
{% trans "Launch Instance (Quota exceeded)" %}
{% else %}
{% trans "Launch Instance" %}
{% endif %}
</a>
{% endif %}
{% url 'horizon:project:network_topology:index' as networkUrl %}
<a href="javascript:void(0);" ng-controller="LaunchInstanceModalController as modal"
ng-click="modal.openLaunchInstanceWizard({successUrl: '{{networkUrl}}'})"
id="instances__action_launch-ng" class="btn btn-default btn-launch
{% if instance_quota_exceeded %}disabled{% endif %}">
<span class="fa fa-cloud-upload"></span>
{% if instance_quota_exceeded %}
{% trans "Launch Instance (Quota exceeded)" %}
{% else %}
{% trans "Launch Instance" %}
{% endif %}
{% endif %}
{% if create_network_allowed %}
<a href="{% url 'horizon:project:network_topology:createnetwork' %}"

View File

@ -21,14 +21,9 @@
<div class="topologyNavi">
<div class="launchButtons">
{% if launch_instance_allowed %}
{% if show_ng_launch %}
{% url 'horizon:project:network_topology:index' as networkUrl %}
<a href="javascript:void(0);" ng-controller="LaunchInstanceModalController as modal" ng-click="modal.openLaunchInstanceWizard({successUrl: '{{networkUrl}}'})" id="instances__action_launch-ng" class="btn btn-default btn-sm btn-launch {% if instance_quota_exceeded %}disabled{% endif %}"><span class="fa fa-cloud-upload"></span> {% if instance_quota_exceeded %}{% trans "Launch Instance (Quota exceeded)"%}{% else %}{% trans "Launch Instance"%}{% endif %}</a>
{% endif %}
{% if show_legacy_launch %}
<a href="{% url 'horizon:project:network_topology:launchinstance' %}" id="instances__action_launch" class="btn btn-default btn-sm btn-launch ajax-modal {% if instance_quota_exceeded %}disabled{% endif %}"><span class="fa fa-cloud-upload"></span> {% if instance_quota_exceeded %}{% trans "Launch Instance (Quota exceeded)"%}{% else %}{% trans "Launch Instance"%}{% endif %}</a>
{% endif %}
{% endif %}
{% if create_network_allowed %}
<a href="{% url 'horizon:project:network_topology:createnetwork' %}" id="networks__action_create" class="btn btn-default btn-sm ajax-modal {% if network_quota_exceeded %}disabled{% endif %}"><span class="fa fa-plus"></span> {% if network_quota_exceeded %}{% trans "Create Network (Quota exceeded)"%}{% else %}{% trans "Create Network"%}{% endif %}</a>
{% endif %}

View File

@ -234,17 +234,3 @@ class NetworkTopologyCreateTests(test.TestCase):
self._test_new_button_disabled_when_quota_exceeded(expected_string,
routers_quota=0)
@test.update_settings(LAUNCH_INSTANCE_LEGACY_ENABLED=True)
@test.create_mocks({quotas: ('tenant_quota_usages',)})
def test_launch_instance_button_disabled_when_quota_exceeded(self):
url = reverse('horizon:project:network_topology:launchinstance')
classes = 'btn btn-default btn-launch ajax-modal'
link_name = "Launch Instance (Quota exceeded)"
expected_string = "<a href='%s' class='%s disabled' "\
"id='instances__action_launch'>" \
"<span class='fa fa-cloud-upload'></span>%s</a>" \
% (url, classes, link_name)
self._test_new_button_disabled_when_quota_exceeded(expected_string,
instances_quota=0)

View File

@ -35,8 +35,6 @@ urlpatterns = [
url(r'^network/(?P<network_id>[^/]+)/subnet/create$',
views.NTCreateSubnetView.as_view(), name='subnet'),
url(r'^json$', views.JSONView.as_view(), name='json'),
url(r'^launchinstance$', views.NTLaunchInstanceView.as_view(),
name='launchinstance'),
url(r'^createnetwork$', views.NTCreateNetworkView.as_view(),
name='createnetwork'),
url(r'^createrouter$', views.NTCreateRouterView.as_view(),

View File

@ -29,8 +29,10 @@ def get_context(request, context=None):
if context is None:
context = {}
context['launch_instance_allowed'] = policy.check(
(("compute", "os_compute_api:servers:create"),), request)
context['launch_instance_allowed'] = (
base.is_service_enabled(request, 'compute') and
policy.check((("compute", "os_compute_api:servers:create"),), request)
)
context['instance_quota_exceeded'] = _quota_exceeded(request, 'instances')
context['create_network_allowed'] = policy.check(
(("network", "create_network"),), request)
@ -41,10 +43,4 @@ def get_context(request, context=None):
policy.check((("network", "create_router"),), request))
context['router_quota_exceeded'] = _quota_exceeded(request, 'router')
context['console_type'] = settings.CONSOLE_TYPE
context['show_ng_launch'] = (
base.is_service_enabled(request, 'compute') and
settings.LAUNCH_INSTANCE_NG_ENABLED)
context['show_legacy_launch'] = (
base.is_service_enabled(request, 'compute') and
settings.LAUNCH_INSTANCE_LEGACY_ENABLED)
return context

View File

@ -49,8 +49,6 @@ from openstack_dashboard.dashboards.project.instances.tables import \
STATUS_DISPLAY_CHOICES as instance_choices
from openstack_dashboard.dashboards.project.instances import\
views as i_views
from openstack_dashboard.dashboards.project.instances.workflows import\
create_instance as i_workflows
from openstack_dashboard.dashboards.project.networks.subnets import\
views as s_views
from openstack_dashboard.dashboards.project.networks.subnets import\
@ -139,14 +137,6 @@ class NTCreateNetworkView(n_views.CreateView):
workflow_class = NTCreateNetwork
class NTLaunchInstance(i_workflows.LaunchInstance):
success_url = "horizon:project:network_topology:index"
class NTLaunchInstanceView(i_views.LaunchInstanceView):
workflow_class = NTLaunchInstance
class NTCreateSubnet(s_workflows.CreateSubnet):
def get_success_url(self):
return reverse("horizon:project:network_topology:index")

View File

@ -12,7 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.conf import settings
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _
@ -242,11 +241,7 @@ class VolumeDetailsSnapshotsTable(volume_tables.VolumesTableBase):
prev_pagination_param = 'prev_snapshot_marker'
table_actions = (VolumeSnapshotsFilterAction, DeleteVolumeSnapshot,)
launch_actions = ()
if settings.LAUNCH_INSTANCE_LEGACY_ENABLED:
launch_actions = (LaunchSnapshot,) + launch_actions
if settings.LAUNCH_INSTANCE_NG_ENABLED:
launch_actions = (LaunchSnapshotNG,) + launch_actions
launch_actions = (LaunchSnapshotNG,)
row_actions = ((CreateVolumeFromSnapshot,) + launch_actions +
(EditVolumeSnapshot, DeleteVolumeSnapshot, CreateBackup,

View File

@ -12,7 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.conf import settings
from django.http import HttpResponse
from django.template import defaultfilters as filters
from django.urls import NoReverseMatch
@ -572,11 +571,7 @@ class VolumesTable(VolumesTableBase):
table_actions = (CreateVolume, AcceptTransfer, DeleteVolume,
VolumesFilterAction)
launch_actions = ()
if settings.LAUNCH_INSTANCE_LEGACY_ENABLED:
launch_actions = (LaunchVolume,) + launch_actions
if settings.LAUNCH_INSTANCE_NG_ENABLED:
launch_actions = (LaunchVolumeNG,) + launch_actions
launch_actions = (LaunchVolumeNG,)
row_actions = ((EditVolume, ExtendVolume,) +
launch_actions +

View File

@ -240,20 +240,6 @@ IMAGES_LIST_FILTER_TENANTS = []
# The default number of lines displayed for instance console log.
INSTANCE_LOG_LENGTH = 35
# The Launch Instance user experience has been significantly enhanced.
# You can choose whether to enable the new launch instance experience,
# the legacy experience, or both. The legacy experience will be removed
# in a future release, but is available as a temporary backup setting to ensure
# compatibility with existing deployments. Further development will not be
# done on the legacy experience. Please report any problems with the new
# experience via the Launchpad tracking system.
#
# Toggle LAUNCH_INSTANCE_LEGACY_ENABLED and LAUNCH_INSTANCE_NG_ENABLED to
# determine the experience to enable. Set them both to true to enable
# both.
LAUNCH_INSTANCE_LEGACY_ENABLED = False
LAUNCH_INSTANCE_NG_ENABLED = True
# A dictionary of settings which can be used to provide the default values for
# properties found in the Launch Instance modal.
LAUNCH_INSTANCE_DEFAULTS = {

View File

@ -1,7 +1,3 @@
# Enable both Launch Instance wizards for the sake of testing
LAUNCH_INSTANCE_LEGACY_ENABLED = True
LAUNCH_INSTANCE_NG_ENABLED = True
# Provide a global setting for switching on/off various integration tests
# scaffolds
INTEGRATION_TESTS_SUPPORT = True

View File

@ -299,19 +299,6 @@ def check_chinese_locale_rename(dummy):
return upgradecheck.Result(upgradecheck.Code.SUCCESS)
@register_check(_("Django launch instance form"))
def check_django_launch_instance_form(dummy):
if settings.LAUNCH_INSTANCE_LEGACY_ENABLED:
return upgradecheck.Result(
upgradecheck.Code.WARNING,
_("The Django version of the launch instance form is deprecated "
"since Wallaby release. Switch to the AngularJS version of the "
"form by setting LAUNCH_INSTANCE_NG_ENABLED to True and "
"LAUNCH_INSTANCE_LEGACY_ENABLED to False.")
)
return upgradecheck.Result(upgradecheck.Code.SUCCESS)
class UpgradeCheckTable(upgradecheck.UpgradeCommands):
_upgrade_checks = CHECKS

View File

@ -0,0 +1,9 @@
---
upgrade:
- |
The Django version of the launch instance form was dropped.
It was deprecated since Wallaby release.
``LAUNCH_INSTANCE_LEGACY_ENABLED`` and ``LAUNCH_INSTANCE_NG_ENABLED``
setting were dropped as horizon uses angular version of launch instance
by default.