fix: persist full container state across stop/start and config-change recreation
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 2m29s
Build App / build-linux (push) Successful in 4m34s
Sync Release to GitHub / sync-release (release) Successful in 1s

- Add home volume (triple-c-home-{id}) for /home/claude to persist
  .claude.json, .local, and other user-level state across restarts
- Add docker commit before recreation: when container_needs_recreation()
  triggers, snapshot the container to preserve system-level changes
  (apt/pip/npm installs), then create the new container from that snapshot
- On Reset/removal: delete snapshot image + both volumes for clean slate
- Remove commit from stop_project_container (stop/start preserves the
  writable layer naturally; no commit needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:16:19 -08:00
parent 3228e6cdd7
commit eb86aa95b7
2 changed files with 144 additions and 16 deletions

View File

@@ -81,12 +81,19 @@ pub async fn remove_project(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Stop and remove container if it exists // Stop and remove container if it exists
if let Some(project) = state.projects_store.get(&project_id) { if let Some(ref project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id { if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await; state.exec_manager.close_sessions_for_container(container_id).await;
let _ = docker::stop_container(container_id).await; let _ = docker::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await; let _ = docker::remove_container(container_id).await;
} }
// Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
}
if let Err(e) = docker::remove_project_volumes(project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
} }
// Clean up keychain secrets for this project // Clean up keychain secrets for this project
@@ -153,25 +160,37 @@ pub async fn start_project_container(
// AWS config path from global settings // AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone(); let aws_config_path = settings.global_aws.aws_config_path.clone();
// Check for existing container
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
let needs_recreation = docker::container_needs_recreation( // Check if config changed — if so, snapshot + recreate
let needs_recreate = docker::container_needs_recreation(
&existing_id, &existing_id,
&project, &project,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
) ).await.unwrap_or(false);
.await
.unwrap_or(false); if needs_recreate {
if needs_recreation { log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
log::info!("Container config changed, recreating container for project {}", project.id); // Snapshot the filesystem before destroying
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
log::warn!("Failed to snapshot container before recreation: {}", e);
}
let _ = docker::stop_container(&existing_id).await; let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?; docker::remove_container(&existing_id).await?;
// Create from snapshot image (preserves system-level changes)
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
snapshot_image
} else {
image_name.clone()
};
let new_id = docker::create_container( let new_id = docker::create_container(
&project, &project,
&docker_socket, &docker_socket,
&image_name, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
@@ -185,10 +204,21 @@ pub async fn start_project_container(
existing_id existing_id
} }
} else { } else {
// Container doesn't exist (first start, or Docker pruned it).
// Check for a snapshot image first — it preserves system-level
// changes (apt/pip/npm installs) from the previous session.
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
log::info!("Creating container from snapshot image for project {}", project.id);
snapshot_image
} else {
image_name.clone()
};
let new_id = docker::create_container( let new_id = docker::create_container(
&project, &project,
&docker_socket, &docker_socket,
&image_name, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
@@ -260,6 +290,14 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?; state.projects_store.set_container_id(&project_id, None)?;
} }
// Remove snapshot image + volumes so Reset creates from the clean base image
if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
}
if let Err(e) = docker::remove_project_volumes(&project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
// Start fresh // Start fresh
start_project_container(project_id, state).await start_project_container(project_id, state).await
} }

View File

@@ -2,6 +2,7 @@ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions, StartContainerOptions, StopContainerOptions,
}; };
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -367,7 +368,19 @@ pub async fn create_container(
}); });
} }
// Named volume for claude config persistence // Named volume for the entire home directory — preserves ~/.claude.json,
// ~/.local (pip/npm globals), and any other user-level state across
// container stop/start cycles.
mounts.push(Mount {
target: Some("/home/claude".to_string()),
source: Some(format!("triple-c-home-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// Named volume for claude config persistence — mounted as a nested volume
// inside the home volume; Docker gives the more-specific mount precedence.
mounts.push(Mount { mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()), target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)), source: Some(format!("triple-c-claude-config-{}", project.id)),
@@ -538,6 +551,83 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e)) .map_err(|e| format!("Failed to remove container: {}", e))
} }
/// Return the snapshot image name for a project.
pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id)
}
/// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal. The Config is left empty so that secrets injected as env vars are
/// NOT baked into the image.
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
// Parse repo:tag
let (repo, tag) = match image_name.rsplit_once(':') {
Some((r, t)) => (r.to_string(), t.to_string()),
None => (image_name.clone(), "latest".to_string()),
};
let options = CommitContainerOptions {
container: container_id.to_string(),
repo: repo.clone(),
tag: tag.clone(),
pause: true,
..Default::default()
};
// Empty config — no env vars / cmd baked in
let config = Config::<String> {
..Default::default()
};
docker
.commit_container(options, config)
.await
.map_err(|e| format!("Failed to commit container snapshot: {}", e))?;
log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag);
Ok(())
}
/// Remove the snapshot image for a project (used on Reset / project removal).
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
docker
.remove_image(
&image_name,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
log::info!("Removed snapshot image {}", image_name);
Ok(())
}
/// Remove both named volumes for a project (used on Reset / project removal).
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
for vol in [
format!("triple-c-home-{}", project.id),
format!("triple-c-claude-config-{}", project.id),
] {
match docker.remove_volume(&vol, None).await {
Ok(_) => log::info!("Removed volume {}", vol),
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
}
}
Ok(())
}
/// Check whether the existing container's configuration still matches the /// Check whether the existing container's configuration still matches the
/// current project settings. Returns `true` when the container must be /// current project settings. Returns `true` when the container must be
/// recreated (mounts or env vars differ). /// recreated (mounts or env vars differ).