mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
Fix mount propagation so container-internal mounts are visible to dependent services
Volume bind mounts into LXC containers inherited private propagation from the host source path, which prevented mounts made inside a container (e.g. NAS mounts via postinit.sh) from propagating back to the host. Dependent services bind-mounting the same volume from the host side would never see these internal mounts. Self-bind each host volume directory and mark it rshared so that container-internal mounts propagate back to the host path. Mark dependency mounts as rslave so they receive propagated mounts but cannot propagate mounts back to the source service. Because rshared propagation means mounts can survive container teardown, add defense-in-depth to uninstall cleanup: unmount any remaining mounts under the package volume path, then refuse to delete if any persist, preventing remove_dir_all from traversing into a live NFS/NAS mount and destroying data.
This commit is contained in:
@@ -61,6 +61,24 @@ pub async fn unmount<P: AsRef<Path>>(mountpoint: P, lazy: bool) -> Result<(), Er
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if any mountpoints exist under (or at) the given path.
|
||||||
|
pub async fn has_mounts_under<P: AsRef<Path>>(path: P) -> Result<bool, Error> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let canonical_path = tokio::fs::canonicalize(path)
|
||||||
|
.await
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("canonicalize {path:?}")))?;
|
||||||
|
|
||||||
|
let mounts_content = tokio::fs::read_to_string("/proc/mounts")
|
||||||
|
.await
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, "read /proc/mounts"))?;
|
||||||
|
|
||||||
|
Ok(mounts_content.lines().any(|line| {
|
||||||
|
line.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.map_or(false, |mp| Path::new(mp).starts_with(&canonical_path))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Unmounts all mountpoints under (and including) the given path, in reverse
|
/// Unmounts all mountpoints under (and including) the given path, in reverse
|
||||||
/// depth order so that nested mounts are unmounted before their parents.
|
/// depth order so that nested mounts are unmounted before their parents.
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use clap::builder::ValueParserFactory;
|
|||||||
use exver::VersionRange;
|
use exver::VersionRange;
|
||||||
use rust_i18n::t;
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::db::model::package::{
|
use crate::db::model::package::{
|
||||||
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
|
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
|
||||||
TaskEntry,
|
TaskEntry,
|
||||||
@@ -19,7 +21,7 @@ use crate::service::effects::callbacks::CallbackHandler;
|
|||||||
use crate::service::effects::prelude::*;
|
use crate::service::effects::prelude::*;
|
||||||
use crate::service::rpc::CallbackId;
|
use crate::service::rpc::CallbackId;
|
||||||
use crate::status::health_check::NamedHealthCheckResult;
|
use crate::status::health_check::NamedHealthCheckResult;
|
||||||
use crate::util::{FromStrParser, VersionString};
|
use crate::util::{FromStrParser, Invoke, VersionString};
|
||||||
use crate::volume::data_dir;
|
use crate::volume::data_dir;
|
||||||
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
|
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ pub async fn mount(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.mount(
|
.mount(
|
||||||
mountpoint,
|
&mountpoint,
|
||||||
if readonly {
|
if readonly {
|
||||||
MountType::ReadOnly
|
MountType::ReadOnly
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +101,15 @@ pub async fn mount(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Make the dependency mount a slave so it receives propagated mounts
|
||||||
|
// (e.g. NAS mounts from the source service) but cannot propagate
|
||||||
|
// mounts back to the source service's volume.
|
||||||
|
Command::new("mount")
|
||||||
|
.arg("--make-rslave")
|
||||||
|
.arg(&mountpoint)
|
||||||
|
.invoke(ErrorKind::Filesystem)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::disk::mount::filesystem::loop_dev::LoopDev;
|
|||||||
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
|
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
|
||||||
use crate::disk::mount::filesystem::{MountType, ReadOnly};
|
use crate::disk::mount::filesystem::{MountType, ReadOnly};
|
||||||
use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
|
use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
|
||||||
|
use crate::disk::mount::util::{is_mountpoint, unmount};
|
||||||
use crate::lxc::{HOST_RPC_SERVER_SOCKET, LxcConfig, LxcContainer};
|
use crate::lxc::{HOST_RPC_SERVER_SOCKET, LxcConfig, LxcContainer};
|
||||||
use crate::net::net_controller::NetService;
|
use crate::net::net_controller::NetService;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
@@ -76,6 +77,7 @@ pub struct PersistentContainer {
|
|||||||
pub(super) rpc_client: UnixRpcClient,
|
pub(super) rpc_client: UnixRpcClient,
|
||||||
pub(super) rpc_server: watch::Sender<Option<(NonDetachingJoinHandle<()>, ShutdownHandle)>>,
|
pub(super) rpc_server: watch::Sender<Option<(NonDetachingJoinHandle<()>, ShutdownHandle)>>,
|
||||||
js_mount: MountGuard,
|
js_mount: MountGuard,
|
||||||
|
host_volume_binds: BTreeMap<VolumeId, MountGuard>,
|
||||||
volumes: BTreeMap<VolumeId, MountGuard>,
|
volumes: BTreeMap<VolumeId, MountGuard>,
|
||||||
assets: Vec<MountGuard>,
|
assets: Vec<MountGuard>,
|
||||||
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
|
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
|
||||||
@@ -120,6 +122,7 @@ impl PersistentContainer {
|
|||||||
.is_ok();
|
.is_ok();
|
||||||
|
|
||||||
let mut volumes = BTreeMap::new();
|
let mut volumes = BTreeMap::new();
|
||||||
|
let mut host_volume_binds = BTreeMap::new();
|
||||||
|
|
||||||
// TODO: remove once packages are reconverted
|
// TODO: remove once packages are reconverted
|
||||||
let added = if is_compat {
|
let added = if is_compat {
|
||||||
@@ -128,13 +131,35 @@ impl PersistentContainer {
|
|||||||
BTreeSet::default()
|
BTreeSet::default()
|
||||||
};
|
};
|
||||||
for volume in s9pk.as_manifest().volumes.union(&added) {
|
for volume in s9pk.as_manifest().volumes.union(&added) {
|
||||||
|
let host_volume_dir = data_dir(DATA_DIR, &s9pk.as_manifest().id, volume);
|
||||||
|
|
||||||
|
// Self-bind the host volume directory and mark it rshared so that
|
||||||
|
// mounts created inside the container (e.g. NAS mounts from
|
||||||
|
// postinit.sh) propagate back to the host path and are visible to
|
||||||
|
// dependent services that bind-mount the same volume.
|
||||||
|
if is_mountpoint(&host_volume_dir).await? {
|
||||||
|
unmount(&host_volume_dir, true).await?;
|
||||||
|
}
|
||||||
|
let host_bind = MountGuard::mount(
|
||||||
|
&Bind::new(&host_volume_dir),
|
||||||
|
&host_volume_dir,
|
||||||
|
MountType::ReadWrite,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Command::new("mount")
|
||||||
|
.arg("--make-rshared")
|
||||||
|
.arg(&host_volume_dir)
|
||||||
|
.invoke(ErrorKind::Filesystem)
|
||||||
|
.await?;
|
||||||
|
host_volume_binds.insert(volume.clone(), host_bind);
|
||||||
|
|
||||||
let mountpoint = lxc_container
|
let mountpoint = lxc_container
|
||||||
.rootfs_dir()
|
.rootfs_dir()
|
||||||
.join("media/startos/volumes")
|
.join("media/startos/volumes")
|
||||||
.join(volume);
|
.join(volume);
|
||||||
let mount = MountGuard::mount(
|
let mount = MountGuard::mount(
|
||||||
&IdMapped::new(
|
&IdMapped::new(
|
||||||
Bind::new(data_dir(DATA_DIR, &s9pk.as_manifest().id, volume)),
|
Bind::new(&host_volume_dir),
|
||||||
vec![IdMap {
|
vec![IdMap {
|
||||||
from_id: 0,
|
from_id: 0,
|
||||||
to_id: 100000,
|
to_id: 100000,
|
||||||
@@ -296,6 +321,7 @@ impl PersistentContainer {
|
|||||||
rpc_server: watch::channel(None).0,
|
rpc_server: watch::channel(None).0,
|
||||||
// procedures: Default::default(),
|
// procedures: Default::default(),
|
||||||
js_mount,
|
js_mount,
|
||||||
|
host_volume_binds,
|
||||||
volumes,
|
volumes,
|
||||||
assets,
|
assets,
|
||||||
images,
|
images,
|
||||||
@@ -439,6 +465,7 @@ impl PersistentContainer {
|
|||||||
let rpc_server = self.rpc_server.send_replace(None);
|
let rpc_server = self.rpc_server.send_replace(None);
|
||||||
let js_mount = self.js_mount.take();
|
let js_mount = self.js_mount.take();
|
||||||
let volumes = std::mem::take(&mut self.volumes);
|
let volumes = std::mem::take(&mut self.volumes);
|
||||||
|
let host_volume_binds = std::mem::take(&mut self.host_volume_binds);
|
||||||
let assets = std::mem::take(&mut self.assets);
|
let assets = std::mem::take(&mut self.assets);
|
||||||
let images = std::mem::take(&mut self.images);
|
let images = std::mem::take(&mut self.images);
|
||||||
let subcontainers = self.subcontainers.clone();
|
let subcontainers = self.subcontainers.clone();
|
||||||
@@ -461,6 +488,11 @@ impl PersistentContainer {
|
|||||||
for (_, volume) in volumes {
|
for (_, volume) in volumes {
|
||||||
errs.handle(volume.unmount(true).await);
|
errs.handle(volume.unmount(true).await);
|
||||||
}
|
}
|
||||||
|
// Unmount host-side shared binds after the rootfs-side volume
|
||||||
|
// mounts. Use delete_mountpoint=false to preserve the data dirs.
|
||||||
|
for (_, host_bind) in host_volume_binds {
|
||||||
|
errs.handle(host_bind.unmount(false).await);
|
||||||
|
}
|
||||||
for assets in assets {
|
for assets in assets {
|
||||||
errs.handle(assets.unmount(true).await);
|
errs.handle(assets.unmount(true).await);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use imbl::vector;
|
|||||||
|
|
||||||
use crate::context::RpcContext;
|
use crate::context::RpcContext;
|
||||||
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
|
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
|
||||||
|
use crate::disk::mount::util::{has_mounts_under, unmount_all_under};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::volume::PKG_VOLUME_DIR;
|
use crate::volume::PKG_VOLUME_DIR;
|
||||||
use crate::{DATA_DIR, PACKAGE_DATA, PackageId};
|
use crate::{DATA_DIR, PACKAGE_DATA, PackageId};
|
||||||
@@ -81,6 +82,22 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
|
|||||||
if !soft {
|
if !soft {
|
||||||
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
|
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
|
||||||
if tokio::fs::metadata(&path).await.is_ok() {
|
if tokio::fs::metadata(&path).await.is_ok() {
|
||||||
|
// Best-effort cleanup of any propagated mounts (e.g. NAS)
|
||||||
|
// that survived container destroy or were never cleaned up
|
||||||
|
// (force-uninstall skips destroy entirely).
|
||||||
|
unmount_all_under(&path, true).await.log_err();
|
||||||
|
// Hard check: refuse to delete if mounts are still active,
|
||||||
|
// to avoid traversing into a live NFS/NAS mount.
|
||||||
|
if has_mounts_under(&path).await? {
|
||||||
|
return Err(Error::new(
|
||||||
|
eyre!(
|
||||||
|
"Refusing to remove {}: active mounts remain under this path. \
|
||||||
|
Unmount them manually and retry.",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
ErrorKind::Filesystem,
|
||||||
|
));
|
||||||
|
}
|
||||||
tokio::fs::remove_dir_all(&path).await?;
|
tokio::fs::remove_dir_all(&path).await?;
|
||||||
}
|
}
|
||||||
let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id);
|
let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user