🛰️ export from upstream (e9f3e38)
This commit is contained in:
Generated
+119
-5
@@ -345,6 +345,31 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -973,6 +998,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -985,6 +1016,15 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -1050,6 +1090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -1102,6 +1143,29 @@ version = "4.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pear"
|
name = "pear"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1322,6 +1386,15 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -1426,6 +1499,19 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.4.15",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1435,7 +1521,7 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1493,6 +1579,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bollard",
|
"bollard",
|
||||||
"clap",
|
"clap",
|
||||||
|
"crossterm",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
"dirs",
|
"dirs",
|
||||||
"figment",
|
"figment",
|
||||||
@@ -1528,6 +1615,12 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@@ -1645,6 +1738,27 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -1773,7 +1887,7 @@ dependencies = [
|
|||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1783,7 +1897,7 @@ version = "0.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2284,7 +2398,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
"env_home",
|
"env_home",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"winsafe",
|
"winsafe",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2529,7 +2643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ See **[Configuration Reference](docs/configuration.md)** for all available optio
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples |
|
| [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples |
|
||||||
| [Command Reference](docs/commands.md) | Every subcommand, flag, and usage pattern |
|
| [Command Reference](docs/commands.md) | Every subcommand, flag, and usage pattern |
|
||||||
|
| [Interactive Mode](docs/interactive-mode.md) | How terminal sessions work, backend selection, and Windows compatibility |
|
||||||
|
| [Architecture](docs/architecture.md) | Isolation model, UID/GID mapping, and container design |
|
||||||
| [Docker Image](docs/docker-image.md) | Base image contents, building, custom Dockerfiles |
|
| [Docker Image](docs/docker-image.md) | Base image contents, building, custom Dockerfiles |
|
||||||
| [SSH Key Access](docs/ssh.md) | Setting up SSH for git inside containers |
|
| [SSH Key Access](docs/ssh.md) | Setting up SSH for git inside containers |
|
||||||
|
|
||||||
@@ -130,8 +132,6 @@ See **[Configuration Reference](docs/configuration.md)** for all available optio
|
|||||||
|
|
||||||
- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI
|
- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI
|
||||||
- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials
|
- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials
|
||||||
- **Full bollard integration** — replace docker compose with direct Docker API calls for faster startup and richer container control
|
|
||||||
- **Interactive TTY mode** — full terminal emulation for agents that need a PTY
|
|
||||||
|
|
||||||
## Cross-Platform
|
## Cross-Platform
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ bollard = [
|
|||||||
"dep:reqwest",
|
"dep:reqwest",
|
||||||
"dep:flate2",
|
"dep:flate2",
|
||||||
"dep:tar",
|
"dep:tar",
|
||||||
|
"dep:crossterm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -44,3 +45,4 @@ futures-util = { version = "0.3", optional = true }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||||
flate2 = { version = "1", optional = true }
|
flate2 = { version = "1", optional = true }
|
||||||
tar = { version = "0.4", optional = true }
|
tar = { version = "0.4", optional = true }
|
||||||
|
crossterm = { version = "0.28", optional = true }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use async_trait::async_trait;
|
|||||||
use bollard::container::LogOutput;
|
use bollard::container::LogOutput;
|
||||||
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
|
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
|
||||||
use bollard::Docker;
|
use bollard::Docker;
|
||||||
|
use crossterm::terminal;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
@@ -14,20 +15,49 @@ use crate::service::{ComposeContext, ComposeServiceDef, Service};
|
|||||||
|
|
||||||
use super::Result;
|
use super::Result;
|
||||||
|
|
||||||
|
/// RAII guard that restores terminal from raw mode on drop.
|
||||||
|
struct RawModeGuard;
|
||||||
|
|
||||||
|
impl RawModeGuard {
|
||||||
|
fn enter() -> std::result::Result<Self, std::io::Error> {
|
||||||
|
terminal::enable_raw_mode()?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RawModeGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
terminal::disable_raw_mode().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct BollardBackend {
|
pub struct BollardBackend {
|
||||||
docker: Docker,
|
docker: Docker,
|
||||||
|
runtime_info: tokio::sync::OnceCell<super::runtime::RuntimeInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BollardBackend {
|
impl BollardBackend {
|
||||||
pub fn new() -> Self {
|
pub fn try_new() -> super::Result<Self> {
|
||||||
let docker = Docker::connect_with_local_defaults()
|
let docker = Docker::connect_with_local_defaults().map_err(|e| {
|
||||||
.expect("failed to connect to Docker daemon");
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
Self { docker }
|
std::io::ErrorKind::ConnectionRefused,
|
||||||
|
format!(
|
||||||
|
"Cannot connect to Docker daemon: {e}. \
|
||||||
|
Check that Docker is running and $DOCKER_HOST is set correctly."
|
||||||
|
),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
docker,
|
||||||
|
runtime_info: tokio::sync::OnceCell::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl super::ContainerBackend for BollardBackend {
|
impl super::ContainerBackend for BollardBackend {
|
||||||
|
// Bollard-native interactive TTY with crossterm raw mode.
|
||||||
|
// Falls back to compose if raw mode is unavailable (e.g. MinTTY/Git Bash).
|
||||||
async fn run_interactive(
|
async fn run_interactive(
|
||||||
&self,
|
&self,
|
||||||
service: &dyn Service,
|
service: &dyn Service,
|
||||||
@@ -37,9 +67,18 @@ impl super::ContainerBackend for BollardBackend {
|
|||||||
shell_override: bool,
|
shell_override: bool,
|
||||||
extra_args: &[String],
|
extra_args: &[String],
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
let compose = super::compose::ComposeBackend;
|
if !self.can_use_bollard_tty() {
|
||||||
compose
|
let compose = super::compose::ComposeBackend;
|
||||||
.run_interactive(service, ctx, config, registry, shell_override, extra_args)
|
return compose
|
||||||
|
.run_interactive(service, ctx, config, registry, shell_override, extra_args)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut compose_def = service.compose_service(ctx);
|
||||||
|
if shell_override {
|
||||||
|
compose_def.entrypoint = vec!["/bin/zsh".to_string()];
|
||||||
|
}
|
||||||
|
self.run_interactive_tty(&compose_def, config, extra_args)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +110,14 @@ impl super::ContainerBackend for BollardBackend {
|
|||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
|
||||||
|
let info = self
|
||||||
|
.runtime_info
|
||||||
|
.get_or_init(|| super::runtime::detect_runtime(&self.docker))
|
||||||
|
.await;
|
||||||
|
Ok(info.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BollardBackend {
|
impl BollardBackend {
|
||||||
@@ -79,6 +126,7 @@ impl BollardBackend {
|
|||||||
compose_def: &ComposeServiceDef,
|
compose_def: &ComposeServiceDef,
|
||||||
config: &SandcageConfig,
|
config: &SandcageConfig,
|
||||||
extra_args: &[String],
|
extra_args: &[String],
|
||||||
|
interactive: bool,
|
||||||
) -> ContainerCreateBody {
|
) -> ContainerCreateBody {
|
||||||
let mut env: Vec<String> = compose_def.environment.clone();
|
let mut env: Vec<String> = compose_def.environment.clone();
|
||||||
if let Some(ref env_map) = config.env {
|
if let Some(ref env_map) = config.env {
|
||||||
@@ -122,7 +170,10 @@ impl BollardBackend {
|
|||||||
|
|
||||||
let mut labels = HashMap::new();
|
let mut labels = HashMap::new();
|
||||||
labels.insert("sandcage.managed".to_string(), "true".to_string());
|
labels.insert("sandcage.managed".to_string(), "true".to_string());
|
||||||
labels.insert("sandcage.mode".to_string(), "acp".to_string());
|
labels.insert(
|
||||||
|
"sandcage.mode".to_string(),
|
||||||
|
if interactive { "interactive" } else { "acp" }.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
ContainerCreateBody {
|
ContainerCreateBody {
|
||||||
image: Some(compose_def.image.clone()),
|
image: Some(compose_def.image.clone()),
|
||||||
@@ -132,7 +183,7 @@ impl BollardBackend {
|
|||||||
user: Some(compose_def.user.clone()),
|
user: Some(compose_def.user.clone()),
|
||||||
env: Some(env),
|
env: Some(env),
|
||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
tty: Some(false),
|
tty: Some(interactive),
|
||||||
open_stdin: Some(true),
|
open_stdin: Some(true),
|
||||||
attach_stdin: Some(true),
|
attach_stdin: Some(true),
|
||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
@@ -148,11 +199,288 @@ impl BollardBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether bollard-native TTY is usable in the current terminal.
|
||||||
|
/// On Windows, MinTTY (Git Bash) uses MSYS PTY pipes instead of a real
|
||||||
|
/// Windows console handle, so `GetConsoleMode` on stdin fails.
|
||||||
|
fn can_use_bollard_tty(&self) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// MinTTY (Git Bash / MSYS2) provides a Windows console handle, so
|
||||||
|
// GetConsoleMode and crossterm raw mode both succeed — but MinTTY's
|
||||||
|
// terminal emulator can't render ANSI output in raw console mode,
|
||||||
|
// producing garbled escape sequences. Detect MSYS2/MinGW by MSYSTEM
|
||||||
|
// env var and fall back to compose, which lets MinTTY handle the PTY.
|
||||||
|
if std::env::var_os("MSYSTEM").is_some() {
|
||||||
|
eprintln!("sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
|
unsafe extern "system" {
|
||||||
|
fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
|
||||||
|
}
|
||||||
|
let handle = std::io::stdin().as_raw_handle();
|
||||||
|
let mut mode: u32 = 0;
|
||||||
|
if unsafe { GetConsoleMode(handle as *mut _, &mut mode) } == 0 {
|
||||||
|
eprintln!("sandcage: tty probe → stdin is not a Windows console handle — using compose");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
eprintln!("sandcage: tty probe → Windows console handle OK (mode=0x{mode:x})");
|
||||||
|
}
|
||||||
|
match terminal::enable_raw_mode() {
|
||||||
|
Ok(()) => {
|
||||||
|
terminal::disable_raw_mode().ok();
|
||||||
|
eprintln!("sandcage: tty probe → crossterm raw mode OK — using bollard TTY");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("sandcage: tty probe → crossterm raw mode failed ({e}) — using compose");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bollard-native interactive session with crossterm raw mode and resize handling.
|
||||||
|
async fn run_interactive_tty(
|
||||||
|
&self,
|
||||||
|
compose_def: &ComposeServiceDef,
|
||||||
|
config: &SandcageConfig,
|
||||||
|
extra_args: &[String],
|
||||||
|
) -> Result<i32> {
|
||||||
|
self.cleanup_orphans().await;
|
||||||
|
|
||||||
|
// Verify image exists
|
||||||
|
let image = &compose_def.image;
|
||||||
|
self.docker.inspect_image(image).await.map_err(|_| {
|
||||||
|
DockerError::ImageNotFound {
|
||||||
|
image: image.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Create and start container with TTY
|
||||||
|
let container_config =
|
||||||
|
self.build_container_config(compose_def, config, extra_args, true);
|
||||||
|
|
||||||
|
let container = self
|
||||||
|
.docker
|
||||||
|
.create_container(
|
||||||
|
None::<bollard::query_parameters::CreateContainerOptions>,
|
||||||
|
container_config,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to create container: {e}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let id = container.id.clone();
|
||||||
|
eprintln!("sandcage: container created → {}", &id[..12.min(id.len())]);
|
||||||
|
|
||||||
|
self.docker
|
||||||
|
.start_container(
|
||||||
|
&id,
|
||||||
|
None::<bollard::query_parameters::StartContainerOptions>,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to start container: {e}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
eprintln!("sandcage: container started");
|
||||||
|
|
||||||
|
// Initial terminal resize
|
||||||
|
if let Ok((w, h)) = terminal::size() {
|
||||||
|
eprintln!("sandcage: terminal size {w}x{h} — resizing container TTY");
|
||||||
|
self.docker
|
||||||
|
.resize_container_tty(
|
||||||
|
&id,
|
||||||
|
bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default()
|
||||||
|
.w(w as i32)
|
||||||
|
.h(h as i32)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter raw mode (guard restores on drop, including panics)
|
||||||
|
let _guard = RawModeGuard::enter().map_err(|e| {
|
||||||
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to enter raw mode: {e}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
eprintln!("sandcage: raw mode entered");
|
||||||
|
|
||||||
|
// Attach to container
|
||||||
|
let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default()
|
||||||
|
.stdin(true)
|
||||||
|
.stdout(true)
|
||||||
|
.stderr(true)
|
||||||
|
.stream(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let bollard::container::AttachContainerResults { mut output, mut input } = self
|
||||||
|
.docker
|
||||||
|
.attach_container(&id, Some(attach_options))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to attach to container: {e}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
eprintln!("sandcage: attached — spawning relay tasks");
|
||||||
|
|
||||||
|
// Container wait task
|
||||||
|
let docker_wait = self.docker.clone();
|
||||||
|
let id_wait = id.clone();
|
||||||
|
let wait_handle = tokio::spawn(async move {
|
||||||
|
let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default()
|
||||||
|
.condition("not-running")
|
||||||
|
.build();
|
||||||
|
let mut stream = docker_wait.wait_container(&id_wait, Some(opts));
|
||||||
|
if let Some(Ok(resp)) = stream.next().await {
|
||||||
|
resp.status_code
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stdin relay task
|
||||||
|
let stdin_task = tokio::spawn(async move {
|
||||||
|
let mut host_stdin = io::stdin();
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
loop {
|
||||||
|
match host_stdin.read(&mut buf).await {
|
||||||
|
Ok(0) => {
|
||||||
|
eprintln!("sandcage: stdin → EOF after {total_bytes} bytes");
|
||||||
|
input.shutdown().await.ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
total_bytes += n;
|
||||||
|
if input.write_all(&buf[..n]).await.is_err() {
|
||||||
|
eprintln!("sandcage: stdin → write to container failed after {total_bytes} bytes");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if input.flush().await.is_err() {
|
||||||
|
eprintln!("sandcage: stdin → flush to container failed after {total_bytes} bytes");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("sandcage: stdin → read error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Output relay — TTY mode sends Console variant, non-TTY sends StdOut/StdErr
|
||||||
|
let output_task = tokio::spawn(async move {
|
||||||
|
let mut host_stdout = io::stdout();
|
||||||
|
let mut host_stderr = io::stderr();
|
||||||
|
let mut chunk_count: usize = 0;
|
||||||
|
let mut total_bytes: usize = 0;
|
||||||
|
while let Some(result) = output.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(LogOutput::Console { message })
|
||||||
|
| Ok(LogOutput::StdOut { message }) => {
|
||||||
|
chunk_count += 1;
|
||||||
|
total_bytes += message.len();
|
||||||
|
host_stdout.write_all(&message).await.ok();
|
||||||
|
host_stdout.flush().await.ok();
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
chunk_count += 1;
|
||||||
|
total_bytes += message.len();
|
||||||
|
host_stderr.write_all(&message).await.ok();
|
||||||
|
host_stderr.flush().await.ok();
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
eprintln!("sandcage: output → unexpected variant: {other:?}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("sandcage: output → stream error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("sandcage: output → stream ended ({chunk_count} chunks, {total_bytes} bytes)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize poller — checks terminal size every 250ms
|
||||||
|
let docker_resize = self.docker.clone();
|
||||||
|
let id_resize = id.clone();
|
||||||
|
let resize_task = tokio::spawn(async move {
|
||||||
|
let mut last_size = terminal::size().unwrap_or((80, 24));
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
if let Ok(current) = terminal::size() {
|
||||||
|
if current != last_size {
|
||||||
|
last_size = current;
|
||||||
|
docker_resize
|
||||||
|
.resize_container_tty(
|
||||||
|
&id_resize,
|
||||||
|
bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default()
|
||||||
|
.w(current.0 as i32)
|
||||||
|
.h(current.1 as i32)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal handler — Ctrl+C stops the container, lets wait_handle return naturally
|
||||||
|
let docker_signal = self.docker.clone();
|
||||||
|
let id_signal = id.clone();
|
||||||
|
let signal_task = tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.ok();
|
||||||
|
eprintln!("sandcage: received interrupt, stopping container...");
|
||||||
|
let opts = bollard::query_parameters::StopContainerOptionsBuilder::default()
|
||||||
|
.t(2)
|
||||||
|
.build();
|
||||||
|
docker_signal
|
||||||
|
.stop_container(&id_signal, Some(opts))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for output to finish (container exited), then clean up tasks
|
||||||
|
let _ = output_task.await;
|
||||||
|
stdin_task.abort();
|
||||||
|
signal_task.abort();
|
||||||
|
resize_task.abort();
|
||||||
|
|
||||||
|
let exit_code = wait_handle.await.unwrap_or(-1);
|
||||||
|
|
||||||
|
// _guard drops here, restoring terminal from raw mode
|
||||||
|
Ok(exit_code as i32)
|
||||||
|
}
|
||||||
|
|
||||||
async fn acp_relay(
|
async fn acp_relay(
|
||||||
&self,
|
&self,
|
||||||
compose_def: &ComposeServiceDef,
|
compose_def: &ComposeServiceDef,
|
||||||
config: &SandcageConfig,
|
config: &SandcageConfig,
|
||||||
extra_args: &[String],
|
extra_args: &[String],
|
||||||
|
) -> Result<i32> {
|
||||||
|
self.run_container(compose_def, config, extra_args, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_container(
|
||||||
|
&self,
|
||||||
|
compose_def: &ComposeServiceDef,
|
||||||
|
config: &SandcageConfig,
|
||||||
|
extra_args: &[String],
|
||||||
|
interactive: bool,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
self.cleanup_orphans().await;
|
self.cleanup_orphans().await;
|
||||||
|
|
||||||
@@ -163,7 +491,8 @@ impl BollardBackend {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let container_config = self.build_container_config(compose_def, config, extra_args);
|
let container_config =
|
||||||
|
self.build_container_config(compose_def, config, extra_args, interactive);
|
||||||
|
|
||||||
let container = self
|
let container = self
|
||||||
.docker
|
.docker
|
||||||
|
|||||||
@@ -100,4 +100,8 @@ impl super::ContainerBackend for ComposeBackend {
|
|||||||
docker::require_compose(&docker_path).await?;
|
docker::require_compose(&docker_path).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
|
||||||
|
Ok(super::runtime::RuntimeInfo::default())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
pub mod compose;
|
pub mod compose;
|
||||||
|
pub mod runtime;
|
||||||
#[cfg(feature = "bollard")]
|
#[cfg(feature = "bollard")]
|
||||||
pub mod bollard;
|
pub mod bollard;
|
||||||
|
#[cfg(feature = "bollard")]
|
||||||
|
pub mod network;
|
||||||
|
#[cfg(feature = "bollard")]
|
||||||
|
pub mod orchestrator;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
@@ -35,16 +40,18 @@ pub trait ContainerBackend: Send + Sync {
|
|||||||
async fn volume_exists(&self, name: &str) -> Result<bool>;
|
async fn volume_exists(&self, name: &str) -> Result<bool>;
|
||||||
|
|
||||||
async fn check_availability(&self) -> Result<()>;
|
async fn check_availability(&self) -> Result<()>;
|
||||||
|
|
||||||
|
async fn runtime_info(&self) -> Result<runtime::RuntimeInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_backend() -> Box<dyn ContainerBackend> {
|
pub fn default_backend() -> Result<Box<dyn ContainerBackend>> {
|
||||||
#[cfg(feature = "bollard")]
|
#[cfg(feature = "bollard")]
|
||||||
{
|
{
|
||||||
Box::new(bollard::BollardBackend::new())
|
Ok(Box::new(bollard::BollardBackend::try_new()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "bollard"))]
|
#[cfg(not(feature = "bollard"))]
|
||||||
{
|
{
|
||||||
Box::new(compose::ComposeBackend)
|
Ok(Box::new(compose::ComposeBackend))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use bollard::Docker;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::docker::DockerError;
|
||||||
|
|
||||||
|
/// Generate a deterministic network name from the workspace path.
|
||||||
|
pub fn network_name(workspace: &Path) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(workspace.to_string_lossy().as_bytes());
|
||||||
|
let hash = hex::encode(hasher.finalize());
|
||||||
|
format!("sandcage-{}", &hash[..12])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or reuse a project-scoped Docker network. Returns the network name.
|
||||||
|
pub async fn ensure_network(
|
||||||
|
docker: &Docker,
|
||||||
|
workspace: &Path,
|
||||||
|
) -> Result<String, DockerError> {
|
||||||
|
let name = network_name(workspace);
|
||||||
|
|
||||||
|
// Check if it already exists
|
||||||
|
if docker.inspect_network(&name, None::<bollard::query_parameters::InspectNetworkOptions>).await.is_ok() {
|
||||||
|
return Ok(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create it
|
||||||
|
let mut labels = HashMap::new();
|
||||||
|
labels.insert("sandcage.managed".to_string(), "true".to_string());
|
||||||
|
|
||||||
|
let config = bollard::models::NetworkCreateRequest {
|
||||||
|
name: name.clone(),
|
||||||
|
driver: Some("bridge".to_string()),
|
||||||
|
labels: Some(labels),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
docker.create_network(config).await.map_err(|e| {
|
||||||
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to create network {name}: {e}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a project network. Only removes if it has the sandcage.managed label.
|
||||||
|
pub async fn cleanup_network(
|
||||||
|
docker: &Docker,
|
||||||
|
network_name: &str,
|
||||||
|
) -> Result<(), DockerError> {
|
||||||
|
// Verify it's ours before removing
|
||||||
|
match docker
|
||||||
|
.inspect_network(network_name, None::<bollard::query_parameters::InspectNetworkOptions>)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => {
|
||||||
|
let is_ours = info
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.map(|l| l.get("sandcage.managed").map(|v| v == "true").unwrap_or(false))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_ours {
|
||||||
|
return Ok(()); // Not our network, don't touch it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => return Ok(()), // Already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
docker.remove_network(network_name).await.map_err(|e| {
|
||||||
|
DockerError::SpawnFailed(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to remove network {network_name}: {e}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_name_is_deterministic() {
|
||||||
|
let p = Path::new("/home/user/projects/my-app");
|
||||||
|
let name1 = network_name(p);
|
||||||
|
let name2 = network_name(p);
|
||||||
|
assert_eq!(name1, name2);
|
||||||
|
assert!(name1.starts_with("sandcage-"));
|
||||||
|
assert_eq!(name1.len(), "sandcage-".len() + 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_paths_produce_different_names() {
|
||||||
|
let a = network_name(Path::new("/a"));
|
||||||
|
let b = network_name(Path::new("/b"));
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::service::ComposeServiceDef;
|
||||||
|
|
||||||
|
use super::network;
|
||||||
|
|
||||||
|
/// A plan for running one or more containers on a shared network.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OrchestrationPlan {
|
||||||
|
pub network_name: String,
|
||||||
|
pub services: Vec<ServiceInstance>,
|
||||||
|
/// Which service to attach stdin/stdout to.
|
||||||
|
pub primary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single container to be started as part of an orchestration.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ServiceInstance {
|
||||||
|
pub name: String,
|
||||||
|
pub compose_def: ComposeServiceDef,
|
||||||
|
pub depends_on: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestrationPlan {
|
||||||
|
/// Create a simple plan for a single service (no network needed).
|
||||||
|
pub fn single(name: &str, compose_def: ComposeServiceDef) -> Self {
|
||||||
|
Self {
|
||||||
|
network_name: String::new(),
|
||||||
|
services: vec![ServiceInstance {
|
||||||
|
name: name.to_string(),
|
||||||
|
compose_def,
|
||||||
|
depends_on: vec![],
|
||||||
|
}],
|
||||||
|
primary: name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a plan with multiple services on a shared network.
|
||||||
|
pub fn multi(
|
||||||
|
workspace: &Path,
|
||||||
|
services: Vec<ServiceInstance>,
|
||||||
|
primary: &str,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
network_name: network::network_name(workspace),
|
||||||
|
services,
|
||||||
|
primary: primary.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this plan requires a Docker network.
|
||||||
|
pub fn needs_network(&self) -> bool {
|
||||||
|
self.services.len() > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return services in dependency order (simple topological sort).
|
||||||
|
/// Services with no dependencies come first.
|
||||||
|
pub fn startup_order(&self) -> Vec<&ServiceInstance> {
|
||||||
|
let mut result: Vec<&ServiceInstance> = Vec::new();
|
||||||
|
let mut placed: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
// Simple iterative approach — works for small service counts
|
||||||
|
let mut remaining: Vec<&ServiceInstance> = self.services.iter().collect();
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
let before = remaining.len();
|
||||||
|
remaining.retain(|s| {
|
||||||
|
if s.depends_on.iter().all(|dep| placed.contains(&dep.as_str())) {
|
||||||
|
result.push(s);
|
||||||
|
placed.push(&s.name);
|
||||||
|
false // remove from remaining
|
||||||
|
} else {
|
||||||
|
true // keep in remaining
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if remaining.len() == before {
|
||||||
|
// Circular dependency — just append the rest
|
||||||
|
result.extend(remaining.drain(..));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::service::ComposeServiceDef;
|
||||||
|
|
||||||
|
fn dummy_def() -> ComposeServiceDef {
|
||||||
|
ComposeServiceDef {
|
||||||
|
image: "test:latest".to_string(),
|
||||||
|
entrypoint: vec!["/bin/sh".to_string()],
|
||||||
|
working_dir: "/workspace".to_string(),
|
||||||
|
user: "1000:1000".to_string(),
|
||||||
|
volumes: vec![],
|
||||||
|
environment: vec![],
|
||||||
|
tty: true,
|
||||||
|
stdin_open: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_plan_has_one_service() {
|
||||||
|
let plan = OrchestrationPlan::single("claude", dummy_def());
|
||||||
|
assert_eq!(plan.services.len(), 1);
|
||||||
|
assert_eq!(plan.primary, "claude");
|
||||||
|
assert!(!plan.needs_network());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_plan_needs_network() {
|
||||||
|
let plan = OrchestrationPlan::multi(
|
||||||
|
Path::new("/workspace"),
|
||||||
|
vec![
|
||||||
|
ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] },
|
||||||
|
ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] },
|
||||||
|
],
|
||||||
|
"app",
|
||||||
|
);
|
||||||
|
assert!(plan.needs_network());
|
||||||
|
assert_eq!(plan.services.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_order_respects_dependencies() {
|
||||||
|
let plan = OrchestrationPlan::multi(
|
||||||
|
Path::new("/workspace"),
|
||||||
|
vec![
|
||||||
|
ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] },
|
||||||
|
ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] },
|
||||||
|
],
|
||||||
|
"app",
|
||||||
|
);
|
||||||
|
let order = plan.startup_order();
|
||||||
|
assert_eq!(order[0].name, "db");
|
||||||
|
assert_eq!(order[1].name, "app");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_order_handles_no_deps() {
|
||||||
|
let plan = OrchestrationPlan::single("solo", dummy_def());
|
||||||
|
let order = plan.startup_order();
|
||||||
|
assert_eq!(order.len(), 1);
|
||||||
|
assert_eq!(order[0].name, "solo");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/// Runtime detection for rootless/rootful Docker and Podman.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RuntimeMode {
|
||||||
|
RootlessDocker,
|
||||||
|
RootfulDocker,
|
||||||
|
Podman,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RuntimeMode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::RootlessDocker => write!(f, "docker (rootless)"),
|
||||||
|
Self::RootfulDocker => write!(f, "docker (rootful)"),
|
||||||
|
Self::Podman => write!(f, "podman (rootless)"),
|
||||||
|
Self::Unknown => write!(f, "unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RuntimeInfo {
|
||||||
|
pub mode: RuntimeMode,
|
||||||
|
pub cgroup_v2: bool,
|
||||||
|
pub api_version: Option<String>,
|
||||||
|
pub server_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RuntimeInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: RuntimeMode::Unknown,
|
||||||
|
cgroup_v2: false,
|
||||||
|
api_version: None,
|
||||||
|
server_version: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeInfo {
|
||||||
|
pub fn supports_resource_limits(&self) -> bool {
|
||||||
|
self.cgroup_v2
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supports_privileged(&self) -> bool {
|
||||||
|
self.mode == RuntimeMode::RootfulDocker
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supports_low_ports(&self) -> bool {
|
||||||
|
self.mode == RuntimeMode::RootfulDocker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "bollard")]
|
||||||
|
fn detect_cgroup_v2() -> bool {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
std::path::Path::new("/sys/fs/cgroup/cgroup.controllers").exists()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "bollard")]
|
||||||
|
pub async fn detect_runtime(docker: &bollard::Docker) -> RuntimeInfo {
|
||||||
|
let info = match docker.info().await {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(_) => return RuntimeInfo::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine mode
|
||||||
|
let mode = if let Some(ref sec_opts) = info.security_options {
|
||||||
|
if sec_opts.iter().any(|s| s.contains("rootless")) {
|
||||||
|
RuntimeMode::RootlessDocker
|
||||||
|
} else {
|
||||||
|
detect_mode_from_root_dir(&info)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detect_mode_from_root_dir(&info)
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_version = info.server_version.clone();
|
||||||
|
|
||||||
|
// Get API version from docker.version()
|
||||||
|
let api_version = match docker.version().await {
|
||||||
|
Ok(ver) => ver.api_version,
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
RuntimeInfo {
|
||||||
|
mode,
|
||||||
|
cgroup_v2: detect_cgroup_v2(),
|
||||||
|
api_version,
|
||||||
|
server_version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "bollard")]
|
||||||
|
fn detect_mode_from_root_dir(info: &bollard::models::SystemInfo) -> RuntimeMode {
|
||||||
|
if let Some(ref root_dir) = info.docker_root_dir {
|
||||||
|
if root_dir.contains(".local/share/docker") {
|
||||||
|
return RuntimeMode::RootlessDocker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuntimeMode::RootfulDocker
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_runtime_mode() {
|
||||||
|
assert_eq!(RuntimeMode::RootlessDocker.to_string(), "docker (rootless)");
|
||||||
|
assert_eq!(RuntimeMode::RootfulDocker.to_string(), "docker (rootful)");
|
||||||
|
assert_eq!(RuntimeMode::Podman.to_string(), "podman (rootless)");
|
||||||
|
assert_eq!(RuntimeMode::Unknown.to_string(), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_resource_limits_requires_cgroup_v2() {
|
||||||
|
let info = RuntimeInfo {
|
||||||
|
mode: RuntimeMode::RootfulDocker,
|
||||||
|
cgroup_v2: false,
|
||||||
|
api_version: None,
|
||||||
|
server_version: None,
|
||||||
|
};
|
||||||
|
assert!(!info.supports_resource_limits());
|
||||||
|
|
||||||
|
let info = RuntimeInfo {
|
||||||
|
cgroup_v2: true,
|
||||||
|
..info
|
||||||
|
};
|
||||||
|
assert!(info.supports_resource_limits());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_privileged_only_rootful() {
|
||||||
|
let rootful = RuntimeInfo {
|
||||||
|
mode: RuntimeMode::RootfulDocker,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(rootful.supports_privileged());
|
||||||
|
|
||||||
|
let rootless = RuntimeInfo {
|
||||||
|
mode: RuntimeMode::RootlessDocker,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!rootless.supports_privileged());
|
||||||
|
|
||||||
|
let podman = RuntimeInfo {
|
||||||
|
mode: RuntimeMode::Podman,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!podman.supports_privileged());
|
||||||
|
|
||||||
|
let unknown = RuntimeInfo::default();
|
||||||
|
assert!(!unknown.supports_privileged());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_low_ports_only_rootful() {
|
||||||
|
let rootful = RuntimeInfo {
|
||||||
|
mode: RuntimeMode::RootfulDocker,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(rootful.supports_low_ports());
|
||||||
|
|
||||||
|
let rootless = RuntimeInfo {
|
||||||
|
mode: RuntimeMode::RootlessDocker,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!rootless.supports_low_ports());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_runtime_info() {
|
||||||
|
let info = RuntimeInfo::default();
|
||||||
|
assert_eq!(info.mode, RuntimeMode::Unknown);
|
||||||
|
assert!(!info.cgroup_v2);
|
||||||
|
assert!(info.api_version.is_none());
|
||||||
|
assert!(info.server_version.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,10 @@ struct RawConfig {
|
|||||||
default_services: Option<Vec<String>>,
|
default_services: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
runtime: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
compose_backend: Option<String>,
|
||||||
|
|
||||||
/// Absorb everything else so we can warn about unknown fields.
|
/// Absorb everything else so we can warn about unknown fields.
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -125,13 +129,17 @@ pub struct SandcageConfig {
|
|||||||
pub default_services: Option<Vec<String>>,
|
pub default_services: Option<Vec<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub compose_backend: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Known keys — used to filter the flattened `extra` map
|
// Known keys — used to filter the flattened `extra` map
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image"];
|
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image", "runtime", "compose_backend"];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Validation helpers
|
// Validation helpers
|
||||||
@@ -155,6 +163,30 @@ fn validate_and_warn(raw: &RawConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate runtime value
|
||||||
|
if let Some(rt) = &raw.runtime {
|
||||||
|
const VALID_RUNTIMES: &[&str] = &["auto", "rootless", "rootful"];
|
||||||
|
if !VALID_RUNTIMES.contains(&rt.as_str()) {
|
||||||
|
eprintln!(
|
||||||
|
"sandcage: warning: unrecognized runtime value '{rt}'. \
|
||||||
|
Valid values: {}",
|
||||||
|
VALID_RUNTIMES.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate compose_backend value
|
||||||
|
if let Some(cb) = &raw.compose_backend {
|
||||||
|
const VALID_COMPOSE_BACKENDS: &[&str] = &["auto", "bollard", "compose"];
|
||||||
|
if !VALID_COMPOSE_BACKENDS.contains(&cb.as_str()) {
|
||||||
|
eprintln!(
|
||||||
|
"sandcage: warning: unrecognized compose_backend value '{cb}'. \
|
||||||
|
Valid values: {}",
|
||||||
|
VALID_COMPOSE_BACKENDS.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate mount strings
|
// Validate mount strings
|
||||||
if let Some(mounts) = &raw.mounts {
|
if let Some(mounts) = &raw.mounts {
|
||||||
for mount in mounts {
|
for mount in mounts {
|
||||||
@@ -189,6 +221,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
|
|||||||
services: raw.services,
|
services: raw.services,
|
||||||
default_services: raw.default_services,
|
default_services: raw.default_services,
|
||||||
image: raw.image,
|
image: raw.image,
|
||||||
|
runtime: raw.runtime,
|
||||||
|
compose_backend: raw.compose_backend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,4 +770,45 @@ services:
|
|||||||
let cfg = load_from_str("").expect("parse empty");
|
let cfg = load_from_str("").expect("parse empty");
|
||||||
assert!(cfg.image.is_none());
|
assert!(cfg.image.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_runtime_and_compose_backend() {
|
||||||
|
let yaml = "runtime: rootless\ncompose_backend: bollard\n";
|
||||||
|
let cfg = load_from_str(yaml).expect("parse runtime and compose_backend");
|
||||||
|
assert_eq!(cfg.runtime.as_deref(), Some("rootless"));
|
||||||
|
assert_eq!(cfg.compose_backend.as_deref(), Some("bollard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_and_compose_backend_default_to_none() {
|
||||||
|
let cfg = load_from_str("").expect("parse empty");
|
||||||
|
assert!(cfg.runtime.is_none());
|
||||||
|
assert!(cfg.compose_backend.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrecognized_runtime_and_compose_backend_still_parse() {
|
||||||
|
let yaml = "runtime: podman\ncompose_backend: swarm\n";
|
||||||
|
let cfg = load_from_str(yaml).expect("unrecognized values should not hard-error");
|
||||||
|
assert_eq!(cfg.runtime.as_deref(), Some("podman"));
|
||||||
|
assert_eq!(cfg.compose_backend.as_deref(), Some("swarm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_all_valid_runtime_values() {
|
||||||
|
for val in &["auto", "rootless", "rootful"] {
|
||||||
|
let yaml = format!("runtime: {val}\n");
|
||||||
|
let cfg = load_from_str(&yaml).expect("parse valid runtime");
|
||||||
|
assert_eq!(cfg.runtime.as_deref(), Some(*val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_all_valid_compose_backend_values() {
|
||||||
|
for val in &["auto", "bollard", "compose"] {
|
||||||
|
let yaml = format!("compose_backend: {val}\n");
|
||||||
|
let cfg = load_from_str(&yaml).expect("parse valid compose_backend");
|
||||||
|
assert_eq!(cfg.compose_backend.as_deref(), Some(*val));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,9 +350,6 @@ pub async fn run_service(
|
|||||||
shell_override: bool,
|
shell_override: bool,
|
||||||
extra_args: &[String],
|
extra_args: &[String],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let docker = require_docker()?;
|
|
||||||
require_compose(&docker).await?;
|
|
||||||
|
|
||||||
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
|
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
|
||||||
service: service.to_string(),
|
service: service.to_string(),
|
||||||
available: registry.names().collect::<Vec<_>>().join(", "),
|
available: registry.names().collect::<Vec<_>>().join(", "),
|
||||||
@@ -365,40 +362,34 @@ pub async fn run_service(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ctx = build_compose_context(workspace, config).await?;
|
let ctx = build_compose_context(workspace, config).await?;
|
||||||
|
|
||||||
|
let backend: Box<dyn crate::backend::ContainerBackend> = match config.compose_backend.as_deref() {
|
||||||
|
Some("compose") => Box::new(crate::backend::compose::ComposeBackend),
|
||||||
|
_ => crate::backend::default_backend()?,
|
||||||
|
};
|
||||||
|
backend.check_availability().await?;
|
||||||
|
|
||||||
let image_tag = format!("{}:latest", ctx.image);
|
let image_tag = format!("{}:latest", ctx.image);
|
||||||
require_image(&docker, &image_tag).await?;
|
if !backend.image_exists(&image_tag).await? {
|
||||||
|
return Err(DockerError::ImageNotFound { image: image_tag });
|
||||||
|
}
|
||||||
|
|
||||||
if config.ssh_mode.as_deref() == Some("volume") {
|
if config.ssh_mode.as_deref() == Some("volume") {
|
||||||
let vol_check = Command::new(&docker)
|
if !backend.volume_exists("sandcage-ssh").await? {
|
||||||
.args(["volume", "inspect", "sandcage-ssh"])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
if !vol_check.map(|o| o.status.success()).unwrap_or(false) {
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
|
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let compose_content = crate::service::compose::generate_compose(registry, &ctx);
|
let exit_code = backend
|
||||||
let compose_file = write_compose_tempfile(&compose_content)?;
|
.run_interactive(svc, &ctx, config, registry, shell_override, extra_args)
|
||||||
let compose_path = compose_file.path().to_string_lossy().into_owned();
|
.await?;
|
||||||
|
|
||||||
let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args);
|
if exit_code != 0 {
|
||||||
|
|
||||||
let mut cmd = Command::new(&docker);
|
|
||||||
cmd.args(&run_args);
|
|
||||||
|
|
||||||
cmd.stdin(std::process::Stdio::inherit())
|
|
||||||
.stdout(std::process::Stdio::inherit())
|
|
||||||
.stderr(std::process::Stdio::inherit());
|
|
||||||
|
|
||||||
let status = cmd.status().await.map_err(DockerError::SpawnFailed)?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
return Err(DockerError::ServiceFailed {
|
return Err(DockerError::ServiceFailed {
|
||||||
service: service.to_string(),
|
service: service.to_string(),
|
||||||
code: status.code().unwrap_or(-1),
|
code: exit_code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,55 @@ enum AppError {
|
|||||||
Setup(#[from] setup::SetupError),
|
Setup(#[from] setup::SetupError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn print_runtime_info(config: &sandcage::config::SandcageConfig) {
|
||||||
|
let backend = match sandcage::backend::default_backend() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if backend.check_availability().await.is_err() {
|
||||||
|
return; // Docker not available — will fail later with a proper error
|
||||||
|
}
|
||||||
|
|
||||||
|
match backend.runtime_info().await {
|
||||||
|
Ok(info) => {
|
||||||
|
use sandcage::backend::runtime::RuntimeMode;
|
||||||
|
match info.mode {
|
||||||
|
RuntimeMode::RootlessDocker => {
|
||||||
|
eprintln!("sandcage: runtime \u{2192} {} (recommended)", info.mode);
|
||||||
|
}
|
||||||
|
RuntimeMode::RootfulDocker => {
|
||||||
|
if config.runtime.as_deref() == Some("rootful") {
|
||||||
|
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
|
||||||
|
} else {
|
||||||
|
eprintln!("sandcage: runtime \u{2192} {} \u{2014} consider rootless for better isolation", info.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuntimeMode::Podman => {
|
||||||
|
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
|
||||||
|
}
|
||||||
|
RuntimeMode::Unknown => {
|
||||||
|
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.cgroup_v2 && info.mode != RuntimeMode::Unknown {
|
||||||
|
eprintln!("sandcage: note: cgroup v2 not detected — resource limits will not be enforced");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref runtime_pref) = config.runtime {
|
||||||
|
match runtime_pref.as_str() {
|
||||||
|
"rootless" if info.mode != RuntimeMode::RootlessDocker && info.mode != RuntimeMode::Podman => {
|
||||||
|
eprintln!("sandcage: error: config requires rootless runtime, but connected daemon is {}", info.mode);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {} // Silent — will fail later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
||||||
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
||||||
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
||||||
@@ -153,6 +202,8 @@ async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool,
|
|||||||
Some(&local_config),
|
Some(&local_config),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
print_runtime_info(&cfg).await;
|
||||||
|
|
||||||
let registry = service::registry::build_default_registry(&cfg);
|
let registry = service::registry::build_default_registry(&cfg);
|
||||||
|
|
||||||
eprintln!("sandcage: service \u{2192} {service}");
|
eprintln!("sandcage: service \u{2192} {service}");
|
||||||
@@ -190,7 +241,7 @@ async fn main() -> miette::Result<()> {
|
|||||||
},
|
},
|
||||||
#[cfg(feature = "bollard")]
|
#[cfg(feature = "bollard")]
|
||||||
Commands::Acp { action } => {
|
Commands::Acp { action } => {
|
||||||
let backend = sandcage::backend::default_backend();
|
let backend = sandcage::backend::default_backend()?;
|
||||||
backend.check_availability().await
|
backend.check_availability().await
|
||||||
.map_err(|e| AppError::Docker(e))?;
|
.map_err(|e| AppError::Docker(e))?;
|
||||||
sandcage::acp::handle(action, backend.as_ref(), None).await
|
sandcage::acp::handle(action, backend.as_ref(), None).await
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="520" viewBox="0 0 700 520">
|
||||||
|
<style>
|
||||||
|
text { font-family: system-ui, -apple-system, sans-serif; }
|
||||||
|
.title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
|
||||||
|
.label { font-size: 13px; fill: #1a1a1a; }
|
||||||
|
.small { font-size: 11px; fill: #555; }
|
||||||
|
.edge { font-size: 11px; font-weight: 600; }
|
||||||
|
.yes { fill: #2d7d46; }
|
||||||
|
.no { fill: #c0392b; }
|
||||||
|
.arrow { stroke: #666; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
|
||||||
|
.diamond { fill: #fff3cd; stroke: #e0c36a; stroke-width: 1.5; }
|
||||||
|
.result-native { fill: #d4edda; stroke: #7bc88f; stroke-width: 1.5; rx: 8; }
|
||||||
|
.result-compose { fill: #cce5ff; stroke: #6cb3f5; stroke-width: 1.5; rx: 8; }
|
||||||
|
.platform { fill: #f0f0f0; stroke: #ccc; stroke-width: 1; stroke-dasharray: 4 2; rx: 6; }
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L8,3 L0,6 Z" fill="#666"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text x="350" y="30" text-anchor="middle" class="title">Interactive Mode: Backend Selection</text>
|
||||||
|
|
||||||
|
<!-- Config check -->
|
||||||
|
<rect x="225" y="50" width="250" height="44" class="diamond" rx="6"/>
|
||||||
|
<text x="350" y="69" text-anchor="middle" class="label">compose_backend = "compose"</text>
|
||||||
|
<text x="350" y="84" text-anchor="middle" class="small">in sandcage.toml?</text>
|
||||||
|
|
||||||
|
<!-- Windows-only box -->
|
||||||
|
<rect x="30" y="140" width="340" height="290" class="platform"/>
|
||||||
|
<text x="50" y="160" class="small" font-style="italic">Windows only</text>
|
||||||
|
|
||||||
|
<!-- MSYSTEM check -->
|
||||||
|
<rect x="100" y="175" width="200" height="44" class="diamond" rx="6"/>
|
||||||
|
<text x="200" y="194" text-anchor="middle" class="label">MSYSTEM env var set?</text>
|
||||||
|
<text x="200" y="209" text-anchor="middle" class="small">(Git Bash / MSYS2)</text>
|
||||||
|
|
||||||
|
<!-- Console handle check -->
|
||||||
|
<rect x="100" y="275" width="200" height="44" class="diamond" rx="6"/>
|
||||||
|
<text x="200" y="294" text-anchor="middle" class="label">Console handle valid?</text>
|
||||||
|
<text x="200" y="309" text-anchor="middle" class="small">(GetConsoleMode)</text>
|
||||||
|
|
||||||
|
<!-- Raw mode check -->
|
||||||
|
<rect x="225" y="380" width="250" height="44" class="diamond" rx="6"/>
|
||||||
|
<text x="350" y="399" text-anchor="middle" class="label">Raw mode supported?</text>
|
||||||
|
<text x="350" y="414" text-anchor="middle" class="small">(crossterm probe)</text>
|
||||||
|
|
||||||
|
<!-- Result: Native TTY -->
|
||||||
|
<rect x="195" y="468" width="200" height="38" class="result-native"/>
|
||||||
|
<text x="295" y="491" text-anchor="middle" class="label" font-weight="600">Native TTY</text>
|
||||||
|
|
||||||
|
<!-- Result: Compose fallback -->
|
||||||
|
<rect x="500" y="200" width="170" height="38" class="result-compose"/>
|
||||||
|
<text x="585" y="223" text-anchor="middle" class="label" font-weight="600">Compose fallback</text>
|
||||||
|
|
||||||
|
<!-- Arrows -->
|
||||||
|
<!-- Config → MSYSTEM (No) -->
|
||||||
|
<path d="M350,94 L350,120 L200,120 L200,175" class="arrow"/>
|
||||||
|
<text x="280" y="115" class="edge no">no</text>
|
||||||
|
|
||||||
|
<!-- Config → Compose (Yes) -->
|
||||||
|
<path d="M475,72 L585,72 L585,200" class="arrow"/>
|
||||||
|
<text x="510" y="65" class="edge yes">yes</text>
|
||||||
|
|
||||||
|
<!-- MSYSTEM → Console (No) -->
|
||||||
|
<path d="M200,219 L200,275" class="arrow"/>
|
||||||
|
<text x="210" y="252" class="edge no">no</text>
|
||||||
|
|
||||||
|
<!-- MSYSTEM → Compose (Yes) -->
|
||||||
|
<path d="M300,197 L430,197 L430,219 L500,219" class="arrow"/>
|
||||||
|
<text x="340" y="190" class="edge yes">yes</text>
|
||||||
|
|
||||||
|
<!-- Console → Raw mode (Yes) -->
|
||||||
|
<path d="M200,319 L200,355 L350,355 L350,380" class="arrow"/>
|
||||||
|
<text x="210" y="345" class="edge yes">yes</text>
|
||||||
|
|
||||||
|
<!-- Console → Compose (No) -->
|
||||||
|
<path d="M300,297 L430,297 L430,219 L500,219" class="arrow"/>
|
||||||
|
<text x="340" y="290" class="edge no">no</text>
|
||||||
|
|
||||||
|
<!-- Raw mode → Native (Yes) -->
|
||||||
|
<path d="M295,424 L295,468" class="arrow"/>
|
||||||
|
<text x="275" y="450" class="edge yes">yes</text>
|
||||||
|
|
||||||
|
<!-- Raw mode → Compose (No) -->
|
||||||
|
<path d="M475,402 L585,402 L585,238" class="arrow"/>
|
||||||
|
<text x="510" y="395" class="edge no">no</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,91 @@
|
|||||||
|
# Interactive Mode
|
||||||
|
|
||||||
|
When you run `sandcage shell` or pass `--shell` to an agent command, sandcage opens an
|
||||||
|
interactive terminal session inside the container. How that session is wired up depends on
|
||||||
|
your terminal — sandcage picks the best available method automatically.
|
||||||
|
|
||||||
|
## Two backends
|
||||||
|
|
||||||
|
### Native TTY (default)
|
||||||
|
|
||||||
|
Sandcage talks directly to the Docker API, puts your terminal into raw mode, and relays
|
||||||
|
keystrokes and output between your terminal and the container. This gives you:
|
||||||
|
|
||||||
|
- **Terminal resize tracking** — the container shell adjusts when you resize the window
|
||||||
|
- **Full TTY fidelity** — colors, cursor movement, and interactive programs (vim, htop, etc.)
|
||||||
|
work as expected
|
||||||
|
- **Graceful shutdown** — the container stops cleanly when the session ends
|
||||||
|
|
||||||
|
This is the same mechanism `docker run -it` uses under the hood.
|
||||||
|
|
||||||
|
### Compose fallback
|
||||||
|
|
||||||
|
When native TTY isn't available, sandcage delegates to `docker compose run`, which manages
|
||||||
|
the terminal connection itself. You get the same interactive shell — the difference is
|
||||||
|
invisible in normal use. The fallback activates automatically; no configuration needed.
|
||||||
|
|
||||||
|
## Which backend am I using?
|
||||||
|
|
||||||
|
Sandcage prints a diagnostic line to stderr when it makes its choice:
|
||||||
|
|
||||||
|
```
|
||||||
|
sandcage: tty probe → crossterm raw mode OK — using bollard TTY
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```
|
||||||
|
sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose
|
||||||
|
```
|
||||||
|
|
||||||
|
These messages appear once at session start and don't affect your shell.
|
||||||
|
|
||||||
|
## When does the fallback activate?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The detection runs through these checks in order:
|
||||||
|
|
||||||
|
| Check | Triggers fallback when… |
|
||||||
|
|---|---|
|
||||||
|
| `compose_backend` config | Set to `"compose"` in `sandcage.toml` — forces compose unconditionally |
|
||||||
|
| MSYSTEM environment variable (Windows) | Present — indicates Git Bash or MSYS2, whose terminal emulator can't handle raw console mode |
|
||||||
|
| Console handle (Windows) | Invalid — stdin is piped or redirected, not a real terminal |
|
||||||
|
| Raw mode probe (all platforms) | Fails — terminal doesn't support raw mode |
|
||||||
|
|
||||||
|
If none of these trigger, sandcage uses native TTY.
|
||||||
|
|
||||||
|
## Exiting the session
|
||||||
|
|
||||||
|
In native TTY mode, your keystrokes are relayed directly to the container shell. This means:
|
||||||
|
|
||||||
|
- **Ctrl+D** or typing `exit` ends the session (same as any shell)
|
||||||
|
- **Ctrl+C** interrupts the current command inside the container — it does *not* kill the
|
||||||
|
session. This matches `docker run -it` behavior.
|
||||||
|
|
||||||
|
In compose fallback mode, Ctrl+C ends the session immediately.
|
||||||
|
|
||||||
|
## Forcing compose mode
|
||||||
|
|
||||||
|
If you run into rendering issues in an unusual terminal, you can force the compose backend
|
||||||
|
by adding this to your `sandcage.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
compose_backend = "compose"
|
||||||
|
```
|
||||||
|
|
||||||
|
This bypasses all detection and always uses `docker compose run` for interactive sessions.
|
||||||
|
|
||||||
|
## Windows terminal compatibility
|
||||||
|
|
||||||
|
| Terminal | Backend used |
|
||||||
|
|---|---|
|
||||||
|
| Windows Terminal / PowerShell | Native TTY |
|
||||||
|
| CMD (Command Prompt) | Native TTY |
|
||||||
|
| Git Bash (MinTTY) | Compose fallback |
|
||||||
|
| MSYS2 / MinGW shells | Compose fallback |
|
||||||
|
| WSL | Native TTY |
|
||||||
|
|
||||||
|
On macOS and Linux, native TTY is used in all standard terminals (iTerm2, Terminal.app,
|
||||||
|
GNOME Terminal, Alacritty, kitty, etc.). The compose fallback would only activate if stdin
|
||||||
|
is piped or the terminal is otherwise non-interactive.
|
||||||
Reference in New Issue
Block a user