diff options
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | README.md | 6 | ||||
| -rwxr-xr-x | remote/sbb-remote | 63 | ||||
| -rw-r--r-- | sb_builder/__init__.py | 2 | ||||
| -rw-r--r-- | sb_builder/mainwindow.py | 11 | ||||
| -rw-r--r-- | tests/test_gui.py | 19 | ||||
| -rw-r--r-- | tests/test_remote.py | 51 |
7 files changed, 146 insertions, 12 deletions
@@ -33,6 +33,12 @@ plans are working files, kept out of git (`.superpowers/`). `gpg-preset-passphrase`. Every other subprocess in the helper runs with `stdin=DEVNULL`, so nothing can read the passphrase off the ssh channel. Never put the passphrase in argv, env, logs or files. +- Second exception: `config-get` / `config-set` (Keystone's settings page) + run before `load_config`, so a broken helper config stays fixable; + `config-set` reads the new text from stdin, validates it with `check()` + (the same rules as `load_config`), keeps `.bak` and replaces atomically. + The config names paths and scripts the helper runs: the ssh login is root + already, so this grants nothing new. - Hints are parsed (`shlex`), never sourced. `sb_builder/hints.py` mirrors slackrepo's own rules (`libexec/functions.d/parsefunctions.sh` in [slackrepo](https://github.com/aclemons/slackrepo)); check that file when changing it. @@ -58,7 +58,11 @@ The design, one spec per phase, is in `docs/design/`. - GUI: `~/.config/sb-builder/config.toml`, see `config.example.toml` - VM: `/etc/sb-builder/config.toml`, see `remote/config.example.toml` -Every key is optional. `File > Reload config` rereads the GUI config. +Every key is optional. Both configs also have settings pages in Keystone (the +quickshell desktop shell); the files stay hand-editable. The GUI rereads its +config when the file changes (`File > Reload config` does it by hand), and +Keystone edits the VM's through `sbb-remote config-get` / `config-set`, which +refuse a file that does not load and keep the previous one as `config.toml.bak`. Column widths, sort order, splitter positions and the window size are kept across runs in `~/.config/sb-builder/state.conf` (delete it to reset them; on Wayland the compositor may still pick the window size). diff --git a/remote/sbb-remote b/remote/sbb-remote index 1c87f80..cabb8d1 100755 --- a/remote/sbb-remote +++ b/remote/sbb-remote @@ -18,7 +18,8 @@ """Helper run over ssh by the sb-builder GUI. Every command prints one JSON object on stdout, except job-follow, which -streams raw log bytes. gpg-unlock reads the passphrase from stdin. Jobs run detached (setsid, stdin=/dev/null), one at a +streams raw log bytes. gpg-unlock reads the passphrase from stdin, config-set +the new config text. Jobs run detached (setsid, stdin=/dev/null), one at a time under a flock, from a fixed table of kinds: the GUI sends a kind and its arguments, this file validates them and builds the argv itself. """ @@ -40,7 +41,7 @@ import time import tomllib import urllib.request -VERSION = "0.9.0" +VERSION = "0.10.0" DEFAULTS = { "state_dir": "/var/lib/sb-builder", "repo_dir": "/var/lib/sbopkg/SBo-danix", @@ -75,16 +76,24 @@ class Error(Exception): pass +def config_path(): + return os.environ.get("SBB_CONFIG", "/etc/sb-builder/config.toml") + + def load_config(path=None): - path = path or os.environ.get("SBB_CONFIG", "/etc/sb-builder/config.toml") - cfg = dict(DEFAULTS) try: - with open(path, "rb") as f: + with open(path or config_path(), "rb") as f: data = tomllib.load(f) except FileNotFoundError: - return cfg + return dict(DEFAULTS) except tomllib.TOMLDecodeError as e: raise Error(f"config: {e}") + return check(data) + + +def check(data): + """The config from parsed TOML over the defaults; Error on an unknown key or a wrong type.""" + cfg = dict(DEFAULTS) for k, v in data.items(): if k not in DEFAULTS: raise Error(f"config: {k}: unknown key") @@ -1086,6 +1095,38 @@ def cmd_gpg_unlock(cfg, stdin=None): return {"unlocked": True} +# ── config editing (Keystone's settings page): no cfg, a broken file must stay fixable ── + +def cmd_config_get(): + try: + with open(config_path()) as f: + text = f.read() + except FileNotFoundError: + text = "" + return {"text": text, "defaults": DEFAULTS, "path": config_path()} + + +def cmd_config_set(stdin=None): + """New config text on stdin: validated, the old file to .bak, then an atomic replace.""" + try: + text = (stdin or sys.stdin.buffer).read().decode() + check(tomllib.loads(text)) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: + raise Error(f"config: {e}") + path = config_path() + mode = 0o644 + if os.path.exists(path): + mode = os.stat(path).st_mode & 0o777 + shutil.copy2(path, path + ".bak") + with tempfile.NamedTemporaryFile("w", dir=os.path.dirname(path), delete=False) as t: + t.write(text) + os.chmod(t.name, mode) + os.replace(t.name, path) + return {"ok": True} + + +CONFIG_COMMANDS = {"config-get": cmd_config_get, "config-set": cmd_config_set} + COMMANDS = { "version": cmd_version, "stats": cmd_stats, @@ -1108,6 +1149,7 @@ COMMANDS = { "fix-current-now": cmd_fix_current_now, "_run": cmd_run, "_hint": cmd_hint, + **CONFIG_COMMANDS, } @@ -1116,9 +1158,12 @@ def main(argv): if not argv or argv[0] not in COMMANDS: names = ",".join(c for c in COMMANDS if not c.startswith("_")) raise Error(f"usage: sbb-remote {{{names}}} [args]") - cfg = load_config() - os.makedirs(os.path.join(cfg["state_dir"], "jobs"), exist_ok=True) - result = COMMANDS[argv[0]](cfg, *argv[1:]) + if argv[0] in CONFIG_COMMANDS: + result = CONFIG_COMMANDS[argv[0]](*argv[1:]) + else: + cfg = load_config() + os.makedirs(os.path.join(cfg["state_dir"], "jobs"), exist_ok=True) + result = COMMANDS[argv[0]](cfg, *argv[1:]) except BrokenPipeError: # the GUI hung up on job-follow; silence the flush at exit too os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py index c845154..267ce94 100644 --- a/sb_builder/__init__.py +++ b/sb_builder/__init__.py @@ -16,4 +16,4 @@ """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM.""" -VERSION = "0.9.0" # must match remote/sbb-remote VERSION +VERSION = "0.10.0" # must match remote/sbb-remote VERSION diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py index 5ebe869..4b0aca2 100644 --- a/sb_builder/mainwindow.py +++ b/sb_builder/mainwindow.py @@ -19,7 +19,7 @@ import os import time -from PyQt6.QtCore import QSettings, QMetaType, QObject, QSize, Qt, QTimer, pyqtSignal, pyqtSlot +from PyQt6.QtCore import QFileSystemWatcher, QSettings, QMetaType, QObject, QSize, Qt, QTimer, pyqtSignal, pyqtSlot from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage from PyQt6.QtGui import QIcon, QKeySequence, QShortcut from PyQt6.QtWidgets import (QHeaderView, QLabel, QMainWindow, QMessageBox, QPushButton, QSplitter, @@ -90,6 +90,10 @@ class MainWindow(QMainWindow): self.state = QSettings(state or os.path.join(os.path.dirname(config.path()), "state.conf"), QSettings.Format.IniFormat) self.cfg, self.remote = cfg, remote + # Keystone's settings page saves here: apply it live (File > Reload config stays) + # ponytail: watches the file only, so one created after startup needs a reload by hand + self.config_watch = QFileSystemWatcher([config.path()], self) + self.config_watch.fileChanged.connect(self._config_changed) self.notifier = notifier or Notifier(self) self.notifier.clicked.connect(self.open_job) self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown @@ -439,6 +443,11 @@ class MainWindow(QMainWindow): self.state.sync() super().closeEvent(event) + def _config_changed(self, path): + if os.path.exists(path): + self.config_watch.addPath(path) # an atomic write replaced the inode, dropping the watch + self.reload_config() + def reload_config(self): cfg, err = config.load() if err: diff --git a/tests/test_gui.py b/tests/test_gui.py index 52e4ea7..c89dc86 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -628,6 +628,25 @@ class SmokeTest(unittest.TestCase): w.dashboard.jobs.clicked.emit() self.assertIs(w.tabs.currentWidget(), w.jobs_tab) + def test_config_file_watched(self): + tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp) + old = os.environ.get("XDG_CONFIG_HOME") + os.environ["XDG_CONFIG_HOME"] = tmp + self.addCleanup(lambda: os.environ.__setitem__("XDG_CONFIG_HOME", old) if old + else os.environ.pop("XDG_CONFIG_HOME")) + os.makedirs(os.path.dirname(config.path())) + + def save(jobs): # an atomic write, as Keystone's settings page does: a new inode + with open(config.path() + ".tmp", "w") as f: + f.write(f"[refresh]\njobs = {jobs}\n") + os.replace(config.path() + ".tmp", config.path()) + save(5) + w, _remote = self.window() + for jobs in (7, 9): # the second one proves the path was watched again + save(jobs) + self.wait(lambda: w.cfg["refresh"]["jobs"] == jobs) + def wait(self, cond, secs=5): end = time.time() + secs while not cond() and time.time() < end: diff --git a/tests/test_remote.py b/tests/test_remote.py index 7288828..3ac584a 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -570,6 +570,57 @@ class PhantomTest(Base): sbb.KINDS["fix-current"](self.cfg, ["x"]) +class ConfigCommandsTest(Base): + def setUp(self): + super().setUp() + self.path = os.environ["SBB_CONFIG"] + with open(self.path) as f: + self.old = f.read() + + def set(self, text): + stdin = io.TextIOWrapper(io.BytesIO(text.encode())) + with mock.patch.object(sbb.sys, "stdin", stdin): + return self.run_main("config-set") + + def text(self): + with open(self.path) as f: + return f.read() + + def test_set_accepted(self): + new = self.old + 'overlays = ["a"]\n' + self.assertEqual(self.set(new), (0, {"ok": True})) + self.assertEqual(self.text(), new) + with open(self.path + ".bak") as f: + self.assertEqual(f.read(), self.old) + self.assertEqual(sbb.load_config()["overlays"], ["a"]) + + def test_set_refused(self): + for text, why in (("state_dir = \n", "config"), ("bogus = 1\n", "bogus: unknown key"), + ("state_dir = 1\n", "state_dir: expected str"), + ("[manifests]\nx = 1\n", "manifests: expected strings")): + rc, data = self.set(text) + self.assertEqual(rc, 1, text) + self.assertIn(why, data["error"]) + self.assertEqual(self.text(), self.old) + self.assertFalse(os.path.exists(self.path + ".bak")) + + def test_get(self): + self.assertEqual(self.run_main("config-get"), + (0, {"text": self.old, "defaults": sbb.DEFAULTS, "path": self.path})) + + def test_get_missing(self): + os.remove(self.path) + self.assertEqual(self.run_main("config-get")[1]["text"], "") + + def test_broken_config_can_be_read_and_fixed(self): + with open(self.path, "w") as f: + f.write("bogus = 1\n") + self.assertEqual(self.run_main("version")[0], 1) + self.assertEqual(self.run_main("config-get")[1]["text"], "bogus = 1\n") + self.assertEqual(self.set(self.old), (0, {"ok": True})) + self.assertEqual(self.run_main("version")[0], 0) + + class MainTest(Base): def test_unknown_command(self): rc, data = self.run_main("nope") |
