Merge "Add Cloud Shell feature"

This commit is contained in:
Zuul 2018-01-19 07:17:36 +00:00 committed by Gerrit Code Review
commit 17c720878f
16 changed files with 505 additions and 6 deletions

View File

@ -0,0 +1 @@
CLOUD_SHELL_IMAGE = "gbraad/openstack-client:alpine"

View File

@ -2,9 +2,19 @@
Configuration
=============
Zun UI has no configuration option.
Image for Cloud Shell
---------------------
For more configurations, see
The image for Cloud Shell is set as `gbraad/openstack-client:alpine`
by default. If you want to use other image, edit `CLOUD_SHELL_IMAGE`
variable in file `_0330_cloud_shell_settings.py.sample`, and copy
it to `horizon/openstack_dashboard/local/local_settings.d/_0330_cloud_shell_settings.py`,
and restart Horizon.
For more configurations
-----------------------
See
`Configuration Guide
<https://docs.openstack.org/horizon/latest/configuration/index.html>`__
in the Horizon documentation.

View File

@ -208,6 +208,10 @@ def container_attach(request, id):
return zunclient(request).containers.attach(id)
def container_resize(request, id, width, height):
return zunclient(request).containers.resize(id, width, height)
def image_list(request, limit=None, marker=None, sort_key=None,
sort_dir=None, detail=True):
return zunclient(request).images.list(limit, marker, sort_key,

View File

@ -82,6 +82,10 @@ class ContainerActions(generic.View):
return client.container_kill(request, id, signal)
elif action == 'attach':
return client.container_attach(request, id)
elif action == 'resize':
width = request.DATA.get("width") or 500
height = request.DATA.get("height") or 400
return client.container_resize(request, id, width, height)
@rest_utils.ajax(data_required=True)
def delete(self, request, id, action):

View File

View File

@ -0,0 +1,27 @@
# 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.
from django.conf import settings
from horizon import views
class CloudShellView(views.HorizonTemplateView):
template_name = 'cloud_shell/cloud_shell.html'
def get_context_data(self, **kwargs):
context = super(CloudShellView, self).get_context_data(**kwargs)
if hasattr(settings, "CLOUD_SHELL_IMAGE"):
context['CLOUD_SHELL_IMAGE'] = settings.CLOUD_SHELL_IMAGE
else:
context['CLOUD_SHELL_IMAGE'] = "gbraad/openstack-client:alpine"
return context

View File

@ -0,0 +1,26 @@
# 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.
FEATURE = True
ADD_ANGULAR_MODULES = [
'horizon.cloud-shell'
]
ADD_SCSS_FILES = [
'cloud-shell/cloud-shell.scss'
]
# A list of extensible header views to be displayed
ADD_HEADER_SECTIONS = [
'zun_ui.content.cloud_shell.views.CloudShellView',
]

View File

@ -0,0 +1,147 @@
/**
* 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";
angular
.module('horizon.cloud-shell')
.controller('horizon.cloud-shell.controller', cloudShellController);
cloudShellController.$inject = [
'$scope',
'horizon.app.core.openstack-service-api.zun',
'horizon.dashboard.container.webRoot',
'horizon.framework.util.http.service'
];
function cloudShellController(
$scope,
zun,
webRoot,
http
) {
var ctrl = this;
ctrl.openInNewWindow = openInNewWindow;
ctrl.close = closeShell;
ctrl.consoleUrl = null;
ctrl.container = {};
ctrl.resizeTerminal = resizeTerminal;
// close existing shell
closeShell();
// default size for shell
var cols = 80;
var rows = 24;
// get openrc v3 for OpenStack Client
var cloudsYaml;
http.get('/project/api_access/clouds.yaml/').then(function(response) {
// cloud.yaml to be set to .config/openstack/clouds.yaml in container
cloudsYaml = response.data;
ctrl.user = cloudsYaml.match(/username: "(.+)"/)[1];
ctrl.project = cloudsYaml.match(/project_name: "(.+)"/)[1];
ctrl.userDomain = cloudsYaml.match(/user_domain_name: "(.+)"/);
ctrl.projectDomain = cloudsYaml.match(/project_domain_name: "(.+)"/);
ctrl.domain = (ctrl.userDomain.length === 2) ? ctrl.userDomain[1] : ctrl.projectDomain[1];
ctrl.region = cloudsYaml.match(/region_name: "(.+)"/)[1];
// container name
ctrl.container.name = "cloud-shell-" + ctrl.user + "-" + ctrl.project +
"-" + ctrl.domain + "-" + ctrl.region;
// get container
zun.getContainer(ctrl.container.name, true).then(onGetContainer, onFailGetContainer);
});
function onGetContainer(response) {
ctrl.container = response.data;
// attach console to existing container
ctrl.consoleUrl = webRoot + "containers/" + ctrl.container.id + "/console";
var console = $("<p>To display console, interactive mode needs to be enabled " +
"when this container was created.</p>");
if (ctrl.container.status !== "Running") {
console = $("<p>Container is not running. Please wait for starting up container.</p>");
} else if (ctrl.container.interactive) {
console = $("<iframe id=\"console_embed\" src=\"" + ctrl.consoleUrl +
"\" style=\"width:100%;height:100%\"></iframe>");
// execute openrc.sh on the container
var command = "sh -c 'printf \"" + cloudsYaml + "\" > ~/.config/openstack/clouds.yaml'";
zun.executeContainer(ctrl.container.id, {command: command}).then(function() {
var command = "sh -c 'printf \"export OS_CLOUD=openstack\" > ~/.bashrc'";
zun.executeContainer(ctrl.container.id, {command: command}).then(function() {
angular.noop();
});
});
}
// append shell content
angular.element("#shell-content").append(console);
}
// watcher for iframe contents loading, seems to emit once.
$scope.$watch(function() {
return angular.element("#shell-content > iframe").contents()
.find("#terminalNode").attr("termCols");
}, resizeTerminal);
// event handler to resize console according to window resize.
angular.element(window).bind('resize', resizeTerminal);
// also, add resizeTerminal into callback attribute for resizer directive
function resizeTerminal() {
var shellIframe = angular.element("#shell-content > iframe");
var newCols = shellIframe.contents().find("#terminalNode").attr("termCols");
var newRows = shellIframe.contents().find("#terminalNode").attr("termRows");
if ((newCols !== cols || newRows !== rows) && newCols > 0 && newRows > 0) {
// resize tty
zun.resizeContainer(ctrl.container.id, {width: newCols, height: newRows}).then(function() {
cols = newCols;
rows = newRows;
});
}
}
function onFailGetContainer() {
// create new container and attach console to it.
var image = angular.element("#cloud-shell-menu").attr("cloud-shell-image");
var model = {
name: ctrl.container.name,
image: image,
command: "/bin/bash",
interactive: true,
run: true,
environment: "OS_CLOUD=openstack",
labels: "cloud-shell=" + ctrl.container.name
};
zun.createContainer(model).then(function (response) {
// attach
onGetContainer({data: {id: response.data.id}});
});
}
function openInNewWindow() {
// open shell in new window
window.open(ctrl.consoleUrl, "_blank");
closeShell();
}
function closeShell() {
// close shell
angular.element("#cloud-shell").remove();
angular.element("#cloud-shell-resizer").remove();
}
}
})();

View File

@ -0,0 +1,23 @@
<div ng-controller="horizon.cloud-shell.controller as ctrl">
<resizer id="cloud-shell-resizer"
direction="horizontal"
height="6"
bottom="#cloud-shell"
callback="ctrl.resizeTerminal()">
</resizer>
<div id="cloud-shell">
<div id="shell-header">
{$ ctrl.container.name $}
<!--
<a class="cloud-shell-external" ng-click="ctrl.openInNewWindow()">
<span class="fa fa-external-link"></span>
</a>
-->
<a class="cloud-shell-close" ng-click="ctrl.close()">
<span class="fa fa-times"></span>
</a>
</div>
<div id="shell-content">
</div>
</div>
</div>

View File

@ -0,0 +1,37 @@
/**
* 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';
/**
* @ngdoc overview
* @name horizon.cloud-shell
* @description
* cloud_shell module to host container for cloud shell.
*/
angular
.module('horizon.cloud-shell', [
'horizon.cloud-shell.resizer',
'ngRoute'
])
.config(config);
config.$inject = ['$provide', '$windowProvider'];
function config($provide, $windowProvider) {
var path = $windowProvider.$get().STATIC_URL + 'cloud-shell/';
$provide.constant('horizon.cloud-shell.basePath', path);
}
})();

