diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6016e38..92fcbbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,44 @@
All notable changes to VesselStack are documented here. The project follows
Semantic Versioning.
+## Unreleased
+
+Planned for 0.6.2 (`VERSION` is not bumped here).
+
+### Highlights
+
+- The Control Panel's **Back up & install** and **update from folder** no longer fail when the backup
+ drive is not mounted. A dialog lets you, for that one request, mount the drive from `/etc/fstab`,
+ mount a connected USB drive (ext4 recommended, exFAT allowed; chosen by UUID from a list built as
+ root), back up to the system disk once, use a different backup folder, or install without a backup
+ (needs an explicit acknowledgement). Nothing is remembered, and neither `/etc/fstab` nor
+ `vesselstack.env` is written; `backup_mode` / `backup_skipped` appear in the operation record for
+ display only. `VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false` (in `vesselstack.env` or the process
+ environment, re-checked by the worker) turns off the system-disk and no-backup choices. A drive
+ mounted by UUID is verified after mounting; the `/etc/fstab` mount uses `nodev,nosuid,noexec`;
+ `--system-disk-once` is refused when the data size cannot be measured. `backup` is accepted only for
+ update actions. Without a choice the upgrade still requires a mounted backup.
+- New controller commands: `vesselstackctl backup --system-disk-once`, `vesselstackctl backup-drive list
+ --json` and `vesselstackctl backup-drive mount --fstab | --uuid UUID`; `vesselstackctl update DIR
+ --backup-choice=system-disk-once|skip` is accepted for the Control Panel and the pinned installer.
+- New read-only `GET /api/backup-preflight` for the dialog.
+
+### Fixes
+
+- `tests/test-lifecycle.sh` no longer fails intermittently on "Backup is missing the Prometheus data directory": the archive listing is read into a variable instead of `tar -t | grep -q` under `pipefail` (SIGPIPE when the match came early). Test-only; product behaviour is unchanged.
+- "Back up to the system disk this once" (terminal menu, pinned installer with a current controller)
+ now writes to `/var/backups/vesselstack` and leaves the unmounted `/media` or `/mnt` folder empty,
+ so the drive can still be mounted there later. An older installed controller still writes into that
+ folder.
+- Tests: the VesselTwin preload listing assertion no longer depends on filesystem directory order.
+
+### Upgrade notes
+
+This cannot help the upgrade that delivers it: the Control Panel worker uses the controller and panel
+that are already installed. For the upgrade to 0.6.2, mount the drive by hand or run the update from a
+terminal with `get-vesselstack.sh`. Rollback: unmount with `umount`; there is nothing to undo in
+`/etc/fstab` or `vesselstack.env`.
+
## 0.6.1 - 2026-11-02
### Highlights
diff --git a/README.md b/README.md
index a6bba0a..f4ca3ad 100644
--- a/README.md
+++ b/README.md
@@ -454,6 +454,15 @@ than the fix); use `get-vesselstack.sh`, or mount the drive first, or run
`sudo env VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=true vesselstackctl update
` to back up to
the system disk once.
+**Backup drive not mounted, from the Control Panel (0.6.2).** The Control Panel can now resolve this
+itself, for that one request: **Back up & install** and **Back up & update from folder** check the
+backup folder first and, when the drive is not mounted, show a dialog to mount it from `/etc/fstab`,
+mount a listed USB drive, back up to the system disk once, use a different backup folder, or install
+without a backup. See [Backups](#backups). This cannot help the upgrade that delivers it: the
+worker uses the controller that is already installed, so for the upgrade to 0.6.2 mount the drive by
+hand or run the update from a terminal. Rollback: unmount with `umount`; nothing is written to
+`/etc/fstab` or `vesselstack.env`.
+
Rollback: set `BOAT_UNITS` back to `us_customary` (or `metric`) in the same file. 0.6.1
needs no other manual step.
@@ -1160,7 +1169,10 @@ Nothing has been changed yet.
- **[3]** checks the mount again after you plug the drive in.
- **[4]** writes this one backup to the system disk after a warning. It is not offered when
the free space is smaller than the data (`du -skx`, 60 s limit) or when `vesselstack.env` sets
- `VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false`. Nothing is saved: the next backup asks again.
+ `VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false`. Since 0.6.2 the archive goes to
+ `/var/backups/vesselstack` on the system disk, not into the unmounted mount folder, which stays
+ empty (so the mount point is still free to mount later). Nothing is saved: the next backup asks
+ again.
- **[c]**, end of input or no answer within 5 minutes cancels without changing anything (in
the pinned installer it prints `Cancelled. The update was not started; your installed VesselStack
was not changed.`; the packages and the release folder may already be in place). An answer must
@@ -1174,6 +1186,45 @@ menu; use `get-vesselstack.sh` (it asks before calling the controller) or
`sudo env VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=true vesselstackctl update `.
Rollback: unmount the drive with `umount`; remove the added `/etc/fstab` line if you do not want it.
+**Control Panel (0.6.2).** The panel cannot answer prompts, so before **Back up & install** and
+**Back up & update from folder** it asks `GET /api/backup-preflight`. If the backup folder is on a
+mounted drive the usual confirmation appears, unchanged. Otherwise a dialog offers, for this request
+only (nothing is saved and `vesselstack.env` is never edited):
+
+- mount the drive from `/etc/fstab`, when there is an entry for the backup folder;
+- mount one of the listed USB drives (chosen by its UUID from a list the root side produced; the
+ browser never sends a device path or a folder). The same rules as the terminal apply: ext4
+ (recommended) or exFAT, removable, not part of the system disk, `nodev,nosuid,noexec` (exFAT also
+ `uid=0,gid=0,umask=0077`), an empty mount folder, and the post-mount ownership and symbolic-link
+ check. `/etc/fstab` is never written; refused and already-mounted drives are listed with the reason;
+- back up to the system disk this once: `/var/backups/vesselstack` when the configured folder is on an
+ unmounted `/media` or `/mnt` mount point (otherwise the configured folder). It is refused when the
+ free space is smaller than the data;
+- use a different backup folder: saves `VESSELSTACK_BACKUP` through the normal settings call, then
+ checks again (not available while Settings has unsaved edits);
+- install without a backup: needs the box **I understand there is no backup to restore if this update
+ fails**. The result banner then says no backup was made.
+
+Setting `VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false` in `vesselstack.env`, or in the environment of the
+panel or the controller, turns off the system-disk and no-backup choices (mounting remains available).
+The Update worker checks this again right before it skips or redirects the backup, and the controller
+refuses `--system-disk-once` while it is set. After a drive is mounted by UUID the controller checks
+that the filesystem now at the mount folder has that UUID, and unmounts it again if not; the
+`/etc/fstab` mount adds `nodev,nosuid,noexec`. The choice applies to `update` and `update-release`
+only (`/api/action` answers 400 for any other action that carries `backup`), and `--system-disk-once`
+is refused when the size of the data could not be measured. The choice is recorded in the operation
+record as `backup_mode` / `backup_skipped` for display only; it is never written to `vesselstack.env`
+or reused by a later request. The mount runs as root outside the panel's sandbox:
+in the update worker for **Back up & install** (after the installer's `--dry-run`, before anything is
+stopped), and in a transient `systemd-run --wait --pipe --collect --property=RuntimeMaxSec=300` unit for **update from folder**
+(before the update starts). The controller gained `vesselstackctl backup --system-disk-once`,
+`vesselstackctl backup-drive list --json` and `vesselstackctl backup-drive mount --fstab | --uuid UUID`;
+`vesselstackctl update DIR --backup-choice=system-disk-once|skip` is accepted for the Control Panel and
+the pinned installer and is not meant for terminal use (a terminal run shows the menu above). The
+panel refuses these choices clearly when the installed controller is older. This cannot help the upgrade that delivers it: the Control Panel worker uses the controller and panel that are already installed. For the upgrade to 0.6.2 itself, mount the drive by hand (or run the update from a terminal with `get-vesselstack.sh`) as before.
+Rollback: unmount with `umount`; to undo an update installed without a backup, restore an earlier
+backup, if you have one (`vesselstackctl restore`, see [Updating](#updating)).
+
Besides the application and data trees the
archive includes `/etc/systemd/system/vesselstack-*`,
`/usr/local/sbin/vesselstack{ctl,-firewall}` and `/etc/vesselstack/update.env`
@@ -1236,8 +1287,9 @@ live database files is insufficient.
This is the one place in the README that describes updating and rolling back.
Updates and backups started from the Control Panel never prompt; when one stops
-for something you can fix, the result banner shows a **To fix:** line with the
-exact step.
+for something you can fix, the result banner shows a **To fix:** line with the exact step.
+**Back up & install** and **update from folder** instead show a dialog when the backup drive is
+not mounted (see [Backups](#backups)).
1. **Back up first** to a mounted external drive (see [Backups](#backups)):
@@ -1272,7 +1324,8 @@ exact step.
**Check for updates**. **Back up & install** asks for confirmation in a dialog, verifies the downloaded SHA-256 manifest, safely extracts the
release, runs preflight, then makes and verifies a backup on a mounted external drive
(`/media` or `/mnt`; the browser cannot change the destination). A failed or unmounted
- backup stops the update. A detached updater restarts the Control Panel and verifies its
+ backup stops the update unless you choose, in the dialog the panel shows, to mount the drive, back
+ up to the system disk once or install without a backup. A detached updater restarts the Control Panel and verifies its
new version before the browser reconnects. The panel uses ordinary HTTP and serializes
its own update, configuration and component operations; do not run CLI maintenance
commands at the same time. Transient updater files under `/var/lib/vesselstack` are not
@@ -1352,7 +1405,8 @@ The backup folder is under `/media` or `/mnt` and the drive is not mounted. From
USB drive, check again, or back up to the system disk once (see [Backups](#backups)). Without
a terminal, or from an installed 0.6.0 or earlier controller, mount the drive and run again, or
use `sudo env VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=true vesselstackctl update ` for one
-backup on the system disk. A FAT32 (vfat) drive is refused; use ext4 or exFAT.
+backup on the system disk. In the Control Panel (0.6.2 or later) **Back up & install** shows a dialog
+for this instead of failing. A FAT32 (vfat) drive is refused; use ext4 or exFAT.
### The installer stops on `BOAT_UNITS` or "cannot be written safely"
@@ -1448,6 +1502,10 @@ Run `python3 -m unittest discover -s tests -p 'test_*.py'` and the shell suites
(protected temporary roots, CI jobs, and the ESPHome config test) as described in
[Development: Developer validation](docs/DEVELOPMENT.md#developer-validation).
+Test assertions on directory listings sort the result first, so they do not depend on filesystem order.
+Archive-listing assertions read `tar -t` into a variable instead of piping it into `grep -q`: under `pipefail` an early match made `tar` die of SIGPIPE, so `tests/test-lifecycle.sh` failed on some filesystems, depending on directory order.
+To reproduce CI on a Mac, run `sudo tests/test-lifecycle.sh` in a privileged `ubuntu:24.04` container with `jq`, `shellcheck`, `python3` and `util-linux` installed.
+
## Documentation policy
Every repository change must update this README in the same pull request. CI
diff --git a/control-panel/app.py b/control-panel/app.py
index 9dca9b7..1999037 100644
--- a/control-panel/app.py
+++ b/control-panel/app.py
@@ -24,6 +24,7 @@
try:
from browser_updates import installed_version, release_catalog, read_operation, public_operation, release_snapshot, running_identity, acquire_slot, release_slot, start_detached
+ from browser_updates import normalize_backup_choice, effective_backup_choice, backup_locked, ctl_supports
except ImportError:
import importlib.util
_spec = importlib.util.spec_from_file_location("browser_updates", Path(__file__).with_name("browser_updates.py"))
@@ -35,6 +36,8 @@
acquire_slot = _updates.acquire_slot; release_slot = _updates.release_slot
start_detached = _updates.start_detached
release_snapshot = _updates.release_snapshot
+ normalize_backup_choice = _updates.normalize_backup_choice; effective_backup_choice = _updates.effective_backup_choice
+ backup_locked = _updates.backup_locked; ctl_supports = _updates.ctl_supports
ROOT = Path(os.environ.get("VESSELSTACK_ROOT", "/opt/vesselstack"))
@@ -700,7 +703,7 @@ def verify_release_tree(release: Path) -> None:
verify_release_path(path)
-def action_command(action: str, parameters: dict[str, Any]) -> list[str]:
+def action_command(action: str, parameters: dict[str, Any], backup_choice: dict[str, str] | None = None) -> list[str]:
if action in {"preflight", "apply", "install"}:
if not INSTALLER.is_file():
raise ValueError(f"Bundled installer not found at {INSTALLER}")
@@ -729,7 +732,12 @@ def action_command(action: str, parameters: dict[str, Any]) -> list[str]:
for candidate in (reviewed_root, resolved):
verify_release_path(candidate)
verify_release_tree(resolved)
- return [str(CTL), "update", str(resolved)]
+ command = [str(CTL), "update", str(resolved)]
+ # backup_choice only ever comes from request_backup_choice(), never straight from the request body.
+ if backup_choice and backup_choice.get("mode") in {"system_disk_once", "skip"}:
+ # The reviewed folder comes first (older controllers read only the first argument).
+ command.append("--backup-choice=" + backup_choice["mode"].replace("_", "-"))
+ return command
raise ValueError("Unsupported action")
@@ -741,6 +749,15 @@ def command_output(command: list[str], timeout: int = 8) -> tuple[int, str]:
return 1, str(exc)
+def command_stdout(command: list[str], timeout: int = 20) -> tuple[int, str]:
+ """Like command_output, but stderr is dropped so a warning cannot corrupt JSON."""
+ try:
+ result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout, check=False)
+ return result.returncode, result.stdout.strip()
+ except (OSError, subprocess.TimeoutExpired):
+ return 1, ""
+
+
def describe_component(name: str, kind: str, enabled: bool, state: str) -> dict[str, Any]:
"""One component as the UI shows it: real name, plain status, no contradictions."""
label, description, section = COMPONENT_INFO.get(name, (name.replace("-", " ").title(), "", ""))
@@ -881,6 +898,92 @@ def backup_status(vessel: dict[str, str] | None = None) -> dict[str, Any]:
return result
+DRIVE_FIELDS = ("device", "label", "size", "fstype")
+
+
+def _drive_rows(value: Any, extra: tuple[str, ...]) -> list[dict[str, str]]:
+ rows: list[dict[str, str]] = []
+ for item in value if isinstance(value, list) else []:
+ if not isinstance(item, dict):
+ continue
+ row = {key: str(item.get(key) or "")[:120] for key in (*DRIVE_FIELDS, *extra)}
+ if "uuid" in extra and not re.fullmatch(r"[A-Za-z0-9-]{1,64}", row["uuid"]):
+ continue
+ rows.append(row)
+ return rows[:20]
+
+
+def backup_is_locked() -> bool:
+ """Locked by vesselstack.env or by the panel's own process environment."""
+ return bool(backup_locked(VESSEL_ENV) or os.environ.get("VESSELSTACK_BACKUP_ALLOW_UNMOUNTED") == "false")
+
+
+def backup_preflight() -> dict[str, Any]:
+ """What the owner may choose before a browser upgrade or folder update. Read-only.
+
+ default_ok means the normal path (a mounted backup folder) will work. The drive list comes from
+ the installed vesselstackctl (root side); the browser can only pick an entry by its UUID, and the
+ root side lists again before it mounts anything. Any problem there yields an empty drive list.
+ """
+ status = backup_status()
+ mount = status.get("mount", "unknown")
+ locked = backup_is_locked()
+ listing: dict[str, Any] = {}
+ if mount == "not_mounted":
+ code, text = command_stdout([str(CTL), "backup-drive", "list", "--json"])
+ if code == 0:
+ try:
+ parsed = json.loads(text)
+ listing = parsed if isinstance(parsed, dict) else {}
+ except ValueError:
+ listing = {}
+ offered = (not locked) and mount in {"not_mounted", "system_disk"}
+ if locked:
+ reason = "Turned off: VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false is set in the configuration."
+ elif not offered:
+ reason = "The backup folder is on a mounted drive or could not be checked."
+ else:
+ reason = ""
+ return {
+ "path": status.get("path", ""),
+ "mount": mount,
+ "default_ok": mount == "mounted",
+ "fstab_mount": bool(listing.get("fstab_mount")) and mount == "not_mounted",
+ "drives": _drive_rows(listing.get("drives"), ("uuid",)),
+ "refused": _drive_rows(listing.get("refused"), ("reason",)),
+ "mounted_elsewhere": _drive_rows(listing.get("mounted_elsewhere"), ("mountpoint",)),
+ "system_disk_once": {"offered": offered, "reason": reason, "target": DEFAULT_BACKUP_DIR if mount == "not_mounted" else status.get("path", "")},
+ "skip": {"offered": not locked},
+ }
+
+
+def request_backup_choice(payload: dict[str, Any], folder: bool = False) -> dict[str, str] | None:
+ """The validated one-shot backup choice of this request (None means the normal mounted backup).
+
+ The release worker checks the installed controller itself; a folder update runs the installed
+ controller directly, so it is checked here.
+ """
+ choice = normalize_backup_choice(payload.get("backup"), backup_is_locked())
+ choice, _ = effective_backup_choice(choice, VESSEL_ENV)
+ if folder and choice is not None and not ctl_supports("backup-drive", str(CTL)):
+ raise ValueError("The installed vesselstackctl is older than this Control Panel and does not support that choice. Mount the drive by hand, or run the update from a terminal.")
+ return choice
+
+
+def folder_update_steps(choice: dict[str, str] | None) -> list[list[str]]:
+ """Commands that run before `vesselstackctl update DIR`: the mount, outside the panel's sandbox.
+
+ The panel runs with a private mount namespace, so a mount made here would not be visible to the
+ rest of the system. A transient unit is started by systemd instead and runs as plain root.
+ """
+ if not choice or choice["mode"] not in {"mount_fstab", "mount_drive"}:
+ return []
+ unit = ["systemd-run", "--wait", "--pipe", "--collect", "--quiet", "--property=RuntimeMaxSec=300",
+ f"--setenv=VESSELSTACK_ROOT={ROOT}", f"--setenv=VESSELSTACK_ENV={VESSEL_ENV}", "--setenv=VESSELSTACK_NONINTERACTIVE=true"]
+ mount = [str(CTL), "backup-drive", "mount"] + (["--fstab"] if choice["mode"] == "mount_fstab" else ["--uuid", choice["uuid"]])
+ return [unit + mount]
+
+
ERROR_LINE = re.compile(r"^(?:\[[^\]]*\]\s*)?(?:ERROR|Error|error|FATAL|Refusing[^:]*|Backup[^:]*failed)[:\s]+(.*)$")
@@ -938,11 +1041,12 @@ def __init__(self) -> None:
self.current: dict[str, Any] | None = None
self.counter = 0
- def start(self, label: str, command_or_factory: list[str] | Any) -> dict[str, Any]:
+ def start(self, label: str, command_or_factory: list[str] | Any, before: Any = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
slot_fd = take_lifecycle_slot()
operation = None
try:
command = command_or_factory() if callable(command_or_factory) else command_or_factory
+ steps = (before() if callable(before) else before) or []
with self.lock:
if self.current and self.current["state"] == "running":
raise ValueError("Another operation is already running")
@@ -952,9 +1056,10 @@ def start(self, label: str, command_or_factory: list[str] | Any) -> dict[str, An
"command": command[0:1] + ["[arguments hidden]"],
"state": "running", "started_at": int(time.time()),
"finished_at": None, "exit_code": None, "output": deque(maxlen=400),
+ **(extra or {}),
}
self.current = operation
- threading.Thread(target=self._run, args=(operation, command, slot_fd), daemon=True).start()
+ threading.Thread(target=self._run, args=(operation, command, slot_fd, steps), daemon=True).start()
return self.public()
except Exception:
if operation is not None:
@@ -966,19 +1071,24 @@ def start(self, label: str, command_or_factory: list[str] | Any) -> dict[str, An
release_lifecycle_slot(slot_fd)
raise
- def _run(self, operation: dict[str, Any], command: list[str], slot_fd) -> None:
+ def _run(self, operation: dict[str, Any], command: list[str], slot_fd, steps: list[list[str]] | None = None) -> None:
environment = os.environ.copy()
environment["VESSELSTACK_SKIP_PANEL_RESTART"] = "true"
environment["VESSELSTACK_NONINTERACTIVE"] = "true" # the panel cannot answer prompts; scripts print "To fix:" guidance instead
exit_code = 1
succeeded = False
try:
- process = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=environment)
- assert process.stdout is not None
- for line in process.stdout:
- with self.lock:
- operation["output"].append(line.rstrip())
- exit_code = process.wait()
+ # Steps (for example mounting the backup drive) run first; the first failure stops everything.
+ exit_code = 0
+ for step in [*(steps or []), command]:
+ process = subprocess.Popen(step, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=environment)
+ assert process.stdout is not None
+ for line in process.stdout:
+ with self.lock:
+ operation["output"].append(line.rstrip())
+ exit_code = process.wait()
+ if exit_code != 0:
+ break
succeeded = exit_code == 0
with self.lock:
if succeeded and operation["label"] in {"apply", "install", "update"} and restart_needed():
@@ -1108,6 +1218,8 @@ def do_GET(self) -> None:
operation = memory if memory.get("state") == "running" else max((persisted, memory), key=lambda item: int(item.get("finished_at") or item.get("started_at") or 0))
vessel = parse_env(VESSEL_ENV)
self.send_json(200, {"components": component_status(), "operation": with_summary(operation), "installed_version": installed_version(ROOT), "boat_name": vessel.get("BOAT_NAME", ""), "backup": backup_status(vessel), "restart_needed": restart_needed(), **RUNNING_IDENTITY})
+ elif path == "/api/backup-preflight":
+ self.send_json(200, backup_preflight())
elif path == "/api/operation":
persisted = public_operation(ROOT)
memory = OPERATIONS.public()
@@ -1153,6 +1265,8 @@ def do_POST(self) -> None:
return
if path == "/api/action":
action = str(payload.get("action", ""))
+ if payload.get("backup") is not None and action not in {"update", "update-release"}:
+ raise ValueError("backup choices apply only to updates")
if action == "restart-panel":
slot = take_lifecycle_slot()
try:
@@ -1166,13 +1280,23 @@ def do_POST(self) -> None:
raise ValueError("release_id and channel are required")
if OPERATIONS.public().get("state") == "running":
raise ValueError("Another operation is already running")
+ choice = request_backup_choice(payload)
slot = take_lifecycle_slot()
try:
snapshot = release_snapshot(release_id, channel, ROOT)
- result = start_detached("update-release", ROOT, RUNNING_IDENTITY, release_id, channel, target_version=snapshot["version"], snapshot=snapshot)
+ extra_args = {"backup": choice} if choice else {}
+ result = start_detached("update-release", ROOT, RUNNING_IDENTITY, release_id, channel, target_version=snapshot["version"], snapshot=snapshot, **extra_args)
finally:
release_lifecycle_slot(slot)
self.send_json(202, result); return
+ if action == "update" and payload.get("backup") is not None:
+ choice = request_backup_choice(payload, folder=True)
+ if choice is not None:
+ info: dict[str, Any] = {"backup_mode": choice["mode"]}
+ if choice["mode"] == "skip":
+ info["backup_skipped"] = True
+ self.send_json(202, OPERATIONS.start(action, lambda: action_command(action, payload, choice), before=lambda: folder_update_steps(choice), extra=info))
+ return
self.send_json(202, OPERATIONS.start(action, lambda: action_command(action, payload)))
return
if path == "/api/restart-panel":
diff --git a/control-panel/browser_updates.py b/control-panel/browser_updates.py
index 1fbb331..b2323e1 100644
--- a/control-panel/browser_updates.py
+++ b/control-panel/browser_updates.py
@@ -43,9 +43,100 @@
MAX_ARCHIVE_EXPANDED = 1024 * 1024 * 1024
FIXED_CTL = "/usr/local/sbin/vesselstackctl"
OPERATION_FILE_NAME = "browser-update.json"
+DEFAULT_BACKUP_DIR = "/var/backups/vesselstack"
+
+# One-shot choices for an upgrade whose backup drive is not mounted. They live only in the per-job
+# request and the operation record; nothing is ever written to vesselstack.env. The same rules run
+# in the panel (admission) and again in the detached worker, which is a copy of this file.
+BACKUP_UUID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
+BACKUP_CONFIRM = {"system_disk_once": "system-disk-once", "skip": "install-without-backup"}
+BACKUP_MODES = ("require_mounted", "mount_fstab", "mount_drive", "system_disk_once", "skip")
+BACKUP_LOCK = re.compile(r"^(export[ \t]+)?VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=[\"']?false", re.MULTILINE)
+BACKUP_SKIP_WARNING = "WARNING: backup skipped at the owner's request for this update; there is no archive to restore from."
PROCESS_BOOT_ID = uuid.uuid4().hex
+def _env_value(config: Path, key: str) -> str:
+ """Last KEY=value in an env file, without quotes. Empty when absent or unreadable."""
+ try:
+ text = Path(config).read_text(errors="replace")
+ except OSError:
+ return ""
+ value = ""
+ for line in text.splitlines():
+ match = re.match(rf"^(?:export[ \t]+)?{re.escape(key)}=(.*)$", line.strip())
+ if match:
+ value = match.group(1).strip()
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
+ value = value[1:-1]
+ return value
+
+
+def backup_locked(config: Path) -> bool:
+ """True when vesselstack.env pins VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false (absent means not locked)."""
+ try:
+ return bool(BACKUP_LOCK.search(Path(config).read_text(errors="replace")))
+ except OSError:
+ return False
+
+
+def backup_destination(config: Path) -> tuple[str, str]:
+ """(resolved backup folder, mounted | not_mounted | system_disk), derived like vesselstackctl backup."""
+ value = _env_value(config, "VESSELSTACK_BACKUP") or os.environ.get("VESSELSTACK_BACKUP") or DEFAULT_BACKUP_DIR
+ destination = os.path.realpath(value)
+ parts = Path(destination).parts
+ if len(parts) >= 3 and parts[1] in {"media", "mnt"}:
+ return destination, "mounted" if os.path.ismount(os.path.join("/", parts[1], parts[2])) else "not_mounted"
+ return destination, "system_disk"
+
+
+def normalize_backup_choice(raw: Any, locked: bool) -> dict[str, str] | None:
+ """Validate the optional backup object. None means the default (require a mounted backup).
+
+ Unknown keys or modes are refused. system_disk_once and skip need their exact confirmation word
+ and are refused while the environment file pins VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false.
+ """
+ if raw is None:
+ return None
+ if not isinstance(raw, dict):
+ raise ValueError("backup must be an object")
+ mode = raw.get("mode")
+ if not isinstance(mode, str) or mode not in BACKUP_MODES:
+ raise ValueError("unknown backup mode")
+ allowed = {"mode"} | ({"uuid"} if mode == "mount_drive" else set()) | ({"confirm"} if mode in BACKUP_CONFIRM else set())
+ if set(raw) - allowed:
+ raise ValueError("unexpected backup field")
+ if mode == "require_mounted":
+ return None
+ if mode == "mount_drive":
+ uuid_value = raw.get("uuid")
+ if not isinstance(uuid_value, str) or not BACKUP_UUID.fullmatch(uuid_value):
+ raise ValueError("backup drive id is invalid")
+ return {"mode": mode, "uuid": uuid_value}
+ if mode in BACKUP_CONFIRM:
+ if locked:
+ raise ValueError("This option is turned off: VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false is set")
+ if raw.get("confirm") != BACKUP_CONFIRM[mode]:
+ raise ValueError("backup confirmation is missing")
+ return {"mode": mode, "confirm": BACKUP_CONFIRM[mode]}
+ return {"mode": mode}
+
+
+def effective_backup_choice(choice: dict[str, str] | None, config: Path) -> tuple[dict[str, str] | None, str]:
+ """Falls back to the default when the drive got mounted after the choice was made.
+
+ Returns (choice or None, a note for the log; empty when nothing changed).
+ """
+ if choice is None:
+ return None, ""
+ _, mount = backup_destination(config)
+ if mount == "mounted":
+ return None, "The backup drive is mounted now, so the normal backup is used."
+ if mount == "system_disk" and choice["mode"] in {"mount_fstab", "mount_drive"}:
+ return None, "The backup folder is not on a removable drive, so the normal backup is used."
+ return choice, ""
+
+
def _require_trusted_path(path: Path) -> Path:
"""Require an absolute path whose existing ancestors are root-owned (or us)."""
raw = os.fspath(path)
@@ -323,7 +414,7 @@ def release_slot(fd) -> None:
os.close(fd)
-def start_detached(action: str, root: Path, old_identity: dict[str, str], release_id: int | None = None, channel: str | None = None, ctl: str = FIXED_CTL, target_version: str | None = None, snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
+def start_detached(action: str, root: Path, old_identity: dict[str, str], release_id: int | None = None, channel: str | None = None, ctl: str = FIXED_CTL, target_version: str | None = None, snapshot: dict[str, Any] | None = None, backup: dict[str, str] | None = None) -> dict[str, Any]:
state = read_operation(root)
if state.get("state") == "running":
raise ValueError("Another operation is already running")
@@ -344,9 +435,13 @@ def start_detached(action: str, root: Path, old_identity: dict[str, str], releas
request = {"id": int(time.time() * 1000), "action": action, "root": str(root), "config": str(root / "config" / "vesselstack.env"), "ctl": ctl, "old_boot_id": old_identity.get("boot_id", ""), "target_version": target_version, "release_id": release_id, "channel": channel}
if action == "update-release":
request["release_snapshot"] = snapshot
+ if backup is not None:
+ request["backup"] = backup
(job / "request.json").write_text(json.dumps(request, sort_keys=True)); os.chmod(job / "request.json", 0o600)
shutil.copy2(Path(__file__), job / "runner.py"); os.chmod(job / "runner.py", 0o700)
operation = {"id": request["id"], "label": action, "state": "running", "phase": "queued", "target_version": request["target_version"], "backup_path": None, "started_at": int(time.time()), "finished_at": None, "exit_code": None, "output": [], "boot_id": old_identity.get("boot_id"), "running_version": old_identity.get("running_version")}
+ if action == "update-release" and backup is not None:
+ operation["backup_mode"] = backup["mode"]
write_operation(root, operation)
command = ["systemd-run", "--unit=vesselstack-browser-update", "--collect", "--property=Type=exec", "/usr/bin/python3", "-I", str(job / "runner.py"), "--worker", str(job / "request.json")]
try:
@@ -601,6 +696,15 @@ def safe_extract(archive: Path, destination: Path, expected_version: str | None
raise ValueError("archive is missing required release structure")
+def ctl_supports(token: str, ctl: str = FIXED_CTL) -> bool:
+ """True when the installed vesselstackctl lists the command or option (older ones cannot)."""
+ try:
+ result = subprocess.run([ctl, "--help"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=15, check=False)
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+ return token in (result.stdout or "")
+
+
def run_update(request: dict[str, Any], operation: dict[str, Any], root: Path) -> None:
"""Execute the reviewed update sequence for a detached worker request."""
release_id = int(request["release_id"])
@@ -609,6 +713,8 @@ def run_update(request: dict[str, Any], operation: dict[str, Any], root: Path) -
saved = request.get("release_snapshot")
if not isinstance(saved, dict):
raise ValueError("release snapshot is required")
+ # The choice was validated at admission; it is checked again here, against the env file as it is now.
+ choice = normalize_backup_choice(request.get("backup"), backup_locked(Path(config)))
fresh = release_snapshot(release_id, channel, root)
assert_snapshot_unchanged(saved, fresh)
target_version = fresh["version"]
@@ -654,27 +760,62 @@ def run_update(request: dict[str, Any], operation: dict[str, Any], root: Path) -
_validate_fixed_ctl()
operation.update(phase="preflight"); write_operation(root, operation)
run_command([str(installer), "--config", config, "--dry-run"], root, operation, 1800, child_env)
- operation.update(phase="backing-up"); write_operation(root, operation)
- backup_result = run_command([FIXED_CTL, "backup", "--require-mounted"], root, operation, 1800, child_env)
- output = (backup_result.stdout or "").splitlines()
- if not output or not output[-1].strip():
- raise ValueError("backup did not report an archive path")
- operation["backup_path"] = output[-1].strip()
- write_operation(root, operation)
+ choice, note = effective_backup_choice(choice, Path(config))
+ mode = choice["mode"] if choice else "require_mounted"
+ # The record names the handling that is really used (a fallback to the normal backup clears it).
+ if choice:
+ operation["backup_mode"] = mode
+ else:
+ operation.pop("backup_mode", None)
+ if mode in {"mount_fstab", "mount_drive"}:
+ if not ctl_supports("backup-drive"):
+ raise ValueError("The installed vesselstackctl is older than this Control Panel and cannot mount the backup drive. Mount the drive by hand, or run the update from a terminal.")
+ operation.update(phase="mounting-backup-drive"); write_operation(root, operation)
+ mount_argv = [FIXED_CTL, "backup-drive", "mount"] + (["--fstab"] if mode == "mount_fstab" else ["--uuid", choice["uuid"]])
+ run_command(mount_argv, root, operation, 300, child_env)
+ elif mode == "system_disk_once" and not ctl_supports("--system-disk-once"):
+ raise ValueError("The installed vesselstackctl is older than this Control Panel and cannot back up to the system disk this once. Mount the drive by hand, or run the update from a terminal.")
+ if mode in {"skip", "system_disk_once"} and (backup_locked(Path(config)) or os.environ.get("VESSELSTACK_BACKUP_ALLOW_UNMOUNTED") == "false"):
+ raise ValueError("This backup option is turned off: VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=false is set. Nothing was changed.")
+ if mode == "skip":
+ # No backup is made and the backup command is never called.
+ operation.update(phase="backing-up", backup_skipped=True, backup_path=None,
+ output=[BACKUP_SKIP_WARNING] + [line for line in operation.get("output") or [] if line != BACKUP_SKIP_WARNING])
+ write_operation(root, operation)
+ else:
+ operation.update(phase="backing-up"); write_operation(root, operation)
+ backup_argv = [FIXED_CTL, "backup", "--system-disk-once"] if mode == "system_disk_once" else [FIXED_CTL, "backup", "--require-mounted"]
+ backup_result = run_command(backup_argv, root, operation, 1800, child_env)
+ output = (backup_result.stdout or "").splitlines()
+ if not output or not output[-1].strip():
+ raise ValueError("backup did not report an archive path")
+ operation["backup_path"] = output[-1].strip()
+ if note:
+ operation["output"] = [note] + list(operation.get("output") or [])
+ write_operation(root, operation)
operation.update(phase="installing"); write_operation(root, operation)
install_env = dict(child_env)
install_env["VESSELSTACK_SKIP_PANEL_RESTART"] = "true"
install_env["VESSELSTACK_ROOT"] = str(root)
install_env["VESSELSTACK_ENV"] = config
run_command([str(installer), "--config", config, "--start"], root, operation, 1800, install_env)
+ keep_skip_warning(operation)
operation.update(phase="restarting"); write_operation(root, operation)
run_command(["systemctl", "restart", "vesselstack-control-panel.service"], root, operation, 60)
+ keep_skip_warning(operation)
operation.update(phase="verifying-restart"); write_operation(root, operation)
identity = verify_restart(root, str(request["old_boot_id"]), target_version)
+ keep_skip_warning(operation)
operation.update(state="succeeded", phase="complete", finished_at=int(time.time()), exit_code=0,
boot_id=identity["boot_id"], running_version=identity["running_version"])
+def keep_skip_warning(operation: dict[str, Any]) -> None:
+ """Every command replaces the logged output, so the skipped-backup warning is put back on top."""
+ if operation.get("backup_skipped"):
+ operation["output"] = [BACKUP_SKIP_WARNING] + [line for line in operation.get("output") or [] if line != BACKUP_SKIP_WARNING]
+
+
def run_worker(request_path: Path) -> None:
request = _read_json_private(request_path, 64 * 1024)
root = Path(str(request.get("root", "")))
@@ -702,6 +843,7 @@ def run_worker(request_path: Path) -> None:
prior = list(operation.get("output") or [])
prior.append(str(exc)[:MAX_OUTPUT])
operation.update(state="failed", phase="failed", finished_at=int(time.time()), exit_code=1, output=prior[-400:])
+ keep_skip_warning(operation)
finally:
write_operation(root, operation); release_slot(lock)
diff --git a/control-panel/static/app.js b/control-panel/static/app.js
index 26cfb08..c17eb99 100644
--- a/control-panel/static/app.js
+++ b/control-panel/static/app.js
@@ -164,6 +164,166 @@ function ask(spec) {
});
}
+// ---------------------------------------------------------------- backup choice (drive not mounted)
+
+const SKIP_ACK_TEXT = "I understand there is no backup to restore if this update fails";
+const mountFolder = (path) => {
+ const parts = String(path || "").split("/").filter(Boolean);
+ return ["media", "mnt"].includes(parts[0]) && parts[1] ? `/${parts[0]}/${parts[1]}` : String(path || "");
+};
+
+function backupOptions(pre) {
+ const folder = mountFolder(pre.path);
+ const options = [];
+ if (pre.fstab_mount) options.push({ id: "fstab", label: "Mount the backup drive now", detail: `It is listed in /etc/fstab for ${folder}.`, ok: "Mount and continue", backup: { mode: "mount_fstab" } });
+ (pre.drives || []).forEach((drive) => options.push({
+ id: `drive:${drive.uuid}`,
+ label: `Mount ${drive.label ? `"${drive.label}"` : "the drive with no name"} (${drive.size}, ${drive.fstype})`,
+ detail: `${drive.device} is mounted at ${folder} until the next restart. /etc/fstab is not changed.`,
+ ok: "Mount and continue",
+ backup: { mode: "mount_drive", uuid: drive.uuid },
+ }));
+ options.push({ id: "recheck", label: "I plugged the drive in; check again", ok: "Check again", action: "recheck" });
+ if (pre.system_disk_once?.offered) options.push({
+ id: "system", label: "Back up to the system disk this once (not recommended)",
+ detail: `Saved in ${pre.system_disk_once.target || "the system backup folder"} on this computer's own disk. It will not survive a failed disk. Nothing is remembered; the next update asks again.`,
+ ok: "Back up to the system disk and continue", backup: { mode: "system_disk_once", confirm: "system-disk-once" },
+ });
+ options.push({ id: "folder", label: "Use a different backup folder", detail: "Saves the new folder as your backup folder setting, then checks again.", ok: "Save folder and check again", action: "folder" });
+ if (pre.skip?.offered) options.push({
+ id: "skip", label: "Install without a backup", danger: true,
+ detail: "Nothing is saved first. If the update fails there is no archive to restore from.",
+ ok: "Install without a backup", backup: { mode: "skip", confirm: "install-without-backup" },
+ });
+ return options;
+}
+
+// Resolves to the chosen backup object, { mode: "require_mounted" } when the drive turned up while the dialog
+// was open (the normal backup then works), or null when cancelled. Nothing chosen here is saved anywhere.
+function askBackupChoice(spec, first) {
+ const dialog = $("#backup-choice-dialog");
+ if (dialog.open) return Promise.resolve(null);
+ const opener = document.activeElement;
+ let current = first;
+ let selected = "";
+ let working = false;
+ let outcome = null;
+ let options = [];
+ const chosen = () => options.find((option) => option.id === selected);
+ const refresh = () => {
+ const option = chosen();
+ $("#backup-choice-folder").hidden = option?.id !== "folder";
+ $("#backup-choice-skip-ack").hidden = option?.id !== "skip";
+ const ok = $("#backup-choice-ok");
+ ok.textContent = working ? "Working…" : option?.ok || "Continue";
+ ok.className = option?.danger ? "danger solid" : "primary";
+ ok.disabled = working || !option
+ || (option.id === "skip" && !$("#backup-choice-ack").checked)
+ || (option.id === "folder" && !$("#backup-choice-path").value.trim());
+ $("#backup-choice-detail").textContent = option?.detail || "";
+ $("#backup-choice-detail").hidden = !option?.detail;
+ };
+ const render = (error = "") => {
+ options = backupOptions(current);
+ if (!chosen()) selected = (options.find((option) => !option.danger) || options[0]).id;
+ const dirty = isConfigDirty();
+ $("#backup-choice-options").innerHTML = options.map((option) => {
+ const blocked = option.id === "folder" && dirty;
+ return ``;
+ }).join("");
+ const notes = [
+ ...(current.refused || []).map((item) => `${item.device} ${item.size} ${item.label ? `"${item.label}" ` : ""}${item.fstype}: ${item.reason}`),
+ ...(current.mounted_elsewhere || []).map((item) => `${item.device} ${item.size} ${item.label ? `"${item.label}" ` : ""}${item.fstype}: already mounted at ${item.mountpoint}. Unmount it first to use it here.`),
+ ...(current.system_disk_once && !current.system_disk_once.offered && current.system_disk_once.reason ? [`System disk: ${current.system_disk_once.reason}`] : []),
+ ];
+ $("#backup-choice-notes").innerHTML = notes.map((note) => `
${escapeHtml(note)}
`).join("");
+ $("#backup-choice-notes").hidden = !notes.length;
+ $("#backup-choice-error").textContent = error;
+ $("#backup-choice-error").hidden = !error;
+ refresh();
+ };
+ const folder = mountFolder(current.path);
+ $("#backup-choice-title").textContent = spec.title;
+ $("#backup-choice-intro").textContent = current.mount === "not_mounted"
+ ? `The backup drive is not mounted (${folder}). ${spec.intro || ""} Choose how to handle the backup for this update only; nothing here is remembered.`.replace(/\s+/g, " ")
+ : `The backup folder ${current.path || ""} is not on an external drive, so the normal backup cannot be required. ${spec.intro || ""} Choose how to handle the backup for this update only.`.replace(/\s+/g, " ");
+ $("#backup-choice-items").innerHTML = (spec.items || []).map((item) => `
${escapeHtml(item)}
`).join("");
+ $("#backup-choice-items").hidden = !(spec.items || []).length;
+ $("#backup-choice-ack-text").textContent = SKIP_ACK_TEXT;
+ $("#backup-choice-ack").checked = false;
+ $("#backup-choice-path").value = "";
+ $("#backup-choice-form").onsubmit = (event) => event.preventDefault();
+ $("#backup-choice-options").onchange = (event) => { selected = String(event.target?.value || ""); refresh(); };
+ $("#backup-choice-ack").onchange = refresh;
+ $("#backup-choice-path").oninput = refresh;
+ $("#backup-choice-cancel").onclick = () => dialog.close("cancel");
+ $("#backup-choice-ok").onclick = async () => {
+ const option = chosen();
+ if (!option || working) return;
+ if (option.id === "skip" && !$("#backup-choice-ack").checked) return;
+ if (option.backup) {
+ outcome = option.backup;
+ dialog.close("ok");
+ return;
+ }
+ const session = state.session;
+ working = true;
+ refresh();
+ try {
+ if (option.action === "folder") {
+ const saved = await api("/api/config", { method: "POST", body: JSON.stringify({ settings: { VESSELSTACK_BACKUP: $("#backup-choice-path").value.trim() } }) });
+ $("#backup-choice-path").value = "";
+ // This option is unavailable while Settings has unsaved edits, so showing the saved values loses nothing.
+ if (saved?.configuration && !isConfigDirty()) { state.config = saved.configuration; renderConfiguration(state.config); }
+ }
+ const next = await api("/api/backup-preflight");
+ if (session !== state.session || !state.token) return;
+ current = next;
+ working = false;
+ if (current.default_ok === true) {
+ outcome = { mode: "require_mounted" };
+ dialog.close("ok");
+ return;
+ }
+ selected = "";
+ render();
+ } catch (error) {
+ if (session !== state.session || !state.token) return;
+ if (error.status === 401) { dialog.close("cancel"); requireReauth("Your session ended."); return; }
+ working = false;
+ render(error.message);
+ }
+ };
+ return new Promise((resolve) => {
+ dialog.onclose = () => {
+ dialog.onclose = null;
+ if (opener && opener.isConnected !== false && typeof opener.focus === "function") opener.focus();
+ resolve(dialog.returnValue === "ok" ? outcome : null);
+ };
+ dialog.returnValue = "";
+ render();
+ dialog.showModal();
+ });
+}
+
+// Asks first. When the normal mounted backup will not work, the owner picks what to do for this one
+// request; otherwise the usual confirmation is shown unchanged. A failed lookup also falls back to the
+// usual confirmation: the server still refuses to install without the normal backup.
+async function confirmWithBackup(spec) {
+ const session = state.session;
+ let pre = null;
+ try {
+ pre = await api("/api/backup-preflight");
+ } catch (error) {
+ if (error.status === 401) { requireReauth("Your session ended."); return { proceed: false }; }
+ }
+ if (session !== state.session || !state.token || state.busy) return { proceed: false };
+ if (!pre || pre.default_ok !== false) return { proceed: await ask(spec) };
+ const choice = await askBackupChoice(spec, pre);
+ if (!choice) return { proceed: false };
+ return choice.mode === "require_mounted" ? { proceed: true } : { proceed: true, backup: choice };
+}
+
// ---------------------------------------------------------------- session
function lock(force = false) {
@@ -433,8 +593,12 @@ function describeOperation(operation) {
}
if (label === "update-release") {
const target = operation.target_version || "the update";
+ if (operation.backup_skipped || operation.backup_mode === "skip") return { running: `Installing ${target} without a backup…`, done: `Installed ${target} (no backup was made)`, failed: `Installing ${target} failed`, next: "No backup was made for this update, so there is no archive to restore from. Check the details before trying again." };
return { running: `Backing up, then installing ${target}…`, done: `Installed ${target}`, failed: `Installing ${target} failed`, next: "Your boat was backed up first if the backup step finished. Check the details before trying again." };
}
+ if (label === "update" && (operation.backup_skipped || operation.backup_mode === "skip")) {
+ return { running: "Updating from the folder without a backup…", done: "Update finished (no backup was made)", failed: "Update failed", next: "No backup was made for this update, so there is no archive to restore from. Check the details before trying again." };
+ }
const entry = OPERATION_TEXT[label];
if (entry) return { running: entry[0], done: entry[1], failed: entry[2], next: entry[3] };
return { running: "Working…", done: "Finished", failed: "Failed", next: "Check the details, then try again." };
@@ -556,6 +720,12 @@ async function startOperation(payload) {
async function runAction(action, extra = {}) {
const spec = action === "update" ? updateConfirm(extra) : CONFIRMS[action];
+ if (action === "update") {
+ const decision = await confirmWithBackup(spec);
+ if (!decision.proceed) return;
+ startOperation({ action, ...extra, ...(decision.backup ? { backup: decision.backup } : {}) });
+ return;
+ }
if (spec && !(await ask(spec))) return;
startOperation({ action, ...extra });
}
@@ -711,8 +881,9 @@ async function checkReleases() {
async function installRelease(releaseId) {
const release = state.releases.find((item) => item.id === Number(releaseId) && releaseEligible(item));
if (!release || state.busy) return;
- if (!(await ask(releaseConfirm(release)))) return;
- startOperation({ action: "update-release", release_id: release.id, channel: state.releaseChannel });
+ const decision = await confirmWithBackup(releaseConfirm(release));
+ if (!decision.proceed) return;
+ startOperation({ action: "update-release", release_id: release.id, channel: state.releaseChannel, ...(decision.backup ? { backup: decision.backup } : {}) });
}
// ---------------------------------------------------------------- settings
diff --git a/control-panel/static/index.html b/control-panel/static/index.html
index f6f062d..e13a7ee 100644
--- a/control-panel/static/index.html
+++ b/control-panel/static/index.html
@@ -158,6 +158,22 @@
+
+