Launch Instance - MultiRegion Support / Networks

This makes it so that networks are only provided
if the network service is enabled.

This bugfix also adds multi-region support.

Details on how to setup a multi-region test
to verify it in horizon are in the bug.

Closes-Bug: #1432401
Co-Authored-By: Shaoquan Chen <sean.chen2@hp.com>
Co-Authored-By: Brian Tully <brian.tully@hp.com>
Co-Authored-By: Richard Jones <r1chardj0n3s@gmail.com>
Change-Id: I4e98c18b579eb5fc9e1b76ddf57d22142a7ddeab
This commit is contained in:
Travis Tripp 2015-03-07 00:01:13 -07:00 committed by Shaoquan Chen
parent c38d3029a0
commit 2d548313cc
16 changed files with 601 additions and 93 deletions

View File

@ -9,6 +9,7 @@
'hz.widget.modal',
'hz.widget.modal-wait-spinner',
'hz.framework.bind-scope',
'hz.framework.workflow',
'hz.widget.transfer-table',
'hz.widget.charts',
'hz.widget.action-list',

View File

@ -5,8 +5,9 @@
<button class="btn nav-item"
ng-class="{'current': currentIndex===$index}"
ng-click="switchTo($index)"
ng-if="step.ready"
ng-repeat="step in steps"
ng-show="ready && step.ready">
ng-show="ready">
<span ng-bind="::step.title"></span>
<span class="status-indicator fa fa-lg fa-warning"
ng-show="wizardForm[steps[$index].formName].$invalid"></span>
@ -14,6 +15,7 @@
</div>
<div class="step"
ng-if="step.ready"
ng-repeat="step in steps"
ng-show="currentIndex===$index">
<ng-include
@ -61,6 +63,7 @@
<help-panel>
<ng-include src="step.helpUrl"
ng-if="step.ready"
ng-repeat="step in steps"
ng-show="currentIndex===$index"></ng-include>
</help-panel>

View File

@ -0,0 +1,101 @@
(function () {
'use strict';
/**
* @ngdoc overview
* @name hz.framework.workflow
* @description
*
* # hz.framework.workflow
*
* This module provides utility function service `workflow` to allow
* decorating a workflow object. A user (developer) friendly workflow
* specification object can be shaped into a format that is friendly to
* {@link hz.widget.wizard `wizard`} by utilizing this service.
*
* The service provides a mechanism of decoupling general wizard UI component
* from business components.
*
* | Factories |
* |--------------------------------------------------------------------------|
* | {@link hz.framework.workflow.factory:workflow `workflow`} |
*
*/
angular.module('hz.framework.workflow', [])
/**
* @ngdoc factory
* @name hz.framework.workflow.factory:workflow
* @module hz.framework.workflow
* @kind function
* @description
*
* Decorate the workflow specification object with specified decorators.
*
* @param {Object} The input workflow specification object
* @param {Array<function>} decorators A list a decorator functions.
*
* @returns {Object} The decorated workflow specification object, the same
* reference to the input spec object.
*
```js
angular.module('MyModule', [])
.factory('myService', ['$q', 'workflow', function ($q, workflow) {
// a workflow specification object:
var spec = {
steps: [
{ requireSomeServices: true },
{ },
{ requireSomeServices: true }
]
};
// define some decorators
var decorators = [
// a decorator
function (spec) {
var steps = spec.steps;
angular.forEach(steps, function (step) {
if (step.requireSomeServices) {
step.checkReadiness = function () {
var d = $q.defer();
// checking if the service is available asynchronously .
setTimeout(function () {
d.resolve();
}, 500);
return d.promise;
};
}
});
},
// another decorator
function (spec) {
//...
}
];
return workflow(spec, decorators);
}]);
```
*/
.factory('workflow', [
function () {
return function (spec, decorators) {
angular.forEach(decorators, function (decorator) {
decorator(spec);
});
return spec;
};
}
]);
})();

View File

