Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb86aa95b7 | |||
| 3228e6cdd7 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
use bollard::Docker;
|
use bollard::Docker;
|
||||||
use std::sync::OnceLock;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
|
static DOCKER: Mutex<Option<Docker>> = Mutex::new(None);
|
||||||
|
|
||||||
pub fn get_docker() -> Result<&'static Docker, String> {
|
pub fn get_docker() -> Result<Docker, String> {
|
||||||
let result = DOCKER.get_or_init(|| {
|
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||||
Docker::connect_with_local_defaults()
|
if let Some(docker) = guard.as_ref() {
|
||||||
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
|
return Ok(docker.clone());
|
||||||
});
|
|
||||||
match result {
|
|
||||||
Ok(docker) => Ok(docker),
|
|
||||||
Err(e) => Err(e.clone()),
|
|
||||||
}
|
}
|
||||||
|
let docker = Docker::connect_with_local_defaults()
|
||||||
|
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))?;
|
||||||
|
guard.replace(docker.clone());
|
||||||
|
Ok(docker)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_docker_available() -> Result<bool, String> {
|
pub async fn check_docker_available() -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
match docker.ping().await {
|
match docker.ping().await {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
|
Err(_) => {
|
||||||
|
// Connection object exists but daemon not responding — clear cache
|
||||||
|
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||||
|
*guard = None;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useUpdates } from "./hooks/useUpdates";
|
|||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
@@ -22,8 +22,13 @@ export default function App() {
|
|||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
let stopPolling: (() => void) | undefined;
|
||||||
checkDocker().then((available) => {
|
checkDocker().then((available) => {
|
||||||
if (available) checkImage();
|
if (available) {
|
||||||
|
checkImage();
|
||||||
|
} else {
|
||||||
|
stopPolling = startDockerPolling();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
@@ -34,6 +39,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(updateTimer);
|
clearTimeout(updateTimer);
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
|
stopPolling?.();
|
||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
@@ -59,6 +59,39 @@ export function useDocker() {
|
|||||||
[setImageExists],
|
[setImageExists],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const startDockerPolling = useCallback(() => {
|
||||||
|
// Don't start if already polling
|
||||||
|
if (pollingRef.current) return () => {};
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const available = await commands.checkDocker();
|
||||||
|
if (available) {
|
||||||
|
clearInterval(interval);
|
||||||
|
pollingRef.current = null;
|
||||||
|
setDockerAvailable(true);
|
||||||
|
// Also check image once Docker is available
|
||||||
|
try {
|
||||||
|
const exists = await commands.checkImageExists();
|
||||||
|
setImageExists(exists);
|
||||||
|
} catch {
|
||||||
|
setImageExists(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Still not available, keep polling
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
pollingRef.current = interval;
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
pollingRef.current = null;
|
||||||
|
};
|
||||||
|
}, [setDockerAvailable, setImageExists]);
|
||||||
|
|
||||||
const pullImage = useCallback(
|
const pullImage = useCallback(
|
||||||
async (imageName: string, onProgress?: (msg: string) => void) => {
|
async (imageName: string, onProgress?: (msg: string) => void) => {
|
||||||
const unlisten = onProgress
|
const unlisten = onProgress
|
||||||
@@ -84,5 +117,6 @@ export function useDocker() {
|
|||||||
checkImage,
|
checkImage,
|
||||||
buildImage,
|
buildImage,
|
||||||
pullImage,
|
pullImage,
|
||||||
|
startDockerPolling,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user