Skip to content

Commit c7b25a6

Browse files
Paul Cwolfsoftwaresystemsltd
andcommitted
Merge beta: v25.4.0 — ZFS pool lifecycle + compose-stack backup grouping
Co-Authored-By: CodeWolf <paul@wolf.uk.com> Co-Authored-By: Wolf Software Systems Ltd <paul@wolf.uk.com>
2 parents 29c92cf + e89270b commit c7b25a6

7 files changed

Lines changed: 627 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "25.3.2"
3+
version = "25.4.0"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 273 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21066,7 +21066,10 @@ pub async fn zfs_status(req: HttpRequest, state: web::Data<AppState>) -> HttpRes
2106621066
let pools = zfs_get_pools();
2106721067
HttpResponse::Ok().json(serde_json::json!({
2106821068
"available": true,
21069-
"pools": pools
21069+
"pools": pools,
21070+
// e.g. "zfs-2.4.2-1~bpo13+1" — surfaced so admins can see what their
21071+
// installed OpenZFS supports (and why AnyRaid isn't offered yet).
21072+
"version": zfs_version_string(),
2107021073
}))
2107121074
}
2107221075

@@ -21371,6 +21374,270 @@ pub async fn zfs_pool_iostat(
2137121374
}
2137221375
}
2137321376

