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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ forkpress stop

On Linux XFS-loop sites, `forkpress storage detach` unmounts the shared volume
only after other running ForkPress servers using that storage view are stopped.
Before deleting a Linux XFS-loop site, remove the hidden per-site directory
inside the shared mount too. `forkpress storage detach` prints the exact
`rm -rf` command while the volume is still attached.

## Logs

Expand Down
177 changes: 169 additions & 8 deletions crates/forkpress-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ use forkpress_server::{
use forkpress_storage::{
CowMergeAuditQuery, CowSiteInit, RemoteBranchOptions, RemoteSiteAdd, add_remote_site,
branch_remote_site, compact_macos_apfs_sparsebundle_file_view, cow_branch_names,
cow_branch_root, create_cow_branch, delete_cow_branch,
cow_branch_root, create_cow_branch, delete_cow_branch, detach_linux_xfs_loop_file_view,
detach_macos_apfs_sparsebundle_file_view, ensure_cow_branch_exists, ensure_cow_file_view_ready,
ensure_cow_main_branch, inspect_cow_merge_audit, list_remote_sites, lock_cow_lifecycle,
lock_cow_operations, merge_cow_branch, prepare_cow_file_view, print_cow_storage_status,
print_macos_cow_storage_status, probe_reflink_dir, probe_remote_site, reset_cow_branch,
resolve_cow_merge_conflict, review_cow_merge_audit_record, show_cow_branch,
write_cow_branch_list, write_cow_strategy_notes,
print_linux_xfs_loop_storage_status, print_macos_cow_storage_status, probe_reflink_dir,
probe_remote_site, reset_cow_branch, resolve_cow_merge_conflict, review_cow_merge_audit_record,
show_cow_branch, write_cow_branch_list, write_cow_strategy_notes,
};
#[cfg(feature = "dev-experiments")]
use forkpress_storage::{copy_tree_cow, plain_branch_names};
Expand Down Expand Up @@ -939,6 +939,11 @@ fn doctor_storage_command(args: DoctorStorageArgs) -> Result<i32> {
" measurement: `du` can overcount APFS clone sharing; compare `df -h {}` before/after branch creation",
layout.macos_cow_mount.display()
);
} else if file_view == FileViewStrategy::LinuxXfsLoop {
println!(
" measurement: `du` can overcount XFS reflink sharing; compare `df -h {}` before/after branch creation",
layout.linux_xfs_mount.display()
);
}
}
}
Expand Down Expand Up @@ -970,7 +975,17 @@ fn doctor_storage_command(args: DoctorStorageArgs) -> Result<i32> {
);
}

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[cfg(target_os = "linux")]
{
println!(" Linux XFS loop volume: available through direct loop/mount syscalls");
println!(" image: {}", layout.linux_xfs_image.display());
println!(" mount: {}", layout.linux_xfs_mount.display());
println!(
" recommendation: forkpress init will mount one shared XFS volume for all ForkPress sites if this process has loop and mount privileges"
);
}

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
println!(" recommendation: file-copy materialization");
}
Expand Down Expand Up @@ -1010,6 +1025,9 @@ fn storage_status_command(args: StorageStatusArgs) -> Result<i32> {
Some(FileViewStrategy::MacosApfsSparsebundle) => {
print_macos_cow_storage_status(&layout)?;
}
Some(FileViewStrategy::LinuxXfsLoop) => {
print_linux_xfs_loop_storage_status(&layout)?;
}
_ => {
if layout.macos_cow_image.exists() || layout.macos_cow_mount.exists() {
print_macos_cow_storage_status(&layout)?;
Expand Down Expand Up @@ -1043,6 +1061,14 @@ fn storage_mount_command(args: StorageMountArgs) -> Result<i32> {
);
println!("Branch roots: {}", layout.cow_branches_dir.display());
}
FileViewStrategy::LinuxXfsLoop => {
println!(
"forkpress: shared Linux XFS COW storage mounted at {}",
layout.linux_xfs_mount.display()
);
println!("Branch roots: {}", layout.cow_branches_dir.display());
println!("Storage roots: {}", layout.linux_xfs_branches_dir.display());
}
FileViewStrategy::Reflink | FileViewStrategy::Copy => {
println!(
"forkpress: storage file view \"{}\" does not use a detachable mount",
Expand All @@ -1062,6 +1088,7 @@ fn storage_detach_command(args: StorageDetachArgs) -> Result<i32> {
args.force,
args.keep_server,
Duration::from_secs(args.timeout),
DetachStorageMode::Explicit,
)? {
println!(
"forkpress: no detachable storage found for {}",
Expand Down Expand Up @@ -1109,23 +1136,101 @@ fn detach_storage_for_layout_if_present(
force: bool,
keep_server: bool,
timeout: Duration,
mode: DetachStorageMode,
) -> Result<bool> {
let manifest = read_site_manifest(layout)?;
let has_linux_xfs = has_linux_xfs_detachable_storage(layout, manifest.as_ref());
let has_macos_cow = manifest.as_ref().and_then(|manifest| manifest.file_view)
== Some(FileViewStrategy::MacosApfsSparsebundle)
|| layout.macos_cow_image.exists()
|| layout.macos_cow_mount.exists();

if !has_macos_cow {
if !has_macos_cow && !has_linux_xfs {
return Ok(false);
}

with_stopped_cow_server_for_storage(layout, keep_server, timeout, || {
detach_macos_apfs_sparsebundle_file_view(layout, force, true)
if has_linux_xfs {
if let Some(record) = other_linux_xfs_server(layout)? {
if should_detach_linux_xfs_storage(mode, Some(&record))? {
detach_linux_xfs_loop_file_view(layout, force, true)
} else {
println!(
"forkpress: leaving shared Linux XFS COW storage mounted at {} because server pid {} at {} is still using it",
layout.linux_xfs_mount.display(),
record.pid,
record.work_dir.display()
);
Ok(())
}
} else {
should_detach_linux_xfs_storage(mode, None)?;
detach_linux_xfs_loop_file_view(layout, force, true)
}
} else {
detach_macos_apfs_sparsebundle_file_view(layout, force, true)
}
})?;
Ok(true)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DetachStorageMode {
Automatic,
Explicit,
}

fn has_linux_xfs_detachable_storage(layout: &Layout, manifest: Option<&SiteManifest>) -> bool {
has_linux_xfs_detachable_storage_state(
manifest,
layout.linux_xfs_site_dir.exists(),
layout.linux_xfs_branches_dir.exists(),
)
}

fn has_linux_xfs_detachable_storage_state(
manifest: Option<&SiteManifest>,
linux_xfs_site_dir_exists: bool,
linux_xfs_branches_dir_exists: bool,
) -> bool {
manifest.and_then(|manifest| manifest.file_view) == Some(FileViewStrategy::LinuxXfsLoop)
|| linux_xfs_site_dir_exists
|| linux_xfs_branches_dir_exists
}

fn other_linux_xfs_server(layout: &Layout) -> Result<Option<ServerRecord>> {
for record in live_server_records()? {
if record.work_dir == layout.work_dir {
continue;
}
let other_layout = Layout::new(record.work_dir.clone())?;
let manifest = read_site_manifest(&other_layout)?;
if has_linux_xfs_detachable_storage(&other_layout, manifest.as_ref()) {
return Ok(Some(record));
}
}
Ok(None)
}

fn should_detach_linux_xfs_storage(
mode: DetachStorageMode,
other: Option<&ServerRecord>,
) -> Result<bool> {
if let Some(record) = other {
return match mode {
DetachStorageMode::Automatic => Ok(false),
DetachStorageMode::Explicit => {
bail!(
"shared Linux XFS COW storage is still used by server pid {} at {}. Stop all ForkPress sites before detaching the shared volume.",
record.pid,
record.work_dir.display()
);
}
};
}
Ok(true)
}

fn with_stopped_cow_server_for_storage<T>(
layout: &Layout,
keep_server: bool,
Expand Down Expand Up @@ -1555,6 +1660,54 @@ mod storage_strategy_tests {
assert!(args.keep_server);
}

#[test]
fn linux_xfs_detachable_storage_is_detected_without_manifest() {
assert!(!has_linux_xfs_detachable_storage_state(None, false, false));
assert!(has_linux_xfs_detachable_storage_state(None, true, false));
assert!(has_linux_xfs_detachable_storage_state(None, false, true));

let reflink_manifest =
SiteManifest::new(StorageStrategy::Cow).with_file_view(FileViewStrategy::Reflink);
assert!(!has_linux_xfs_detachable_storage_state(
Some(&reflink_manifest),
false,
false
));

let linux_xfs_manifest =
SiteManifest::new(StorageStrategy::Cow).with_file_view(FileViewStrategy::LinuxXfsLoop);
assert!(has_linux_xfs_detachable_storage_state(
Some(&linux_xfs_manifest),
false,
false
));
}

#[test]
fn automatic_linux_xfs_detach_leaves_shared_mount_for_other_servers() {
let record = ServerRecord {
pid: 12345,
child_pid: None,
work_dir: PathBuf::from("/tmp/forkpress-other/.forkpress"),
host: "127.0.0.1".to_string(),
port: 18080,
root_host: "wp.localhost".to_string(),
log: PathBuf::from("/tmp/forkpress-other/.forkpress/logs/forkpress-server.log"),
};

assert!(
!should_detach_linux_xfs_storage(DetachStorageMode::Automatic, Some(&record)).unwrap()
);
assert!(should_detach_linux_xfs_storage(DetachStorageMode::Automatic, None).unwrap());

let err = should_detach_linux_xfs_storage(DetachStorageMode::Explicit, Some(&record))
.unwrap_err();
assert!(
err.to_string()
.contains("Stop all ForkPress sites before detaching the shared volume")
);
}

#[cfg(unix)]
#[test]
fn cow_storage_lifecycle_waits_for_background_start_lock() {
Expand Down Expand Up @@ -1952,7 +2105,13 @@ fn start_command(args: StartArgs) -> Result<i32> {

drop(php);
drop(_registration);
detach_storage_for_layout_if_present(&layout, false, false, Duration::from_secs(10))?;
detach_storage_for_layout_if_present(
&layout,
false,
false,
Duration::from_secs(10),
DetachStorageMode::Automatic,
)?;

Ok(0)
}
Expand Down Expand Up @@ -2179,6 +2338,7 @@ fn server_stop_command(args: ServerStopArgs) -> Result<i32> {
args.force,
false,
Duration::from_secs(args.timeout),
DetachStorageMode::Automatic,
)?;
}

Expand Down Expand Up @@ -4020,6 +4180,7 @@ fn start_cow_php_server(
.unwrap_or(FileViewStrategy::Copy);
let storage_branches_dir = match file_view {
FileViewStrategy::MacosApfsSparsebundle => layout.macos_cow_branches_dir.clone(),
FileViewStrategy::LinuxXfsLoop => layout.linux_xfs_branches_dir.clone(),
FileViewStrategy::Reflink | FileViewStrategy::Copy => layout.cow_branches_dir.clone(),
};

Expand Down
Loading
Loading