diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 984239d..9474083 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -118,6 +118,9 @@ jobs:
- name: Build hm
run: cargo build -p harmont-cli
+ - name: Enable FUSE allow_other
+ run: sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf
+
- name: Restore harmont Docker cache
uses: actions/cache/restore@v4
with:
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
index d923926..ad8eff1 100644
--- a/.github/workflows/examples.yml
+++ b/.github/workflows/examples.yml
@@ -109,6 +109,9 @@ jobs:
- name: Mark hm executable
run: chmod +x bin/hm
+ - name: Enable FUSE allow_other
+ run: sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf
+
- name: Run example via hm run
working-directory: examples/${{ matrix.example }}
env:
diff --git a/Cargo.lock b/Cargo.lock
index a372b05..b7f1902 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1211,7 +1211,7 @@ dependencies = [
[[package]]
name = "hm-plugin-cloud"
-version = "0.1.0"
+version = "0.0.0-dev"
dependencies = [
"anyhow",
"base64",
@@ -1248,9 +1248,12 @@ dependencies = [
name = "hm-util"
version = "0.0.0-dev"
dependencies = [
+ "anyhow",
"dirs",
"tempfile",
"tokio",
+ "tracing",
+ "which 6.0.3",
"windows",
]
diff --git a/README.md b/README.md
index f42e471..1f80327 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
- Website · Docs · Slack
+ Website · Docs · Slack
> [!WARNING]
@@ -174,7 +174,7 @@ Go, Python, Java, C++, React, Next.js, and more.
## Documentation
For the full pipeline reference, rich examples, and more — see the
-[docs](https://harmont.dev/docs).
+[docs](https://docs.harmont.dev).
## License
diff --git a/crates/hm-util/Cargo.toml b/crates/hm-util/Cargo.toml
index 9e10e8a..4eea739 100644
--- a/crates/hm-util/Cargo.toml
+++ b/crates/hm-util/Cargo.toml
@@ -7,8 +7,12 @@ repository.workspace = true
description = "Shared OS and filesystem utilities for Harmont crates."
[dependencies]
+anyhow = { workspace = true }
dirs = "6"
+tempfile = "3"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "fs", "io-util"] }
+tracing = { workspace = true }
+which = "6"
[target.'cfg(windows)'.dependencies.windows]
version = "0.62"
diff --git a/crates/hm-util/src/cow.rs b/crates/hm-util/src/cow.rs
new file mode 100644
index 0000000..fd0ac7b
--- /dev/null
+++ b/crates/hm-util/src/cow.rs
@@ -0,0 +1,429 @@
+//! Platform-native copy-on-write directory cloning.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::OnceLock;
+
+use anyhow::{Context, Result, bail};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CowStrategy {
+ ApfsClone,
+ Reflink,
+ FuseOverlay,
+ FullCopy,
+}
+
+/// Detect the best available COW strategy for the current platform.
+/// Result is cached after the first call.
+#[must_use]
+pub fn detect_strategy() -> CowStrategy {
+ static STRATEGY: OnceLock = OnceLock::new();
+ *STRATEGY.get_or_init(detect_strategy_inner)
+}
+
+/// Probe result for a single strategy.
+#[derive(Debug, Clone)]
+pub struct StrategyProbe {
+ pub strategy: CowStrategy,
+ pub available: bool,
+ pub reason: &'static str,
+}
+
+/// Test all strategies and report which are available.
+/// Used for diagnostics / user-facing warnings.
+#[must_use]
+#[allow(clippy::vec_init_then_push)]
+pub fn diagnose_strategies() -> Vec {
+ let mut probes = Vec::new();
+
+ #[cfg(target_os = "macos")]
+ probes.push(StrategyProbe {
+ strategy: CowStrategy::ApfsClone,
+ available: true,
+ reason: "macOS APFS detected",
+ });
+
+ #[cfg(target_os = "linux")]
+ {
+ probes.push(StrategyProbe {
+ strategy: CowStrategy::Reflink,
+ available: probe_reflink(),
+ reason: if probe_reflink() {
+ "filesystem supports reflinks"
+ } else {
+ "filesystem does not support reflinks (btrfs/XFS required)"
+ },
+ });
+ let fuse_ok = probe_fuse_overlayfs();
+ probes.push(StrategyProbe {
+ strategy: CowStrategy::FuseOverlay,
+ available: fuse_ok,
+ reason: if fuse_ok {
+ "fuse-overlayfs mount succeeded"
+ } else if which::which("fuse-overlayfs").is_err() {
+ "fuse-overlayfs not installed"
+ } else {
+ "fuse-overlayfs mount failed (missing /dev/fuse or user_allow_other?)"
+ },
+ });
+ }
+
+ probes.push(StrategyProbe {
+ strategy: CowStrategy::FullCopy,
+ available: true,
+ reason: "always available (slow)",
+ });
+
+ probes
+}
+
+#[allow(clippy::missing_const_for_fn)]
+fn detect_strategy_inner() -> CowStrategy {
+ #[cfg(target_os = "macos")]
+ {
+ return CowStrategy::ApfsClone;
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ if probe_reflink() {
+ return CowStrategy::Reflink;
+ }
+ if probe_fuse_overlayfs() {
+ return CowStrategy::FuseOverlay;
+ }
+ return CowStrategy::FullCopy;
+ }
+
+ #[allow(unreachable_code)]
+ CowStrategy::FullCopy
+}
+
+#[cfg(target_os = "linux")]
+fn probe_reflink() -> bool {
+ let Ok(tmp) = tempfile::tempdir() else {
+ return false;
+ };
+ let src = tmp.path().join("src");
+ let dst = tmp.path().join("dst");
+ if std::fs::write(&src, b"x").is_err() {
+ return false;
+ }
+ Command::new("cp")
+ .args(["--reflink=always"])
+ .arg(&src)
+ .arg(&dst)
+ .stderr(std::process::Stdio::null())
+ .status()
+ .is_ok_and(|s| s.success())
+}
+
+#[cfg(target_os = "linux")]
+fn probe_fuse_overlayfs() -> bool {
+ if which::which("fuse-overlayfs").is_err() {
+ return false;
+ }
+ let Ok(tmp) = tempfile::tempdir() else {
+ return false;
+ };
+ let lower = tmp.path().join("lower");
+ let upper = tmp.path().join("upper");
+ let work = tmp.path().join("work");
+ let merged = tmp.path().join("merged");
+ for d in [&lower, &upper, &work, &merged] {
+ if std::fs::create_dir(d).is_err() {
+ return false;
+ }
+ }
+ let opts = format!(
+ "lowerdir={},upperdir={},workdir={},allow_other,squash_to_uid=0,squash_to_gid=0",
+ lower.display(),
+ upper.display(),
+ work.display(),
+ );
+ let ok = Command::new("fuse-overlayfs")
+ .args(["-o", &opts])
+ .arg(&merged)
+ .stderr(std::process::Stdio::null())
+ .status()
+ .is_ok_and(|s| s.success());
+ if ok {
+ let bin = if which::which("fusermount3").is_ok() {
+ "fusermount3"
+ } else {
+ "fusermount"
+ };
+ let _ = Command::new(bin)
+ .args(["-u"])
+ .arg(&merged)
+ .stderr(std::process::Stdio::null())
+ .status();
+ }
+ ok
+}
+
+/// Clone `src` to `dst` using the best available COW mechanism.
+///
+/// # Errors
+///
+/// Returns an error if `dst` already exists, if parent directories cannot
+/// be created, or if the underlying copy operation fails.
+pub fn cow_clone_dir(src: &Path, dst: &Path) -> Result<()> {
+ if dst.exists() {
+ bail!("destination already exists: {}", dst.display());
+ }
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)
+ .with_context(|| format!("create parent dirs for {}", dst.display()))?;
+ }
+
+ if try_platform_cow(src, dst)? {
+ return Ok(());
+ }
+
+ copy_dir_recursive(src, dst)
+}
+
+fn try_platform_cow(src: &Path, dst: &Path) -> Result {
+ #[cfg(target_os = "macos")]
+ {
+ let status = Command::new("cp")
+ .args(["-c", "-R", "-p"])
+ .arg(src)
+ .arg(dst)
+ .stderr(std::process::Stdio::null())
+ .status()
+ .context("spawn cp -c")?;
+ if status.success() {
+ return Ok(true);
+ }
+ let _ = std::fs::remove_dir_all(dst);
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ let status = Command::new("cp")
+ .args(["--reflink=always", "-a"])
+ .arg(src)
+ .arg(dst)
+ .stderr(std::process::Stdio::null())
+ .status()
+ .context("spawn cp --reflink")?;
+ if status.success() {
+ return Ok(true);
+ }
+ let _ = std::fs::remove_dir_all(dst);
+
+ let status = Command::new("cp")
+ .args(["-a"])
+ .arg(src)
+ .arg(dst)
+ .stderr(std::process::Stdio::null())
+ .status()
+ .context("spawn cp -a")?;
+ if status.success() {
+ return Ok(true);
+ }
+ let _ = std::fs::remove_dir_all(dst);
+ }
+
+ Ok(false)
+}
+
+fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
+ std::fs::create_dir_all(dst).with_context(|| format!("create {}", dst.display()))?;
+ for entry in std::fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
+ let entry = entry?;
+ let ty = entry.file_type()?;
+ let src_path = entry.path();
+ let dst_path = dst.join(entry.file_name());
+ if ty.is_dir() {
+ copy_dir_recursive(&src_path, &dst_path)?;
+ } else if ty.is_symlink() {
+ let target = std::fs::read_link(&src_path)?;
+ #[cfg(unix)]
+ std::os::unix::fs::symlink(&target, &dst_path)?;
+ #[cfg(windows)]
+ std::os::windows::fs::symlink_file(&target, &dst_path)?;
+ } else {
+ std::fs::copy(&src_path, &dst_path)
+ .with_context(|| format!("copy {}", src_path.display()))?;
+ }
+ }
+ Ok(())
+}
+
+pub struct OverlayMount {
+ merged: PathBuf,
+ upper: PathBuf,
+ mounted: std::sync::atomic::AtomicBool,
+}
+
+impl OverlayMount {
+ /// Mount a fuse-overlayfs filesystem merging the given layers.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if directory creation fails or `fuse-overlayfs`
+ /// exits with a non-zero status.
+ pub fn mount(
+ lower_dirs: &[&Path],
+ upper_dir: &Path,
+ work_dir: &Path,
+ merged_path: &Path,
+ ) -> Result {
+ std::fs::create_dir_all(upper_dir)?;
+ std::fs::create_dir_all(work_dir)?;
+ std::fs::create_dir_all(merged_path)?;
+
+ let lowerdir: String = lower_dirs
+ .iter()
+ .map(|p| p.to_string_lossy().into_owned())
+ .collect::>()
+ .join(":");
+
+ let opts = format!(
+ "lowerdir={lowerdir},upperdir={},workdir={},allow_other,squash_to_uid=0,squash_to_gid=0",
+ upper_dir.display(),
+ work_dir.display(),
+ );
+
+ let output = Command::new("fuse-overlayfs")
+ .args(["-o", &opts])
+ .arg(merged_path)
+ .output()
+ .context("spawn fuse-overlayfs")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ bail!(
+ "fuse-overlayfs mount failed (exit {}): {stderr}\nlowerdir={}, upper={}, merged={}",
+ output.status.code().unwrap_or(-1),
+ lowerdir,
+ upper_dir.display(),
+ merged_path.display(),
+ );
+ }
+
+ Ok(Self {
+ merged: merged_path.to_path_buf(),
+ upper: upper_dir.to_path_buf(),
+ mounted: std::sync::atomic::AtomicBool::new(true),
+ })
+ }
+
+ #[must_use]
+ pub fn merged_path(&self) -> &Path {
+ &self.merged
+ }
+
+ #[must_use]
+ pub fn upper_dir(&self) -> &Path {
+ &self.upper
+ }
+
+ /// Unmount the fuse-overlayfs filesystem. Safe to call multiple times.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if `fusermount` cannot be spawned or exits
+ /// with a non-zero status.
+ pub fn unmount(&self) -> Result<()> {
+ if !self
+ .mounted
+ .swap(false, std::sync::atomic::Ordering::AcqRel)
+ {
+ return Ok(());
+ }
+ let bin = if which::which("fusermount3").is_ok() {
+ "fusermount3"
+ } else {
+ "fusermount"
+ };
+ let status = Command::new(bin)
+ .args(["-u"])
+ .arg(&self.merged)
+ .stderr(std::process::Stdio::null())
+ .status()
+ .with_context(|| format!("spawn {bin} -u"))?;
+ if !status.success() {
+ bail!("{bin} -u {} failed", self.merged.display());
+ }
+ Ok(())
+ }
+}
+
+impl std::fmt::Debug for OverlayMount {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("OverlayMount")
+ .field("merged", &self.merged)
+ .field("upper", &self.upper)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Drop for OverlayMount {
+ fn drop(&mut self) {
+ if let Err(e) = self.unmount() {
+ tracing::warn!(%e, path = %self.merged.display(), "fuse-overlayfs unmount failed");
+ }
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ #[test]
+ fn cow_clone_creates_identical_tree() {
+ let tmp = tempfile::tempdir().unwrap();
+ let src = tmp.path().join("src");
+ fs::create_dir_all(src.join("sub")).unwrap();
+ fs::write(src.join("a.txt"), b"hello").unwrap();
+ fs::write(src.join("sub/b.txt"), b"world").unwrap();
+
+ let dst = tmp.path().join("dst");
+ cow_clone_dir(&src, &dst).unwrap();
+
+ assert_eq!(fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
+ assert_eq!(fs::read_to_string(dst.join("sub/b.txt")).unwrap(), "world");
+ }
+
+ #[test]
+ fn cow_clone_is_isolated() {
+ let tmp = tempfile::tempdir().unwrap();
+ let src = tmp.path().join("src");
+ fs::create_dir(&src).unwrap();
+ fs::write(src.join("f.txt"), b"original").unwrap();
+
+ let dst = tmp.path().join("dst");
+ cow_clone_dir(&src, &dst).unwrap();
+
+ // Mutate dst; src must be unchanged.
+ fs::write(dst.join("f.txt"), b"modified").unwrap();
+ assert_eq!(fs::read_to_string(src.join("f.txt")).unwrap(), "original");
+ assert_eq!(fs::read_to_string(dst.join("f.txt")).unwrap(), "modified");
+ }
+
+ #[test]
+ fn cow_clone_fails_if_dst_exists() {
+ let tmp = tempfile::tempdir().unwrap();
+ let src = tmp.path().join("src");
+ fs::create_dir(&src).unwrap();
+ let dst = tmp.path().join("dst");
+ fs::create_dir(&dst).unwrap();
+
+ assert!(cow_clone_dir(&src, &dst).is_err());
+ }
+
+ #[test]
+ fn detect_strategy_returns_something() {
+ // Should always detect at least FullCopy.
+ let s = detect_strategy();
+ assert!(!matches!(s, CowStrategy::FuseOverlay));
+ // Can't assert specific strategy (platform-dependent) but it must not panic.
+ }
+}
diff --git a/crates/hm-util/src/dirs.rs b/crates/hm-util/src/dirs.rs
index 4838e3f..e4b1120 100644
--- a/crates/hm-util/src/dirs.rs
+++ b/crates/hm-util/src/dirs.rs
@@ -31,6 +31,16 @@ pub fn harmont_plugin_state_dir() -> Option {
harmont_data_dir().map(|d| d.join("state"))
}
+/// `~/.harmont/cache/` — local build cache root.
+pub fn harmont_cache_dir() -> Option {
+ harmont_config_dir().map(|h| h.join("cache"))
+}
+
+/// `~/.harmont/cache/workspaces/` — COW workspace cache root.
+pub fn harmont_workspace_cache_dir() -> Option {
+ harmont_cache_dir().map(|c| c.join("workspaces"))
+}
+
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -59,4 +69,16 @@ mod tests {
let p = harmont_plugin_state_dir().unwrap();
assert!(p.ends_with("harmont/state"));
}
+
+ #[test]
+ fn harmont_cache_dir_resolves() {
+ let p = harmont_cache_dir().unwrap();
+ assert!(p.to_string_lossy().contains("cache"));
+ }
+
+ #[test]
+ fn harmont_workspace_cache_dir_resolves() {
+ let p = harmont_workspace_cache_dir().unwrap();
+ assert!(p.to_string_lossy().contains("workspaces"));
+ }
}
diff --git a/crates/hm-util/src/lib.rs b/crates/hm-util/src/lib.rs
index c5284c5..e35ba64 100644
--- a/crates/hm-util/src/lib.rs
+++ b/crates/hm-util/src/lib.rs
@@ -1,2 +1,3 @@
+pub mod cow;
pub mod dirs;
pub mod os;
diff --git a/crates/hm/src/orchestrator/cache.rs b/crates/hm/src/orchestrator/cache.rs
index e0bdc5a..1e3208e 100644
--- a/crates/hm/src/orchestrator/cache.rs
+++ b/crates/hm/src/orchestrator/cache.rs
@@ -1,38 +1,16 @@
//! Host-side cache decision.
//!
-//! Resolves a wire-typed [`CommandStep`] against the local Docker
-//! daemon and returns the wire-typed [`CacheDecision`] consumed by
-//! step-executor plugins (design spec §5.5).
+//! Resolves a wire-typed [`CommandStep`] against the local COW
+//! workspace cache directory and returns the wire-typed
+//! [`CacheDecision`] consumed by step execution.
//!
//! Cache keys are computed by `harmont.keygen` at plan time and ride
-//! along the JSON in `cache.key`. We turn them into Docker image tags
-//! and consult the local image store.
+//! along the JSON in `cache.key`.
-use anyhow::Result;
-use hm_plugin_protocol::{CacheDecision, CommandStep, SnapshotRef};
-
-use crate::orchestrator::docker_client::DockerClient;
+use std::path::{Path, PathBuf};
-/// `harmont-local/:`. Step key is
-/// sanitised to `[a-zA-Z0-9_-]` (Docker tag rules). Returns `None`
-/// when the step has no cache or a policy of `"none"`.
-///
-/// The cache key is the SHA-256 hex resolved at plan time by
-/// `harmont.keygen`. We truncate to the first 16 hex chars (8 bytes)
-/// for the image tag — collision odds across a developer's local
-/// cache are negligible. The cloud path uses the full key elsewhere;
-/// that divergence is acceptable for local-only tags since they're
-/// never resolved across machines.
-fn cache_image_tag(step: &CommandStep) -> Option {
- let cache = step.cache.as_ref()?;
- if cache.policy == "none" {
- return None;
- }
- let key = cache.key.as_deref()?;
- let safe = sanitize_for_tag(&step.key);
- let short = &key[..key.len().min(16)];
- Some(format!("harmont-local/{safe}:{short}"))
-}
+use anyhow::{Context, Result};
+use hm_plugin_protocol::{CacheDecision, CommandStep, SnapshotRef};
fn sanitize_for_tag(s: &str) -> String {
s.chars()
@@ -46,58 +24,127 @@ fn sanitize_for_tag(s: &str) -> String {
.collect()
}
-/// The outcome of a cache lookup: the wire-typed decision plus any
-/// stale images that should be garbage-collected after the new image
-/// is committed.
+// ---------------------------------------------------------------------------
+// COW workspace cache
+// ---------------------------------------------------------------------------
+
+/// The outcome of a COW workspace cache lookup.
#[derive(Debug)]
-pub struct CacheOutcome {
+pub struct CowCacheOutcome {
pub decision: CacheDecision,
- /// Stale cache images for this step that should be removed after
- /// the new image is committed successfully.
- pub stale_tags: Vec,
+ pub cache_to: Option,
+ pub stale_dirs: Vec,
}
-/// Decide cache outcome for a step against the local Docker daemon.
+/// Resolve the on-disk cache directory for a step's COW workspace.
+///
+/// Returns `None` when the step has no cache, a `"none"` policy, or no
+/// cache key — matching the same guard logic as [`cache_image_tag`].
///
-/// Returns hit (snapshot already present), miss-with-tag (run and commit
-/// afterwards), or miss-no-commit (`cache.policy == "none"` or no cache
-/// key).
+/// # Errors
+/// Returns an error if the config directory cannot be resolved.
+pub fn cow_cache_dir(step: &CommandStep) -> Result