Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,17 @@ jobs:
- name: Run unit tests
run: cargo test --workspace

- name: Run MinIO integration tests
- name: Install FUSE for mount integration tests
run: sudo apt-get update && sudo apt-get install -y fuse3

- name: Run JuiceFS integration tests
env:
SMOLFS_RUN_INTEGRATION: "1"
SMOLFS_TEST_REDIS_METADATA_URL: redis://127.0.0.1:6379/1
SMOLFS_TEST_S3_BUCKET: http://127.0.0.1:9000/smolfs-ci
ACCESS_KEY: minioadmin
SECRET_KEY: minioadmin
run: cargo test -p smolfs-juicefs --test minio_integration -- --nocapture
run: cargo test -p smolfs-juicefs --test minio_integration --test readonly_mount -- --nocapture

- name: Build Python wheel
run: uvx maturin==${{ env.MATURIN_VERSION }} build --manifest-path bindings/python/Cargo.toml --interpreter python --out dist
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ Useful options:
completes.
- `--foreground` runs the mount process in the foreground instead of starting it
in the background.
- `--readonly` (alias `--read-only`) mounts the volume read-only. This is useful
for data the current agent should not modify, such as model weights,
training/evaluation data, or reference documents produced by another process.
Writes to a read-only mount are rejected by the filesystem with
`Read-only file system` (EROFS), and `smolfs flush NAME` returns an error
because there is nothing to flush.

### Flush, Inspect, and Unmount

Expand Down Expand Up @@ -258,9 +264,9 @@ cat ./workspace/hello.txt
| `smolfs doctor` | Checks SmolFS storage, local mount support, and configuration. |
| `smolfs init NAME --dev` | Creates a local development volume. |
| `smolfs init NAME --metadata URL --storage TYPE --bucket BUCKET` | Creates a cloud volume with explicit metadata and object storage. |
| `smolfs mount NAME PATH` | Mounts a volume at a local directory. |
| `smolfs flush NAME` | Probes the mounted volume and syncs a small file through it. |
| `smolfs status [NAME]` | Shows known volumes and current mountpoints. |
| `smolfs mount NAME PATH` | Mounts a volume at a local directory. Add `--readonly` to mount read-only. |
| `smolfs flush NAME` | Probes the mounted volume and syncs a small file through it. Errors on a read-only mount. |
| `smolfs status [NAME]` | Shows known volumes, mountpoints, and mount mode (`ro`/`rw`). |
| `smolfs unmount NAME` | Unmounts a mounted volume and asks SmolFS to flush. |
| `smolfs umount NAME` | Alias for `smolfs unmount NAME`. |

