Skip to content

Commit 2f9ec70

Browse files
committed
feat: self-contained agnostic runtime with project-local storage
- Rewrite VM backend as vm/ module with separate initramfs builder - Implement robust BusyBox nc-based agent with handler script pattern - Add project-local .containust/ directory for per-project state - Add global ~/.containust/cache/ for shared immutable VM assets - Inject CPIO directory entries for /tmp /run /var /root /proc /sys /dev - Fix init script to create directories before mounting - Fix container chroot setup with proper dynamic library copying - Fix complex shell command execution via temporary script files - Add step-by-step colored UX output with timing in CLI - Fix detect_backend test to account for QEMU availability on macOS - Add node-hello.ctst example serving Hello World on port 6500 - Refactor engine.rs into smaller focused functions for clippy compliance
1 parent 0844921 commit 2f9ec70

13 files changed

Lines changed: 1999 additions & 194 deletions

File tree

Cargo.lock

Lines changed: 1066 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ serde_yaml = "0.9"
7171
tar = "0.4"
7272
flate2 = "1"
7373

74+
# HTTP client
75+
reqwest = { version = "0.12", features = ["blocking"] }
76+
7477
# Utilities
7578
which = "7"
79+
ctrlc = "3"
7680
uuid = { version = "1", features = ["v4", "serde"] }
7781
chrono = { version = "0.4", features = ["serde"] }
7882
tempfile = "3"

crates/containust-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ tokio = { workspace = true }
2626
serde = { workspace = true }
2727
serde_yaml = { workspace = true }
2828
serde_json = { workspace = true }
29+
ctrlc = { workspace = true }
2930

3031
[lints]
3132
workspace = true
Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
//! `ctst run` — Deploy the component graph.
1+
//! `ctst run` — Deploy and run the component graph.
2+
3+
use std::path::Path;
4+
use std::sync::atomic::{AtomicBool, Ordering};
5+
use std::sync::Arc;
6+
use std::time::Instant;
27

38
use clap::Args;
4-
use containust_runtime::engine::Engine;
9+
use containust_runtime::engine::{DeployedComponent, Engine};
510

611
/// Arguments for the `run` command.
712
#[derive(Args, Debug)]
@@ -10,34 +15,120 @@ pub struct RunArgs {
1015
#[arg(default_value = "containust.ctst")]
1116
pub file: String,
1217

13-
/// Run in detached mode.
18+
/// Run in detached mode (don't wait for Ctrl+C).
1419
#[arg(short, long)]
1520
pub detach: bool,
1621
}
1722

23+
const BOLD: &str = "\x1b[1m";
24+
const DIM: &str = "\x1b[2m";
25+
const GREEN: &str = "\x1b[32m";
26+
const CYAN: &str = "\x1b[36m";
27+
const YELLOW: &str = "\x1b[33m";
28+
const RESET: &str = "\x1b[0m";
29+
1830
/// Executes the `run` command.
1931
///
20-
/// Creates an engine instance, checks backend availability,
21-
/// and deploys all components from the `.ctst` file.
22-
///
2332
/// # Errors
2433
///
2534
/// Returns an error if deployment fails.
2635
pub fn execute(args: RunArgs) -> anyhow::Result<()> {
27-
let engine = Engine::new();
36+
let total_start = Instant::now();
37+
print_header();
2838

39+
let path = std::path::Path::new(&args.file);
40+
if !path.exists() {
41+
return Err(anyhow::anyhow!(
42+
"Composition file not found: {}\n\
43+
Create a .ctst file or specify a path: ctst run <file>",
44+
args.file
45+
));
46+
}
47+
48+
let engine = Engine::new();
2949
if !engine.is_available() {
30-
println!("Warning: Native container backend not available on this platform.");
31-
println!("A lightweight Linux VM will be used (requires QEMU).");
50+
print_vm_notice();
3251
}
3352

34-
let path = std::path::Path::new(&args.file);
35-
let ids = engine.deploy(path).map_err(|e| anyhow::anyhow!("{e}"))?;
53+
let deployed = deploy_and_report(&engine, path, total_start)?;
3654

37-
println!("Deployed {} container(s):", ids.len());
38-
for id in &ids {
39-
println!(" {id}");
55+
if args.detach {
56+
eprintln!();
57+
eprintln!(" Running detached. Use {BOLD}ctst stop{RESET} to stop all containers.");
58+
return Ok(());
4059
}
4160

61+
wait_for_shutdown(&engine, &deployed)
62+
}
63+
64+
fn print_header() {
65+
eprintln!();
66+
eprintln!(" {BOLD}Containust{RESET} {DIM}v{}{RESET}", env!("CARGO_PKG_VERSION"));
67+
eprintln!();
68+
}
69+
70+
fn print_vm_notice() {
71+
eprintln!(" {YELLOW}Note:{RESET} No native container support on this OS.");
72+
eprintln!(" A lightweight Linux VM will be used (requires QEMU).");
73+
eprintln!();
74+
}
75+
76+
fn deploy_and_report(
77+
engine: &Engine,
78+
path: &Path,
79+
total_start: Instant,
80+
) -> anyhow::Result<Vec<DeployedComponent>> {
81+
let deployed = engine.deploy(path).map_err(|e| anyhow::anyhow!("{e}"))?;
82+
83+
eprintln!();
84+
eprintln!(
85+
" {GREEN}{BOLD}Deployed {}{RESET} container(s) in {:.1}s:",
86+
deployed.len(),
87+
total_start.elapsed().as_secs_f64()
88+
);
89+
eprintln!();
90+
91+
for comp in &deployed {
92+
let port_info = comp
93+
.port
94+
.map_or_else(String::new, |p| format!(" {CYAN}->{RESET} http://localhost:{p}"));
95+
eprintln!(" {GREEN}●{RESET} {BOLD}{}{RESET} {DIM}[{}]{RESET}{port_info}", comp.name, comp.id);
96+
}
97+
98+
let ports: Vec<_> = deployed.iter().filter_map(|c| c.port).collect();
99+
if !ports.is_empty() {
100+
eprintln!();
101+
for port in &ports {
102+
eprintln!(" {CYAN}Access at:{RESET} {BOLD}http://localhost:{port}{RESET}");
103+
}
104+
}
105+
106+
let project_dir = containust_common::constants::project_dir(path);
107+
eprintln!();
108+
eprintln!(" {DIM}Project state: {}{RESET}", project_dir.display());
109+
110+
Ok(deployed)
111+
}
112+
113+
fn wait_for_shutdown(engine: &Engine, _deployed: &[DeployedComponent]) -> anyhow::Result<()> {
114+
eprintln!();
115+
eprintln!(" Press {BOLD}Ctrl+C{RESET} to stop all containers...");
116+
117+
let running = Arc::new(AtomicBool::new(true));
118+
let r = running.clone();
119+
ctrlc::set_handler(move || {
120+
r.store(false, Ordering::SeqCst);
121+
})
122+
.map_err(|e| anyhow::anyhow!("failed to set Ctrl+C handler: {e}"))?;
123+
124+
while running.load(Ordering::SeqCst) {
125+
std::thread::sleep(std::time::Duration::from_millis(250));
126+
}
127+
128+
eprintln!();
129+
eprintln!(" Stopping containers...");
130+
engine.stop_all().map_err(|e| anyhow::anyhow!("{e}"))?;
131+
eprintln!(" {GREEN}All containers stopped.{RESET}");
132+
42133
Ok(())
43134
}

crates/containust-cli/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
#![allow(
77
clippy::unnecessary_wraps,
88
clippy::needless_pass_by_value,
9-
clippy::print_stdout
9+
clippy::print_stdout,
10+
clippy::print_stderr
1011
)]
1112