@ -0,0 +1,59 @@
(function () {
'use strict';
describe('hz.framework.workflow module', function () {
it('should have been defined', function () {
expect(angular.module('hz.framework.workflow')).toBeDefined();
});
});
describe('workflow factory', function () {
var workflow,
spec,
decorators = [
function (spec) {
angular.forEach(spec.steps, function (step) {
if (step.requireSomeServices) {
step.checkReadiness = function () {};
}
});
}
];
beforeEach(module('hz.framework.workflow'));
beforeEach(inject(function ($injector) {
workflow = $injector.get('workflow');
spec = {
steps: [
{ requireSomeServices: true },
{ },
{ requireSomeServices: true }
]
};
}));
it('workflow is defined', function () {
expect(workflow).toBeDefined();
});
it('workflow is a function', function () {
expect(angular.isFunction(workflow)).toBe(true);
});
it('can be decorated', function () {
workflow(spec, decorators);
var steps = spec.steps;
expect(steps[0].checkReadiness).toBeDefined();
expect(angular.isFunction(steps[0].checkReadiness)).toBe(true);
expect(steps[1].checkReadiness).not.toBeDefined();
expect(steps[2].checkReadiness).toBeDefined();
expect(angular.isFunction(steps[2].checkReadiness)).toBe(true);
});
});
})();

View File

