# sb-builder phase 1: core design Date: 2026-10-19 Status: approved ## Goal One Qt6 desktop GUI to drive the SlackBuild tooling on the build VM (`buildsystem`): slackrepo, mkhint, overlay sync (sbpull, sbshadow), full repo rebuild, and SBo orphan discovery. Long-running work runs as jobs on the VM that survive the GUI disconnecting, and the GUI can reattach to them later. ## Phases Each phase gets its own spec, plan and implementation cycle. 1. **Core** (this spec): remote helper with the job model, GUI shell with tabs, Dashboard, Jobs tab, VM control, config, notifications. Job kinds: `sbpull`, `sbshadow`. 2. **Packages + slackrepo**: package browser over the large SBo-danix tree (search and filter by name, category, overlay, maintainer, build status); Build, Rebuild, Bulk update screens. 3. **Full repo rebuild**: `slackrepo_setup_SBo-danix.sh`, destructive, guarded. 4. **Hints**: mkhint browse, edit, update, create, `-F`, diff before save. Requires non-interactive flags in mkhint (it has about 10 `read -p` prompts and wraps slackrepo at `mkhint:1798`). 5. **Orphans**: SBo orphan discovery (`EMAIL="NOEMAIL"` in `.info`, the `sbo-orphans` logic from slackbuilds-superrepo), including orphaned deps in REQUIRES. Needs an SBo-stable tree, which is not on the VM today. Out of scope for all phases so far: overlay git operations (status, commit, push in my-slackbuilds and friends). ## Stack - GUI: Python 3 + PyQt6 (stock in Slackware -current, 6.11), including `PyQt6.QtDBus` for notifications. No third-party packages. - Helper: Python 3 stdlib only (VM runs 3.12, so `tomllib` is available). - Config: TOML, read with `tomllib`. - License: GPLv2-only. ## Architecture ``` desktop buildsystem (root) +----------------------+ ssh (ControlMaster) +---------------------------+ | sb-builder (PyQt6) | ----------------------> | sbb-remote (py3 stdlib) | | tabs: Dashboard, | sbb-remote | stats / jobs / | | Jobs (+later tabs) | <---------------------- | job-start / job-follow / | | QProcess per call | JSON on stdout | job-kill, flock, setsid | +----------------------+ | /var/lib/sb-builder/ | | +---------------------------+ | virsh (local libvirt) v domain "buildsystem" ``` ### Repository layout ``` sb_builder/__main__.py entry point, config load sb_builder/config.py GUI config: defaults, load, validate sb_builder/remote.py the only code that runs ssh; also virsh sb_builder/mainwindow.py tabs, menu, status bar, notifications sb_builder/dashboard.py Dashboard tab sb_builder/jobs.py Jobs tab sb_builder/fmt.py text formatting shared by the tabs sb-builder launcher (python3 -m sb_builder) remote/sbb-remote VM helper, single file config.example.toml GUI config example remote/config.example.toml helper config example install.sh local install; --remote deploys helper to VM tests/test_remote.py tests/test_gui.py ``` ### Transport Every GUI call is a QProcess running: ``` ssh -o ControlMaster=auto -o ControlPath=/sbb-%C -o ControlPersist=10m \ sbb-remote [args...] ``` Calls are async. ssh exit 255 means the connection failed. Exit 127 means the helper is not installed. ### Trust boundary The helper runs only a fixed table of job kinds. The GUI sends a kind name plus arguments; the helper validates each argument and builds argv itself. No shell strings and no paths cross ssh. Validation rules: - unknown kind: rejected - `sbpull` target: must be listed in the helper config `overlays` - flags: only the ones the kind declares (`--yes`, `--no`) - package names (phase 2 onward): `^[A-Za-z0-9._+-]+$` ### Version check On connect the GUI runs `sbb-remote version`, which also returns the configured `overlays` (the GUI builds its sbpull menu from them). A mismatch with the GUI's own version shows a banner telling the user to run `./install.sh --remote`. ## Helper: `sbb-remote` ### State ``` /lock flock target, held for the whole job /jobs//meta.json kind, args, argv, pid, start, end, exit, status /jobs//log combined stdout+stderr, raw bytes ``` `` is `YYYYMMDD-HHMMSS-`. `status` is one of `running`, `ok`, `failed`, `killed`, `lost`, `busy`. Logs are kept forever (`# ponytail:` comment in code; add pruning once disk usage is visible on the Dashboard). ### Commands All print one JSON object on stdout, except `job-follow`. | command | behaviour | |---|---| | `version` | `{"version": "...", "overlays": [...]}` | | `stats` | Dashboard data: load, uptime, disk free for repo and state dirs, package counts (total, personal, pentesting, shadowed = overlay packages also present in the pristine `current` branch, as sbshadow computes it), last sync per overlay (time of the newest commit touching `/` in `repo_dir`), slackrepo summary from its sqlite DB (built, failed, never built). Any missing source yields `null` for that field, never an error | | `jobs` | list of all jobs' meta. A job whose meta says `running` but whose pid is gone (`os.kill(pid, 0)` fails) is reported and rewritten as `lost` | | `job-start [args]` | validate; spawn `sbb-remote _run ` with `start_new_session=True`, `stdin=/dev/null`, stdout/stderr to `/dev/null`; wait up to 3s for `_run` to write status; return `{"id": ...}`, or `{"busy": ""}` after deleting the refused job's dir | | `job-follow ` | write log bytes from `offset` to stdout, keep following until the job is no longer `running`, then exit 0 | | `job-kill ` | SIGTERM to the job's process group; `_run` records `killed` | | `_run ` | internal, see below | ### `_run` 1. Take a non-blocking `fcntl.flock` on `/lock`. If held, write status `busy` and exit. 2. Start the job argv with `stdin=/dev/null`, stdout and stderr to the log file, in its own process group. Write `pid` and status `running`. 3. Wait. Write `end`, `exit` and final status (`ok` on 0, `killed` on SIGTERM, else `failed`). Jobs never read input. A tool that prompts anyway gets EOF on `read` and, under `set -e`, exits non-zero: the job fails, it never hangs and it never answers yes by accident. Destructive steps are confirmed in the GUI. ### Phase 1 job kinds | kind | argv built by helper | |---|---| | `sbpull [--yes\|--no]` | `bash -c '. "$1" && sbpull "$2" "$3"' _ ` | | `sbshadow [--yes\|--no]` | `sbshadow ` | The flag is required in both kinds (a job has no stdin). ### Helper config: `/etc/sb-builder/config.toml` ```toml state_dir = "/var/lib/sb-builder" repo_dir = "/var/lib/sbopkg/SBo-danix" upstream_branch = "current" slackrepo_db = "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3" sbpull_script = "/usr/local/share/slackrepo_setup/sbpull.bash" overlays = ["personal", "pentesting"] ``` Missing file: these defaults. Unknown key or wrong type: every command returns `{"error": "config: : "}` and exits 1. ### Errors Top-level handler catches any exception, prints `{"error": ""}`, exits 1. ## Changes in slackrepo_setup Done in that repo, its own commit, redeployed to the VM. - `sbshadow --yes | --no`: no flag keeps the current interactive prompt. `--yes` applies without asking. `--no` prints the plan and leaves the tree unchanged (preview). `mkhint -F` runs in all cases, as it does today on abort. - `sbshadow` honours `SBO_REPO` to override the repo path, so it can be tested against a throwaway repo. - `sbpull [--yes|--no]`: passes the flag to `sbshadow`, and returns `sbshadow`'s exit status (today it returns `popd`'s, so a failure would show as a successful job). GUI flow: `sbpull personal --no` pulls and shows the shadow plan in the log; the Jobs tab offers "Apply shadowing", which starts `sbshadow --yes`. ## GUI ### Main window - QTabWidget. Dashboard is pinned. Other tabs are closable and single-instance. - Menu: `File > Reload config`, `File > Quit`, `Open > Jobs` (later phases add entries). - Status bar: connection state (ok, down, VM off, VM paused, VM running but ssh unreachable, helper version mismatch), running job if any (click opens it in Jobs), transient messages. ### Dashboard Grid of QGroupBox tiles filled from `stats`, plus `jobs` for the Jobs tile: - VM: load, uptime, disk free - Repo: total packages, personal, pentesting, shadowed - slackrepo: built, failed, never built - Sync: last pull per overlay - Jobs: running job and last 10 (status, duration) - Orphans: placeholder tile, filled in phase 5 Refreshes every `refresh.dashboard` seconds while visible, plus a Refresh button. `null` fields show "n/a". When the connection is down the last values stay, greyed, with "stale since HH:MM". ### Jobs tab - Left: job list (status icon, kind and args, start, duration), refreshed every `refresh.jobs` seconds. - Right: log view (QPlainTextEdit, ANSI escapes stripped, `\r` rewrites the current line), Kill button with confirm. - Toolbar `Start`: one entry per overlay for `sbpull --no`, plus `sbshadow --no`, `sbshadow --yes` (confirm dialog). - After a `--no` run whose log lists `shadow:` or `unshadow:` lines, an "Apply shadowing" button starts `sbshadow --yes`. - Selecting a job starts `job-follow` from offset 0. If the stream drops while the job is still running, the next successful `jobs` poll restarts it from the last received offset (the poll itself backs off while the VM is down, see Error handling). - `job-start` returning busy: dialog "busy: " with [Show job]. ### Notifications freedesktop `org.freedesktop.Notifications.Notify` via QtDBus. Events: `finished`, `failed`, `killed`, `lost`, and `vm-up` (VM started and ssh answering). Clicking a notification (`ActionInvoked`) raises the window and opens the job. The GUI tracks the job states it has seen, so each job end notifies once. ### VM control When ssh fails and `vm.domain` is set, the GUI runs `virsh -c domstate ` locally: | domstate | behaviour | |---|---| | `shut off` | status "VM off", [Start VM] (automatic when `vm.autostart`): `virsh start`, then poll ssh with backoff, notify `vm-up` | | `paused` | status "VM paused", [Resume]: `virsh resume` | | `running` | status "VM running, ssh unreachable", keep retrying | | virsh missing or error | plain "down" | No shutdown control. ### GUI config: `$XDG_CONFIG_HOME/sb-builder/config.toml` ```toml [remote] target = "buildsystem" # any ssh destination ssh_options = [] # extra ssh args; ControlMaster options always added [refresh] dashboard = 30 jobs = 5 [notify] enabled = true events = ["finished", "failed", "killed", "lost", "vm-up"] [vm] libvirt_uri = "qemu:///system" domain = "buildsystem" # empty disables VM control autostart = false ``` Missing file: these defaults. Invalid key or type: dialog naming the key, then defaults are used. Read at startup and on `File > Reload config`. No settings dialog yet. ## Error handling summary | case | behaviour | |---|---| | ssh down | status bar, VM check, stale tiles; the `jobs` poll interval doubles from `refresh.jobs` up to 30s, and resets once ssh answers | | helper missing or version mismatch | banner with `./install.sh --remote` | | busy | dialog with [Show job] | | follow drop | reconnect from offset, job unaffected | | helper exception | `{"error"}` shown in status bar | | `_run` died, VM rebooted mid-job | `lost`, red in list, notification | | config invalid | dialog naming key, defaults used | ## Testing stdlib `unittest`, no frameworks. - `tests/test_remote.py`: imports `remote/sbb-remote` as a module with a temp `state_dir`. Covers argument validation (unknown kind, overlay not in config, unknown flag), lock busy, `lost` detection, `job-follow` from an offset, and a full `_run` of a fake kind patched into the kind table (ok, failed, killed). - `tests/test_gui.py`: config load and validation, `remote.parse`, GUI and helper versions equal, both example configs equal the defaults. Smoke tests under `QT_QPA_PLATFORM=offscreen` with a fake Remote: tabs, Dashboard from a canned `stats` dict with `null` fields, job-end notification sent once, VM-off offer, carriage-return handling in the log view. - slackrepo_setup: sbshadow `--yes` and `--no` verified in a throwaway git repo, the same way the current behaviour was tested. - First run against the real VM: manual, with the user. ## Install - `install.sh`: symlinks the `sb-builder` launcher into `~/bin` (`BINDIR` overrides), so repo edits are live; copies `config.example.toml` to the config path only if no config exists. - `install.sh --remote`: scp `remote/sbb-remote` to `:/usr/local/bin/` (`SBB_TARGET`, default `buildsystem`), create `/etc/sb-builder/config.toml` from the example only if missing. Asks before deploying. A SlackBuild comes later.