From b150b76d061446aca5933b8c1968af72688d227d Mon Sep 17 00:00:00 2001 From: Matt Hill <9935159+MattDHill@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:09:56 -0600 Subject: [PATCH] fix(start-core): refuse a dependency mount whose volume does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Bind::pre_mount` creates a missing source for a read-write bind, which is right for a package's own subpaths but wrong for a pointer mount: with the dependency not installed, the effect created `volumes//data/` on the host as a plain directory and bound it, so the dependent started against an empty folder with only the dependency warning to say so. When the dependency was installed later, `ensure_volume_root` adopted that orphan as its live root, leaving it a directory rather than a subvolume — so `InstallBackup::snapshot` has nothing to snapshot and the package gets no rollback point on update. The mount now fails with a localized error naming the missing volume when the dependency's volume root does not exist, matching what a read-only mount already did. A missing subpath inside an existing volume is still created for a read-write mount. Observed with paperless-startos pointing its consume folder at a FileBrowser Quantum that had been uninstalled. Co-Authored-By: Claude Opus 5 (1M context) --- projects/start-os/CHANGELOG.md | 3 +++ projects/start-sdk/docs/src/dependencies.md | 2 ++ projects/start-sdk/lib/mainFn/Mounts.ts | 3 ++- .../crates/start-core/locales/i18n.yaml | 6 ++++++ .../src/service/effects/dependency.rs | 20 ++++++++++++++----- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index 5f7d4e6646..9b95b35476 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -453,6 +453,9 @@ for the detail behind its highlights. - **The OS log stays focused on actionable errors on a network whose router advertises a route with more than one next hop.** +- **A service that mounts a dependency's files read-write fails to start when + that dependency is not installed**, naming the missing volume. + ### Security - **Service mount paths are validated and confined to their intended diff --git a/projects/start-sdk/docs/src/dependencies.md b/projects/start-sdk/docs/src/dependencies.md index ec37cdd48b..ecb7e8a0b3 100644 --- a/projects/start-sdk/docs/src/dependencies.md +++ b/projects/start-sdk/docs/src/dependencies.md @@ -164,6 +164,8 @@ const mounts = sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mo }) ``` +The volume has to exist when the container starts: if the dependency is not installed, the mount fails, `main` throws, and the service shows the error alongside its dependency warning. A `subpath` that does not exist yet inside the volume is created for a read-write mount. + ## Init Order Dependencies are resolved during initialization in this order: diff --git a/projects/start-sdk/lib/mainFn/Mounts.ts b/projects/start-sdk/lib/mainFn/Mounts.ts index f49b4d4a47..5536a27753 100644 --- a/projects/start-sdk/lib/mainFn/Mounts.ts +++ b/projects/start-sdk/lib/mainFn/Mounts.ts @@ -114,7 +114,8 @@ export class Mounts { /** * Add a mount from a dependency package's volume. The mount is always a * directory — StartOS does not bind a dependency's file. To reach a single - * file, mount the directory holding it. + * file, mount the directory holding it. The volume must exist when the + * container starts; a missing dependency fails the start. * * @param options - Dependency ID, volume ID, mountpoint, readonly flag, and optional subpath * @returns A new Mounts instance with this dependency mount added diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index f55d534e90..e4477ef41a 100644 --- a/shared-libs/crates/start-core/locales/i18n.yaml +++ b/shared-libs/crates/start-core/locales/i18n.yaml @@ -2567,6 +2567,12 @@ service.effects.dependency.unknown-dependency-kind: es_ES: "tipo de dependencia desconocido %{kind}" fr_FR: "type de dépendance inconnu %{kind}" pl_PL: "nieznany rodzaj zależności %{kind}" +service.effects.dependency.volume-missing: + en_US: "volume %{volume} of %{package} does not exist — is %{package} installed?" + de_DE: "Volume %{volume} von %{package} existiert nicht — ist %{package} installiert?" + es_ES: "el volumen %{volume} de %{package} no existe — ¿está instalado %{package}?" + fr_FR: "le volume %{volume} de %{package} n'existe pas — %{package} est-il installé ?" + pl_PL: "wolumin %{volume} pakietu %{package} nie istnieje — czy %{package} jest zainstalowany?" # service/service_actor.rs service.service-actor.error-synchronizing-state: diff --git a/shared-libs/crates/start-core/src/service/effects/dependency.rs b/shared-libs/crates/start-core/src/service/effects/dependency.rs index 9af9d4f2b2..34204f7c0a 100644 --- a/shared-libs/crates/start-core/src/service/effects/dependency.rs +++ b/shared-libs/crates/start-core/src/service/effects/dependency.rs @@ -174,11 +174,21 @@ pub async fn mount( }: MountParams, ) -> Result<(), Error> { let context = context.deref()?; - let source = confined_join( - &data_dir(DATA_DIR, &package_id, &volume_id), - subpath.as_deref().unwrap_or(Path::new("")), - ) - .await?; + let volume = data_dir(DATA_DIR, &package_id, &volume_id); + if tokio::fs::metadata(&volume).await.is_err() { + return Err(Error::new( + eyre!( + "{}", + t!( + "service.effects.dependency.volume-missing", + package = package_id, + volume = volume_id + ) + ), + ErrorKind::NotFound, + )); + } + let source = confined_join(&volume, subpath.as_deref().unwrap_or(Path::new(""))).await?; let rootfs = context .seed .persistent_container