horizon/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-model.servi...

898 lines
27 KiB
JavaScript

/**
* 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.
*/
(function () {
'use strict';
var push = Array.prototype.push;
var noop = angular.noop;
/**
* @ngdoc overview
* @name horizon.dashboard.project.workflow.launch-instance
*
* @description
* Manage workflow of creating server.
*/
angular
.module('horizon.dashboard.project.workflow.launch-instance')
.factory('launchInstanceModel', launchInstanceModel);
launchInstanceModel.$inject = [
'$q',
'$log',
'horizon.app.core.openstack-service-api.cinder',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.nova',
'horizon.app.core.openstack-service-api.novaExtensions',
'horizon.app.core.openstack-service-api.security-group',
'horizon.app.core.openstack-service-api.serviceCatalog',
'horizon.app.core.openstack-service-api.settings',
'horizon.dashboard.project.workflow.launch-instance.boot-source-types',
'horizon.framework.widgets.toast.service',
'horizon.app.core.openstack-service-api.policy',
'horizon.dashboard.project.workflow.launch-instance.step-policy'
];
/**
* @ngdoc service
* @name launchInstanceModel
*
* @param {Object} $q
* @param {Object} $log
* @param {Object} cinderAPI
* @param {Object} glanceAPI
* @param {Object} neutronAPI
* @param {Object} novaAPI
* @param {Object} novaExtensions
* @param {Object} securityGroup
* @param {Object} serviceCatalog
* @param {Object} settings
* @param {Object} bootSourceTypes
* @param {Object} toast
* @param {Object} policy
* @param {Object} stepPolicy
* @description
* This is the M part in MVC design pattern for launch instance
* wizard workflow. It is responsible for providing data to the
* view of each step in launch instance workflow and collecting
* user's input from view for creation of new instance. It is
* also the center point of communication between launch instance
* UI and services API.
* @returns {Object} The model
*/
function launchInstanceModel(
$q,
$log,
cinderAPI,
glanceAPI,
neutronAPI,
novaAPI,
novaExtensions,
securityGroup,
serviceCatalog,
settings,
bootSourceTypes,
toast,
policy,
stepPolicy
) {
var initPromise;
/**
* @ngdoc model api object
*/
var model = {
initializing: false,
initialized: false,
/*eslint-disable max-len */
/**
* @name newInstanceSpec
*
* @description
* A dictionary like object containing specification collected from user's
* input. Its required properties include:
*
* @property {String} name: The new server name.
* @property {String} source_type: The type of source
* Valid options: (image | snapshot | volume | volume_snapshot)
* @property {String} source_id: The ID of the image / volume to use.
* @property {String} flavor_id: The ID of the flavor to use.
*
* Other parameters are accepted as per the underlying novaclient:
* - https://github.com/openstack/python-novaclient/blob/master/novaclient/v2/servers.py#L417
* But may be required additional values as per nova:
* - https://github.com/openstack/horizon/blob/master/openstack_dashboard/api/rest/nova.py#L127
*
* The JS code only needs to set the values below as they are made.
* The createInstance function will map them appropriately.
*/
/*eslint-enable max-len */
// see initializeNewInstanceSpec
newInstanceSpec: {},
/**
* cloud service properties, they should be READ-ONLY to all UI controllers
*/
availabilityZones: [],
flavors: [],
allowedBootSources: [],
images: [],
allowCreateVolumeFromImage: false,
imageSnapshots: [],
keypairs: [],
metadataDefs: {
flavor: null,
image: null,
volume: null,
instance: null,
hints: null
},
networks: [],
ports: [],
neutronEnabled: false,
novaLimits: {},
securityGroups: [],
serverGroups: [],
volumeBootable: false,
volumes: [],
volumeSnapshots: [],
metadataTree: null,
hintsTree: null,
/**
* This is to inform users current situation is under loading or not
*/
loaded: {
// Availability Zones on Details tab
availabilityZones: false
},
/**
* api methods for UI controllers
*/
initialize: initialize,
createInstance: createInstance
};
// Local function.
function initializeNewInstanceSpec() {
model.newInstanceSpec = {
availability_zone: null,
admin_pass: null,
config_drive: false,
description: null,
// REQUIRED Server Key. Null allowed.
user_data: '',
disk_config: 'AUTO',
// REQUIRED
flavor: null,
instance_count: 1,
// REQUIRED Server Key
key_pair: [],
// REQUIRED
name: null,
networks: [],
ports: [],
scheduler_hints: {},
// REQUIRED Server Key. May be empty.
security_groups: [],
server_groups: [],
// REQUIRED for JS logic (image | snapshot | volume | volume_snapshot)
source_type: null,
source: [],
create_volume_default: true,
// REQUIRED for JS logic
hide_create_volume: false,
vol_create: false,
vol_delete_on_instance_delete: false,
vol_size: 1
};
}
function initializeLoadStatus() {
angular.forEach(model.loaded, function(val, key) {
model.loaded[key] = false;
});
}
/**
* @ngdoc method
* @name launchInstanceModel.initialize
* @returns {promise}
*
* @description
* Send request to get all data to initialize the model.
*/
function initialize(deep) {
var deferred, promise;
// Each time opening launch instance wizard, we need to do this, or
// we can call the whole methods `reset` instead of `initialize`.
initializeNewInstanceSpec();
initializeLoadStatus();
if (model.initializing) {
promise = initPromise;
} else if (model.initialized && !deep) {
deferred = $q.defer();
promise = deferred.promise;
deferred.resolve();
} else {
model.initializing = true;
model.allowedBootSources.length = 0;
var launchInstanceDefaults = settings.getSetting('LAUNCH_INSTANCE_DEFAULTS');
settings.getSetting('DEFAULT_BOOT_SOURCE').then(
function (response) {
model.defaultBootSource = response;
});
promise = $q.all([
novaAPI.getAvailabilityZones().then(onGetAvailabilityZones)
.finally(onGetAvailabilityZonesComplete),
novaAPI.getFlavors({
is_public: true,
get_extras: true
}).then(onGetFlavors, noop),
novaAPI.getKeypairs().then(onGetKeypairs, noop),
novaAPI.getLimits(true).then(onGetNovaLimits, noop),
securityGroup.query().then(onGetSecurityGroups, noop),
serviceCatalog.ifTypeEnabled('network').then(getNetworks, noop),
launchInstanceDefaults.then(addImageSourcesIfEnabled, noop),
launchInstanceDefaults.then(setDefaultValues, noop),
launchInstanceDefaults.then(addVolumeSourcesIfEnabled, noop)
]);
promise.then(onInitSuccess, onInitFail);
}
return promise;
}
function onInitSuccess() {
model.initializing = false;
model.initialized = true;
// This provides supplemental data non-critical to launching
// an instance. Therefore we load it only if the critical data
// all loads successfully.
getServerGroups();
getMetadataDefinitions();
}
function onInitFail() {
model.initializing = false;
model.initialized = false;
}
function setDefaultValues(defaults) {
if (!defaults) {
return;
}
if ('config_drive' in defaults) {
model.newInstanceSpec.config_drive = defaults.config_drive;
}
if ('create_volume' in defaults) {
// Append "_default" to distinguish from the 'vol_create' item
model.newInstanceSpec.create_volume_default = defaults.create_volume;
}
if ('hide_create_volume' in defaults) {
model.newInstanceSpec.hide_create_volume = defaults.hide_create_volume;
}
}
/**
* @ngdoc method
* @name launchInstanceModel.createInstance
* @returns {promise}
*
* @description
* Send request for creating server.
*/
function createInstance() {
var finalSpec = angular.copy(model.newInstanceSpec);
cleanNullProperties(finalSpec);
setFinalSpecBootsource(finalSpec);
setFinalSpecFlavor(finalSpec);
setFinalSpecNetworks(finalSpec);
setFinalSpecPorts(finalSpec);
setFinalSpecKeyPairs(finalSpec);
setFinalSpecSecurityGroups(finalSpec);
setFinalSpecServerGroup(finalSpec);
setFinalSpecSchedulerHints(finalSpec);
setFinalSpecMetadata(finalSpec);
return novaAPI.createServer(finalSpec).then(successMessage);
}
function successMessage() {
var numberInstances = model.newInstanceSpec.instance_count;
var message = ngettext('Scheduled creation of %s instance.',
'Scheduled creation of %s instances.',
numberInstances);
toast.add('info', interpolate(message, [numberInstances]));
}
function cleanNullProperties(finalSpec) {
// Initially clean fields that don't have any value.
for (var key in finalSpec) {
if (finalSpec.hasOwnProperty(key) && finalSpec[key] === null) {
delete finalSpec[key];
}
}
}
//
// Local
//
function onGetAvailabilityZones(data) {
model.availabilityZones.length = 0;
push.apply(
model.availabilityZones,
data.data.items.filter(function (zone) {
return zone.zoneState && zone.zoneState.available;
})
.map(function (zone) {
return {label: zone.zoneName, value: zone.zoneName};
})
);
if (model.availabilityZones.length === 1) {
model.newInstanceSpec.availability_zone = model.availabilityZones[0].value;
} else if (model.availabilityZones.length > 1) {
// There are 2 or more; allow ability for nova scheduler to pick,
// and make that the default.
model.availabilityZones.unshift({
label: gettext("Any Availability Zone"),
value: ""
});
model.newInstanceSpec.availability_zone = model.availabilityZones[0].value;
}
}
function onGetAvailabilityZonesComplete() {
model.loaded.availabilityZones = true;
}
// Flavors
function onGetFlavors(data) {
model.flavors.length = 0;
push.apply(model.flavors, data.data.items);
}
function setFinalSpecFlavor(finalSpec) {
if (finalSpec.flavor) {
finalSpec.flavor_id = finalSpec.flavor.id;
} else {
delete finalSpec.flavor_id;
}
delete finalSpec.flavor;
}
// Keypairs
function onGetKeypairs(data) {
angular.extend(
model.keypairs,
data.data.items.map(function (e) {
e.keypair.id = 'li_keypair:' + e.keypair.name;
return e.keypair;
}));
if (data.data.items.length === 1) {
model.newInstanceSpec.key_pair.push(data.data.items[0].keypair);
}
}
function setFinalSpecKeyPairs(finalSpec) {
// Nova only wants the key name. It is a required field, even if None.
if (!finalSpec.key_name && finalSpec.key_pair.length === 1) {
finalSpec.key_name = finalSpec.key_pair[0].name;
} else if (!finalSpec.key_name) {
finalSpec.key_name = null;
}
delete finalSpec.key_pair;
}
// Security Groups
function onGetSecurityGroups(data) {
model.securityGroups.length = 0;
angular.forEach(data.data.items, function addDefault(item) {
// 'default' is a special security group in neutron. It can not be
// deleted and is guaranteed to exist. It by default contains all
// of the rules needed for an instance to reach out to the network
// so the instance can provision itself.
if (item.name === 'default') {
model.newInstanceSpec.security_groups.push(item);
}
});
push.apply(model.securityGroups, data.data.items);
}
function setFinalSpecSecurityGroups(finalSpec) {
// pull out the ids from the security groups objects
var securityGroupIds = [];
finalSpec.security_groups.forEach(function(securityGroup) {
if (model.neutronEnabled) {
securityGroupIds.push(securityGroup.id);
} else {
securityGroupIds.push(securityGroup.name);
}
});
finalSpec.security_groups = securityGroupIds;
}
// Server Groups
function getServerGroups() {
policy.ifAllowed(stepPolicy.serverGroups).then(function() {
novaAPI.getServerGroups().then(onGetServerGroups, noop);
}, noop);
}
function onGetServerGroups(data) {
model.serverGroups.length = 0;
push.apply(model.serverGroups, data.data.items);
}
function setFinalSpecServerGroup(finalSpec) {
if (finalSpec.server_groups.length > 0) {
finalSpec.scheduler_hints.group = finalSpec.server_groups[0].id;
}
delete finalSpec.server_groups;
}
// Networks
function getNetworks() {
return neutronAPI.getNetworks().then(onGetNetworks, noop).then(getPorts, noop);
}
function onGetNetworks(data) {
model.neutronEnabled = true;
model.networks.length = 0;
push.apply(model.networks,
data.data.items.filter(function(net) {
return net.subnets.length > 0;
}));
if (model.networks.length === 1) {
model.newInstanceSpec.networks.push(model.networks[0]);
}
return data;
}
function setFinalSpecNetworks(finalSpec) {
finalSpec.nics = [];
finalSpec.networks.forEach(function (network) {
finalSpec.nics.push(
{
"net-id": network.id,
"v4-fixed-ip": ""
});
});
delete finalSpec.networks;
}
function getPorts(networks) {
model.ports.length = 0;
networks.data.items.forEach(function(network) {
return neutronAPI.getPorts({network_id: network.id}).then(
function(ports) {
onGetPorts(ports, network);
}, noop
);
});
}
function onGetPorts(networkPorts, network) {
var ports = [];
networkPorts.data.items.forEach(function(port) {
// no device_owner means that the port can be attached
if (port.device_owner === "" && port.admin_state === "UP") {
port.subnet_names = getPortSubnets(port, network.subnets);
port.network_name = network.name;
if (!port.hasOwnProperty("trunk_id")) {
ports.push(port);
}
}
});
push.apply(model.ports, ports);
}
// helper function to return an object of IP:NAME pairs for subnet mapping
function getPortSubnets(port, subnets) {
var subnetNames = {};
port.fixed_ips.forEach(function (ip) {
subnets.forEach(function (subnet) {
if (ip.subnet_id === subnet.id) {
subnetNames[ip.ip_address] = subnet.name;
}
});
});
return subnetNames;
}
function setFinalSpecPorts(finalSpec) {
// nics should already be filled so we only append to it
finalSpec.ports.forEach(function (port) {
finalSpec.nics.push(
{
"port-id": port.id
});
});
delete finalSpec.ports;
}
// Boot Source
function addImageSourcesIfEnabled(config) {
// in case settings are deleted or not present
var allEnabled = !config;
// if the settings are missing or the specific setting is missing default to true
var enabledImage = allEnabled || !config.disable_image;
var enabledSnapshot = allEnabled || !config.disable_instance_snapshot;
if (enabledImage || enabledSnapshot) {
var filter = {status: 'active', sort_key: 'name', sort_dir: 'asc'};
glanceAPI.getImages(filter).then(
function(data) {
onGetImageSources(data, enabledImage, enabledSnapshot);
}
);
}
}
function addVolumeSourcesIfEnabled(config) {
var volumeDeferred = $q.defer();
var volumeSnapshotDeferred = $q.defer();
var absoluteLimitsDeferred = $q.defer();
serviceCatalog
.ifTypeEnabled('volumev2')
.then(onVolumeServiceEnabled, onCheckVolumeV3);
function onCheckVolumeV3() {
serviceCatalog
.ifTypeEnabled('volumev3')
.then(onVolumeServiceEnabled, resolvePromises);
}
function onVolumeServiceEnabled() {
model.volumeBootable = true;
novaExtensions
.ifNameEnabled('BlockDeviceMappingV2Boot')
.then(onBootToVolumeSupported);
if (!config || !config.disable_volume) {
getVolumes().then(resolveVolumes, failVolumes);
getAbsoluteLimits().then(resolveAbsoluteLimitsDeferred, resolveAbsoluteLimitsDeferred);
} else {
resolveVolumes();
resolveAbsoluteLimitsDeferred();
}
if (!config || !config.disable_volume_snapshot) {
getVolumeSnapshots().then(resolveVolumeSnapshots, failVolumeSnapshots);
} else {
resolveVolumeSnapshots();
}
}
function onBootToVolumeSupported() {
model.allowCreateVolumeFromImage = true;
}
function getVolumes() {
return cinderAPI.getVolumes({status: 'available', bootable: 1})
.then(onGetVolumes);
}
function getAbsoluteLimits() {
return cinderAPI.getAbsoluteLimits().then(onGetCinderLimits);
}
function getVolumeSnapshots() {
return cinderAPI.getVolumeSnapshots({status: 'available'})
.then(onGetVolumeSnapshots);
}
function resolvePromises() {
volumeDeferred.resolve();
volumeSnapshotDeferred.resolve();
absoluteLimitsDeferred.resolve();
}
function resolveVolumes() {
volumeDeferred.resolve();
}
function failVolumes() {
volumeDeferred.resolve();
}
function resolveVolumeSnapshots() {
volumeSnapshotDeferred.resolve();
}
function failVolumeSnapshots() {
volumeSnapshotDeferred.resolve();
}
function resolveAbsoluteLimitsDeferred() {
absoluteLimitsDeferred.resolve();
}
return $q.all(
[
volumeDeferred.promise,
volumeSnapshotDeferred.promise,
absoluteLimitsDeferred.promise
]);
}
function isBootableImageType(image) {
// This is a blacklist of images that can not be booted.
// If the image container type is in the blacklist
// The evaluation will result in a 0 or greater index.
return bootSourceTypes.NON_BOOTABLE_IMAGE_TYPES.indexOf(image.container_format) < 0;
}
function getImageType(image) {
if (image === null || !angular.isDefined(image.properties) ||
!(angular.isDefined(image.properties.image_type) ||
angular.isDefined(image.properties.block_device_mapping))) {
return 'image';
}
return image.properties.image_type ||
angular.fromJson(image.properties.block_device_mapping)[0].source_type ||
'image';
}
function isValidImage(image) {
return isBootableImageType(image) &&
(!image.properties || image.properties.image_type !== 'snapshot');
}
function isValidSnapshot(image) {
return getImageType(image) === 'snapshot' && isBootableImageType(image);
}
function onGetImageSources(data, enabledImage, enabledSnapshot) {
model.imageSnapshots.length = 0;
model.images.length = 0;
angular.forEach(data.data.items, function(image) {
if (isValidSnapshot(image) && enabledSnapshot) {
model.imageSnapshots.push(image);
} else if (isValidImage(image) && enabledImage) {
image.name_or_id = image.name || image.id;
model.images.push(image);
}
});
if (enabledImage) {
addAllowedBootSource(
model.images, bootSourceTypes.IMAGE, gettext('Image')
);
}
if (enabledSnapshot) {
addAllowedBootSource(
model.imageSnapshots, bootSourceTypes.INSTANCE_SNAPSHOT,
gettext('Instance Snapshot')
);
}
}
function onGetVolumes(data) {
model.volumes.length = 0;
push.apply(model.volumes, data.data.items);
addAllowedBootSource(model.volumes, bootSourceTypes.VOLUME, gettext('Volume'));
}
function onGetVolumeSnapshots(data) {
cinderAPI.getVolumes({bootable: 1}).then(function (volumes) {
onGetBootableVolumeSnapshots(volumes.data.items, data.data.items);
});
}
function onGetBootableVolumeSnapshots(bootableVolumes, volumeSnapshots) {
var bootableVolumeIds = [];
bootableVolumes.forEach(function(volume) {
bootableVolumeIds.push(volume.id);
});
model.volumeSnapshots.length = 0;
push.apply(model.volumeSnapshots, volumeSnapshots.filter(function (volumeSnapshot) {
return bootableVolumeIds.indexOf(volumeSnapshot.volume_id) !== -1;
}));
addAllowedBootSource(
model.volumeSnapshots,
bootSourceTypes.VOLUME_SNAPSHOT,
gettext('Volume Snapshot')
);
}
function addAllowedBootSource(rawTypes, type, label) {
if (rawTypes) {
var selected = model.defaultBootSource === type;
model.allowedBootSources.push({
type: type,
label: label,
selected: selected
});
model.allowedBootSources.sort(function(a, b) {
return a.type > b.type;
});
}
}
function setFinalSpecBootsource(finalSpec) {
finalSpec.source_id = finalSpec.source && finalSpec.source[0] && finalSpec.source[0].id;
delete finalSpec.source;
switch (finalSpec.source_type.type) {
case bootSourceTypes.IMAGE:
setFinalSpecBootImageToVolume(finalSpec);
break;
case bootSourceTypes.INSTANCE_SNAPSHOT:
setFinalSpecBootImageToVolume(finalSpec);
break;
case bootSourceTypes.VOLUME:
setFinalSpecBootFromVolumeDevice(finalSpec, 'volume');
break;
case bootSourceTypes.VOLUME_SNAPSHOT:
setFinalSpecBootFromVolumeDevice(finalSpec, 'snapshot');
break;
default:
$log.error("Unknown source type: " + finalSpec.source_type);
}
// The following are all fields gathered into simple fields by
// steps so that the view can simply bind to simple model attributes
// that are then transformed a single time to Nova's expectation
// at launch time.
delete finalSpec.source_type;
delete finalSpec.vol_create;
delete finalSpec.vol_delete_on_instance_delete;
delete finalSpec.vol_size;
}
function setFinalSpecBootImageToVolume(finalSpec) {
if (finalSpec.vol_create) {
// Specify null to get Autoselection (not empty string)
finalSpec.block_device_mapping_v2 = [];
finalSpec.block_device_mapping_v2.push(
{
'source_type': bootSourceTypes.IMAGE,
'destination_type': bootSourceTypes.VOLUME,
'delete_on_termination': finalSpec.vol_delete_on_instance_delete,
'uuid': finalSpec.source_id,
'boot_index': '0',
'volume_size': finalSpec.vol_size
}
);
finalSpec.source_id = null;
}
}
function setFinalSpecBootFromVolumeDevice(finalSpec, sourceType) {
finalSpec.block_device_mapping_v2 = [];
finalSpec.block_device_mapping_v2.push(
{
'source_type': sourceType,
'destination_type': bootSourceTypes.VOLUME,
'delete_on_termination': finalSpec.vol_delete_on_instance_delete,
'uuid': finalSpec.source_id,
'boot_index': '0'
}
);
// Source ID must be empty for API
finalSpec.source_id = '';
}
// Cinder Limits
function onGetCinderLimits(response) {
model.cinderLimits = response.data;
}
// Nova Limits
function onGetNovaLimits(data) {
angular.extend(model.novaLimits, data.data);
}
// Scheduler hints
function setFinalSpecSchedulerHints(finalSpec) {
if (model.hintsTree) {
var hints = model.hintsTree.getExisting();
if (!angular.equals({}, hints)) {
angular.forEach(hints, function(value, key) {
finalSpec.scheduler_hints[key] = value + '';
});
}
}
}
// Instance metadata
function setFinalSpecMetadata(finalSpec) {
if (model.metadataTree) {
var meta = model.metadataTree.getExisting();
if (!angular.equals({}, meta)) {
angular.forEach(meta, function(value, key) {
meta[key] = value + '';
});
finalSpec.meta = meta;
}
}
}
// Metadata Definitions
/**
* Metadata definitions provide supplemental information in source image detail
* rows and are used on the metadata tab for adding metadata to the instance and
* on the scheduler hints tab.
*/
function getMetadataDefinitions() {
// Metadata definitions often apply to multiple resource types. It is optimal to make a
// single request for all desired resource types.
// <key>: [<resource_type>, <properties_target>]
var resourceTypes = {
flavor: ['OS::Nova::Flavor', ''],
image: ['OS::Glance::Image', ''],
volume: ['OS::Cinder::Volumes', ''],
instance: ['OS::Nova::Server', 'metadata']
};
angular.forEach(resourceTypes, applyForResourceType);
// Need to check setting and policy for scheduler hints
$q.all([
settings.ifEnabled('LAUNCH_INSTANCE_DEFAULTS.enable_scheduler_hints', true, true),
policy.ifAllowed(stepPolicy.schedulerHints)
]).then(function getSchedulerHints() {
applyForResourceType(['OS::Nova::Server', 'scheduler_hints'], 'hints');
});
}
function applyForResourceType(resourceType, key) {
glanceAPI
.getNamespaces({ resource_type: resourceType[0],
properties_target: resourceType[1] }, true)
.then(function(data) {
var namespaces = data.data.items;
// This will ensure that the metaDefs model object remains
// unchanged until metadefs are fully loaded. Otherwise,
// partial results are loaded and can result in some odd
// display behavior.
model.metadataDefs[key] = namespaces;
});
}
return model;
}
})();