Expand Down
2 changes: 2 additions & 0 deletions bindings/node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface MountVolumeOptions {
name: string
path: string
checkStorage?: boolean
readOnly?: boolean
}
export interface UnmountOptions {
force?: boolean
Expand All @@ -26,6 +27,7 @@ export interface VolumeInfo {
bucket: string
dev: boolean
mountpoint?: string
readOnly: boolean
}
export interface MountInfo {
name: string
Expand Down
4 changes: 4 additions & 0 deletions bindings/node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub struct MountVolumeOptions {
pub name: String,
pub path: String,
pub check_storage: Option<bool>,
pub read_only: Option<bool>,
}

#[napi(object)]
Expand All @@ -35,6 +36,7 @@ pub struct VolumeInfo {
pub bucket: String,
pub dev: bool,
pub mountpoint: Option<String>,
pub read_only: bool,
}

#[napi(object)]
Expand Down Expand Up @@ -172,6 +174,7 @@ impl From<MountVolumeOptions> for MountVolume {
path: PathBuf::from(value.path),
foreground: false,
check_storage: value.check_storage.unwrap_or(false),
readonly: value.read_only.unwrap_or(false),
}
}
}
Expand All @@ -185,6 +188,7 @@ impl From<smolfs_core::VolumeInfo> for VolumeInfo {
bucket: value.bucket,
dev: value.dev,
mountpoint: value.mountpoint.map(|path| path.display().to_string()),
read_only: value.read_only,
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion bindings/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ struct VolumeInfo {
dev: bool,
#[pyo3(get)]
mountpoint: Option<String>,
#[pyo3(get)]
read_only: bool,
}

#[pyclass]
Expand Down Expand Up @@ -106,20 +108,22 @@ impl PySmolFs {
.map_err(to_py_err)
}

#[pyo3(signature = (name, path, *, foreground=false, check_storage=false))]
#[pyo3(signature = (name, path, *, foreground=false, check_storage=false, read_only=false))]
fn mount(
&self,
name: String,
path: String,
foreground: bool,
check_storage: bool,
read_only: bool,
) -> PyResult<MountInfo> {
self.inner
.mount(MountVolume {
name,
path: PathBuf::from(path),
foreground,
check_storage,
readonly: read_only,
})
.map(MountInfo::from)
.map_err(to_py_err)
Expand Down Expand Up @@ -154,6 +158,7 @@ impl From<smolfs_core::VolumeInfo> for VolumeInfo {
bucket: value.bucket,
dev: value.dev,
mountpoint: value.mountpoint.map(|path| path.display().to_string()),
read_only: value.read_only,
}
}
}
Expand Down
25 changes: 21 additions & 4 deletions crates/smolfs-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ enum Command {
foreground: bool,
#[arg(long, help = "Test object storage access before mounting")]
check_storage: bool,
#[arg(
long,
visible_alias = "read-only",
help = "Mount the volume read-only; writes are rejected by the filesystem"
)]
readonly: bool,
},
#[command(about = "Show configured SmolFS volumes")]
Status {
Expand Down Expand Up @@ -159,18 +165,21 @@ fn main() -> Result<()> {
path,
foreground,
check_storage,
readonly,
} => {
let fs = SmolFs::from_env()?;
let mount = fs.mount(MountVolume {
name,
path,
foreground,
check_storage,
readonly,
})?;
println!(
"Mounted volume {} at {}",
"Mounted volume {} at {}{}",
mount.name,
mount.mountpoint.display()
mount.mountpoint.display(),
if readonly { " (read-only)" } else { "" }
);
}
Command::Status { name, json } => {
Expand All @@ -187,12 +196,20 @@ fn main() -> Result<()> {
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "-".into());
let mode = if volume.mountpoint.is_none() {
"-"
} else if volume.read_only {
"ro"
} else {
"rw"
};
println!(
"{}\t{}\t{}\t{}",
"{}\t{}\t{}\t{}\t{}",
volume.name,
if volume.dev { "dev" } else { "cloud" },
volume.storage,
mountpoint
mountpoint,
mode
);
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/smolfs-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pub enum SmolFsError {
#[error("volume {name:?} is not mounted")]
VolumeNotMounted { name: String },

#[error("volume {name:?} is mounted read-only")]
VolumeReadOnly { name: String },

#[error("local mount support is unavailable: {detail}\nFix: {fix}")]
MountSupportUnavailable { detail: String, fix: String },

Expand Down
31 changes: 31 additions & 0 deletions crates/smolfs-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub struct Volume {
pub bucket: String,
pub dev: bool,
pub mountpoint: Option<PathBuf>,
#[serde(default)]
pub read_only: bool,
pub created_at: String,
pub updated_at: String,
}
Expand All @@ -31,6 +33,7 @@ pub struct MountVolume {
pub path: PathBuf,
pub foreground: bool,
pub check_storage: bool,
pub readonly: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -47,6 +50,7 @@ pub struct VolumeInfo {
pub bucket: String,
pub dev: bool,
pub mountpoint: Option<PathBuf>,
pub read_only: bool,
}

impl From<&Volume> for VolumeInfo {
Expand All @@ -58,6 +62,7 @@ impl From<&Volume> for VolumeInfo {
bucket: volume.bucket.clone(),
dev: volume.dev,
mountpoint: volume.mountpoint.clone(),
read_only: volume.read_only,
}
}
}
Expand Down Expand Up @@ -89,3 +94,29 @@ pub struct MountSupportReport {
pub struct StatusReport {
pub volumes: Vec<VolumeInfo>,
}

#[cfg(test)]
mod tests {
use super::{Volume, VolumeInfo};

fn volume(read_only: bool) -> Volume {
Volume {
name: "demo".into(),
backend: "juicefs".into(),
metadata_url: "sqlite3://demo.db".into(),
storage: "file".into(),
bucket: "/tmp/objects".into(),
dev: true,
mountpoint: None,
read_only,
created_at: "now".into(),
updated_at: "now".into(),
}
}

#[test]
fn volume_info_carries_read_only() {
assert!(VolumeInfo::from(&volume(true)).read_only);
assert!(!VolumeInfo::from(&volume(false)).read_only);
}
}
25 changes: 25 additions & 0 deletions crates/smolfs-core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ mod tests {
bucket: "/tmp/objects".into(),
dev: true,
mountpoint: None,
read_only: false,
created_at: "now".into(),
updated_at: "now".into(),
};
Expand All @@ -138,4 +139,28 @@ mod tests {
assert_eq!(registry.read("demo").unwrap().name, "demo");
assert_eq!(registry.list().unwrap().len(), 1);
}

#[test]
fn reads_legacy_volume_without_read_only_field() {
let temp = tempfile::tempdir().unwrap();
let home = SmolFsHome::new(temp.path());
home.ensure_layout().unwrap();
let path = home.volume_path("legacy");
// A volume record written before the read_only field existed.
let legacy = r#"
name = "legacy"
backend = "juicefs"
metadata_url = "sqlite3://legacy.db"
storage = "file"
bucket = "/tmp/objects"
dev = true
created_at = "now"
updated_at = "now"
"#;
std::fs::write(&path, legacy).unwrap();

let registry = Registry::new(home);
let volume = registry.read("legacy").unwrap();
assert!(!volume.read_only);
}
}
34 changes: 33 additions & 1 deletion crates/smolfs-juicefs/src/juicefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ impl JuiceFs {
if opts.check_storage {
spec = spec.arg("--check-storage");
}
if opts.readonly {
spec = spec.arg("--read-only");
}
spec
}

Expand Down Expand Up @@ -97,10 +100,22 @@ impl JuiceFs {

#[cfg(test)]
mod tests {
use smolfs_core::Volume;
use std::path::PathBuf;

use smolfs_core::{MountVolume, Volume};

use super::JuiceFs;

fn mount_opts(readonly: bool) -> MountVolume {
MountVolume {
name: "demo".into(),
path: PathBuf::from("/tmp/demo"),
foreground: false,
check_storage: false,
readonly,
}
}

fn volume() -> Volume {
Volume {
name: "demo".into(),
Expand All @@ -110,6 +125,7 @@ mod tests {
bucket: "/tmp/objects".into(),
dev: true,
mountpoint: None,
read_only: false,
created_at: "now".into(),
updated_at: "now".into(),
}
Expand All @@ -123,4 +139,20 @@ mod tests {
assert!(spec.args.contains(&"--storage".to_string()));
assert!(spec.args.contains(&"--bucket".to_string()));
}

#[test]
fn mount_command_includes_read_only_when_requested() {
let juicefs = JuiceFs::new("/bin/juicefs");
let log = PathBuf::from("/tmp/demo.log");
let spec = juicefs.mount_spec(&volume(), &mount_opts(true), &log);
assert!(spec.args.contains(&"--read-only".to_string()));
}

#[test]
fn mount_command_omits_read_only_by_default() {
let juicefs = JuiceFs::new("/bin/juicefs");
let log = PathBuf::from("/tmp/demo.log");
let spec = juicefs.mount_spec(&volume(), &mount_opts(false), &log);
assert!(!spec.args.contains(&"--read-only".to_string()));
}
}
Loading
Loading