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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Content-addressed caching keyed by lockfile SHA256 hash.
**States:**
- `Miss` -> create volume, mount read-write
- `Building` -> mount read-write (resume after crash)
- `Complete` -> mount read-only (immutable)
- `Complete` -> skip re-finalization, eligible for GC

**Volume naming:** `mino-cache-{ecosystem}-{hash12}`

Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ Mino automatically caches package manager dependencies using content-addressed v
|-------|-------|------|
| Miss | read-write | No cache exists, creating new |
| Building | read-write | In progress or crashed (retryable) |
| Complete | read-only | Finalized, immutable |
| Complete | read-write | Finalized, skip re-finalization |

4. **Environment Variables**: Automatically configured:
```
Expand All @@ -419,8 +419,7 @@ Mino automatically caches package manager dependencies using content-addressed v

### Security

- **Tamper-proof**: Complete caches are mounted read-only
- **Content-addressed**: Changing dependencies = new hash = new cache
- **Content-addressed**: Same lockfile = same cache volume; changing dependencies = new hash = new cache
- **Isolated**: Each unique lockfile gets its own cache volume

### Cache Management
Expand Down
14 changes: 8 additions & 6 deletions images/base/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ RUN useradd -m -s /bin/zsh -u 1000 developer \
&& mkdir -p /workspace /cache \
&& chown -R developer:developer /workspace /cache

# PATH: ~/.local/bin (symlinks) > ~/.npm-global/bin > /opt/mino-tools (fallback)
ENV PATH="/home/developer/.local/bin:/home/developer/.npm-global/bin:/opt/mino-tools:${PATH}"
# PATH: ~/.claude/bin > ~/.local/bin (symlinks) > ~/.npm-global/bin > /opt/mino-tools (fallback)
ENV PATH="/home/developer/.claude/bin:/home/developer/.local/bin:/home/developer/.npm-global/bin:/opt/mino-tools:${PATH}"

# Git configuration for better diffs
RUN git config --system core.pager delta \
Expand Down Expand Up @@ -262,7 +262,7 @@ RUN chmod +x /usr/local/bin/mino-bootstrap /usr/local/bin/mino-entrypoint
ARG MINO_BASE_VERSION=dev
RUN echo "$MINO_BASE_VERSION" > /etc/mino-base-version

# Healthcheck script (sources nvm first — claude and node both live there)
# Healthcheck script (sources nvm for node; claude is on PATH via ~/.claude/bin)
RUN printf '#!/bin/bash\nset -e\nif [ -d "$HOME/.nvm" ]; then\n export NVM_DIR="$HOME/.nvm"\n [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"\nfi\ncommand -v claude >/dev/null 2>&1\ncommand -v node >/dev/null 2>&1\ncommand -v git >/dev/null 2>&1\ntest -d /workspace\ntest -d /cache\ntest "$(whoami)" = "developer"\necho "ok"\n' \
> /usr/local/bin/mino-healthcheck \
&& chmod +x /usr/local/bin/mino-healthcheck
Expand Down Expand Up @@ -304,13 +304,14 @@ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
ZSHRC

# Claude Code CLI (user-level install via nvm node, updatable by developer)
RUN bash -c 'source ~/.nvm/nvm.sh && npm install -g @anthropic-ai/claude-code'
# Claude Code CLI (native installer, auto-updates)
RUN curl -fsSL https://claude.ai/install.sh | bash

# Copy developer home to /etc/skel/ for bootstrap on new home volumes
USER root
RUN cp -a /home/developer/.oh-my-zsh /etc/skel/.oh-my-zsh \
&& cp -a /home/developer/.nvm /etc/skel/.nvm \
&& cp -a /home/developer/.claude /etc/skel/.claude \
&& cp /home/developer/.zshrc /etc/skel/.zshrc \
&& touch /etc/skel/.zsh_history

Expand All @@ -335,7 +336,8 @@ RUN /opt/mino-tools/rg --version \
&& /opt/mino-tools/sd --version

# Verify user-level installs
RUN bash -c 'source ~/.nvm/nvm.sh && claude --version && node --version && npm --version' \
RUN claude --version \
&& bash -c 'source ~/.nvm/nvm.sh && node --version && npm --version' \
&& git --version

HEALTHCHECK CMD mino-healthcheck
Expand Down
2 changes: 1 addition & 1 deletion images/base/mino-bootstrap
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ fi
# --- Step: skeleton (copy dotfiles from /etc/skel/) ---
# Skip if already bootstrapped (image or volume has files from podman auto-copy)
if [ "$ALREADY_BOOTSTRAPPED" = false ] && [ ! -f "$STEP_DIR/skeleton" ]; then
for item in .oh-my-zsh .nvm .local .zshrc .zsh_history; do
for item in .oh-my-zsh .nvm .claude .local .zshrc .zsh_history; do
if [ ! -e "$HOME/$item" ] && [ -e "/etc/skel/$item" ]; then
cp -a "/etc/skel/$item" "$HOME/$item" 2>/dev/null || true
fi
Expand Down
5 changes: 4 additions & 1 deletion images/base/mino-entrypoint
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#!/usr/bin/env bash
/usr/local/bin/mino-bootstrap || true

# Source nvm so node/claude/npm are on PATH for all commands (not just zsh)
# Claude Code native install (on PATH before nvm)
export PATH="${HOME}/.claude/bin:${PATH}"

# Source nvm so node/npm are on PATH for all commands (not just zsh)
export NVM_DIR="${HOME}/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

Expand Down
7 changes: 3 additions & 4 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
//! Persistent cache system for dependency caching
//!
//! Provides content-addressed caching keyed by lockfile hashes.
//! Caches are immutable once finalized, ensuring tamper-proof builds.
//!
//! # Security Model
//!
//! - Cache keys derived from lockfile SHA256 hash
//! - Complete caches mounted read-only (immutable)
//! - Content-addressed: same lockfile = same cache volume
//! - Changing cache contents requires different lockfile = different hash
//! - Incomplete caches (from crashes) remain writable for retry
//! - Incomplete caches (from crashes) remain retryable
//!
//! # Cache States
//!
//! | State | Mount | Description |
//! |-------|-------|-------------|
//! | Miss | rw | No volume exists, creating new |
//! | Building | rw | In progress or crashed, retryable |
//! | Complete | ro | Finalized, immutable |
//! | Complete | rw | Finalized, skip re-finalization |
pub mod lockfile;
pub mod sidecar;
Expand Down
79 changes: 12 additions & 67 deletions src/cache/volume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,11 @@ pub enum CacheState {
Miss,
/// Volume exists but session hasn't completed cleanly
Building,
/// Volume is finalized and immutable
/// Volume is finalized (skip re-finalization, eligible for GC)
Complete,
}

impl CacheState {
/// Whether this cache should be mounted read-only
pub fn is_readonly(&self) -> bool {
matches!(self, Self::Complete)
}

/// Parse from label value
pub fn from_label(s: &str) -> Self {
match s {
Expand Down Expand Up @@ -237,40 +232,25 @@ pub struct CacheMount {
pub volume_name: String,
/// Mount path inside container
pub container_path: String,
/// Whether to mount read-only
pub readonly: bool,
/// Ecosystem for setting env vars
pub ecosystem: Ecosystem,
}

impl CacheMount {
/// Generate the volume mount string for podman
pub fn volume_arg(&self) -> String {
let ro = if self.readonly { ":ro" } else { "" };
format!("{}:{}{}", self.volume_name, self.container_path, ro)
format!("{}:{}", self.volume_name, self.container_path)
}
}

/// Determine cache mounts for a set of lockfiles
pub fn plan_cache_mounts(
lockfiles: &[LockfileInfo],
volume_states: &HashMap<String, CacheState>,
) -> Vec<CacheMount> {
pub fn plan_cache_mounts(lockfiles: &[LockfileInfo]) -> Vec<CacheMount> {
lockfiles
.iter()
.map(|info| {
let volume_name = info.volume_name();
let state = volume_states
.get(&volume_name)
.copied()
.unwrap_or(CacheState::Miss);

CacheMount {
volume_name,
container_path: "/cache".to_string(),
readonly: state.is_readonly(),
ecosystem: info.ecosystem,
}
.map(|info| CacheMount {
volume_name: info.volume_name(),
container_path: "/cache".to_string(),
ecosystem: info.ecosystem,
})
.collect()
}
Expand All @@ -292,13 +272,6 @@ mod tests {
use super::*;
use std::path::PathBuf;

#[test]
fn cache_state_readonly() {
assert!(!CacheState::Miss.is_readonly());
assert!(!CacheState::Building.is_readonly());
assert!(CacheState::Complete.is_readonly());
}

#[test]
fn cache_state_label_roundtrip() {
for state in [CacheState::Building, CacheState::Complete] {
Expand Down Expand Up @@ -370,33 +343,25 @@ mod tests {
let mount = CacheMount {
volume_name: "mino-cache-npm-abc123".to_string(),
container_path: "/cache".to_string(),
readonly: true,
ecosystem: Ecosystem::Npm,
};

assert_eq!(mount.volume_arg(), "mino-cache-npm-abc123:/cache:ro");

let mount_rw = CacheMount {
readonly: false,
..mount
};
assert_eq!(mount_rw.volume_arg(), "mino-cache-npm-abc123:/cache");
assert_eq!(mount.volume_arg(), "mino-cache-npm-abc123:/cache");
}

#[test]
fn plan_cache_mounts_miss() {
fn plan_cache_mounts_creates_mounts() {
let lockfiles = vec![LockfileInfo {
ecosystem: Ecosystem::Npm,
path: PathBuf::from("/test/package-lock.json"),
hash: "abc123def456".to_string(),
}];

let states = HashMap::new(); // No existing volumes

let mounts = plan_cache_mounts(&lockfiles, &states);
let mounts = plan_cache_mounts(&lockfiles);

assert_eq!(mounts.len(), 1);
assert!(!mounts[0].readonly); // Miss = read-write
assert_eq!(mounts[0].volume_name, "mino-cache-npm-abc123def456");
assert_eq!(mounts[0].container_path, "/cache");
}

#[test]
Expand All @@ -417,24 +382,4 @@ mod tests {
assert_eq!(vol.hash, "uvhash123456");
assert_eq!(vol.state, CacheState::Building);
}

#[test]
fn plan_cache_mounts_complete() {
let lockfiles = vec![LockfileInfo {
ecosystem: Ecosystem::Npm,
path: PathBuf::from("/test/package-lock.json"),
hash: "abc123def456".to_string(),
}];

let mut states = HashMap::new();
states.insert(
"mino-cache-npm-abc123def456".to_string(),
CacheState::Complete,
);

let mounts = plan_cache_mounts(&lockfiles, &states);

assert_eq!(mounts.len(), 1);
assert!(mounts[0].readonly); // Complete = read-only
}
}
15 changes: 5 additions & 10 deletions src/cli/commands/run/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async fn setup_cache_for_lockfile(
runtime.volume_inspect(&volume_name).await?
};

let (state, should_finalize) = match existing {
let should_finalize = match existing {
Some(vol_info) => {
let label_state = CacheVolume::from_labels(&vol_info.name, &vol_info.labels)
.map(|c| c.state)
Expand All @@ -96,11 +96,11 @@ async fn setup_cache_for_lockfile(
match resolved {
CacheState::Complete => {
debug!(
"Cache hit for {} ({}), mounting read-only",
"Cache hit for {} ({}), reusing complete cache",
info.ecosystem,
&info.hash[..8]
);
(CacheState::Complete, false)
false
}
CacheState::Building | CacheState::Miss => {
debug!(
Expand All @@ -125,7 +125,7 @@ async fn setup_cache_for_lockfile(
warn!("Failed to backfill sidecar for {}: {}", volume_name, e);
}
}
(CacheState::Building, true)
true
}
}
}
Expand Down Expand Up @@ -160,18 +160,13 @@ async fn setup_cache_for_lockfile(
None => CacheState::Building,
};

if resolved == CacheState::Complete {
(CacheState::Complete, false)
} else {
(CacheState::Building, true)
}
resolved != CacheState::Complete
}
};

let mount = CacheMount {
volume_name,
container_path: "/cache".to_string(),
readonly: state.is_readonly(),
ecosystem: info.ecosystem,
};

Expand Down
Loading