use std::path::Path;
use crate::context::Context;
use crate::error::Error;
use crate::podman::setup::MACHINE_NAME;
pub const LAB_PORT: u16 = 14978;
pub fn container_name(state: &str, id: &str) -> String {
format!("objectiveai-laboratory-{state}-{id}")
}
fn container_command(exe: &Path) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(exe);
if let Some(dir) = exe.parent() {
cmd.env("CONTAINERS_HELPER_BINARY_DIR", dir);
}
if std::env::consts::OS != "linux" {
cmd.arg("--connection").arg(MACHINE_NAME);
}
cmd
}
pub struct Mount {
pub host: String,
pub container: String,
}
fn default_cwd() -> String {
"/".to_string()
}
pub struct LaboratoryInfo {
pub id: String,
pub image: String,
pub mounts: Vec<Mount>,
pub env: Vec<(String, String)>,
pub cwd: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Label {
id: String,
image: String,
mounts: Vec<LabelMount>,
env: Vec<[String; 2]>,
#[serde(default = "default_cwd")]
cwd: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct LabelMount {
host: String,
container: String,
}
pub async fn create(
ctx: &Context,
id: &str,
image: &str,
mounts: &[Mount],
env: &[(String, String)],
cwd: &str,
) -> Result<(), Error> {
let exe = ctx.podman().await?;
let state = ctx.filesystem.state();
let name = container_name(state, id);
let label = Label {
id: id.to_string(),
image: image.to_string(),
mounts: mounts
.iter()
.map(|m| LabelMount {
host: m.host.clone(),
container: m.container.clone(),
})
.collect(),
env: env.iter().map(|(k, v)| [k.clone(), v.clone()]).collect(),
cwd: cwd.to_string(),
};
let label_json = serde_json::to_string(&label)
.map_err(|e| Error::Podman(format!("serialize laboratory label: {e}")))?;
let mut create_cmd = container_command(exe);
create_cmd
.arg("create")
.arg("--name")
.arg(&name)
.arg("-p")
.arg(format!("127.0.0.1::{LAB_PORT}/tcp"));
for m in mounts {
create_cmd
.arg("-v")
.arg(format!("{}:{}", m.host, m.container));
}
for (k, v) in env {
create_cmd.arg("-e").arg(format!("{k}={v}"));
}
create_cmd.arg("-e").arg(format!("PORT={LAB_PORT}"));
create_cmd
.arg("-e")
.arg(format!("OBJECTIVEAI_LABORATORY_ID={id}"));
create_cmd
.arg("-e")
.arg(format!("OBJECTIVEAI_LABORATORY_CWD={cwd}"));
create_cmd
.arg("--label")
.arg(format!("objectiveai.laboratory={label_json}"))
.arg("--entrypoint")
.arg(
r#"["/bin/sh","-c","chmod +x /objectiveai-mcp-laboratory && exec /objectiveai-mcp-laboratory"]"#,
)
.arg(image);
let output = create_cmd
.output()
.await
.map_err(|e| Error::Podman(format!("spawn podman create: {e}")))?;
if !output.status.success() {
return Err(Error::Podman(format!(
"podman create {name}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let bin = ctx.filesystem.bin_dir().join("objectiveai-mcp-laboratory");
let output = container_command(exe)
.arg("cp")
.arg(&bin)
.arg(format!("{name}:/objectiveai-mcp-laboratory"))
.output()
.await
.map_err(|e| Error::Podman(format!("spawn podman cp: {e}")))?;
if !output.status.success() {
return Err(Error::Podman(format!(
"podman cp into {name}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
pub async fn start(ctx: &Context, id: &str) -> Result<(), Error> {
let exe = ctx.podman().await?;
let name = container_name(ctx.filesystem.state(), id);
let output = container_command(exe)
.arg("start")
.arg(&name)
.output()
.await
.map_err(|e| Error::Podman(format!("spawn podman start: {e}")))?;
if !output.status.success() {
return Err(Error::Podman(format!(
"podman start {name}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
pub async fn host_port(ctx: &Context, id: &str) -> Result<u16, Error> {
let exe = ctx.podman().await?;
let name = container_name(ctx.filesystem.state(), id);
let output = container_command(exe)
.arg("port")
.arg(&name)
.arg(format!("{LAB_PORT}/tcp"))
.output()
.await
.map_err(|e| Error::Podman(format!("spawn podman port: {e}")))?;
if !output.status.success() {
return Err(Error::Podman(format!(
"podman port {name}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.ok_or_else(|| {
Error::Podman(format!("podman port {name}: no mapping for {LAB_PORT}/tcp"))
})?;
let port_str = line.rsplit_once(':').map(|(_, p)| p).unwrap_or(line);
port_str.parse::<u16>().map_err(|e| {
Error::Podman(format!(
"podman port {name}: unparseable host port {port_str:?}: {e}"
))
})
}
pub async fn list(ctx: &Context) -> Result<Vec<LaboratoryInfo>, Error> {
let exe = ctx.podman().await?;
let state = ctx.filesystem.state();
let output = container_command(exe)
.arg("ps")
.arg("-a")
.arg("--filter")
.arg(format!("name=objectiveai-laboratory-{state}-"))
.arg("--format")
.arg("json")
.output()
.await
.map_err(|e| Error::Podman(format!("spawn podman ps: {e}")))?;
if !output.status.success() {
return Err(Error::Podman(format!(
"podman ps: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| Error::Podman(format!("parse podman ps output: {e}")))?;
let array = value
.as_array()
.ok_or_else(|| Error::Podman("podman ps output: expected a JSON array".to_string()))?;
let mut labs = Vec::new();
for elem in array {
let Some(label_str) = elem
.get("Labels")
.and_then(|l| l.get("objectiveai.laboratory"))
.and_then(|v| v.as_str())
else {
continue;
};
let label: Label = serde_json::from_str(label_str)
.map_err(|e| Error::Podman(format!("parse laboratory label: {e}")))?;
labs.push(LaboratoryInfo {
id: label.id,
image: label.image,
mounts: label
.mounts
.into_iter()
.map(|m| Mount {
host: m.host,
container: m.container,
})
.collect(),
env: label.env.into_iter().map(|[k, v]| (k, v)).collect(),
cwd: label.cwd,
});
}
Ok(labs)
}