398 lines
13 KiB
Python
Executable File
398 lines
13 KiB
Python
Executable File
#!/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 <id> — detailed status for one backend
|
|
start <id> — systemctl start <service>
|
|
stop <id> — systemctl stop <service>
|
|
restart <id> — systemctl restart <service>
|
|
cluster-info <id> — 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:]))
|