Skip to content

Commit

Permalink
WiP
Browse files Browse the repository at this point in the history
  • Loading branch information
jelly committed Jul 18, 2024
1 parent 29f40b5 commit 517ef66
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 35 deletions.
30 changes: 30 additions & 0 deletions pkg/storaged/btrfs/get-path-uuid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import os
import os.path
import subprocess
import sys


def main(path):
# If the path does not exist, we will create but need to verify it lives on the same volume
if not os.path.exists(path):
if path.endswith('/'):
path = path.rstrip('/')
path = os.path.dirname(path)

# bail out if the parent path is not found
if not os.path.exists(path):
sys.exit(2)

try:
sys.stdout.write(subprocess.check_output(["findmnt", "--output", "UUID,fstype", "--json", "--target", path]).decode().strip())
except subprocess.SubprocessError as exc:
print(exc, file=sys.stderr)
sys.exit(3)


if __name__ == "__main__":
if len(sys.argv) != 2:
sys.stderr.write("Path not provided\n")
sys.exit(1)

main(sys.argv[1])
116 changes: 84 additions & 32 deletions pkg/storaged/btrfs/subvolume.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ import {
get_fstab_config_with_client, reload_systemd, extract_option, parse_options,
flatten, teardown_active_usage,
} from "../utils.js";
import { btrfs_usage, validate_subvolume_name, parse_subvol_from_options, validate_snapshot_path } from "./utils.jsx";
import { btrfs_usage, validate_subvolume_name, parse_subvol_from_options, validate_snapshots_location } from "./utils.jsx";
import { at_boot_input, update_at_boot_input, mounting_dialog, mount_options } from "../filesystem/mounting-dialog.jsx";
import {
dialog_open, TextInput, CheckBoxes,
TeardownMessage, init_teardown_usage,
SelectOneRadioVerticalTextInput,
} from "../dialog.jsx";
import { check_mismounted_fsys, MismountAlert } from "../filesystem/mismounting.jsx";
import {
Expand Down Expand Up @@ -180,60 +181,111 @@ function subvolume_create(volume, subvol, parent_dir) {
});
}