View File

@ -0,0 +1,51 @@
#cloud-shell {
position: fixed;
z-index: 10;
bottom: 0px;
width: 100%;
height: 200px;
background-color: black;
color: white;
}
#shell-header {
position: relative;
width: 100%;
height: 20px;
background-color: gray;
padding-left: 3px;
}
#shell-content {
height: calc(100% - 20px);
}
.cloud-shell-external {
position: relative;
top: 0px;
padding-left: 3px;
color: cyan;
}
.cloud-shell-external:hover {
color: red;
}
.cloud-shell-close {
position: relative;
top: 0px;
float: right;
padding-right: 3px;
color: white;
}
.cloud-shell-close:hover {
color: red;
}
#cloud-shell-resizer {
position: absolute;
z-index: 10;
bottom: 200px;
height: 6px;
width: 100%;
background-color: lightgray;
cursor: n-resize;
}
#cloud-shell-resize-holder {
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,56 @@
/**
* 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";
angular
.module('horizon.cloud-shell')
.factory('horizon.cloud-shell.service', cloudShellService);
cloudShellService.$inject = [
'$rootScope',
'$templateRequest',
'horizon.cloud-shell.basePath'
];
function cloudShellService(
$rootScope,
$templateRequest,
basePath
) {
var service = {
init: init
};
return service;
function init () {
// remove existing cloud shell
angular.element(".cloud_shell").remove();
// load html for cloud shell
$templateRequest(basePath + 'cloud-shell.html').then(function (html) {
var scope = $rootScope.$new();
var template = angular.element(html);
// compile html
angular.element(document.body).injector().invoke(['$compile', function ($compile) {
$compile(template)(scope);
angular.element('body').append(template);
}]);
});
}
}
})();

View File

@ -0,0 +1,94 @@
/**
* 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';
angular
.module('horizon.cloud-shell.resizer', [])
.directive('resizer', resizer);
resizer.$inject = ['$document'];
function resizer($document) {
var directive = {
restrict: 'E',
scope: {
direction: '@',
max: '@',
left: '@',
right: '@',
top: '@',
bottom: '@',
width: '@',
height: '@',
callback: '&'
},
link: link
};
return directive;
////////////////////
function link($scope, $element) {
$element.on('mousedown', function(event) {
event.preventDefault();
$document.on('mousemove', mousemove);
$document.on('mouseup', mouseup);
});
function mousemove(event) {
if ($scope.direction === 'vertical') {
// Handle vertical resizer
var x = event.pageX;
if ($scope.max && x > $scope.max) {
x = parseInt($scope.max, 10);
}
$element.css({
left: x + 'px'
});
$($scope.left).css({
width: x + 'px'
});
$($scope.right).css({
left: (x + parseInt($scope.width, 10)) + 'px'
});
} else {
// Handle horizontal resizer
var y = window.innerHeight - event.pageY;
$element.css({
bottom: y + 'px'
});
$($scope.top).css({
bottom: (y + parseInt($scope.height, 10)) + 'px'
});
$($scope.bottom).css({
height: y + 'px'
});
}
}
function mouseup() {
$document.unbind('mousemove', mousemove);
$document.unbind('mouseup', mouseup);
if (typeof $scope.callback === "function") {
$scope.callback();
}
}
}
}
})();

View File

@ -3,7 +3,7 @@
}
.console {
margin-top: 10px;
height: 500px;
height: calc(100vh - 300px);
}
textarea#output {
height: 25em;

View File

@ -44,6 +44,7 @@
unpauseContainer: unpauseContainer,
executeContainer: executeContainer,
killContainer: killContainer,
resizeContainer: resizeContainer,
pullImage: pullImage,
getImages: getImages
};
@ -64,9 +65,12 @@
return apiService.patch(containersPath + id, params).error(error(msg));
}
function getContainer(id) {
var msg = gettext('Unable to retrieve the Container.');
return apiService.get(containersPath + id).error(error(msg));
function getContainer(id, suppressError) {
var promise = apiService.get(containersPath + id);
return suppressError ? promise : promise.error(function() {
var msg = gettext('Unable to retrieve the Container.');
toastService.add('error', msg);
});
}
function getContainers() {
@ -144,6 +148,11 @@
return apiService.post(containersPath + id + '/kill', params).error(error(msg));
}
function resizeContainer(id, params) {
var msg = gettext('Unable to resize console.');
return apiService.post(containersPath + id + '/resize', params).error(error(msg));
}
////////////
// Images //
////////////

View File

@ -0,0 +1,10 @@
{% load i18n %}
<!-- Menu Item for Extensible Header -->
<span
id="cloud-shell-menu"
class="fa fa-terminal"
cloud-shell-image="{{ CLOUD_SHELL_IMAGE }}"
onclick="angular.element(document.body).injector().get('horizon.cloud-shell.service').init();">
{% trans "Cloud Shell" %}
</span>