21377+
/// First line of `zfs version` (e.g. "zfs-2.4.2-1~bpo13+1"), or None if it
21378+
/// can't be read. Shown in the UI so admins know what their installed OpenZFS
21379+
/// can and can't do (klasSponsor 2026-07-22 asked for AnyRaid management —
21380+
/// AnyRaid is not in any RELEASED OpenZFS as of 2026-07, so the UI shows the
21381+
/// version plus an honest "pending upstream" note instead of unusable options).
21382+
fn zfs_version_string() -> Option<String> {
21383+
let out = std::process::Command::new("zfs").arg("version").output().ok()?;
21384+
if !out.status.success() {
21385+
return None;
21386+
}
21387+
String::from_utf8_lossy(&out.stdout)
21388+
.lines()
21389+
.next()
21390+
.map(|l| l.trim().to_string())
21391+
}
21392+
21393+
/// Valid ZFS pool name: begins with a letter; letters/digits/-_.: after; not a
21394+
/// reserved vdev keyword (zpoolconcepts(7): mirror/raidz/draid/spare/log).
21395+
fn valid_zpool_name(name: &str) -> bool {
21396+
let mut chars = name.chars();
21397+
let first_ok = chars.next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false);
21398+
let rest_ok = name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'));
21399+
let reserved = ["mirror", "raidz", "draid", "spare", "log"];
21400+
let not_reserved = !reserved.iter().any(|r| name == *r || name.starts_with(&format!("{}0", r)) || name.starts_with(&format!("{}1", r)) || name.starts_with(&format!("{}2", r)) || name.starts_with(&format!("{}3", r)));
21401+
first_ok && rest_ok && not_reserved
21402+
}
21403+
21404+
/// GET /api/storage/zfs/importable — pools visible to `zpool import` (exported
21405+
/// or foreign, e.g. a disk shelf pulled from a TrueNAS box — TrueNAS pools are
21406+
/// plain OpenZFS, so migrating to WolfStack is a straight import).
21407+
pub async fn zfs_importable(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
21408+
if let Err(e) = require_auth(&req, &state) { return e; }
21409+
// `zpool import` scans every device; seconds, not millis — off the runtime.
21410+
let output = web::block(|| std::process::Command::new("zpool").arg("import").output()).await;
21411+
let output = match output {
21412+
Ok(Ok(o)) => o,
21413+
_ => return HttpResponse::InternalServerError().json(serde_json::json!({ "error": "zpool import failed to run" })),
21414+
};
21415+
// Exit status is non-zero when there is nothing to import — that's an
21416+
// empty list, not an error.
21417+
let text = format!(
21418+
"{}\n{}",
21419+
String::from_utf8_lossy(&output.stdout),
21420+
String::from_utf8_lossy(&output.stderr)
21421+
);
21422+
let mut pools: Vec<serde_json::Value> = Vec::new();
21423+
let mut current: Option<(String, String, String)> = None; // name, id, state
21424+
for line in text.lines() {
21425+
let t = line.trim();
21426+
if let Some(rest) = t.strip_prefix("pool:") {
21427+
if let Some((n, i, s)) = current.take() {
21428+
pools.push(serde_json::json!({ "name": n, "id": i, "state": s }));
21429+
}
21430+
current = Some((rest.trim().to_string(), String::new(), String::new()));
21431+
} else if let Some(rest) = t.strip_prefix("id:")
21432+
&& let Some(c) = current.as_mut() { c.1 = rest.trim().to_string(); }
21433+
else if let Some(rest) = t.strip_prefix("state:")
21434+
&& let Some(c) = current.as_mut() { c.2 = rest.trim().to_string(); }
21435+
}
21436+
if let Some((n, i, s)) = current.take() {
21437+
pools.push(serde_json::json!({ "name": n, "id": i, "state": s }));
21438+
}
21439+
HttpResponse::Ok().json(serde_json::json!({ "pools": pools }))
21440+
}
21441+
21442+
/// POST /api/storage/zfs/pool/import — {pool, force?} import by name or id.
21443+
/// `force` maps to `zpool import -f` (pool last used on another system — the
21444+
/// normal case for a TrueNAS migration, where the pool was never exported).
21445+
pub async fn zfs_pool_import(
21446+
req: HttpRequest,
21447+
state: web::Data<AppState>,
21448+
body: web::Json<serde_json::Value>,
21449+
) -> HttpResponse {
21450+
if let Err(e) = require_auth(&req, &state) { return e; }
21451+
let pool = body.get("pool").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21452+
let force = body.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
21453+
// Accept a pool name or a numeric pool id.
21454+
let is_id = !pool.is_empty() && pool.chars().all(|c| c.is_ascii_digit());
21455+
if pool.is_empty() || (!is_id && !valid_zpool_name(&pool)) {
21456+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid pool name" }));
21457+
}
21458+
let output = web::block(move || {
21459+
let mut args: Vec<&str> = vec!["import"];
21460+
if force { args.push("-f"); }
21461+
args.push(&pool);
21462+
std::process::Command::new("zpool").args(&args).output()
21463+
.map(|o| (o, pool.clone()))
21464+
})
21465+
.await;
21466+
match output {
21467+
Ok(Ok((out, pool))) if out.status.success() => {
21468+
HttpResponse::Ok().json(serde_json::json!({ "message": format!("Pool '{}' imported", pool) }))
21469+
}
21470+
Ok(Ok((out, _))) => {
21471+
let err = String::from_utf8_lossy(&out.stderr).to_string();
21472+
HttpResponse::InternalServerError().json(serde_json::json!({ "error": err.trim() }))
21473+
}
21474+
_ => HttpResponse::InternalServerError().json(serde_json::json!({ "error": "zpool import failed to run" })),
21475+
}
21476+
}
21477+
21478+
/// POST /api/storage/zfs/pool/create — {name, layout, disks[], mountpoint?, force?}.
21479+
///
21480+
/// Layouts are the RELEASED OpenZFS vdev types only (stripe/mirror/raidz1-3).
21481+
/// AnyRaid/anymirror (mixed-size vdevs) is deliberately NOT offered: it is not
21482+
/// in any released OpenZFS (still in review upstream as of 2026-07) and its
21483+
/// final CLI syntax is not frozen — offering it would ship guessed commands.
21484+
/// The UI explains this; support lands here the release after OpenZFS ships it.
21485+
///
21486+
/// DESTRUCTIVE: creating a pool takes ownership of the member disks. Every
21487+
/// disk must pass the same paranoid in-use gate as dedicate-disk
21488+
/// (mounted/LVM/mdraid/LUKS/swap/ZFS/bcache/Ceph anywhere on the device) —
21489+
/// `force` does NOT bypass that gate, it only maps to `zpool create -f`
21490+
/// (which clears foreign non-active labels). A disk that is part of the
21491+
/// running system can never be pulled into a pool through this endpoint.
21492+
pub async fn zfs_pool_create(
21493+
req: HttpRequest,
21494+
state: web::Data<AppState>,
21495+
body: web::Json<serde_json::Value>,
21496+
) -> HttpResponse {
21497+
if let Err(e) = require_auth(&req, &state) { return e; }
21498+
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21499+
let layout = body.get("layout").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21500+
let force = body.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
21501+
let mountpoint = body.get("mountpoint").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21502+
let disks: Vec<String> = body.get("disks").and_then(|v| v.as_array())
21503+
.map(|a| a.iter().filter_map(|d| d.as_str()).map(|s| s.trim().to_string()).collect())
21504+
.unwrap_or_default();
21505+
21506+
if !valid_zpool_name(&name) {
21507+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid pool name (must start with a letter; letters, digits, - _ . : only)" }));
21508+
}
21509+
// (vdev keyword, minimum devices) per zpoolconcepts(7).
21510+
let (vdev_kw, min_disks): (Option<&str>, usize) = match layout.as_str() {
21511+
"stripe" => (None, 1),
21512+
"mirror" => (Some("mirror"), 2),
21513+
"raidz1" => (Some("raidz1"), 2),
21514+
"raidz2" => (Some("raidz2"), 3),
21515+
"raidz3" => (Some("raidz3"), 4),
21516+
_ => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid layout (stripe, mirror, raidz1, raidz2, raidz3)" })),
21517+
};
21518+
if disks.len() < min_disks {
21519+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Layout '{}' needs at least {} disks", layout, min_disks) }));
21520+
}
21521+
if !mountpoint.is_empty() && (!mountpoint.starts_with('/') || mountpoint.contains("..")) {
21522+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Mountpoint must be an absolute path" }));
21523+
}
21524+
for d in &disks {
21525+
if !d.starts_with("/dev/") || d.contains("..") || d.chars().any(|c| c.is_whitespace()) {
21526+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Invalid device path: {}", d) }));
21527+
}
21528+
if !std::path::Path::new(d).exists() {
21529+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Device not found: {}", d) }));
21530+
}
21531+
}
21532+
let mut seen = std::collections::HashSet::new();
21533+
if !disks.iter().all(|d| seen.insert(d.clone())) {
21534+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Duplicate device in disk list" }));
21535+
}
21536+
21537+
let result = web::block(move || -> Result<String, String> {
21538+
// Safety gates — never overridable from the API (matches dedicate-disk).
21539+
for d in &disks {
21540+
if storage::is_protected_device(d)? {
21541+
return Err(format!("{} holds a protected system mountpoint — refusing", d));
21542+
}
21543+
if storage::device_or_children_in_use(d)? {
21544+
return Err(format!(
21545+
"{} is in use (mounted, or holds LVM/mdraid/LUKS/swap/ZFS/bcache/Ceph data) — \
21546+
clear it first if it really is expendable", d
21547+
));
21548+
}
21549+
}
21550+
let mut args: Vec<String> = vec!["create".into()];
21551+
if force { args.push("-f".into()); }
21552+
if !mountpoint.is_empty() {
21553+
args.push("-m".into());
21554+
args.push(mountpoint.clone());
21555+
}
21556+
args.push(name.clone());
21557+
if let Some(kw) = vdev_kw { args.push(kw.into()); }
21558+
args.extend(disks.iter().cloned());
21559+
let out = std::process::Command::new("zpool").args(&args).output()
21560+
.map_err(|e| format!("zpool: {}", e))?;
21561+
if out.status.success() {
21562+
Ok(format!("Pool '{}' created ({} on {} disks)", name, layout, disks.len()))
21563+
} else {
21564+
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
21565+
}
21566+
})
21567+
.await;
21568+
21569+
match result {
21570+
Ok(Ok(msg)) => HttpResponse::Ok().json(serde_json::json!({ "message": msg })),
21571+
Ok(Err(e)) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
21572+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("{}", e) })),
21573+
}
21574+
}
21575+
21576+
/// DELETE /api/storage/zfs/pool — {pool, confirm} destroy a pool. `confirm`
21577+
/// must EXACTLY equal the pool name (the UI makes the admin type it) — this is
21578+
/// an irreversible, whole-pool data loss operation.
21579+
pub async fn zfs_pool_destroy(
21580+
req: HttpRequest,
21581+
state: web::Data<AppState>,
21582+
body: web::Json<serde_json::Value>,
21583+
) -> HttpResponse {
21584+
if let Err(e) = require_auth(&req, &state) { return e; }
21585+
let pool = body.get("pool").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21586+
let confirm = body.get("confirm").and_then(|v| v.as_str()).unwrap_or("").trim();
21587+
if !valid_zpool_name(&pool) {
21588+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid pool name" }));
21589+
}
21590+
if confirm != pool {
21591+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Confirmation text does not match the pool name" }));
21592+
}
21593+
let output = web::block(move || {
21594+
std::process::Command::new("zpool").args(["destroy", &pool]).output()
21595+
.map(|o| (o, pool.clone()))
21596+
})
21597+
.await;
21598+
match output {
21599+
Ok(Ok((out, pool))) if out.status.success() => {
21600+
HttpResponse::Ok().json(serde_json::json!({ "message": format!("Pool '{}' destroyed", pool) }))
21601+
}
21602+
Ok(Ok((out, _))) => {
21603+
let err = String::from_utf8_lossy(&out.stderr).to_string();
21604+
HttpResponse::InternalServerError().json(serde_json::json!({ "error": err.trim() }))
21605+
}
21606+
_ => HttpResponse::InternalServerError().json(serde_json::json!({ "error": "zpool destroy failed to run" })),
21607+
}
21608+
}
21609+
21610+
/// POST /api/storage/zfs/dataset — {name} create a dataset (name must be
21611+
/// pool/path, validated to the zfs charset).
21612+
pub async fn zfs_dataset_create(
21613+
req: HttpRequest,
21614+
state: web::Data<AppState>,
21615+
body: web::Json<serde_json::Value>,
21616+
) -> HttpResponse {
21617+
if let Err(e) = require_auth(&req, &state) { return e; }
21618+
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21619+
let valid = !name.is_empty()
21620+
&& name.contains('/')
21621+
&& !name.contains("..")
21622+
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':'))
21623+
&& !name.starts_with('/')
21624+
&& !name.ends_with('/');
21625+
if !valid {
21626+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid dataset name (pool/path)" }));
21627+
}
21628+
let output = std::process::Command::new("zfs").args(["create", "-p", &name]).output();
21629+
match output {
21630+
Ok(out) if out.status.success() => {
21631+
HttpResponse::Ok().json(serde_json::json!({ "message": format!("Dataset '{}' created", name) }))
21632+
}
21633+
Ok(out) => {
21634+
let err = String::from_utf8_lossy(&out.stderr).to_string();
21635+
HttpResponse::InternalServerError().json(serde_json::json!({ "error": err.trim() }))
21636+
}
21637+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("{}", e) })),
21638+
}
21639+
}
21640+
2137421641
// ─── File Manager ───
2137521642

