#!/usr/bin/env python3 """SysDeck - Remote FS Bridge Helper Author: Jeremy Anderson (https://dcos.net) Manages remote / distributed filesystem backends as systemd services and surfaces cluster status from each backend's CLI tool. The bridge auto-detects which backends are installed on the host; the panel renders a card per detected backend with cluster status, pool/brick/ volume counts, and Start/Stop/Restart controls. v0.0.35 directive: "then a remote fs manager such as ceph, and others but not nfs or amanada fs." The backends included match that directive — distributed / shared-storage filesystems with their own cluster management surface: Backends shipped: Ceph — distributed object storage · LGPL-2.1 · ceph / cephfs GlusterFS — scale-out network filesystem · GPL-2.0 · gluster MooseFS — distributed fault-tolerant FS · GPL-2.0 · moosefs BeeGFS — parallel cluster filesystem · BeeGFS EULA (free) · beegfs OrangeFS — parallel FS (PVFS2 successor) · OpenSource · orangefs Explicitly EXCLUDED per directive: NFS — kernel-builtin, no admin panel beyond `nfsd` daemon; no cluster, no remote-FS-as-data-store semantics. Operators who need NFS use cockpit-nfs (separate plugin). Amanda — backup system (AMANDA = Advanced Maryland Automatic Network Disk Archiver), NOT a remote/distributed filesystem. Operators who need backup use a dedicated backup solution. Subcommands: summary — list all detected backends with status + cluster info status — detailed status for one backend start — systemctl start stop — systemctl stop restart — systemctl restart cluster-info — backend-specific cluster status (ceph status, gluster pool list, moosefs-cli info, etc.) Cockpit way (v0.0.31+ pattern): the bridge runs systemctl via subprocess directly — no `sudo` shell-out. The JS panel passes { superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts the operator via polkit for the org.sysdeck.remotefs.modify action (added in v0.0.35). Usage: python3 /usr/lib/sysdeck/bridge/remotefs.py summary python3 /usr/lib/sysdeck/bridge/remotefs.py start ceph python3 /usr/lib/sysdeck/bridge/remotefs.py cluster-info glusterfs """ import json import shutil import subprocess import sys from datetime import datetime from typing import Any # ── Backend Registry ─────────────────────────────────────────────── # Each entry: (id, name, family, default_port, systemd_unit, cli_tool, # config_paths, license, homepage, install_hint) BACKEND_REGISTRY = [ ( "ceph", "Ceph", "object-storage", 6789, "ceph.target", "ceph", ["/etc/ceph/ceph.conf"], "LGPL-2.1", "https://ceph.io/", "Arch: pacman -S ceph · Debian: apt install ceph · Fedora: dnf install ceph", ), ( "glusterfs", "GlusterFS", "scale-out-fs", 24007, "glusterd.service", "gluster", ["/etc/glusterfs/glusterd.vol"], "GPL-2.0", "https://www.gluster.org/", "Arch: pacman -S glusterfs · Debian: apt install glusterfs-server · Fedora: dnf install glusterfs-server", ), ( "moosefs", "MooseFS", "distributed-fs", 9420, "moosefs-master.service", "moosefs-cli", ["/etc/mfs/mfsmaster.cfg"], "GPL-2.0", "https://moosefs.com/", "Arch: pacman -S moosefs · Debian: apt install moosefs-master · Fedora: dnf install moosefs-master", ), ( "beegfs", "BeeGFS", "parallel-fs", 8008, "beegfs-meta.service", "beegfs-ctl", ["/etc/beegfs/beegfs-meta.conf"], "BeeGFS EULA (free)", "https://www.beegfs.io/", "Arch: yay -S beegfs · Debian: apt install beegfs-meta · Fedora: see beegfs.io docs", ), ( "orangefs", "OrangeFS", "parallel-fs", 3334, "pvfs2-server.service", "pvfs2-server", ["/etc/orangefs/orangefs-server.conf"], "OpenSource (BSD-3)", "http://www.orangefs.org/", "Arch: yay -S orangefs · Debian: apt install orangefs-server · Fedora: dnf install orangefs-server", ), ] # ── EXCLUDED backends — documented here so future contributors # don't accidentally add them back. EXCLUDED = { "nfs": "kernel-builtin; no cluster; no remote-FS-as-data-store semantics. Use cockpit-nfs.", "amanda": "backup system, not a remote/distributed filesystem. Use a dedicated backup solution.", } def _have(binary: str) -> bool: return shutil.which(binary) is not None _SYSTEMCTL = shutil.which("systemctl") def _unit_loaded(unit: str) -> bool: if not unit or not _SYSTEMCTL: return False out = subprocess.run( ["systemctl", "list-unit-files", unit], capture_output=True, text=True, timeout=5, ).stdout return unit in out def _systemctl_show(unit: str, props: list[str]) -> dict[str, str]: if not _SYSTEMCTL: return {} out = subprocess.run( ["systemctl", "show", unit, "--property=" + ",".join(props)], capture_output=True, text=True, timeout=5, ).stdout.strip() result = {} for line in out.splitlines(): k, _, v = line.partition("=") if k: result[k] = v return result def _service_status(unit: str) -> dict[str, Any]: if not unit: return {"status": "uninstalled", "active": "", "sub": "", "uptime_seconds": 0} props = _systemctl_show(unit, ["ActiveState", "SubState", "ActiveEnterTimestamp"]) active = props.get("ActiveState", "unknown") sub = props.get("SubState", "unknown") uptime = 0 ts = props.get("ActiveEnterTimestamp", "") if ts: try: dt = datetime.strptime(ts[:25], "%a %Y-%m-%d %H:%M:%S") uptime = int((datetime.now() - dt).total_seconds()) except (ValueError, OSError): pass if active == "active": status = "running" elif active == "activating": status = "starting" elif active == "failed": status = "error" elif active in ("inactive", "deactivating"): status = "stopped" else: status = "unknown" return {"active": active, "sub": sub, "status": status, "uptime_seconds": uptime} def _detect_backend(entry) -> dict[str, Any]: (bid, name, family, port, unit, cli, configs, lic, homepage, install_hint) = entry cli_available = _have(cli) if cli else False unit_loaded = _unit_loaded(unit) if unit else False config_present = any(_path_exists(p) for p in configs) if unit_loaded: state = _service_status(unit) status = state["status"] active_state = state["active"] sub_state = state["sub"] uptime = state["uptime_seconds"] elif cli_available or config_present: status = "stopped" active_state = "" sub_state = "" uptime = 0 else: status = "uninstalled" active_state = "" sub_state = "" uptime = 0 config_path = next((p for p in configs if _path_exists(p)), "") return { "id": bid, "name": name, "family": family, "status": status, "active": active_state, "sub": sub_state, "uptime_seconds": uptime, "port": port, "url": f"http://127.0.0.1:{port}" if port else "", "serviceUnit": unit, "cli": cli, "configPath": config_path, "license": lic, "homepage": homepage, "installHint": install_hint, "supported": True, "excluded": False, } def _path_exists(p: str) -> bool: try: return bool(p) and __import__("os").path.exists(p) except (OSError, ValueError): return False def cmd_summary() -> dict[str, Any]: backends = [_detect_backend(e) for e in BACKEND_REGISTRY] installed = [b for b in backends if b["status"] != "uninstalled"] running = [b for b in backends if b["status"] == "running"] return { "backends": backends, "totalBackends": len(backends), "installedCount": len(installed), "runningCount": len(running), "excluded": EXCLUDED, } def _find_backend(bid: str): for e in BACKEND_REGISTRY: if e[0] == bid: return e return None def cmd_status(bid: str) -> dict[str, Any]: e = _find_backend(bid) if not e: return {"error": f"Unknown backend: {bid}"} return _detect_backend(e) def _systemctl(action: str, unit: str) -> dict[str, Any]: if not unit: return {"rc": 127, "success": False, "stderr": "no systemd unit for this backend"} if not _SYSTEMCTL: return {"rc": 127, "success": False, "stderr": "systemctl not present on this host (no systemd)"} try: r = subprocess.run( ["systemctl", action, unit], capture_output=True, text=True, timeout=30, ) return { "action": action, "service": unit, "rc": r.returncode, "success": r.returncode == 0, "output": (r.stdout or "").strip(), "stderr": (r.stderr or "").strip(), } except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: return {"action": action, "service": unit, "rc": 1, "success": False, "stderr": str(exc)} def cmd_start(bid: str) -> dict[str, Any]: e = _find_backend(bid) if not e: return {"error": f"Unknown backend: {bid}"} return _systemctl("start", e[4]) def cmd_stop(bid: str) -> dict[str, Any]: e = _find_backend(bid) if not e: return {"error": f"Unknown backend: {bid}"} return _systemctl("stop", e[4]) def cmd_restart(bid: str) -> dict[str, Any]: e = _find_backend(bid) if not e: return {"error": f"Unknown backend: {bid}"} return _systemctl("restart", e[4]) def cmd_cluster_info(bid: str) -> dict[str, Any]: """Backend-specific cluster status query. Each backend has its own CLI for cluster status: ceph → ceph status (JSON via --format=json) glusterfs → gluster pool list moosefs → moosefs-cli info beegfs → beegfs-ctl --listnodes orangefs → pvfs2-server -m """ e = _find_backend(bid) if not e: return {"error": f"Unknown backend: {bid}"} (bid2, name, family, port, unit, cli, configs, lic, homepage, install_hint) = e # One probe table, not five copy-pasted branches: each backend # maps to its argv; a CLI that is not installed is a state the panel # can render, not a traceback. probes: dict[str, list[str]] = { "ceph": ["ceph", "status", "--format=json"], "glusterfs": ["gluster", "pool", "list"], "moosefs": ["moosefs-cli", "info"], "beegfs": ["beegfs-ctl", "--listnodes"], "orangefs": ["pvfs2-server", "-m"], } argv = probes.get(bid2) if argv is None: return {"error": f"No cluster-info handler for {bid2}"} try: out = subprocess.run(argv, capture_output=True, text=True, timeout=15) except FileNotFoundError: return {"available": False, "reason": f"{argv[0]} is not installed", "install": install_hint or ""} except subprocess.TimeoutExpired: return {"available": False, "reason": f"{argv[0]} timed out after 15s"} if bid2 == "ceph": if out.returncode != 0: return {"error": out.stderr.strip() or f"ceph status returned {out.returncode}"} try: data = json.loads(out.stdout) return { "backend": bid2, "raw": data, "summary": { "health": data.get("health", {}).get("status", "?"), "fsid": data.get("fsid", "?"), "monmap": data.get("monmap", {}).get("num_mons", 0), "osdmap": data.get("osdmap", {}).get("osdmap", {}).get("num_osds", 0), "pgmap": data.get("pgmap", {}).get("num_pgs", 0), }, "rawText": out.stdout[:4000], } except json.JSONDecodeError: return {"backend": bid2, "rawText": out.stdout[:4000]} return { "backend": bid2, "rawText": out.stdout[:4000] if out.returncode == 0 else "", "stderr": out.stderr.strip() if out.returncode != 0 else "", "rc": out.returncode, } def main(argv: list[str]) -> int: if not argv or argv[0] in ("-h", "--help"): print(__doc__) return 0 cmd = argv[0] if cmd == "summary": print(json.dumps(cmd_summary(), indent=2)) elif cmd == "status": bid = argv[1] if len(argv) > 1 else "" print(json.dumps(cmd_status(bid), indent=2)) elif cmd == "start": bid = argv[1] if len(argv) > 1 else "" print(json.dumps(cmd_start(bid), indent=2)) elif cmd == "stop": bid = argv[1] if len(argv) > 1 else "" print(json.dumps(cmd_stop(bid), indent=2)) elif cmd == "restart": bid = argv[1] if len(argv) > 1 else "" print(json.dumps(cmd_restart(bid), indent=2)) elif cmd == "cluster-info": bid = argv[1] if len(argv) > 1 else "" print(json.dumps(cmd_cluster_info(bid), indent=2)) else: print(json.dumps({"error": f"Unknown command: {cmd}"})) return 2 return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))