Initial commit: Triple-C app, container, and CI
Tauri v2 desktop app (React/TypeScript + Rust) for managing containerized Claude Code environments. Includes Gitea Actions workflow for building and pushing the sandbox container image, and a BUILDING.md guide for manual app builds on Linux and Windows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
23
app/src-tauri/src/docker/client.rs
Normal file
23
app/src-tauri/src/docker/client.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use bollard::Docker;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
|
||||
|
||||
pub fn get_docker() -> Result<&'static Docker, String> {
|
||||
let result = DOCKER.get_or_init(|| {
|
||||
Docker::connect_with_local_defaults()
|
||||
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
|
||||
});
|
||||
match result {
|
||||
Ok(docker) => Ok(docker),
|
||||
Err(e) => Err(e.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_docker_available() -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
match docker.ping().await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
|
||||
}
|
||||
}
|
||||
223
app/src-tauri/src/docker/container.rs
Normal file
223
app/src-tauri/src/docker/container.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
StartContainerOptions, StopContainerOptions,
|
||||
};
|
||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{container_config, ContainerInfo, Project};
|
||||
|
||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([
|
||||
("name".to_string(), vec![container_name.clone()]),
|
||||
]);
|
||||
|
||||
let containers: Vec<ContainerSummary> = docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||
|
||||
// Match exact name (Docker prepends /)
|
||||
let expected = format!("/{}", container_name);
|
||||
for c in &containers {
|
||||
if let Some(names) = &c.names {
|
||||
if names.iter().any(|n| n == &expected) {
|
||||
return Ok(c.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn create_container(
|
||||
project: &Project,
|
||||
api_key: Option<&str>,
|
||||
docker_socket_path: &str,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
let image = container_config::full_image_name();
|
||||
|
||||
let mut env_vars: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(key) = api_key {
|
||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
||||
}
|
||||
|
||||
if let Some(ref token) = project.git_token {
|
||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||
}
|
||||
if let Some(ref name) = project.git_user_name {
|
||||
env_vars.push(format!("GIT_USER_NAME={}", name));
|
||||
}
|
||||
if let Some(ref email) = project.git_user_email {
|
||||
env_vars.push(format!("GIT_USER_EMAIL={}", email));
|
||||
}
|
||||
|
||||
let mut mounts = vec![
|
||||
// Project directory -> /workspace
|
||||
Mount {
|
||||
target: Some("/workspace".to_string()),
|
||||
source: Some(project.path.clone()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
// Named volume for claude config persistence
|
||||
Mount {
|
||||
target: Some("/home/claude/.claude".to_string()),
|
||||
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
||||
typ: Some(MountTypeEnum::VOLUME),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
// SSH keys mount (read-only)
|
||||
if let Some(ref ssh_path) = project.ssh_key_path {
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/claude/.ssh".to_string()),
|
||||
source: Some(ssh_path.clone()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Docker socket (only if allowed)
|
||||
if project.allow_docker_access {
|
||||
mounts.push(Mount {
|
||||
target: Some("/var/run/docker.sock".to_string()),
|
||||
source: Some(docker_socket_path.to_string()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config {
|
||||
image: Some(image),
|
||||
hostname: Some("triple-c".to_string()),
|
||||
env: Some(env_vars),
|
||||
labels: Some(labels),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
host_config: Some(host_config),
|
||||
tty: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let options = CreateContainerOptions {
|
||||
name: container_name,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = docker
|
||||
.create_container(Some(options), config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create container: {}", e))?;
|
||||
|
||||
Ok(response.id)
|
||||
}
|
||||
|
||||
pub async fn start_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.start_container(container_id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start container: {}", e))
|
||||
}
|
||||
|
||||
pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.stop_container(
|
||||
container_id,
|
||||
Some(StopContainerOptions { t: 10 }),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop container: {}", e))
|
||||
}
|
||||
|
||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||
}
|
||||
|
||||
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
let docker = get_docker()?;
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => {
|
||||
let status = info
|
||||
.state
|
||||
.and_then(|s| s.status)
|
||||
.map(|s| format!("{:?}", s))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
Ok(Some(ContainerInfo {
|
||||
container_id: container_id.clone(),
|
||||
project_id: project.id.clone(),
|
||||
status,
|
||||
image: container_config::full_image_name(),
|
||||
}))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let all_containers: Vec<ContainerSummary> = docker
|
||||
.list_containers(Some(ListContainersOptions::<String> {
|
||||
all: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||
|
||||
// Filter out Triple-C managed containers
|
||||
let siblings: Vec<ContainerSummary> = all_containers
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if let Some(labels) = &c.labels {
|
||||
!labels.contains_key("triple-c.managed")
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(siblings)
|
||||
}
|
||||
183
app/src-tauri/src/docker/exec.rs
Normal file
183
app/src-tauri/src/docker/exec.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use super::client::get_docker;
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&self.exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(true),
|
||||
cmd: Some(cmd),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
let exec_id = exec.id.clone();
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec_id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, mut input } => {
|
||||
// Output reader task
|
||||
let session_id_clone = session_id.to_string();
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = output.next() => {
|
||||
match msg {
|
||||
Some(Ok(output)) => {
|
||||
on_output(output.into_bytes().to_vec());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
on_exit();
|
||||
let _ = shutdown_tx_clone;
|
||||
});
|
||||
|
||||
// Input writer task
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = input_rx.recv().await {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
log::error!("Failed to write to exec stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
StartExecResults::Detached => {
|
||||
return Err("Exec started in detached mode".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.resize(cols, rows).await
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
96
app/src-tauri/src/docker/image.rs
Normal file
96
app/src-tauri/src/docker/image.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use bollard::image::{BuildImageOptions, ListImagesOptions};
|
||||
use bollard::models::ImageSummary;
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::container_config;
|
||||
|
||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||
|
||||
pub async fn image_exists() -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
let full_name = container_config::full_image_name();
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![full_name],
|
||||
)]);
|
||||
|
||||
let images: Vec<ImageSummary> = docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list images: {}", e))?;
|
||||
|
||||
Ok(!images.is_empty())
|
||||
}
|
||||
|
||||
pub async fn build_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
let full_name = container_config::full_image_name();
|
||||
|
||||
// Create a tar archive in memory containing Dockerfile and entrypoint.sh
|
||||
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
|
||||
|
||||
let options = BuildImageOptions {
|
||||
t: full_name.as_str(),
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if let Some(stream) = output.stream {
|
||||
on_progress(stream);
|
||||
}
|
||||
if let Some(error) = output.error {
|
||||
return Err(format!("Build error: {}", error));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut archive = tar::Builder::new(&mut buf);
|
||||
|
||||
// Add Dockerfile
|
||||
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(dockerfile_bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
|
||||
|
||||
// Add entrypoint.sh
|
||||
let entrypoint_bytes = ENTRYPOINT.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(entrypoint_bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
|
||||
// Flush to make sure all data is written
|
||||
let _ = buf.flush();
|
||||
Ok(buf)
|
||||
}
|
||||
9
app/src-tauri/src/docker/mod.rs
Normal file
9
app/src-tauri/src/docker/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod client;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
|
||||
pub use client::*;
|
||||
pub use container::*;
|
||||
pub use image::*;
|
||||
pub use exec::*;
|
||||
Reference in New Issue
Block a user