function snapshot_create(volume, subvol, parent_dir) {
async function snapshot_create(volume, subvol, parent_dir) {
const block = client.blocks[volume.path];

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable block.

let action_variants = [
{ tag: null, Title: _("Create and mount") },
{ tag: "nomount", Title: _("Create only") }
console.log(volume, subvol);
const action_variants = [
{ tag: null, Title: _("Create snapshot") },
];

if (client.in_anaconda_mode()) {
action_variants = [
{ tag: "nomount", Title: _("Create") }
];
}
const getCurrentDate = async () => {
const out = await cockpit.spawn(["date", "+%s"]);
const now = parseInt(out.trim()) * 1000;
const d = new Date(now);
d.setSeconds(0);
d.setMilliseconds(0);
return d;
};

const folder_exists = async (path) => {
// Check if path does not exist and can be created
try {
await cockpit.spawn(["test", "-d", path]);
return true;
} catch {
return false;
}
};

const date = await getCurrentDate();
const current_date = date.toISOString().split("T")[0];
const current_date_time = date.toISOString().replace(":00.000Z", "");
const choices = [
{
value: "current_date",
title: cockpit.format(_("Current date $0"), current_date),
},
{
value: "current_date_time",
title: cockpit.format(_("Current date and time $0"), current_date_time),
},
{
value: "custom_name",
title: _("Custom name"),
type: "radioWithInput",
},
];

dialog_open({
Title: _("Create snapshot"),
Fields: [
TextInput("path", _("Path"),
TextInput("subvolume", _("Subvolume"),
{
value: subvol.pathname,
disabled: true,
}),
TextInput("snapshots_location", _("Snapshots location"),
{
validate: path => validate_snapshot_path(path)
placeholder: cockpit.format(_("Example, $0"), "/.snapshots"),
explanation: _("Snapshots must reside within their subvolume."),
validate: path => validate_snapshots_location(path, volume),
}),
CheckBoxes("readonly", _("Read-only"),
SelectOneRadioVerticalTextInput("snapshot_name", _("Snapshot name"),
{
value: { checked: "current_date", inputs: { } },
choices,
validate: (val, _values, _variant) => {
if (val.checked === "custom_name")
return validate_subvolume_name(val.inputs.custom_name);
}
}),

CheckBoxes("readonly", _("Option"),
{
fields: [
{ tag: "on", title: _("Make the new snapshot readonly") }
{ tag: "on", title: _("Read-only") }
],
}),
TextInput("mount_point", _("Mount Point"),
{
validate: (val, _values, variant) => {
return is_valid_mount_point(client,
block,
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) {
// Create snapshot location if it does not exists
console.log("values", vals);
const exists = await folder_exists(vals.snapshots_location);
if (!exists) {
await cockpit.spawn(["btrfs", "subvolume", "create", vals.snapshots_location], { superuser: "require", err: "message" });
}

// HACK: cannot use block_btrfs.CreateSnapshot as it always creates a subvolume relative to MountPoints[0] which
// makes it impossible to handle a situation where we have multiple subvolumes mounted.
// https://github.com/storaged-project/udisks/issues/1242
const cmd = ["btrfs", "subvolume", "snapshot"];
if (vals.readonly)
if (vals.readonly?.on)
cmd.push("-r");
await cockpit.spawn([...cmd, parent_dir, vals.path], { superuser: "require", err: "message" });
await btrfs_poll();
if (vals.mount_point !== "") {
await set_mount_options(subvol, block, vals);
let snapshot_name = "";
if (vals.snapshot_name.checked == "current_date") {
snapshot_name = current_date;
} else if (vals.snapshot_name.checked === "current_date_time") {
snapshot_name = current_date_time;
} else if (vals.snapshot_name.checked === "custom_name") {
snapshot_name = vals.snapshot_name.inputs.custom_name;
}
console.log([...cmd, `/${subvol.pathname}`, `${vals.snapshots_location}/${snapshot_name}`]);
// TODO: need full path to subvolume
// ERROR: cannot snapshot '/home': Read-only file system
// This happens when a snapshot already exists!
await cockpit.spawn([...cmd, `/${subvol.pathname}`, `${vals.snapshots_location}/${snapshot_name}`], { superuser: "require", err: "message" });
}
}
});
Expand Down
44 changes: 42 additions & 2 deletions pkg/storaged/btrfs/utils.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import cockpit from "cockpit";

import { decode_filename } from "../utils.js";
import * as python from "python.js";
import get_path_uuid from "./get-path-uuid.py";

const _ = cockpit.gettext;

Expand Down Expand Up @@ -89,7 +91,45 @@ export function validate_subvolume_name(name) {
return cockpit.format(_("Name cannot contain the character '/'."));
}

export function validate_snapshot_path(path) {
export async function validate_snapshots_location(path, volume) {
if (path === "")
return _("Path cannot be empty.");
return _("Location cannot be empty.");

try {
const output = await python.spawn([get_path_uuid], [path],
{ environ: ["LANGUAGE=" + (cockpit.language || "en")] });
console.log(output);
const path_info = JSON.parse(output);
if (path_info.filesystems.length !== 1)
return _("Unable to detect filesystem for given path");

const fs = path_info.filesystems[0];
if (fs.fstype !== "btrfs")
return _("Provided path is not btrfs");

if (fs.uuid !== volume.data.uuid)
return _("Snapshot location needs to be on the same btrfs volume");
} catch (err) {
if (err.exit_status == 2)
return _("Parent of snapshot location does not exist");
console.warn("Unable to detect UUID of snapshot location", err);
}
// const path_exists = await folder_exists(path);
// // Verify that the parent is in the same btrfs volume
// if (!path_exists) {
// }
//
// try {
// const output = await cockpit.spawn(["findmnt", "-o", "UUID", "-n", "-T", path], { err: "message" });
// const uuid = output.trim();
// if (uuid !== volume.data.uuid) {
// return _("Snapshot location needs to be on the same btrfs volume");
// }
// } catch (err) {
// console.log(err);
// if (err?.message === "") {
// return _("Given path does not exist");
// }
// console.warn("Unable to detect UUID of snapshot location", err);
// }
}
30 changes: 30 additions & 0 deletions pkg/storaged/btrfs/verify-btrfs-snapshot-location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import os
import os.path
import subprocess
import sys


def main(path):
# If the path does not exist, we will create but need to verify it lives on the same volume
if not os.path.exists(path):
if path.endswith('/'):
path = path.rstrip('/')
path = os.path.dirname(path)

# bail out if the parent path is not found
if not os.path.exists(path):
sys.exit(2)

try:
print(subprocess.check_output(["findmnt", "--output", "UUID", "--no-heading", "--target", path]))
except subprocess.SubprocessError as exc:
print(exc, file=sys.stderr)
sys.exit(3)


if __name__ == "__main__":
if len(sys.argv) != 2:
sys.stdout.write("Path not provided\n")
sys.exit(1)

main(sys.argv[1])
45 changes: 44 additions & 1 deletion pkg/storaged/dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ const Row = ({ field, values, errors, onChange }) => {
);
} else if (!field.bare) {
return (
<FormGroup validated={validated} hasNoPaddingTop={field.hasNoPaddingTop}>
<FormGroup isInline={field.isInline || false} validated={validated} hasNoPaddingTop={field.hasNoPaddingTop}>
{ field_elts }
{ nested_elts }
<FormHelper helperText={explanation} helperTextInvalid={validated && error} />
Expand Down Expand Up @@ -601,6 +601,7 @@ export const TextInput = (tag, title, options) => {
title,
options,
initial_value: options.value || "",
isInline: options.isInline || false,

render: (val, change, validated) =>
<TextInputPF4 data-field={tag} data-field-type="text-input"
Expand Down Expand Up @@ -752,6 +753,48 @@ export const SelectOneRadioVertical = (tag, title, options) => {
};
};

export const SelectOneRadioVerticalTextInput = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || { checked: {}, inputs: {} },
hasNoPaddingTop: true,

render: (val, change) => {
const fieldset = options.choices.map(c => {
const ftag = tag + "." + c.value;
const fval = val.checked === c.value;
const tval = val.inputs[c.value] || '';
function fchange(newval) {
val.checked = newval;
change(val);
}

function tchange(newval) {
val.inputs[c.value] = newval;
change(val);
}

return (
<React.Fragment key={c.value}>
<Radio isChecked={fval} data-data={fval}
id={ftag}
onChange={() => fchange(c.value)} label={c.title} />
{fval !== false && c?.type === "radioWithInput" && <TextInputPF4 id={ftag + "-input"} value={tval} onChange={(_event, value) => tchange(value)} />}
</React.Fragment>
);
});

return (
<div data-field={tag} data-field-type="select-radio">
{fieldset}
</div>
);
}
};
};

export const SelectRow = (tag, headers, options) => {
return {
tag,
Expand Down

0 comments on commit 517ef66

Please sign in to comment.