@ -39,6 +39,41 @@ limitations under the License.
});
};
/**
* @name hz.api.keystoneApi.getCurrentUserSession
* @description
* Gets the current User Session Information
* @example
* {
* "available_services_regions": [
* "RegionOne"
* ],
* "domain_id": null,
* "domain_name": null,
* "enabled": true,
* "id": "2138efda19264c64b69551c6b08054c9",
* "is_superuser": true,
* "project_id": "53fafe441399439a852d3bd81c22caf6",
* "project_name": "demo",
* "roles": [
* {
* "name": "admin"
* }
* ],
* "services_region": "RegionOne",
* "user_domain_id": "default",
* "user_domain_name": "Default",
* "username": "admin"
* }
*/
this.getCurrentUserSession = function(config) {
return apiService.get('/api/keystone/user-session/', config)
.error(function () {
horizon.alert('error',
gettext('Unable to retrieve the current user session.'));
});
};
this.getUser = function(user_id) {
return apiService.get('/api/keystone/users/' + user_id)
.error(function () {
@ -220,6 +255,46 @@ limitations under the License.
angular.module('hz.api')
.service('keystoneAPI', ['apiService', KeystoneAPI]);
/**
* @ngdoc service
* @name hz.api.userSession
* @description
* Provides cached access to the user session. The cache may be reset
* at any time by accessing the cache and calling removeAll, which means
* that the next call to any function in this service will retrieve fresh
* results after the cache is cleared. This allows programmatic refresh of
* the cache.
*
* The cache in current horizon (Kilo non-single page app) only has a
* lifetime of the current page. The cache is reloaded every time you change
* panels. It also happens when you change the region selector at the top
* of the page, and when you log back in.
*
* So, at least for now, this seems to be a reliable way that will
* make only a single request to get user information for a
* particular page or modal. Making this a service allows it to be injected
* and used transparently where needed without making every single use of it
* pass it through as an argument.
*/
function userSession($cacheFactory, keystoneAPI) {
var service = {};
service.cache = $cacheFactory('hz.api.userSession', {capacity: 1});
service.get = function () {
return keystoneAPI.getCurrentUserSession({cache: service.cache})
.then(function (response) {
return response.data;
}
);
};
return service;
}
angular.module('hz.api')
.factory('userSession', ['$cacheFactory', 'keystoneAPI', userSession]);
/**
* @ngdoc service
@ -230,49 +305,133 @@ limitations under the License.
* by accessing the cache and calling removeAll. The next call to any
* function will retrieve fresh results.
*
* The enabled extensions do not change often, so using cached data will
* speed up results. Even on a local devstack in informal testing,
* this saved between 30 - 100 ms per request.
* The cache in current horizon (Kilo non-single page app) only has a
* lifetime of the current page. The cache is reloaded every time you change
* panels. It also happens when you change the region selector at the top
* of the page, and when you log back in.
*
* So, at least for now, this seems to be a reliable way that will
* make only a single request to get user information for a
* particular page or modal. Making this a service allows it to be injected
* and used transparently where needed without making every single use of it
* pass it through as an argument.
*/
function ServiceCatalog($cacheFactory, $q, keystoneAPI) {
function serviceCatalog($cacheFactory, $q, keystoneAPI, userSession) {
var service = {};
service.cache = $cacheFactory('hz.api.serviceCatalog', {capacity: 1});
/**
* @name hz.api.serviceCatalog.get
* @description
* Returns the service catalog. This is cached.
*
* @example
*
```js
serviceCatalog.get()
.then(doSomething, doSomethingElse);
```
*/
service.get = function() {
return keystoneAPI.serviceCatalog({cache: service.cache})
.then(function(data){
return data.data;
.then(function(response){
return response.data;
}
);
};
service.ifTypeEnabled = function(desired, doThis) {
return service.get().then(function(result){
if (enabled(result, 'type', desired)){
return $q.when(doThis());
}
/**
* @name hz.api.serviceCatalog.ifTypeEnabled
* @description
* Checks if the desired service is enabled. If it is enabled, use the
* promise returned to execute the desired function. If it is not enabled,
* The promise will be rejected.
*
* @param {string} desiredType The type of service desired.
*
* @example
* Assume if the network service is enabled, you want to get networks,
* if it isn't, then you will do something else.
* Assume getNetworks is a function that hits Neutron.
* Assume doSomethingElse is a function that does something else if
* the network service is not enabled (optional)
*
```js
serviceCatalog.ifTypeEnabled('network')
.then(getNetworks, doSomethingElse);
```
*/
service.ifTypeEnabled = function (desiredType) {
var deferred = $q.defer();
$q.all(
{
session: userSession.get(),
catalog: service.get()
}
).then(
onDataLoaded,
onDataFailure
);
function onDataLoaded(d) {
if (typeHasEndpointsInRegion(d.catalog,
desiredType,
d.session.services_region)) {
deferred.resolve();
} else {
deferred.reject(interpolate(
gettext('Service type is not enabled: %(desiredType)s'),
{desiredType: desiredType},
true));
}
}
function onDataFailure() {
deferred.reject(gettext('Cannot get service catalog from keystone.'));
}
return deferred.promise;
};
function enabled(resources, key, desired) {
if(resources) {
return resources.some(function (resource) {
return resource[key] === desired;
});
function typeHasEndpointsInRegion(catalog, desiredType, desiredRegion) {
var matchingSvcs = catalog.filter(function (svc) {
return svc.type === desiredType;
});
// Ignore region for identity. Identity service endpoint
// should not change for different regions.
if (desiredType === 'identity' && matchingSvcs.length > 0) {
return true;
} else {
return false;
return matchingSvcs.some(function (svc) {
return svc.endpoints.some(function (endpoint) {
return getEndpointRegion(endpoint) === desiredRegion;
});
});
}
}
return service;
/*
* In Keystone V3, region has been deprecated in favor of
* region_id.
*
* This method provides a way to get region that works for
* both Keystone V2 and V3.
*/
function getEndpointRegion(endpoint) {
return endpoint.region_id || endpoint.region;
}
return service;
}
angular.module('hz.api')
.factory('serviceCatalog', ['$cacheFactory',
'$q',
'keystoneAPI',
ServiceCatalog]);
'userSession',
serviceCatalog]);
}());

View File

@ -57,6 +57,7 @@
<script src='{{ STATIC_URL }}angular/metadata-display/metadata-display.js'></script>
<script src='{{ STATIC_URL }}angular/magic-search/magic-search.js'></script>
<script src='{{ STATIC_URL }}angular/validators/validators.js'></script>
<script src='{{ STATIC_URL }}angular/workflow/workflow.js'></script>
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery.quicksearch.js'></script>
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.tablesorter.js"></script>

View File

@ -41,6 +41,7 @@ class ServicesTests(test.JasmineTests):
'angular/transfer-table/transfer-table.js',
'angular/validators/validators.js',
'angular/wizard/wizard.js',
'angular/workflow/workflow.js',
'angular/metadata-display/metadata-display.js',
'horizon/js/angular/filters/filters.js',
]
@ -59,6 +60,7 @@ class ServicesTests(test.JasmineTests):
'angular/transfer-table/transfer-table.spec.js',
'angular/wizard/wizard.spec.js',
'angular/validators/validators.spec.js',
'angular/workflow/workflow.spec.js',
'angular/metadata-tree/metadata-tree.spec.js',
'angular/metadata-display/metadata-display.spec.js',
'horizon/js/angular/filters/filters.spec.js',

View File

@ -529,3 +529,31 @@ class ServiceCatalog(generic.View):
user.
"""
return request.user.service_catalog
@urls.register
class UserSession(generic.View):
"""API for a single keystone user.
"""
url_regex = r'keystone/user-session/$'
allowed_fields = {
'available_services_regions',
'domain_id',
'domain_name',
'enabled',
'id',
'is_superuser',
'project_id',
'project_name',
'roles',
'services_region',
'user_domain_id',
'user_domain_name',
'username'
}
@rest_utils.ajax()
def get(self, request):
"""Get the current user session.
"""
return {k: getattr(request.user, k, None) for k in self.allowed_fields}

View File

@ -27,6 +27,7 @@ LAUNCH_INST = 'dashboard/launch-instance/'
ADD_JS_FILES = [
'dashboard/dashboard.module.js',
'dashboard/workflow/workflow.js',
LAUNCH_INST + 'launch-instance.js',
LAUNCH_INST + 'launch-instance.model.js',
LAUNCH_INST + 'source/source.js',
@ -40,6 +41,7 @@ ADD_JS_FILES = [
ADD_JS_SPEC_FILES = [
'dashboard/dashboard.module.spec.js',
'dashboard/workflow/workflow.spec.js',
LAUNCH_INST + 'launch-instance.spec.js',
LAUNCH_INST + 'launch-instance.model.spec.js',
LAUNCH_INST + 'source/source.spec.js',

View File

@ -2,7 +2,8 @@
'use strict';
var module = angular.module('hz.dashboard', [
'hz.dashboard.launch-instance'
'hz.dashboard.launch-instance',
'hz.dashboard.workflow'
]);
module.constant('dashboardBasePath', '/static/dashboard/');

View File

@ -1,61 +1,67 @@
(function () {
'use strict';
var module = angular.module('hz.dashboard.launch-instance', [ 'ngSanitize']);
var module = angular.module('hz.dashboard.launch-instance', [ 'ngSanitize' ]);
module.factory('launchInstanceWorkflow', ['dashboardBasePath', function (path) {
module.factory('launchInstanceWorkflow', [
'dashboardBasePath',
'dashboardWorkflow',
return {
title: gettext('Launch Instance'),
function (path, dashboardWorkflow) {
steps: [
{
title: gettext('Select Source'),
templateUrl: path + 'launch-instance/source/source.html',
helpUrl: path + 'launch-instance/source/source.help.html',
formName: 'launchInstanceSourceForm'
return dashboardWorkflow({
title: gettext('Launch Instance'),
steps: [
{
title: gettext('Select Source'),
templateUrl: path + 'launch-instance/source/source.html',
helpUrl: path + 'launch-instance/source/source.help.html',
formName: 'launchInstanceSourceForm'
},
{
title: gettext('Flavor'),
templateUrl: path + 'launch-instance/flavor/flavor.html',
helpUrl: path + 'launch-instance/flavor/flavor.help.html',
formName: 'launchInstanceFlavorForm'
},
{
title: gettext('Networks'),
templateUrl: path + 'launch-instance/network/network.html',
helpUrl: path + 'launch-instance/network/network.help.html',
formName: 'launchInstanceNetworkForm',
requiredServiceTypes: ['network']
},
{
title: gettext('Security Groups'),
templateUrl: path + 'launch-instance/security-groups/security-groups.html',
helpUrl: path + 'launch-instance/security-groups/security-groups.help.html',
formName: 'launchInstanceAccessAndSecurityForm'
},
{
title: gettext('Key Pair'),
templateUrl: path + 'launch-instance/keypair/keypair.html',
helpUrl: path + 'launch-instance/keypair/keypair.help.html',
formName: 'launchInstanceKeypairForm'
},
{
title: gettext('Configuration'),
templateUrl: path + 'launch-instance/configuration/configuration.html',
helpUrl: path + 'launch-instance/configuration/configuration.help.html',
formName: 'launchInstanceConfigurationForm'
}
],
btnText: {
finish: gettext('Launch Instance')
},
{
title: gettext('Flavor'),
templateUrl: path + 'launch-instance/flavor/flavor.html',
helpUrl: path + 'launch-instance/flavor/flavor.help.html',
formName: 'launchInstanceFlavorForm'
},
{
title: gettext('Network'),
templateUrl: path + 'launch-instance/network/network.html',
helpUrl: path + 'launch-instance/network/network.help.html',
formName: 'launchInstanceNetworkForm'
},
{
title: gettext('Security Groups'),
templateUrl: path + 'launch-instance/security-groups/security-groups.html',
helpUrl: path + 'launch-instance/security-groups/security-groups.help.html',
formName: 'launchInstanceAccessAndSecurityForm'
},
{
title: gettext('Key Pair'),
templateUrl: path + 'launch-instance/keypair/keypair.html',
helpUrl: path + 'launch-instance/keypair/keypair.help.html',
formName: 'launchInstanceKeypairForm'
},
{
title: gettext('Configuration'),
templateUrl: path + 'launch-instance/configuration/configuration.html',
helpUrl: path + 'launch-instance/configuration/configuration.help.html',
formName: 'launchInstanceConfigurationForm'
btnIcon: {
finish: 'fa fa-cloud-download'
}
],
btnText: {
finish: gettext('Launch Instance')
},
btnIcon: {
finish: 'fa fa-cloud-download'
}
};
}]);
});
}
]);
// Using bootstrap-ui modal widget
module.constant('launchInstanceWizardModalSpec', {

View File

@ -1,7 +1,8 @@
(function () {
'use strict';
var push = Array.prototype.push;
var push = Array.prototype.push,
noop = angular.noop;
/**
* @ngdoc overview
@ -183,22 +184,27 @@
model.allowedBootSources.length = 0;
promise = $q.all(
promise = $q.all([
getImages(),
neutronAPI.getNetworks().then(onGetNetworks),
novaAPI.getAvailabilityZones().then(onGetAvailabilityZones),
novaAPI.getFlavors().then(onGetFlavors),
novaAPI.getKeypairs().then(onGetKeypairs),
novaAPI.getLimits().then(onGetNovaLimits),
securityGroup.query().then(onGetSecurityGroups),
serviceCatalog.ifTypeEnabled('volume', onLoadVolumes)
);
novaAPI.getAvailabilityZones().then(onGetAvailabilityZones, noop),
novaAPI.getFlavors().then(onGetFlavors, noop),
novaAPI.getKeypairs().then(onGetKeypairs, noop),
novaAPI.getLimits().then(onGetNovaLimits, noop),
securityGroup.query().then(onGetSecurityGroups, noop),
serviceCatalog.ifTypeEnabled('network').then(getNetworks, noop),
serviceCatalog.ifTypeEnabled('volume').then(getVolumes, noop)
]);
promise.then(function() {
model.initializing = false;
model.initialized = true;
initPromise = null;
});
promise.then(
function() {
model.initializing = false;
model.initialized = true;
},
function () {
model.initializing = false;
model.initialized = false;
}
);
}
return promise;
@ -322,6 +328,10 @@
// Networks
function getNetworks() {
return neutronAPI.getNetworks().then(onGetNetworks, noop);
}
function onGetNetworks(data) {
model.networks.length = 0;
push.apply(model.networks, data.data.items);
@ -369,21 +379,21 @@
addAllowedBootSource(model.imageSnapshots, SOURCE_TYPE_SNAPSHOT, gettext('Instance Snapshot'));
}
function onLoadVolumes(){
var volumeLoadPromises = [];
function getVolumes(){
var volumePromises = [];
// Need to check if Volume service is enabled before getting volumes
model.volumeBootable = true;
addAllowedBootSource(model.volumes, SOURCE_TYPE_VOLUME, 'Volume');
addAllowedBootSource(model.volumes, SOURCE_TYPE_VOLUME, gettext('Volume'));
addAllowedBootSource(model.volumeSnapshots, SOURCE_TYPE_VOLUME_SNAPSHOT, gettext('Volume Snapshot'));
volumeLoadPromises.push(cinderAPI.getVolumes({ status: 'available', bootable: 1 }).then(onGetVolumes));
volumeLoadPromises.push(cinderAPI.getVolumeSnapshots({ status: 'available' }).then(onGetVolumeSnapshots));
volumePromises.push(cinderAPI.getVolumes({ status: 'available', bootable: 1 }).then(onGetVolumes));
volumePromises.push(cinderAPI.getVolumeSnapshots({ status: 'available' }).then(onGetVolumeSnapshots));
// Can only boot image to volume if the Nova extension is enabled.
novaExtensions.ifNameEnabled('BlockDeviceMappingV2Boot', function(){
model.allowCreateVolumeFromImage = true;
});
return $q.all(volumeLoadPromises);
return $q.all(volumePromises);
}
function onGetVolumes(data) {

View File

@ -49,7 +49,7 @@
function LaunchInstanceNetworkCtrl($scope) {
$scope.label = {
title: gettext('Network'),
title: gettext('Networks'),
subtitle: gettext('Networks provide the communication channels for instances in the cloud.'),
network: gettext('Network'),
subnet_associated: gettext('Subnets Associated'),

View File

@ -0,0 +1,107 @@
(function () {
'use strict';
var forEach = angular.forEach;
/**
* @ngdoc overview
* @name hz.dashboard.workflow
* @description
*
* # hz.dashboard.workflow
*
* This module provides utility function factory `dashboardWorkflow` and
* `dashboardWorkflowDecorator`.
*
* | Factories |
* |------------------------------------------------------------------------------------------------|
* | {@link hz.dashboard.workflow.factory:dashboardWorkflowDecorator `dashboardWorkflowDecorator`} |
*
*/
angular.module('hz.dashboard.workflow', [])
/**
* @ngdoc factory
* @name hz.dashboard.workflow.factory:dashboardWorkflowDecorator
* @module hz.dashboard.workflow
* @kind function
* @description
*
* A workflow decorator function that adds checkReadiness method to step in
* the work flow. checkReadiness function will check is a bunch of certain
* types of OpenStack services is enabled in the cloud for that step to show
* on the user interface.
*
* Injected dependencies:
* - $q
* - serviceCatalog hz.api.serviceCatalog
*
* @param {Object} spec The input workflow specification object.
* @returns {Object} The decorated workflow specification object, the same
* reference to the input spec object.
*
* | Factories |
* |------------------------------------------------------------------------------------------------|
* | {@link hz.dashboard.workflow.factory:dashboardWorkflowDecorator `dashboardWorkflowDecorator`} |
*
*/
.factory('dashboardWorkflowDecorator', ['$q', 'serviceCatalog',
function ($q, serviceCatalog) {
function decorate(spec) {
forEach(spec.steps, function (step) {
var types = step.requiredServiceTypes;
if (types && types.length > 0) {
step.checkReadiness = function () {
return $q.all(types.map(function (type) {
return serviceCatalog.ifTypeEnabled(type);
}));
};
}
});
}
return function (spec) {
decorate(spec);
return spec;
};
}
])
/**
* @ngdoc factory
* @name hz.dashboard.workflow.factory:dashboardWorkflow
* @module hz.dashboard.workflow
* @kind function
* @description
*
* Injected dependencies:
* - workflow {@link hz.framework.workflow.factory:workflow `workflow`}
* - dashboardWorkflowDecorator {@link hz.dashboard.workflow.factory
* :dashboardWorkflowDecorator `dashboardWorkflowDecorator`}
*
* @param {Object} The input workflow specification object
*
* @returns {Object} The decorated workflow specification object, the same
* reference to the input spec object.
*
* | Factories |
* |------------------------------------------------------------------------------|
* | {@link hz.dashboard.workflow.factory:dashboardWorkflow `dashboardWorkflow`} |
*
*/
.factory('dashboardWorkflow', [
'workflow',
'dashboardWorkflowDecorator',
function (workflow, dashboardWorkflowDecorator) {
var decorators = [dashboardWorkflowDecorator];
return function (spec) {
return workflow(spec, decorators);
};
}
]);
})();

View File

@ -0,0 +1,10 @@
(function () {
'use strict';
describe('hz.dashboard.workflow module', function () {
it('should have been defined', function () {
expect(angular.module('hz.dashboard.workflow')).toBeDefined();
});
});
})();

View File

@ -581,3 +581,21 @@ class KeystoneRestTestCase(test.TestCase):
content = jsonutils.dumps(request.user.service_catalog,
sort_keys=settings.DEBUG)
self.assertEqual(content, response.content)
#
# User Session
#
@mock.patch.object(keystone.api, 'keystone')
def test_user_session_get(self, kc):
request = self.mock_rest_request()
request.user = mock.Mock(
services_region='some region',
super_secret_thing='not here',
is_authenticated=lambda: True,
spec=['services_region', 'super_secret_thing']
)
response = keystone.UserSession().get(request)
self.assertStatusCode(response, 200)
content = jsonutils.loads(response.content)
self.assertEqual(content['services_region'], 'some region')
self.assertNotIn('super_secret_thing', content)