Merge "Drop Django based implementation of launch instance"
This commit is contained in:
commit
047b81e979
@ -2262,47 +2262,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