cockpit/pkg/storaged/utils.js

795 lines
26 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2015 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/>.
*/
import cockpit from "cockpit";
import * as service from "service";
import * as timeformat from "timeformat";
const _ = cockpit.gettext;
const C_ = cockpit.gettext;
/* UTILITIES
*/
export function compare_versions(a, b) {
function to_ints(str) {
return str.split(".").map(function (s) { return s ? parseInt(s, 10) : 0 });
}
const a_ints = to_ints(a);
const b_ints = to_ints(b);
const len = Math.min(a_ints.length, b_ints.length);
let i;
for (i = 0; i < len; i++) {
if (a_ints[i] == b_ints[i])
continue;
return a_ints[i] - b_ints[i];
}
return a_ints.length - b_ints.length;
}
export let hostnamed = cockpit.dbus("org.freedesktop.hostname1").proxy();
// for unit tests
let orig_hostnamed;
export function mock_hostnamed(value) {
if (value) {
orig_hostnamed = hostnamed;
hostnamed = value;
} else {
hostnamed = orig_hostnamed;
}
}
export function array_find(array, pred) {
for (let i = 0; i < array.length; i++)
if (pred(array[i]))
return array[i];
return undefined;
}
export function flatten(array_of_arrays) {
if (array_of_arrays.length > 0)
return Array.prototype.concat.apply([], array_of_arrays);
else
return [];
}
export function decode_filename(encoded) {
return cockpit.utf8_decoder().decode(cockpit.base64_decode(encoded).slice(0, -1));
}
export function encode_filename(decoded) {
return cockpit.base64_encode(cockpit.utf8_encoder().encode(decoded)
.concat([0]));
}
export function fmt_size(bytes) {
return cockpit.format_bytes(bytes);
}
export function fmt_size_long(bytes) {
const with_decimal_unit = cockpit.format_bytes(bytes, 1000);
const with_binary_unit = cockpit.format_bytes(bytes, 1024);
/* Translators: Used in "..." */
return with_decimal_unit + ", " + with_binary_unit + ", " + bytes + " " + C_("format-bytes", "bytes");
}
export function fmt_rate(bytes_per_sec) {
return cockpit.format_bytes_per_sec(bytes_per_sec);
}
export function format_temperature(kelvin) {
const celsius = kelvin - 273.15;
const fahrenheit = 9.0 * celsius / 5.0 + 32.0;
return celsius.toFixed(1) + "° C / " + fahrenheit.toFixed(1) + "° F";
}
export function format_fsys_usage(used, total) {
let text = "";
let parts = cockpit.format_bytes(total, undefined, { separate: true, precision: 2 });
text = " / " + parts.join(" ");
const unit = parts[1];
parts = cockpit.format_bytes(used, unit, { separate: true, precision: 2 });
return parts[0] + text;
}
export function format_delay(d) {
return timeformat.distanceToNow(new Date().valueOf() + d);
}
export function format_size_and_text(size, text) {
return fmt_size(size) + " " + text;
}
export function validate_mdraid_name(name) {
return validate_lvm2_name(name);
}
export function validate_lvm2_name(name) {
if (name === "")
return _("Name cannot be empty.");
if (name.length > 127)
return _("Name cannot be longer than 127 characters.");
const m = name.match(/[^a-zA-Z0-9+._-]/);
if (m) {
if (m[0].search(/\s+/) === -1)
return cockpit.format(_("Name cannot contain the character '$0'."), m[0]);
else
return cockpit.format(_("Name cannot contain whitespace."), m[0]);
}
}
export function validate_fsys_label(label, type) {
const fs_label_max = {
xfs: 12,
ext4: 16,
vfat: 11,
ntfs: 128,
};
const limit = fs_label_max[type.replace("luks+", "")];
const bytes = cockpit.utf8_encoder().encode(label);
if (limit && bytes.length > limit) {
// Let's not confuse people with encoding issues unless
// they use funny characters.
if (bytes.length == label.length)
return cockpit.format(_("Name cannot be longer than $0 characters"), limit);
else
return cockpit.format(_("Name cannot be longer than $0 bytes"), limit);
}
}
export function block_name(block) {
return decode_filename(block.PreferredDevice);
}
export function mdraid_name(mdraid) {
if (!mdraid.Name)
return "";
const parts = mdraid.Name.split(":");
if (parts.length != 2)
return mdraid.Name;
/* Check the static (from /etc/hostname) and transient (acquired from DHCP server via
* NetworkManager → hostnamed, may not exist) host name -- if either one matches, we
* consider the RAID a local one and just show the device name.
* Otherwise it's a remote one, and include the host in the name.
*
* However: if we call hostnamed too early, before the dbus.proxy() promise is
* fulfilled, it will not be valid yet (hostnamed properties are undefined);
* it's too inconvenient to make this function asynchronous, so just don't
* show the host name in this case. */
if (hostnamed.StaticHostname === undefined || parts[0] == hostnamed.StaticHostname || parts[0] == hostnamed.Hostname)
return parts[1];
else
return cockpit.format(_("$name (from $host)"),
{
name: parts[1],
host: parts[0]
});
}
export function lvol_name(lvol) {
let type;
if (lvol.Type == "pool")
type = _("Pool for thin logical volumes");
else if (lvol.ThinPool != "/")
type = _("Thin logical volume");
else if (lvol.Origin != "/")
type = _("Logical volume (snapshot)");
else
type = _("Logical volume");
return cockpit.format('$0 "$1"', type, lvol.Name);
}
export function drive_name(drive) {
const name_parts = [];
if (drive.Vendor)
name_parts.push(drive.Vendor);
if (drive.Model)
name_parts.push(drive.Model);
let name = name_parts.join(" ");
if (drive.Serial)
name += " (" + drive.Serial + ")";
else if (drive.WWN)
name += " (" + drive.WWN + ")";
return name;
}
export function get_block_link_parts(client, path) {
let is_part, is_crypt, is_lvol;
while (true) {
if (client.blocks_part[path] && client.blocks_ptable[client.blocks_part[path].Table]) {
is_part = true;
path = client.blocks_part[path].Table;
} else if (client.blocks[path] && client.blocks[client.blocks[path].CryptoBackingDevice]) {
is_crypt = true;
path = client.blocks[path].CryptoBackingDevice;
} else
break;
}
if (client.blocks_lvm2[path] && client.lvols[client.blocks_lvm2[path].LogicalVolume])
is_lvol = true;
const block = client.blocks[path];
if (!block)
return;
let location, link;
if (client.mdraids[block.MDRaid]) {
location = ["mdraid", client.mdraids[block.MDRaid].UUID];
link = cockpit.format(_("RAID device $0"), mdraid_name(client.mdraids[block.MDRaid]));
} else if (client.blocks_lvm2[path] &&
client.lvols[client.blocks_lvm2[path].LogicalVolume] &&
client.vgroups[client.lvols[client.blocks_lvm2[path].LogicalVolume].VolumeGroup]) {
const target = client.vgroups[client.lvols[client.blocks_lvm2[path].LogicalVolume].VolumeGroup].Name;
location = ["vg", target];
link = cockpit.format(_("LVM2 volume group $0"), target);
} else {
const vdo = client.legacy_vdo_overlay.find_by_block(block);
if (vdo) {
location = ["vdo", vdo.name];
link = cockpit.format(_("VDO device $0"), vdo.name);
} else {
location = [block_name(block).replace(/^\/dev\//, "")];
if (client.drives[block.Drive])
link = drive_name(client.drives[block.Drive]);
else
link = block_name(block);
}
}
// Partitions of logical volumes are shown as just logical volumes.
let format;
if (is_lvol && is_crypt)
format = _("Encrypted logical volume of $0");
else if (is_part && is_crypt)
format = _("Encrypted partition of $0");
else if (is_lvol)
format = _("Logical volume of $0");
else if (is_part)
format = _("Partition of $0");
else if (is_crypt)
format = _("Encrypted $0");
else
format = "$0";
return {
location: location,
format: format,
link: link
};
}
export function go_to_block(client, path) {
const parts = get_block_link_parts(client, path);
cockpit.location.go(parts.location);
}
export function get_partitions(client, block) {
const partitions = client.blocks_partitions[block.path];
function process_level(level, container_start, container_size) {
let n;
let last_end = container_start;
const total_end = container_start + container_size;
let block, start, size, is_container, is_contained;
const result = [];
function append_free_space(start, size) {
// There is a lot of rounding and aligning going on in
// the storage stack. All of udisks2, libblockdev,
// and libparted seem to contribute their own ideas of
// where a partition really should start.
//
// The start of partitions are aggressively rounded
// up, sometimes twice, but the end is not aligned in
// the same way. This means that a few megabytes of
// free space will show up between partitions.
//
// We hide these small free spaces because they are
// unexpected and can't be used for anything anyway.
//
// "Small" is anything less than 3 MiB, which seems to
// work okay. (The worst case is probably creating
// the first logical partition inside a extended
// partition with udisks+libblockdev. It leads to a 2
// MiB gap.)
if (size >= 3 * 1024 * 1024) {
result.push({ type: 'free', start: start, size: size });
}
}
for (n = 0; n < partitions.length; n++) {
block = client.blocks[partitions[n].path];
start = partitions[n].Offset;
size = partitions[n].Size;
is_container = partitions[n].IsContainer;
is_contained = partitions[n].IsContained;
if (block === null)
continue;
if (level === 0 && is_contained)
continue;
if (level == 1 && !is_contained)
continue;
if (start < container_start || start + size > container_start + container_size)
continue;
append_free_space(last_end, start - last_end);
if (is_container) {
result.push({
type: 'container',
block: block,
size: size,
partitions: process_level(level + 1, start, size)
});
} else {
result.push({ type: 'block', block: block });
}
last_end = start + size;
}
append_free_space(last_end, total_end - last_end);
return result;
}
return process_level(0, 0, block.Size);
}
export function get_available_spaces(client) {
function is_free(path) {
const block = client.blocks[path];
const block_ptable = client.blocks_ptable[path];
const block_part = client.blocks_part[path];
const block_pvol = client.blocks_pvol[path];
function has_fs_label() {
if (!block.IdUsage)
return false;
// Devices with a LVM2_member label need to actually be
// associated with a volume group.
if (block.IdType == 'LVM2_member' && (!block_pvol || !client.vgroups[block_pvol.VolumeGroup]))
return false;
return true;
}
function is_mpath_member() {
if (!client.drives[block.Drive])
return false;
if (!client.drives_block[block.Drive]) {
// Broken multipath drive
return true;
}
const members = client.drives_multipath_blocks[block.Drive];
for (let i = 0; i < members.length; i++) {
if (members[i] == block)
return true;
}
return false;
}
function is_vdo_backing_dev() {
return !!client.legacy_vdo_overlay.find_by_backing_block(block);
}
return (!block.HintIgnore &&
block.Size > 0 &&
!has_fs_label() &&
!is_mpath_member() &&
!is_vdo_backing_dev() &&
!block_ptable &&
!(block_part && block_part.IsContainer));
}
function make(path) {
const block = client.blocks[path];
const parts = get_block_link_parts(client, path);
const text = cockpit.format(parts.format, parts.link);
return { type: 'block', block: block, size: block.Size, desc: text };
}
const spaces = Object.keys(client.blocks).filter(is_free)
.sort(make_block_path_cmp(client))
.map(make);
function add_free_spaces(block) {
const parts = get_partitions(client, block);
let i, p, link_parts, text;
for (i in parts) {
p = parts[i];
if (p.type == 'free') {
link_parts = get_block_link_parts(client, block.path);
text = cockpit.format(link_parts.format, link_parts.link);
spaces.push({
type: 'free',
block: block,
start: p.start,
size: p.size,
desc: cockpit.format(_("unpartitioned space on $0"), text)
});
}
}
}
for (const p in client.blocks_ptable)
add_free_spaces(client.blocks[p]);
return spaces;
}
export function prepare_available_spaces(client, spcs) {
function prepare(spc) {
if (spc.type == 'block')
return cockpit.resolve(spc.block.path);
else if (spc.type == 'free') {
const block_ptable = client.blocks_ptable[spc.block.path];
return block_ptable.CreatePartition(spc.start, spc.size, "", "", { });
}
}
return Promise.all(spcs.map(prepare));
}
export function is_snap(client, block) {
const block_fsys = client.blocks_fsys[block.path];
return block_fsys && block_fsys.MountPoints.map(decode_filename).some(mp => mp.indexOf("/snap/") == 0 || mp.indexOf("/var/lib/snapd/snap/") == 0);
}
export function get_other_devices(client) {
return Object.keys(client.blocks).filter(path => {
const block = client.blocks[path];
const block_part = client.blocks_part[path];
const block_lvm2 = client.blocks_lvm2[path];
return ((!block_part || block_part.Table == "/") &&
block.Drive == "/" &&
block.CryptoBackingDevice == "/" &&
block.MDRaid == "/" &&
(!block_lvm2 || block_lvm2.LogicalVolume == "/") &&
!block.HintIgnore &&
block.Size > 0 &&
!client.legacy_vdo_overlay.find_by_block(block) &&
!client.blocks_stratis_fsys[block.path] &&
!is_snap(client, block));
});
}
/* Comparison function for sorting lists of block devices.
We sort by major:minor numbers to get the expected order when
there are more than 10 devices of a kind. For example, if you
have 20 loopback devices named loop0 to loop19, sorting them
alphabetically would put them in the wrong order
loop0, loop1, loop10, loop11, ..., loop2, ...
Sorting by major:minor is an easy way to do the right thing.
*/
export function block_cmp(a, b) {
return a.DeviceNumber - b.DeviceNumber;
}
export function make_block_path_cmp(client) {
return function(path_a, path_b) {
return block_cmp(client.blocks[path_a], client.blocks[path_b]);
};
}
let multipathd_service;
export function get_multipathd_service () {
if (!multipathd_service)
multipathd_service = service.proxy("multipathd");
return multipathd_service;
}
export function get_parent(client, path) {
if (client.blocks_part[path] && client.blocks[client.blocks_part[path].Table])
return client.blocks_part[path].Table;
if (client.blocks[path] && client.blocks[client.blocks[path].CryptoBackingDevice])
return client.blocks[path].CryptoBackingDevice;
if (client.blocks[path] && client.drives[client.blocks[path].Drive])
return client.blocks[path].Drive;
if (client.blocks[path] && client.mdraids[client.blocks[path].MDRaid])
return client.blocks[path].MDRaid;
if (client.blocks_lvm2[path] && client.lvols[client.blocks_lvm2[path].LogicalVolume])
return client.blocks_lvm2[path].LogicalVolume;
if (client.lvols[path] && client.vgroups[client.lvols[path].VolumeGroup])
return client.lvols[path].VolumeGroup;
if (client.blocks_stratis_fsys[path])
return client.blocks_stratis_fsys[path].Pool;
}
export function get_direct_parent_blocks(client, path) {
let parent = get_parent(client, path);
if (!parent)
return [];
if (client.blocks[parent])
return [parent];
if (client.mdraids[parent])
return client.mdraids_members[parent].map(function (m) { return m.path });
if (client.lvols[parent])
parent = client.lvols[parent].VolumeGroup;
if (client.vgroups[parent])
return client.vgroups_pvols[parent].map(function (pv) { return pv.path });
if (client.stratis_pools[parent])
return client.stratis_pool_blockdevs[parent].map(bd => client.slashdevs_block[bd.Devnode].path);
return [];
}
export function get_parent_blocks(client, path) {
const direct_parents = get_direct_parent_blocks(client, path);
const direct_and_indirect_parents = flatten(direct_parents.map(function (p) {
return get_parent_blocks(client, p);
}));
return [path].concat(direct_and_indirect_parents);
}
export function is_netdev(client, path) {
const block = client.blocks[path];
const drive = block && client.drives[block.Drive];
if (drive && drive.Vendor == "LIO-ORG")
return true;
if (block && block.Major == 43) // NBD
return true;
return false;
}
function get_children(client, path) {
const children = [];
if (client.blocks_cleartext[path]) {
children.push(client.blocks_cleartext[path].path);
}
if (client.blocks_ptable[path]) {
client.blocks_partitions[path].forEach(function (part) {
if (!part.IsContainer)
children.push(part.path);
});
}
if (client.blocks_part[path] && client.blocks_part[path].IsContainer) {
const ptable_path = client.blocks_part[path].Table;
client.blocks_partitions[ptable_path].forEach(function (part) {
if (part.IsContained)
children.push(part.path);
});
}
if (client.vgroups[path]) {
client.vgroups_lvols[path].forEach(function (lvol) {
if (client.lvols_block[lvol.path])
children.push(client.lvols_block[lvol.path].path);
});
}
if (client.lvols_pool_members[path]) {
for (const lvol of client.lvols_pool_members[path]) {
const block = client.lvols_block[lvol.path];
if (block)
children.push(block.path);
}
}
if (client.stratis_pools[path]) {
client.stratis_pool_filesystems[path].forEach(function (fsys) {
const block = client.slashdevs_block[fsys.Devnode];
if (block)
children.push(block.path);
});
}
return children;
}
export function get_active_usage(client, path, top_action, child_action) {
function get_usage(path, level) {
const block = client.blocks[path];
const fsys = client.blocks_fsys[path];
const mdraid = block && client.mdraids[block.MDRaidMember];
const pvol = client.blocks_pvol[path];
const vgroup = pvol && client.vgroups[pvol.VolumeGroup];
const vdo = block && client.legacy_vdo_overlay.find_by_backing_block(block);
const stratis_blockdev = block && client.blocks_stratis_blockdev[path];
const stratis_pool = stratis_blockdev && client.stratis_pools[stratis_blockdev.Pool];
const usage = flatten(get_children(client, path).map(p => get_usage(p, level + 1)));
function get_actions(teardown_action) {
const actions = [];
if (teardown_action)
actions.push(teardown_action);
const global_action = (level == 0 || (block && client.blocks[block.CryptoBackingDevice] && level == 1)) ? top_action : child_action || top_action;
if (global_action)
actions.push(global_action);
return actions;
}
if (fsys && fsys.MountPoints.length > 0) {
fsys.MountPoints.forEach(mp => {
usage.push({
level: level,
usage: 'mounted',
block: block,
location: decode_filename(mp),
actions: get_actions(_("unmount")),
blocking: false,
});
});
} else if (mdraid) {
const active_state = array_find(mdraid.ActiveDevices, function (as) {
return as[0] == block.path;
});
usage.push({
level: level,
usage: 'mdraid-member',
block: block,
mdraid: mdraid,
location: mdraid_name(mdraid.Name),
actions: get_actions(_("remove from RAID")),
blocking: !(active_state && active_state[1] < 0)
});
} else if (vgroup) {
usage.push({
level: level,
usage: 'pvol',
block: block,
vgroup: vgroup,
pvol: pvol,
location: vgroup.Name,
actions: get_actions(_("remove from LVM2")),
blocking: pvol.FreeSize != pvol.Size
});
} else if (vdo) {
usage.push({
level: level,
usage: 'vdo-backing',
block: block,
vdo: vdo,
location: vdo.name,
blocking: true
});
} else if (stratis_pool) {
usage.push({
level: level,
usage: 'stratis-pool-member',
block: block,
stratis_pool: stratis_pool,
location: stratis_pool.Name,
blocking: true
});
} else if (block && !client.blocks_cleartext[block.path]) {
usage.push({
level: level,
usage: 'none',
block: block,
actions: get_actions(null),
blocking: false
});
}
return usage;
}
let usage = get_usage(path, 0);
if (usage.length == 1 && usage[0].level == 0 && usage[0].usage == "none")
usage = [];
usage.Blocking = usage.some(u => u.blocking);
usage.Teardown = usage.some(u => !u.blocking);
return usage;
}
export function teardown_active_usage(client, usage) {
// The code below is complicated by the fact that the last
// physical volume of a volume group can not be removed
// directly (even if it is completely empty). We want to
// remove the whole volume group instead in this case.
//
// However, we might be removing the last two (or more)
// physical volumes here, and it is easiest to catch this
// condition upfront by reshuffling the data structures.
function unmount(mounteds) {
return Promise.all(mounteds.map(m => {
return client.unmount_at(m.location, m.users);
}));
}
function mdraid_remove(members) {
return Promise.all(members.map(m => m.mdraid.RemoveDevice(m.block.path, { wipe: { t: 'b', v: true } })));
}
function pvol_remove(pvols) {
const by_vgroup = { };
pvols.forEach(function (p) {
if (!by_vgroup[p.vgroup.path])
by_vgroup[p.vgroup.path] = [];
by_vgroup[p.vgroup.path].push(p.block);
});
function handle_vg(p) {
const vg = client.vgroups[p];
const pvs = by_vgroup[p];
// If we would remove all physical volumes of a volume
// group, remove the whole volume group instead.
if (pvs.length == client.vgroups_pvols[p].length) {
return vg.Delete(true, { 'tear-down': { t: 'b', v: true } }).then(reload_systemd);
} else {
return Promise.all(pvs.map(pv => vg.RemoveDevice(pv.path, true, {})));
}
}
return Promise.all(Object.keys(by_vgroup).map(handle_vg));
}
return Promise.all(Array.prototype.concat(
unmount(usage.filter(function(use) { return use.usage == "mounted" })),
mdraid_remove(usage.filter(function(use) { return use.usage == "mdraid-member" })),
pvol_remove(usage.filter(function(use) { return use.usage == "pvol" }))
));
}
// TODO - generalize this to arbitrary number of arguments (when needed)
export function fmt_to_array(fmt, arg) {
const index = fmt.indexOf("$0");
if (index >= 0)
return [fmt.slice(0, index), arg, fmt.slice(index + 2)];
else
return [fmt];
}
export function reload_systemd() {
return cockpit.spawn(["systemctl", "daemon-reload"], { superuser: "require", err: "message" });
}
export function is_mounted_synch(block) {
return (cockpit.spawn(["findmnt", "-n", "-o", "TARGET", "-S", decode_filename(block.Device)],
{ superuser: true, err: "message" })
.then(data => data.trim())
.catch(() => false));
}
export function for_each_async(arr, func) {
return arr.reduce((promise, elt) => promise.then(() => func(elt)), Promise.resolve());
}