2137621643
/// Validate and normalize a file path — prevent traversal attacks
@@ -40863,6 +41130,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
4086341130
.route("/api/storage/zfs/pool/scrub", web::post().to(zfs_pool_scrub))
4086441131
.route("/api/storage/zfs/pool/status", web::get().to(zfs_pool_status))
4086541132
.route("/api/storage/zfs/pool/iostat", web::get().to(zfs_pool_iostat))
41133+
.route("/api/storage/zfs/importable", web::get().to(zfs_importable))
41134+
.route("/api/storage/zfs/pool/import", web::post().to(zfs_pool_import))
41135+
.route("/api/storage/zfs/pool/create", web::post().to(zfs_pool_create))
41136+
.route("/api/storage/zfs/pool", web::delete().to(zfs_pool_destroy))
41137+
.route("/api/storage/zfs/dataset", web::post().to(zfs_dataset_create))
4086641138
// File Manager
4086741139
.route("/api/files/browse", web::get().to(files_browse))
4086841140
.route("/api/files/mkdir", web::post().to(files_mkdir))

src/backup/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ pub struct BackupTarget {
7474
/// Empty for every other target type.
7575
#[serde(default, skip_serializing_if = "String::is_empty")]
7676
pub system_path: String,
77+
/// Docker targets that belong to a Docker Compose project carry the
78+
/// project name (the `com.docker.compose.project` label). The UI groups
79+
/// these into "stacks" so an operator can select a whole compose stack —
80+
/// every service, each with its binds/volumes and the shared compose
81+
/// definition — as one action (klasSponsor 2026-07-23). None for
82+
/// non-compose targets; absent from older configs (serde default).
83+
#[serde(default, skip_serializing_if = "Option::is_none")]
84+
pub compose_project: Option<String>,
7785
/// Native-LXC targets only: stop the container for the duration of the
7886
/// tar (fully consistent archive, at the cost of downtime AND a restart
7987
/// whose clean boot is the container's problem — wolfscale-3 2026-07-05:
@@ -96,6 +104,7 @@ impl Default for BackupTarget {
96104
specs: None,
97105
exclude_mounts: Vec::new(),
98106
system_path: String::new(),
107+
compose_project: None,
99108
stop_for_backup: false,
100109
}
101110
}
@@ -7224,9 +7233,11 @@ pub fn run_schedule_now(id: &str) -> Result<String, String> {
72247233
pub fn list_available_targets() -> Vec<BackupTarget> {
72257234
let mut targets = Vec::new();
72267235

7227-
// Docker containers — include image and state
7236+
// Docker containers — include image, state, and (for compose-managed
7237+
// containers) the compose project so the UI can group them into stacks.
72287238
if let Ok(output) = Command::new("docker")
7229-
.args(["ps", "-a", "--format", "{{.Names}}\t{{.Image}}\t{{.State}}"])
7239+
.args(["ps", "-a", "--format",
7240+
"{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Label \"com.docker.compose.project\"}}"])
72307241
.output()
72317242
{
72327243
for line in String::from_utf8_lossy(&output.stdout).lines() {
@@ -7235,12 +7246,17 @@ pub fn list_available_targets() -> Vec<BackupTarget> {
72357246
if name.is_empty() { continue; }
72367247
let image = parts.get(1).unwrap_or(&"").to_string();
72377248
let state = parts.get(2).map(|s| s.to_string());
7249+
let compose_project = parts.get(3)
7250+
.map(|s| s.trim())
7251+
.filter(|s| !s.is_empty())
7252+
.map(|s| s.to_string());
72387253
targets.push(BackupTarget {
72397254
target_type: BackupTargetType::Docker,
72407255
name,
72417256
hostname: None,
72427257
state,
72437258
specs: if image.is_empty() { None } else { Some(image) },
7259+
compose_project,
72447260
..Default::default()
72457261
});
72467262
}

src/storage/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,7 +1417,7 @@ fn lsblk_field(device: &str, col: &str) -> String {
14171417
/// the wipe-safety gate, so it must catch everything that would mean "this disk
14181418
/// already holds data" — otherwise dedicate-disk would silently destroy an LVM PV
14191419
/// or LUKS volume that has nothing mounted right now (code review 2026-06-25).
1420-
fn device_or_children_in_use(device: &str) -> Result<bool, String> {
1420+
pub(crate) fn device_or_children_in_use(device: &str) -> Result<bool, String> {
14211421
// Subsystems that hold data (or an active pool/array) WITHOUT a mountpoint — a
14221422
// wipe would destroy them. ZFS/bcache/Ceph added after the paranoid disk review.
14231423
const CLAIMED: &[&str] = &[
@@ -2491,7 +2491,7 @@ fn validate_device(device: &str) -> Result<(), String> {
24912491
}
24922492

24932493
/// Check if a device or any of its children are mounted at a protected mount point
2494-
fn is_protected_device(device: &str) -> Result<bool, String> {
2494+
pub(crate) fn is_protected_device(device: &str) -> Result<bool, String> {
24952495
let output = Command::new("lsblk")
24962496
.args(["-Jno", "NAME,MOUNTPOINTS,TYPE", device])
24972497
.output()

0 commit comments

Comments
 (0)