1213
mod commands;

crates/containust-common/src/constants.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,39 @@ pub fn data_dir() -> &'static PathBuf {
2525
DATA_DIR.get_or_init(resolve_data_dir)
2626
}
2727

28+
/// Returns the global cache directory for immutable shared assets
29+
/// (Alpine kernel, initramfs). Stored at `~/.containust/cache/`.
30+
pub fn global_cache_dir() -> PathBuf {
31+
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
32+
let cache = PathBuf::from(home).join(".containust").join("cache");
33+
let _ = std::fs::create_dir_all(&cache);
34+
return cache;
35+
}
36+
PathBuf::from(SYSTEM_DATA_DIR).join("cache")
37+
}
38+
39+
/// Resolves the project-local `.containust/` directory next to a `.ctst` file.
40+
/// Creates the directory if it doesn't exist.
41+
pub fn project_dir(ctst_path: &std::path::Path) -> PathBuf {
42+
let cwd = || std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
43+
let parent = ctst_path
44+
.canonicalize()
45+
.ok()
46+
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
47+
.unwrap_or_else(|| {
48+
ctst_path.parent().map_or_else(cwd, |p| {
49+
if p.as_os_str().is_empty() {
50+
cwd()
51+
} else {
52+
p.to_path_buf()
53+
}
54+
})
55+
});
56+
let project = parent.join(".containust");
57+
let _ = std::fs::create_dir_all(&project);
58+
project
59+
}
60+
2861
/// Returns the default state file path.
2962
pub fn default_state_file() -> String {
3063
data_dir().join("state.json").to_string_lossy().into_owned()

crates/containust-runtime/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ chrono = { workspace = true }
2121
nix = { workspace = true }
2222
libc = { workspace = true }
2323
which = { workspace = true }
24+
reqwest = { workspace = true }
25+
flate2 = { workspace = true }
26+
tar = { workspace = true }
2427

2528
[dev-dependencies]
2629
tempfile = { workspace = true }

crates/containust-runtime/src/backend/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,17 @@ mod tests {
165165
}
166166

167167
#[test]
168-
fn detect_backend_returns_usable_backend() {
168+
fn detect_backend_returns_instance() {
169169
let backend = detect_backend();
170+
// On Linux the native backend is always available.
171+
// On macOS/Windows the VM backend is available when QEMU is installed.
170172
#[cfg(target_os = "linux")]
171173
assert!(backend.is_available());
172174
#[cfg(not(target_os = "linux"))]
173-
assert!(!backend.is_available());
175+
{
176+
// Just verify detect_backend returns a valid object.
177+
let _ = backend.is_available();
178+
}
174179
}
175180

176181
#[test]

0 commit comments

Comments
 (0)