Files
Triple-C/app/src-tauri/src/docker/image.rs
Josh Knapp 2e81b52205
All checks were successful
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m43s
Build Container / build-container (push) Successful in 16s
Add container-native scheduled task system with timezone support
Introduces a cron-based scheduler that lets Claude set up recurring and
one-time tasks inside containers. Tasks run as separate Claude Code agents
and persist across container recreation via the named volume.

New files:
- container/triple-c-scheduler: CLI for add/remove/enable/disable/list/logs/run/notifications
- container/triple-c-task-runner: cron wrapper with flock, logging, notifications, auto-cleanup

Key changes:
- Dockerfile: add cron package and COPY both scripts
- entrypoint.sh: timezone setup, cron daemon, crontab restore, env saving
- container.rs: init=true for zombie reaping, TZ env, scheduler instructions, timezone recreation check
- image.rs: embed scheduler scripts in build context
- app_settings.rs + types.ts: timezone field
- settings_commands.rs: detect_host_timezone via iana-time-zone crate
- SettingsPanel.tsx: timezone input with auto-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:57:22 +00:00

160 lines
5.0 KiB
Rust

use bollard::image::{BuildImageOptions, CreateImageOptions, 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");
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec![image_name.to_string()],
)]);
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 pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
let docker = get_docker()?;
// Parse image name into from_image and tag
let (from_image, tag) = if let Some(pos) = image_name.rfind(':') {
// Check that the colon is part of a tag, not a port
let after_colon = &image_name[pos + 1..];
if after_colon.contains('/') {
// The colon is part of a port (e.g., host:port/repo)
(image_name, "latest")
} else {
(&image_name[..pos], after_colon)
}
} else {
(image_name, "latest")
};
let options = CreateImageOptions {
from_image,
tag,
..Default::default()
};
let mut stream = docker.create_image(Some(options), None, None);
while let Some(result) = stream.next().await {
match result {
Ok(info) => {
let mut msg_parts = Vec::new();
if let Some(ref status) = info.status {
msg_parts.push(status.clone());
}
if let Some(ref progress) = info.progress {
msg_parts.push(progress.clone());
}
if !msg_parts.is_empty() {
on_progress(msg_parts.join(" "));
}
if let Some(ref error) = info.error {
return Err(format!("Pull error: {}", error));
}
}
Err(e) => return Err(format!("Pull stream error: {}", e)),
}
}
Ok(())
}
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::local_build_image_name();
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);
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)?;
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)?;
let scheduler_bytes = SCHEDULER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(scheduler_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
let task_runner_bytes = TASK_RUNNER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(task_runner_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
archive.finish()?;
}
let _ = buf.flush();
Ok(buf)
}