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:
parent
9d1bb3626b
commit
6ac31e0ba8
@ -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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
@ -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,)
|
||||
|
@ -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,
|
||||
|
@ -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
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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
|
@ -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:
|
||||
|
@ -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' %}"
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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,
|
||||
|
@ -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 +
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user