/*
* Copyright (C) 2023 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React from "react";
import client from "../client";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { CardHeader, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { Table, Tbody, Tr, Td } from '@patternfly/react-table';
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { VolumeIcon } from "../icons/gnome-icons.jsx";
import { fmt_to_fragments } from "utils.jsx";
import { StorageButton, StorageUsageBar, StorageLink, StorageOnOff } from "../storage-controls.jsx";
import {
StorageCard, StorageDescription, ChildrenTable, PageTable, Actions,
new_page, new_card, PAGE_CATEGORY_VIRTUAL,
get_crossrefs, navigate_away_from_card
} from "../pages.jsx";
import {
get_active_usage, teardown_active_usage, for_each_async,
get_available_spaces, prepare_available_spaces,
decode_filename, should_ignore,
} from "../utils.js";
import {
dialog_open, SelectSpaces, TextInput, PassInput, SelectOne, SizeSlider, CheckBoxes, Group, Message,
BlockingMessage, TeardownMessage,
init_teardown_usage
} from "../dialog.jsx";
import { validate_url, get_tang_adv } from "../crypto/tang.jsx";
import { is_valid_mount_point } from "../filesystem/utils.jsx";
import { at_boot_input, update_at_boot_input, mount_options } from "../filesystem/mounting-dialog.jsx";
import {
validate_pool_name, std_reply, with_stored_passphrase,
confirm_tang_trust,
validate_fs_name, set_mount_options, destroy_filesystem
} from "./utils.jsx";
import { make_stratis_filesystem_page } from "./filesystem.jsx";
const _ = cockpit.gettext;
const fsys_min_size = 512 * 1024 * 1024;
const fsys_round_size = 1024 * 1024;
/* Abstractions over the r6 and r8 API revisions, and V1 and V2 pool
* formats.
*/
/* Key descriptions are returned as an array with elements of type
*
* { slot: number | null, keydesc: string }
*
* A V1 pool will have at most one entry in the array, and it will
* have `slot == null`. V2 pools have zero or more entries with
* numbered slots.
*/
function get_key_descriptions(pool) {
const result = [];
if (!pool.Encrypted)
return result;
if (client.stratis_interface_revision < 8) {
const key_desc = pool.KeyDescription;
/* The KeyDescription property has type "(b(bs))".
The first "b" is false when the encryption of the pool is
inconsistent (not all block devices have the same LUKS
header). We pretend that the pool doesn't have any
passphrase in that case.
The second "b" is true when the pool has a passphrase, and
the "s" is the description of the key for it.
Thus, we detect an object of shape
[ true, [ true, "..." ] ]
and fish the string out of it.
*/
if (key_desc[0] && key_desc[1][0])
result.push({ slot: null, keydesc: key_desc[1][1] });
} else {
let key_descs = pool.KeyDescriptions;
/* The KeyDescriptions property itself has type "v". For a V1
pool this variant carries a value of type "b(bs)" just as
with the r6 API (see above). For a V2 pool, it carries a
"a(us)" value, an array of "slot number plus key
description".
Since the property itself has type "v", we need to unwrap
this variant explicitly.
*/
/* HACK - https://github.com/stratis-storage/stratisd/issues/3895
Introduced in stratisd 3.8.0, fixed in stratisd 3.8.3.
Change notifications drop the variant wrapping for some
reason, so we only do the unwrapping when "key_descs" is
indeed a variant.
*/
if ("t" in key_descs)
key_descs = key_descs.v;
if (pool.MetadataVersion === 1) {
if (key_descs[0] && key_descs[1][0])
result.push({ slot: null, keydesc: key_descs[1][1] });
} else if (pool.MetadataVersion === 2) {
for (const key_desc of key_descs)
result.push({ slot: key_desc[0], keydesc: key_desc[1] });
}
}
return result;
}
/* Clevis information is returned as an array with elements of type
*
* { slot: number | null, pin: "tang", url: string }
* | { slot: number | null, pin: string }
*
* A V1 pool will have at most one entry in the array, and it will
* have `slot == null`. V2 pools have zero or more entries with
* numbered slots.
*/
function get_clevis_infos(pool) {
const result = [];
if (!pool.Encrypted)
return result;
if (client.stratis_interface_revision < 8) {
const clevis_info = pool.ClevisInfo;
/* The ClevisInfo property has type "(b(b(ss)))".
The first "b" is false when the encryption of the pool is
inconsistent (not all block devices have the same LUKS
header). We pretend that the pool doesn't have any Clevis
infos in that case.
The second "b" is true when the pool has a Clevis info, and
the two "s" are the pin and its JSON encoded configuration,
respectively.
Thus, we detect an object of shape
[ true, [ true, [ "...", "..." ] ] ]
and fish the strings out of it.
Cockpit only knows about the "tang" pin in detail, so we
detect that as well and only then decode the configuration
in order to extract the url from it.
*/
if (clevis_info[0] && clevis_info[1][0]) {
if (clevis_info[1][1][0] === "tang") {
const config = JSON.parse(clevis_info[1][1][1]);
result.push({ slot: null, pin: "tang", url: config.url });
} else {
result.push({ slot: null, pin: clevis_info[1][1][0] });
}
}
} else {
let clevis_infos = pool.ClevisInfos;
/* The ClevisInfos property itself has type "v". For a V1 pool
this variant carries a value of type "b(b(ss)" just as with
the r6 API (see above). For a V2 pool, it carries a "a(u(ss))"
value, an array of "slot number plus pin-plus-config".
Since the property itself has type "v", we need to unwrap
this variant explicitly.
*/
/* HACK - https://github.com/stratis-storage/stratisd/issues/3895
Introduced in stratisd 3.8.0, fixed in stratisd 3.8.3.
Change notifications drop the variant wrapping for some
reason, so we only do the unwrapping when "clevis_infos" is
indeed a variant.
*/
if ("t" in clevis_infos)
clevis_infos = clevis_infos.v;
if (pool.MetadataVersion === 1) {
if (clevis_infos[0] && clevis_infos[1][0]) {
if (clevis_infos[1][1][0] === "tang") {
const config = JSON.parse(clevis_infos[1][1][1]);
result.push({ slot: null, pin: "tang", url: config.url });
} else {
result.push({ slot: null, pin: clevis_infos[1][1][0] });
}
}
} else if (pool.MetadataVersion === 2) {
for (const clevis_info of clevis_infos) {
if (clevis_info[1][0] === "tang") {
const config = JSON.parse(clevis_info[1][1]);
result.push({ slot: clevis_info[0], pin: "tang", url: config.url });
} else {
result.push({ slot: clevis_info[0], pin: clevis_info[1][0] });
}
}
}
}
return result;
}
function bind_keyring(pool, keydesc) {
if (client.stratis_interface_revision < 8)
return pool.BindKeyring(keydesc);
else
return pool.BindKeyring(keydesc, [false, 0]);
}
function rebind_keyring(pool, keydesc, slot) {
if (client.stratis_interface_revision < 8)
return pool.RebindKeyring(keydesc);
else
return pool.RebindKeyring(keydesc, [slot !== null, slot || 0]);
}
function unbind_keyring(pool, slot) {
if (client.stratis_interface_revision < 8)
return pool.UnbindKeyring();
else
return pool.UnbindKeyring([slot !== null, slot || 0]);
}
function bind_clevis(pool, pin, config) {
if (client.stratis_interface_revision < 8)
return pool.BindClevis(pin, config);
else
return pool.BindClevis(pin, config, [false, 0]);
}
function unbind_clevis(pool, slot) {
if (client.stratis_interface_revision < 8)
return pool.UnbindClevis();
else
return pool.UnbindClevis([slot !== null, slot || 0]);
}
/* Utilities for key descriptions and passphrases
*/
export async function get_stored_keydescs() {
return await client.stratis_manager.ListKeys().catch(() => []);
}
async function get_new_keydesc(pool) {
const key_descs = get_key_descriptions(pool);
const stored_keydescs = await get_stored_keydescs();
// Let's arbitrarily stop after 1000 tries to avoid excessive
// looping. That shouldn't happen, but...
let desc;
for (let i = 0; i < 1000; i++) {
desc = pool.Name + (i > 0 ? "." + i.toFixed() : "");
if (!key_descs.find(kd => kd.keydesc === desc) && !stored_keydescs.includes(desc))
break;
}
return desc;
}
function PoolPassphrase(tag, pool, main, stored_keydescs, force) {
const all_key_descs = get_key_descriptions(pool);
const clevis_infos = get_clevis_infos(pool);
const available_key_descs = all_key_descs.filter(kd => !stored_keydescs.includes(kd.keydesc));
const can_use_passphrase = available_key_descs.length > 0;
const have_stored_passphrase = all_key_descs.length > available_key_descs.length;
const need_passphrase = (force || clevis_infos.length === 0) && !have_stored_passphrase;
const single_tang_url = (clevis_infos.length === 1 && clevis_infos[0].pin === "tang" && !have_stored_passphrase && clevis_infos[0].url);
const only_tang = clevis_infos.every(ci => ci.pin === "tang") && !have_stored_passphrase;
if (can_use_passphrase) {
let extra_explanation;
if (need_passphrase) {
extra_explanation = _("Please provide an existing pool passphrase.");
} else if (single_tang_url) {
extra_explanation = cockpit.format(_("If the keyserver at $0 is not reachable, you can provide an existing passphrase."), single_tang_url);
} else if (only_tang) {
extra_explanation = _("If none of the keyservers is reachable, you can provide an existing passphrase.");
} else {
/* Clevis other than "tang" and/or passphrases already in the keyring.
*/
extra_explanation = _("If none of the non-interactive unlock methods works, you can provide an existing passphrase.");
}
return PassInput(tag, _("Pool passphrase"), {
validate: val => need_passphrase && !val.length && _("Passphrase cannot be empty"),
explanation: main + " " + extra_explanation,
});
} else if (single_tang_url) {
return Message(main + " " + cockpit.format(_("The keyserver at $0 must be reachable."), single_tang_url));
} else if (only_tang) {
return Message(main + " " + _("At least one keyserver must be reachable."));
} else {
/* Clevis other than "tang" and/or passphrases already in the keyring.
*/
return Message(main + " " + _("At least one of the non-interactive unlock methods must work."));
}
}
async function with_pool_passphrase(pool, passphrase, func) {
const stored_keydescs = await get_stored_keydescs();
const key_descs = get_key_descriptions(pool).filter(kd => !stored_keydescs.includes(kd.key_descs));
if (!passphrase || key_descs.length === 0)
return func();
let err;
for (const kd of key_descs) {
try {
return await with_stored_passphrase(client, kd.keydesc, passphrase, func);
} catch (e) {
err = e;
}
}
throw err;
}
/* Operations
*/
function destroy_pool(pool) {
return for_each_async(client.stratis_pool_filesystems[pool.path], fsys => destroy_filesystem(fsys))
.then(() => client.stratis_manager.DestroyPool(pool.path).then(std_reply));
}
function create_fs(pool) {
const filesystems = client.stratis_pool_filesystems[pool.path];
const stats = client.stratis_pool_stats[pool.path];
const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"];
let action_variants;
if (!client.in_anaconda_mode()) {
action_variants = [
{ tag: null, Title: _("Create and mount") },
{ tag: "nomount", Title: _("Create only") },
];
} else {
action_variants = [
{ tag: "nomount", Title: _("Create") },
];
}
dialog_open({
Title: _("Create filesystem"),
Fields: [
TextInput("name", _("Name"),
{
validate: name => validate_fs_name(null, name, filesystems)
}),
Group(_("Stratis filesystem"), [
CheckBoxes("set_custom_size", null,
{
value: {
enabled: !pool.Overprovisioning,
},
fields: [
{ tag: "enabled", title: _("Set initial size") },
]
}),
SizeSlider("size", null,
{
visible: vals => vals.set_custom_size.enabled,
min: fsys_min_size,
max: pool.Overprovisioning ? stats.pool_total : stats.pool_free,
allow_infinite: pool.Overprovisioning,
round: fsys_round_size,
}),
CheckBoxes("set_custom_limit", null,
{
value: {
enabled: false,
},
fields: [
{ tag: "enabled", title: _("Limit size") },
]
}),
SizeSlider("limit", null,
{
visible: vals => vals.set_custom_limit.enabled,
min: fsys_min_size,
max: pool.Overprovisioning ? stats.pool_total : stats.pool_free,
allow_infinite: true,
round: fsys_round_size,
}),
]),
TextInput("mount_point", _("Mount point"),
{
validate: (val, values, variant) => {
return is_valid_mount_point(client,
null,
client.add_mount_point_prefix(val),
variant === "nomount");
}
}),
mount_options(false, false),
at_boot_input(),
],
update: update_at_boot_input,
Action: {
Variants: action_variants,
action: async function (vals) {
let size_spec = [false, ""]; let limit_spec = [false, ""];
if (vals.set_custom_size.enabled)
size_spec = [true, vals.size.toString()];
if (vals.set_custom_limit.enabled)
limit_spec = [true, vals.limit.toString()];
const result = await pool.CreateFilesystems([[vals.name, size_spec, limit_spec]]).then(std_reply);
if (result[0])
await set_mount_options(result[1][0][0], vals, forced_options);
}
}
});
}
function delete_pool(pool, card) {
const usage = get_active_usage(client, pool.path, _("delete"));
if (usage.Blocking) {
dialog_open({
Title: cockpit.format(_("$0 is in use"),
pool.Name),
Body: BlockingMessage(usage)
});
return;
}
dialog_open({
Title: cockpit.format(_("Permanently delete $0?"), pool.Name),
Teardown: TeardownMessage(usage),
Action: {
Danger: _("Deleting a Stratis pool will erase all data it contains."),
Title: _("Delete"),
action: async function () {
await teardown_active_usage(client, usage);
await destroy_pool(pool);
navigate_away_from_card(card);
}
},
Inits: [
init_teardown_usage(client, usage)
]
});
}
function rename_pool(pool) {
dialog_open({
Title: _("Rename Stratis pool"),
Fields: [
TextInput("name", _("Name"),
{
value: pool.Name,
validate: name => validate_pool_name(pool, name)
})
],
Action: {
Title: _("Rename"),
action: function (vals) {
return pool.SetName(vals.name).then(std_reply);
}
}
});
}
async function add_disks(pool) {
const blockdevs = client.stratis_pool_blockdevs[pool.path] || [];
const is_v1_pool = client.stratis_interface_revision < 8 || pool.MetadataVersion === 1;
const stored_keydescs = await get_stored_keydescs();
dialog_open({
Title: _("Add block devices"),
Fields: [
SelectOne("tier", _("Tier"), {
choices: [
{ value: "data", title: _("Data") },
{
value: "cache",
title: _("Cache"),
}
]
}),
SelectSpaces("disks", _("Block devices"), {
empty_warning: _("No disks are available."),
validate: function(disks) {
if (disks.length === 0)
return _("At least one disk is needed.");
},
spaces: get_available_spaces()
}),
...(is_v1_pool
? [PoolPassphrase("pool_passphrase", pool, _("Adding blockdevices requires unlocking the pool."), stored_keydescs, true)]
: []),
],
Action: {
Title: _("Add"),
action: function(vals) {
return prepare_available_spaces(client, vals.disks)
.then(paths => {
const devs = paths.map(p => decode_filename(client.blocks[p].PreferredDevice));
function add() {
if (vals.tier === "data") {
return pool.AddDataDevs(devs).then(std_reply);
} else if (vals.tier === "cache") {
const has_cache = blockdevs.some(bd => bd.Tier === 1);
const method = has_cache ? "AddCacheDevs" : "InitCache";
return pool[method](devs).then(std_reply);
}
}
return with_pool_passphrase(pool, vals.pool_passphrase, add);
});
}
}
});
}
function make_stratis_filesystem_pages(parent, pool) {
const filesystems = client.stratis_pool_filesystems[pool.path];
const stats = client.stratis_pool_stats[pool.path];
const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"];
filesystems.forEach((fs, i) => make_stratis_filesystem_page(parent, pool, fs,
stats.fsys_offsets[i],
forced_options));
}
export function make_stratis_pool_page(parent, pool) {
const degraded_ops = pool.AvailableActions && pool.AvailableActions !== "fully_operational";
const blockdevs = client.stratis_pool_blockdevs[pool.path] || [];
const can_grow = blockdevs.some(bd => (bd.NewPhysicalSize[0] &&
Number(bd.NewPhysicalSize[1]) > Number(bd.TotalPhysicalSize)));
const stats = client.stratis_pool_stats[pool.path];
const use = pool.TotalPhysicalUsed[0] && [Number(pool.TotalPhysicalUsed[1]), Number(pool.TotalPhysicalSize)];
if (should_ignore(client, pool.path))
return;
const pool_card = new_card({
title: pool.Encrypted ? _("Encrypted Stratis pool") : _("Stratis pool"),
next: null,
page_location: ["pool", pool.Uuid],
page_name: pool.Name,
page_icon: VolumeIcon,
page_category: PAGE_CATEGORY_VIRTUAL,
page_size: (use
?
{ fmt_to_fragments(_("Passphrase removal may prevent unlocking $0."), {pool.Name}) }
{ fmt_to_fragments(_("Remove $0?"), {info.pin === "tang" ? info.url : info.pin}) }
{ fmt_to_fragments(_("Removal may prevent unlocking $0."), {pool.Name}) }