From 03b3bbfc165e04c099bbd757c748ccccd03b9641 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Thu, 15 Aug 2024 14:38:59 +0300 Subject: [PATCH] WIP - poll with python This reduces the roundtrips and allows us to maintain "temporary" mount points for btrfs filesystems that are not otherwise mounted. The process will be shutdown when the cockpit session is closed by cockpit-ws, so we get reliable cleanup. --- pkg/storaged/btrfs/btrfs-tool.py | 193 ++++++++++++++++++++++++ pkg/storaged/client.js | 172 ++++++++------------- pkg/storaged/filesystem/mismounting.jsx | 18 ++- pkg/storaged/utils.js | 3 + 4 files changed, 271 insertions(+), 115 deletions(-) create mode 100755 pkg/storaged/btrfs/btrfs-tool.py diff --git a/pkg/storaged/btrfs/btrfs-tool.py b/pkg/storaged/btrfs/btrfs-tool.py new file mode 100755 index 000000000000..618b4a27faa9 --- /dev/null +++ b/pkg/storaged/btrfs/btrfs-tool.py @@ -0,0 +1,193 @@ +#! /usr/bin/python3 + +# btrfs-tool -- Query and monitor btrfs filesystems +# +# This program monitors all btrfs filesystems and reports their +# subvolumes and other things. +# +# It can do that continously, or as a one shot operation. The tool +# mounts btrfs filesystems as necessary to retrieve the requested +# information, but does it in a polite way: they are mounted once and +# then left mounted until that is no longer needed. Typically, you +# might see some mounts when a Cockpit session starts, and the +# corresponding unmounts when it ends. +# +# This tool can be run multiple times concurrently, and it wont get +# confused. + +import contextlib +import subprocess +import json +import re +import sys +import time +import os +import fcntl +import select +import signal + +TMP_MP_DIR = "/var/lib/cockpit/btrfs" + +@contextlib.contextmanager +def atomic_file(path): + os.makedirs(TMP_MP_DIR, exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT) + fcntl.flock(fd, fcntl.LOCK_EX) + data = os.read(fd, 100000) + blob = json.loads(data) if len(data) > 0 else { } + try: + yield blob + data = json.dumps(blob).encode() + b"\n" + os.lseek(fd, 0, os.SEEK_SET) + os.truncate(fd, 0) + os.write(fd, data) + finally: + os.close(fd) + +def list_filesystems(): + output = json.loads(subprocess.check_output(["lsblk", "-Jplno", "NAME,FSTYPE,UUID,MOUNTPOINTS"])) + filesystems = {} + for b in output['blockdevices']: + if b['fstype'] == "btrfs": + uuid = b['uuid'] + mps = list(filter(lambda x: x is not None and not x.startswith(TMP_MP_DIR), b['mountpoints'])) + if uuid not in filesystems: + filesystems[uuid] = { 'uuid': uuid, 'devices': [ b['name'] ], 'mountpoints': mps } + else: + filesystems[uuid]['devices'] += [ b['name'] ] + filesystems[uuid]['mountpoints'] += mps + return filesystems + +tmp_mountpoints = set() + +def add_tmp_mountpoint(uuid, dev): + global tmp_mountpoints + if uuid not in tmp_mountpoints: + sys.stderr.write(f"ADDING {uuid}\n") + tmp_mountpoints.add(uuid) + with atomic_file(TMP_MP_DIR + "/db") as db: + if uuid in db and db[uuid] > 0: + db[uuid] += 1 + else: + db[uuid] = 1 + # XXX - mount as read-only? That interferes with other mounts. + dir = TMP_MP_DIR + "/" + uuid + sys.stderr.write(f"MOUNTING {dir}\n") + os.makedirs(dir, mode=0x400, exist_ok=True) + subprocess.check_call(["mount", dev, dir]) + +def remove_tmp_mountpoint(uuid): + global tmp_mountpoints + if uuid in tmp_mountpoints: + sys.stderr.write(f"REMOVING {uuid}\n") + tmp_mountpoints.remove(uuid) + with atomic_file(TMP_MP_DIR + "/db") as db: + if db[uuid] == 1: + dir = TMP_MP_DIR + "/" + uuid + try: + sys.stderr.write(f"UNMOUNTING {dir}\n") + subprocess.check_call(["umount", dir]) + subprocess.check_call(["rmdir", dir]) + except: + # XXX - log error, try harder? + pass + del db[uuid] + else: + db[uuid] -= 1 + +def remove_all_tmp_mountpoints(): + for mp in set(tmp_mountpoints): + remove_tmp_mountpoint(mp) + +def ensure_mount_point(fs): + if len(fs['mountpoints']) > 0: + remove_tmp_mountpoint(fs['uuid']) + return fs['mountpoints'][0] + else: + add_tmp_mountpoint(fs['uuid'], fs['devices'][0]) + return TMP_MP_DIR + "/" + fs['uuid'] + +def get_subvolume_info(fs): + mp = ensure_mount_point(fs) + try: + lines = subprocess.check_output(["btrfs", "subvolume", "list", "-apuq", mp]).splitlines() + subvols = [] + for line in lines: + match = re.match(b"ID (\\d+).*parent (\\d+).*parent_uuid (.*)uuid (.*) path (/)?(.*)", line); + if match: + subvols += [ + { + 'pathname': match[6].decode(errors='replace'), + 'id': int(match[1]), + 'parent': int(match[2]), + 'uuid': match[4].decode(), + 'parent_uuid': None if match[3][0] == ord("-") else match[3].decode() + } + ] + return subvols + except: + # XXX - export error message? + return None + +def get_default_subvolume(fs): + mp = ensure_mount_point(fs) + output = subprocess.check_output(["btrfs", "subvolume", "get-default", mp]) + match = re.match(b"ID (\\d+).*", output); + if match: + return int(match[1]); + +def get_usages(fs): + output = subprocess.check_output(["btrfs", "filesystem", "show", "--raw", fs['uuid']]) + usages = {} + for line in output.splitlines(): + match = re.match(b".*used\\s+(\\d+)\\s+path\\s+([\\w/]+).*", line) + if match: + usages[match[2].decode()] = int(match[1]); + return usages; + +def poll(): + sys.stderr.write("POLL\n") + filesystems = list_filesystems() + info = { } + for fs in filesystems.values(): + info[fs['uuid']] = { + 'subvolumes': get_subvolume_info(fs), + 'default_subvolume': get_default_subvolume(fs), + 'usages': get_usages(fs), + } + return info + +def cmd_monitor(): + old_infos = poll() + sys.stdout.write(json.dumps(old_infos) + "\n") + sys.stdout.flush() + while True: + time.sleep(5.0) + new_infos = poll() + if new_infos != old_infos: + sys.stdout.write(json.dumps(new_infos) + "\n") + sys.stdout.flush() + old_infos = new_infos + +def cmd_poll(): + infos = poll() + sys.stdout.write(json.dumps(infos) + "\n") + sys.stdout.flush() + +def cmd(args): + if len(args) > 1: + if args[1] == "poll": + cmd_poll() + elif args[1] == "monitor": + cmd_monitor() + +def main(args): + signal.signal(signal.SIGTERM, lambda _signo, _stack: sys.exit(0)) + try: + cmd(args) + except RuntimeError as err: + sys.stderr.write(str(err) + "\n") + finally: + remove_all_tmp_mountpoints() + +main(sys.argv) diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index a75749d42b73..26f42fdff0cc 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -39,6 +39,8 @@ import { export_mount_point_mapping } from "./anaconda.jsx"; import { dequal } from 'dequal/lite'; +import btrfs_tool_py from "./btrfs/btrfs-tool.py"; + /* STORAGED CLIENT */ @@ -200,114 +202,6 @@ client.swap_sizes = instance_sampler([{ name: "swapdev.length" }, { name: "swapdev.free" }, ], "direct"); -export async function btrfs_poll() { - const usage_regex = /used\s+(?\d+)\s+path\s+(?[\w/]+)/; - if (!client.uuids_btrfs_subvols) - client.uuids_btrfs_subvols = { }; - if (!client.uuids_btrfs_usage) - client.uuids_btrfs_usage = { }; - if (!client.uuids_btrfs_default_subvol) - client.uuids_btrfs_default_subvol = { }; - if (!client.uuids_btrfs_volume) - return; - - if (!client.superuser.allowed || !client.features.btrfs) { - return; - } - - const uuids_subvols = { }; - const uuids_usage = { }; - const btrfs_default_subvol = { }; - for (const uuid of Object.keys(client.uuids_btrfs_volume)) { - const blocks = client.uuids_btrfs_blocks[uuid]; - if (!blocks) - continue; - - // In multi device setups MountPoints can be on either of the block devices, so try them all. - const MountPoints = blocks.map(block => { - return client.blocks_fsys[block.path]; - }).map(block_fsys => block_fsys.MountPoints).reduce((accum, current) => accum.concat(current)); - const mp = MountPoints[0]; - if (mp) { - const mount_point = utils.decode_filename(mp); - try { - // HACK: UDisks GetSubvolumes method uses `subvolume list -p` which - // does not show the full subvolume path which we want to show in the UI - // - // $ btrfs subvolume list -p /run/butter - // ID 256 gen 7 parent 5 top level 5 path one - // ID 257 gen 7 parent 256 top level 256 path two - // ID 258 gen 7 parent 257 top level 257 path two/three/four - // - // $ btrfs subvolume list -ap /run/butter - // ID 256 gen 7 parent 5 top level 5 path /one - // ID 257 gen 7 parent 256 top level 256 path one/two - // ID 258 gen 7 parent 257 top level 257 path /one/two/three/four - const output = await cockpit.spawn(["btrfs", "subvolume", "list", "-apuq", mount_point], { superuser: "require", err: "message" }); - const subvols = [{ pathname: "/", id: 5, parent: null }]; - for (const line of output.split("\n")) { - const m = line.match(/ID (\d+).*parent (\d+).*parent_uuid (.*)uuid (.*) path (\/)?(.*)/); - if (m) { - // The parent uuid is the uuid of which this subvolume is a snapshot. - // https://github.com/torvalds/linux/blob/8d025e2092e29bfd13e56c78e22af25fac83c8ec/include/uapi/linux/btrfs.h#L885 - let parent_uuid = m[3].trim(); - // BTRFS_UUID_SIZE is 16 - parent_uuid = parent_uuid.length < 16 ? null : parent_uuid; - subvols.push({ pathname: m[6], id: Number(m[1]), parent: Number(m[2]), uuid: m[4], parent_uuid }); - } - } - uuids_subvols[uuid] = subvols; - } catch (err) { - console.warn(`unable to obtain subvolumes for mount point ${mount_point}`, err); - } - - // HACK: Obtain the default subvolume, required for mounts in which do not specify a subvol and subvolid. - // In the future can be obtained via UDisks, it requires the btrfs partition to be mounted somewhere. - // https://github.com/storaged-project/udisks/commit/b6966b7076cd837f9d307eef64beedf01bc863ae - try { - const output = await cockpit.spawn(["btrfs", "subvolume", "get-default", mount_point], { superuser: "require", err: "message" }); - const id_match = output.match(/ID (\d+).*/); - if (id_match) - btrfs_default_subvol[uuid] = Number(id_match[1]); - } catch (err) { - console.warn(`unable to obtain default subvolume for mount point ${mount_point}`, err); - } - - // HACK: UDisks should expose a better btrfs API with btrfs device information - // https://github.com/storaged-project/udisks/issues/1232 - // TODO: optimise into just parsing one `btrfs filesystem show`? - try { - const usage_output = await cockpit.spawn(["btrfs", "filesystem", "show", "--raw", uuid], { superuser: "require", err: "message" }); - const usages = {}; - for (const line of usage_output.split("\n")) { - const match = usage_regex.exec(line); - if (match) { - const { used, device } = match.groups; - usages[device] = used; - } - } - uuids_usage[uuid] = usages; - } catch (err) { - console.warn(`btrfs filesystem show ${uuid}`, err); - } - } else { - uuids_subvols[uuid] = null; - uuids_usage[uuid] = null; - } - } - - if (!dequal(client.uuids_btrfs_subvols, uuids_subvols) || !dequal(client.uuids_btrfs_usage, uuids_usage) || - !dequal(client.uuids_btrfs_default_subvol, btrfs_default_subvol)) { - debug("btrfs_pol new subvols:", uuids_subvols); - client.uuids_btrfs_subvols = uuids_subvols; - client.uuids_btrfs_usage = uuids_usage; - debug("btrfs_pol usage:", uuids_usage); - client.uuids_btrfs_default_subvol = btrfs_default_subvol; - debug("btrfs_pol default subvolumes:", btrfs_default_subvol); - client.update(); - } -} - function btrfs_findmnt_poll() { if (!client.btrfs_mounts) client.btrfs_mounts = { }; @@ -391,15 +285,73 @@ function btrfs_findmnt_poll() { }); } +function btrfs_update(data) { + if (!client.uuids_btrfs_subvols) + client.uuids_btrfs_subvols = { }; + if (!client.uuids_btrfs_usage) + client.uuids_btrfs_usage = { }; + if (!client.uuids_btrfs_default_subvol) + client.uuids_btrfs_default_subvol = { }; + + const uuids_subvols = { }; + const uuids_usage = { }; + const default_subvol = { }; + + for (const uuid in data) { + console.log("DATA", uuid, data[uuid]); + uuids_subvols[uuid] = [{ pathname: "/", id: 5, parent: null }].concat(data[uuid].subvolumes); + uuids_usage[uuid] = data[uuid].usages; + default_subvol[uuid] = data[uuid].default_subvolume; + } + + if (!dequal(client.uuids_btrfs_subvols, uuids_subvols) || !dequal(client.uuids_btrfs_usage, uuids_usage) || + !dequal(client.uuids_btrfs_default_subvol, default_subvol)) { + debug("btrfs_pol new subvols:", uuids_subvols); + client.uuids_btrfs_subvols = uuids_subvols; + client.uuids_btrfs_usage = uuids_usage; + debug("btrfs_pol usage:", uuids_usage); + client.uuids_btrfs_default_subvol = default_subvol; + debug("btrfs_pol default subvolumes:", default_subvol); + client.update(); + } +} + +export async function btrfs_poll() { + const data = JSON.parse(await python.spawn(btrfs_tool_py, ["poll"], { superuser: "require" })); + btrfs_update(data); +} + +function btrfs_start_monitor() { + if (!client.superuser.allowed || !client.features.btrfs) { + return; + } + + const channel = python.spawn(btrfs_tool_py, ["monitor"], { superuser: "require" }); + let buf = ""; + + channel.stream(output => { + buf += output; + const lines = buf.split("\n"); + buf = lines[lines.length - 1]; + if (lines.length >= 2) { + const data = JSON.parse(lines[lines.length - 2]); + btrfs_update(data); + } + }); + + channel.catch(err => { + console.log("BTRFS MONITOR ERROR", err); + }); +} + function btrfs_start_polling() { debug("starting polling for btrfs subvolumes"); - window.setInterval(btrfs_poll, 5000); client.uuids_btrfs_subvols = { }; client.uuids_btrfs_usage = { }; client.uuids_btrfs_default_subvol = { }; client.btrfs_mounts = { }; - btrfs_poll(); btrfs_findmnt_poll(); + btrfs_start_monitor(); } /* Derived indices. diff --git a/pkg/storaged/filesystem/mismounting.jsx b/pkg/storaged/filesystem/mismounting.jsx index 0fb5fb7756cf..d2579bd56781 100644 --- a/pkg/storaged/filesystem/mismounting.jsx +++ b/pkg/storaged/filesystem/mismounting.jsx @@ -45,13 +45,24 @@ export function check_mismounted_fsys(backing_block, content_block, fstab_config if (!(block_fsys || dir)) return; + function ignore_mount(m) { + // We don't complain about the rootfs, it's probably + // configured somewhere else, like in the bootloader. + if (m == "/") + return true; + + // This is the mount point used for monitoring btrfs filesystems. + if (m.indexOf("/var/lib/cockpit/btrfs/") == 0) + return true; + } + const mounted_at = get_mount_points(client, block_fsys, subvol); const split_options = parse_options(opts); const opt_noauto = extract_option(split_options, "noauto"); const opt_noauto_intent = extract_option(split_options, "x-cockpit-never-auto"); const opt_systemd_automount = split_options.indexOf("x-systemd.automount") >= 0; const is_mounted = mounted_at.indexOf(dir) >= 0; - const other_mounts = mounted_at.filter(m => m != dir); + const other_mounts = mounted_at.filter(m => m != dir && !ignore_mount(m)); const crypto_backing_noauto = get_cryptobacking_noauto(client, backing_block); let type; @@ -68,10 +79,7 @@ export function check_mismounted_fsys(backing_block, content_block, fstab_config else if (is_mounted && opt_noauto && !opt_noauto_intent && !opt_systemd_automount) type = "no-mount-on-boot"; } else if (other_mounts.length > 0) { - // We don't complain about the rootfs, it's probably - // configured somewhere else, like in the bootloader. - if (other_mounts[0] != "/") - type = "mounted-no-config"; + type = "mounted-no-config"; } if (type) diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js index 6b8a61015c34..e87e8a2f933e 100644 --- a/pkg/storaged/utils.js +++ b/pkg/storaged/utils.js @@ -861,6 +861,9 @@ export function get_active_usage(client, path, top_action, child_action, is_temp } function enter_unmount(block, location, is_top) { + if (location.indexOf("/var/lib/cockpit/btrfs/") == 0) + return; + const [, mount_point] = get_fstab_config_with_client(client, block); const has_fstab_entry = is_temporary && location == mount_point;