cockpit/pkg/kubernetes/scripts/volumes.js

1043 lines
41 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2016 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/
(function() {
var angular = require('angular');
require('angular-route');
require('angular-dialog.js');
require('./date');
require('./listing');
require('./kube-client');
require('./utils');
require('../views/volumes-page.html');
require('../views/pv-page.html');
require('../views/pv-body.html');
require('../views/pvc-body.html');
require('../views/pv-claim.html');
require('../views/volume-body.html');
require('../views/pvc-delete.html');
require('../views/pv-delete.html');
require('../views/pv-modify.html');
var VOLUME_FACTORY_SUFFIX = "VolumeFields";
angular.module('kubernetes.volumes', [
'ngRoute',
'kubeClient',
'kubernetes.date',
'kubernetes.listing',
'ui.cockpit',
])
.config([
'$routeProvider',
function($routeProvider) {
$routeProvider
.when('/volumes', {
templateUrl: 'views/volumes-page.html',
controller: 'VolumeCtrl'
})
.when('/volumes/:target', {
controller: 'VolumeCtrl',
templateUrl: 'views/pv-page.html'
});
}
])
/*
* The controller for the volumes view.
*/
.controller('VolumeCtrl', [
'$scope',
'kubeLoader',
'kubeSelect',
'ListingState',
'filterService',
'$routeParams',
'$location',
'volumeActions',
'$timeout',
function($scope, loader, select, ListingState, filterService,
$routeParams, $location, actions, $timeout) {
var target = $routeParams["target"] || "";
$scope.target = target;
$scope.pvFailure = null;
loader.listen(function() {
$scope.pending = select().kind("PersistentVolumeClaim")
.statusPhase("Pending");
$scope.pvs = select().kind("PersistentVolume");
if (target)
$scope.item = select().kind("PersistentVolume")
.name(target)
.one();
}, $scope);
loader.watch("PersistentVolume", $scope)
.catch(function (ex) {
$scope.pvFailure = ex.message;
});
loader.watch("PersistentVolumeClaim", $scope);
loader.watch("Endpoints", $scope);
loader.watch("Pod", $scope);
$scope.listing = new ListingState($scope);
/* All the actions available on the $scope */
angular.extend($scope, actions);
/* Redirect after a delete */
$scope.deletePV = function(item) {
var promise = actions.deletePV(item);
/* If the promise is successful, redirect to another page */
promise.then(function() {
if ($scope.target)
$location.path('/volumes/');
});
return promise;
};
$scope.$on("activate", function(ev, id) {
ev.preventDefault();
$location.path('/volumes/' + id);
});
}
])
.directive('pvBody',
function() {
return {
restrict: 'A',
templateUrl: 'views/pv-body.html'
};
}
)
.directive('pvcBody',
function() {
return {
restrict: 'A',
templateUrl: 'views/pvc-body.html',
scope: {
item: '=item',
settings: '=settings'
},
};
}
)
.directive('pvClaim', [
'volumeData',
'kubeLoader',
function(volumeData, loader) {
return {
restrict: 'A',
templateUrl: 'views/pv-claim.html',
link: function(scope, element, attrs) {
loader.listen(function() {
scope.pvc = volumeData.claimForVolume(scope.item);
scope.pods = volumeData.podsForClaim(scope.pvc);
}, scope);
loader.watch("PersistentVolume", scope);
loader.watch("PersistentVolumeClaim", scope);
loader.watch("Pod", scope);
},
};
}
])
.directive('volumeBody',
function() {
return {
restrict: 'A',
templateUrl: 'views/volume-body.html',
scope: {
volume: '=volume'
},
};
}
)
.factory("volumeData", [
'kubeSelect',
"KubeTranslate",
"KubeMapNamedArray",
function (select, translate, mapNamedArray) {
const _ = translate.gettext;
var KNOWN_VOLUME_TYPES = {
"gcePersistentDisk" : _("GCE Persistent Disk"),
"awsElasticBlockStore" : _("AWS Elastic Block Store"),
"gitRepo" : _("Git Repository"),
"secret" : _("Secret"),
"emptyDir" : _("Empty Directory"),
"hostPath" : _("Host Path"),
"glusterfs" : _("Gluster FS"),
"nfs" : _("NFS Mount"),
"rbd" : _("Rados Block Device"),
"iscsi" : _("ISCSI"),
"cinder" : _("Cinder"),
"cephfs" : _("Ceph Filesystem Mount"),
"fc" : _("Fibre Channel"),
"flocker" : _("Flocker"),
"flexVolume" : _("Flex"),
"azureFile" : _("Azure")
};
var ACCESS_MODES = {
"ReadWriteOnce" : _("Read and write from a single node"),
"ReadOnlyMany" : _("Read only from multiple nodes"),
"ReadWriteMany" : _("Read and write from multiple nodes"),
};
var RECLAIM_POLICIES = {
"Retain" : _("Retain"),
"Delete" : _("Delete"),
"Recycle" : _("Recycle")
};
select.register({
name: "volumeName",
digest: function(arg) {
if (typeof arg === "string")
return arg;
var spec = arg.spec || {};
return spec.volumeName;
}
});
select.register({
name: "claim",
digests: function(arg) {
if (typeof arg === "string")
return ["claimName=" + arg];
var i;
var ret = [];
var spec = arg.spec || {};
var vols = spec.volumes || [];
for (i in vols) {
var claim = vols[i].persistentVolumeClaim || {};
if (claim.claimName)
ret.push("claimName=" + claim.claimName);
}
return ret;
}
});
function getVolumeType(volume) {
var keys = Object.keys(KNOWN_VOLUME_TYPES);
var i;
volume = volume || {};
for (i = 0; i < keys.length; i++) {
var key = keys[i];
if (volume[key])
return key;
}
}
function getVolumeLabel(volume) {
var type = getVolumeType(volume);
if (type)
return KNOWN_VOLUME_TYPES[type];
return _("Unknown");
}
function claimForVolume(volume) {
var uid = "";
if (volume && volume.spec.claimRef)
uid = volume.spec.claimRef.uid || "";
return select().kind("PersistentVolumeClaim")
.uid(uid)
.one();
}
function claimFromVolumeSource(source, namespace) {
source = source || {};
return select().kind("PersistentVolumeClaim")
.namespace(namespace || "")
.name(source.claimName || "")
.one();
}
function podsForClaim(claim) {
var meta;
claim = claim || {};
meta = claim.metadata || {};
return select().kind("Pod")
.namespace(meta.namespace || "")
.claim(meta.name || "");
}
function volumesForPod(item) {
var volumes;
var i, j, container, volumeMounts, name;
if (item && !item.volumes) {
if (item.spec)
volumes = mapNamedArray(item.spec.volumes);
else
volumes = { };
if (item.spec && item.spec.containers) {
for (i = 0; i < item.spec.containers.length; i++) {
container = item.spec.containers[i];
volumeMounts = container.volumeMounts || [];
for (j = 0; j < volumeMounts.length; j++) {
name = volumeMounts[j].name;
if (!volumes[name])
volumes[name] = {};
if (!volumes[name]['mounts'])
volumes[name]['mounts'] = {};
volumes[name]['mounts'][container.name] = volumeMounts[j];
}
}
}
item.volumes = volumes;
}
return item ? item.volumes : { };
}
return {
podsForClaim: podsForClaim,
volumesForPod: volumesForPod,
claimFromVolumeSource: claimFromVolumeSource,
claimForVolume: claimForVolume,
getVolumeType: getVolumeType,
getVolumeLabel: getVolumeLabel,
reclaimPolicies: RECLAIM_POLICIES,
accessModes: ACCESS_MODES,
};
}
])
.factory('volumeActions', [
'$modal',
'$injector',
'volumeData',
function($modal, $injector, volumeData) {
function canEdit(item) {
var spec = item ? item.spec : {};
var type = volumeData.getVolumeType(spec);
if (type)
return $injector.has(type + VOLUME_FACTORY_SUFFIX);
return true;
}
function deleteItem(item, resolve, template, ev) {
if (ev)
ev.stopPropagation();
return $modal.open({
animation: false,
controller: 'VolumeDeleteCtrl',
templateUrl: template,
resolve: resolve,
}).result;
}
function deletePVC(item, ev) {
var resolve = {
dialogData: function() {
return { item: item,
pvc: item,
pods: volumeData.podsForClaim(item) };
}
};
return deleteItem(item, resolve, 'views/pvc-delete.html', ev);
}
function deletePV(item, ev) {
var resolve = {
dialogData: function() {
return { item: item };
}
};
return deleteItem(item, resolve, 'views/pv-delete.html', ev);
}
function createPV(item) {
return $modal.open({
animation: false,
controller: 'PVModifyCtrl',
templateUrl: 'views/pv-modify.html',
resolve: {
dialogData: function() {
return { pvc : item };
}
},
}).result;
}
function modifyPV(item) {
return $modal.open({
animation: false,
controller: 'PVModifyCtrl',
templateUrl: 'views/pv-modify.html',
resolve: {
dialogData: function() {
return { item: item };
}
},
}).result;
}
return {
modifyPV: modifyPV,
createPV: createPV,
deletePV: deletePV,
deletePVC: deletePVC,
canEdit: canEdit,
};
}
])
.factory("defaultVolumeFields", [
"volumeData",
"KubeStringToBytes",
"KubeTranslate",
"KUBE_NAME_RE",
function (volumeData, stringToBytes, translate, NAME_RE) {
const _ = translate.gettext;
function build (item, type) {
if (!item)
item = {};
var spec = item.spec || {};
var requests, storage;
if (item.kind == "PersistentVolumeClaim") {
requests = spec.resources ? spec.resources.requests : {};
storage = requests ? requests.storage : "";
} else {
storage = spec.capacity ? spec.capacity.storage : "";
}
var fields = {
"capacity" : storage || "",
"policy" : spec.persistentVolumeReclaimPolicy || "Retain",
"accessModes": volumeData.accessModes,
"reclaimPolicies": volumeData.reclaimPolicies,
};
var i;
for (i in spec.accessModes || []) {
fields[spec.accessModes[i]] = true;
}
return fields;
}
function validate (item, fields) {
var ex, spec, name, capacity, policy, i, validModes;
var accessModes = [];
var ret = {
errors: [],
data: null,
};
validModes = Object.keys(fields.accessModes || {});
for (i = 0; i < validModes.length; i++) {
var mode = validModes[i];
if (fields[mode])
accessModes.push(mode);
}
if (accessModes.length < 1) {
ex = new Error(_("Please select a valid access mode"));
ex.target = "#last-access";
ret.errors.push(ex);
ex = null;
}
name = fields.name ? fields.name.trim() : fields.name;
if (!item && (!name || !NAME_RE.test(name))) {
ex = new Error(_("Please provide a valid name"));
ex.target = "#modify-name";
ret.errors.push(ex);
ex = null;
}
capacity = fields.capacity ? fields.capacity.trim() : fields.capacity;
if (!item && (!capacity || !stringToBytes(capacity))) {
ex = new Error(_("Please provide a valid storage capacity."));
ex.target = "#modify-capacity";
ret.errors.push(ex);
ex = null;
}
policy = fields.policy ? fields.policy.trim() : fields.policy;
if (!fields.reclaimPolicies || !fields.reclaimPolicies[policy]) {
ex = new Error(_("Please select a valid policy option."));
ex.target = "#last-policy";
ret.errors.push(ex);
ex = null;
}
if (ret.errors.length < 1) {
spec = {
"accessModes" : accessModes,
"capacity" : { "storage" : capacity },
"persistentVolumeReclaimPolicy" : policy,
};
if (item) {
ret.data = {
spec: spec
};
} else {
ret.data = {
kind: "PersistentVolume",
metadata: {
name: fields.name.trim()
},
spec: spec
};
}
}
return ret;
}
return {
build: build,
validate: validate,
};
}
])
.factory("glusterfs" + VOLUME_FACTORY_SUFFIX, [
"volumeData",
"KubeTranslate",
"kubeSelect",
function (volumeData, translate, select) {
const _ = translate.gettext;
function build(item) {
if (!item)
item = {};
var spec = item.spec || {};
var glusterfs = spec.glusterfs || {};
return {
endpoint: glusterfs.endpoints,
endpointOptions: select().kind("Endpoints"),
glusterfsPath: glusterfs.path,
readOnly: glusterfs.readOnly,
reclaimPolicies: {
"Retain" : volumeData.reclaimPolicies["Retain"]
},
};
}
function validate (item, fields) {
var ex, endpoint, path;
var ret = {
errors: [],
data: null,
};
endpoint = fields.endpoint ? fields.endpoint.trim() : fields.endpoint;
if (!select().kind("Endpoints")
.name(endpoint)
.one()) {
ex = new Error(_("Please select a valid endpoint"));
ex.target = "#modify-endpoint";
ret.errors.push(ex);
ex = null;
}
// The API calls glusterfs volume name, path.
path = fields.glusterfsPath ? fields.glusterfsPath.trim() : fields.glusterfsPath;
if (!path) {
ex = new Error(_("Please provide a GlusterFS volume name"));
ex.target = "#modify-glusterfs-path";
ret.errors.push(ex);
ex = null;
}
if (ret.errors.length < 1) {
ret.data = {
endpoints: endpoint,
path: path,
readOnly: !!fields.readOnly
};
}
return ret;
}
return {
build: build,
validate: validate,
};
}
])
.factory("nfs" + VOLUME_FACTORY_SUFFIX, [
"volumeData",
"KubeTranslate",
function (volumeData, translate) {
const _ = translate.gettext;
function build(item) {
if (!item)
item = {};
var spec = item.spec || {};
var nfs = spec.nfs || {};
return {
server: nfs.server,
path: nfs.path,
readOnly: nfs.readOnly,
reclaimPolicies: {
"Recycle" : volumeData.reclaimPolicies["Recycle"],
"Retain" : volumeData.reclaimPolicies["Retain"],
},
};
}
function validate (item, fields) {
var regex = /^[a-z0-9.:-]+$/i;
var ex, server, path;
var ret = {
errors: [],
data: null,
};
server = fields.server ? fields.server.trim() : fields.server;
if (!server || !regex.test(server)) {
ex = new Error(_("Please provide a valid NFS server"));
ex.target = "#nfs-modify-server";
ret.errors.push(ex);
ex = null;
}
path = fields.path ? fields.path.trim() : fields.path;
if (!path || path.search("/") !== 0) {
ex = new Error(_("Please provide a valid path"));
ex.target = "#modify-path";
ret.errors.push(ex);
ex = null;
}
if (ret.errors.length < 1) {
ret.data = {
server: server,
path: path,
readOnly: !!fields.readOnly
};
}
return ret;
}
return {
build: build,
validate: validate,
};
}
])
.factory("hostPath" + VOLUME_FACTORY_SUFFIX, [
"volumeData",
"KubeTranslate",
function (volumeData, translate) {
const _ = translate.gettext;
function build(item) {
if (!item)
item = {};
var spec = item.spec || {};
var hp = spec.hostPath || {};
return {
path: hp.path,
readOnly: hp.readOnly,
reclaimPolicies: {
"Recycle" : volumeData.reclaimPolicies["Recycle"],
"Retain" : volumeData.reclaimPolicies["Retain"],
},
};
}
function validate (item, fields) {
var ex, path;
var ret = {
errors: [],
data: null,
};
path = fields.path ? fields.path.trim() : fields.path;
if (!path || path.search("/") !== 0) {
ex = new Error(_("Please provide a valid path"));
ex.target = "#modify-path";
ret.errors.push(ex);
ex = null;
}
if (ret.errors.length < 1) {
ret.data = {
path: path,
};
}
return ret;
}
return {
build: build,
validate: validate,
};
}
])
.controller("VolumeDeleteCtrl", [
"$scope",
"$modalInstance",
"dialogData",
"volumeData",
"kubeMethods",
"kubeLoader",
function($scope, $instance, dialogData, volumeData, methods, loader) {
angular.extend($scope, dialogData);
loader.listen(function() {
if ($scope.pvc)
$scope.pods = volumeData.podsForClaim($scope.pvc);
}, $scope);
$scope.performDelete = function performDelete() {
return methods.delete($scope.item);
};
}
])
.factory("iscsi" + VOLUME_FACTORY_SUFFIX, [
"volumeData",
"KubeTranslate",
function (volumeData, translate) {
const _ = translate.gettext;
function build(item) {
if (!item)
item = {};
var spec = item.spec || {};
var source = spec.iscsi || {};
return {
target: source.targetPortal,
fstype: source.fsType || "ext4",
iqn: source.iqn,
lun: source.lun || 0,
iface: source.iscsiInterface,
readOnly: source.readOnly,
ReadWriteOnce: true,
accessModes: {
"ReadWriteOnce": volumeData.accessModes["ReadWriteOnce"],
},
reclaimPolicies: {
"Retain": volumeData.reclaimPolicies["Retain"],
},
};
}
function validate (item, fields) {
var ex, lun, target, iqn, fs, iface;
var regex = /^[a-z0-9.:-]+$/i;
var ret = {
errors: [],
data: null,
};
target = fields.target ? fields.target.trim() : fields.target;
if (!target || !regex.test(target)) {
ex = new Error(_("Please provide a valid target"));
ex.target = "#modify-iscsi-target";
ret.errors.push(ex);
ex = null;
}
iqn = fields.iqn ? fields.iqn.trim() : fields.iqn;
if (!iqn || !regex.test(iqn)) {
ex = new Error(_("Please provide a valid qualified name"));
ex.target = "#modify-iscsi-iqn";
ret.errors.push(ex);
ex = null;
}
lun = parseInt(fields.lun ? fields.lun.trim() : fields.lun, 10);
if (isNaN(lun)) {
ex = new Error(_("Please provide a valid logical unit number"));
ex.target = "#modify-iscsi-lun";
ret.errors.push(ex);
ex = null;
}
fs = fields.fstype ? fields.fstype.trim() : "ext4";
if (!regex.test(fs)) {
ex = new Error(_("Please provide a valid filesystem type"));
ex.target = "#modify-fstype";
ret.errors.push(ex);
ex = null;
}
iface = fields.iface ? fields.iface.trim() : undefined;
if (iface && !regex.test(iface)) {
ex = new Error(_("Please provide a valid interface"));
ex.target = "#modify-iscsi-iface";
ret.errors.push(ex);
ex = null;
}
if (ret.errors.length < 1) {
ret.data = {
iqn: iqn,
lun: lun,
fsType: fs,
targetPortal: target,
readOnly: !!fields.readOnly
};
if (iface)
ret.data["iscsiInterface"] = iface;
}
return ret;
}
return {
build: build,
validate: validate,
};
}
])
.controller("PVModifyCtrl", [
"$q",
"$scope",
"$injector",
"$modalInstance",
"dialogData",
"volumeData",
"defaultVolumeFields",
"kubeMethods",
"KubeTranslate",
function($q, $scope, $injector, $instance, dialogData, volumeData,
defaultVolumeFields, methods, translate) {
const _ = translate.gettext;
var volumeFields, valName;
angular.extend($scope, dialogData);
$scope.types = [
{
name: _("NFS"),
type: "nfs",
},
{
name: _("Host Path"),
type: "hostPath",
},
{
name: _("ISCSI"),
type: "iscsi",
},
{
name: _("GlusterFS"),
type: "glusterfs",
},
];
function selectType(type) {
$scope.current_type = type;
valName = $scope.current_type + VOLUME_FACTORY_SUFFIX;
$scope.fields = defaultVolumeFields.build($scope.item || $scope.pvc);
if ($injector.has(valName)) {
volumeFields = $injector.get(valName, "PVModifyCtrl");
angular.extend($scope.fields, volumeFields.build($scope.item || $scope.pvc));
} else {
$scope.$applyAsync(function () {
$scope.$dismiss();
});
}
}
if ($scope.item) {
$scope.current_type = volumeData.getVolumeType($scope.item.spec);
selectType(volumeData.getVolumeType($scope.item.spec));
} else {
$scope.selected = $scope.types[0];
selectType($scope.selected.type);
}
function validate() {
var defer = $q.defer();
var resp, main_resp;
var errors = [];
if (!$scope.item) {
valName = $scope.current_type + VOLUME_FACTORY_SUFFIX;
if ($injector.has(valName))
volumeFields = $injector.get(valName, "PVModifyCtrl");
else
errors.push(new Error(_("Sorry, I don't know how to modify this volume")));
}
if (volumeFields) {
resp = volumeFields.validate($scope.item, $scope.fields);
errors = resp.errors;
}
main_resp = defaultVolumeFields.validate($scope.item, $scope.fields);
errors = errors.concat(main_resp.errors);
if (errors.length > 0) {
defer.reject(errors);
} else {
main_resp.data.spec[$scope.current_type] = resp ? resp.data : null;
defer.resolve(main_resp.data);
}
return defer.promise;
}
function updatePVC() {
/*
* HACK: https://github.com/kubernetes/kubernetes/issues/21498
*
* Until the API gets a way to manually
* trigger the process that matches a volume with a claim
* we change something else on the claim to try to kick things
* off sooner. Otherwise in the worse case
* the user will need to wait for around 10 mins.
*/
if ($scope.pvc) {
return methods.patch($scope.pvc, {
"metadata" : {
"annotations" : {
"claimTrigger": "on"
}
}
})
.then(function () {
methods.patch($scope.pvc, {
"metadata" : {
"annotations" : {
"claimTrigger": null
}
}
});
});
}
}
$scope.select = function(type) {
$scope.selected = type;
selectType(type.type);
};
$scope.setField = function(name, value) {
$scope.fields[name] = value;
};
$scope.hasField = function(name) {
return $scope.fields.hasOwnProperty(name);
};
$scope.performModify = function performModify() {
return validate().then(function(data) {
var res;
if (!$scope.item)
res = methods.create(data, null);
else
res = methods.patch($scope.item, data);
return res.then(updatePVC);
});
};
}
])
.filter("formatReadOnly", [
"KubeTranslate",
function(translate) {
return function(readOnly) {
if (readOnly)
return translate.gettext("Yes");
else
return translate.gettext("No");
};
}
])
.filter("formatPartitionNumber", [
function() {
return function(partition) {
if (!partition)
return 0;
else
return partition;
};
}
])
.filter("formatVolumeType", [
'volumeData',
function(volumeData) {
return function(volume) {
return volumeData.getVolumeLabel(volume || {});
};
}
])
.filter("reclaimLabel", [
'volumeData',
function(volumeData) {
return function(policy) {
var label = volumeData.reclaimPolicies[policy || ""];
return label || policy;
};
}
])
.filter("accessModeLabel", [
'volumeData',
function(volumeData) {
return function(mode) {
var label = volumeData.accessModes[mode || ""];
return label || mode;
};
}
]);
}());