Generate compose YAML programmatically via serde
Fragile string replacement and env var indirection replaced with typed structs.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/Cargo.lock b/Cargo.lock
index 47618f2..983f071 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -176,6 +176,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "eyre"
version = "0.6.12"
@@ -201,6 +207,12 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -213,12 +225,28 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -328,6 +356,8 @@ dependencies = [
"clap",
"color-eyre",
"fs-err",
+ "serde",
+ "serde_yaml",
"tracing",
"tracing-subscriber",
"xdg",
@@ -356,6 +386,55 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -474,6 +553,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "utf8parse"
version = "0.2.2"
diff --git a/Cargo.toml b/Cargo.toml
index f3164b7..7c86d60 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,8 @@ edition = "2024"
clap = { version = "*", features = ["derive"] }
color-eyre = "*"
fs-err = "*"
+serde = { version = "*", features = ["derive"] }
+serde_yaml = "*"
tracing = "*"
tracing-subscriber = { version = "*", features = ["env-filter"] }
xdg = "*"
diff --git a/assets/compose.yml b/assets/compose.yml
deleted file mode 100644
index b9bd9a2..0000000
--- a/assets/compose.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-services:
- agent:
- build:
- context: "${RAMEKIN_BUILD_CONTEXT:-.}"
- dockerfile: "${RAMEKIN_DOCKERFILE:-Dockerfile}"
- image: ramekin-agent
- stdin_open: true
- tty: true
- environment:
- - RAMEKIN_FIREWALL=${RAMEKIN_FIREWALL:-true}
- volumes:
- - "${RAMEKIN_WORKSPACE:-.}:/workspace"
- - "${RAMEKIN_DATA_DIR}:/root/.pi"
- - "${RAMEKIN_CONFIG_DIR}/settings.json:/root/.pi/agent/settings.json"
- - "${RAMEKIN_CONFIG_DIR}/keybindings.json:/root/.pi/agent/keybindings.json"
- - "${RAMEKIN_CONFIG_DIR}/AGENTS.md:/root/.pi/agent/AGENTS.md"
- - type: bind
- source: "${RAMEKIN_EXTENSION}"
- target: /root/.pi/agent/extensions/ramekin.ts
- read_only: true
diff --git a/src/main.rs b/src/main.rs
index d9f0822..50444c0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,12 +1,12 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use clap::Parser;
use color_eyre::eyre::{Context, Result, bail};
+use serde::Serialize;
use tracing::{error, info};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
-const COMPOSE_YML: &str = include_str!("../assets/compose.yml");
const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
const RAMEKIN_EXTENSION: &str = include_str!("../assets/ramekin.ts");
@@ -70,13 +70,9 @@ fn run() -> Result<()> {
.create_cache_directory("")
.wrap_err("failed to create cache directory")?;
- // Inject host git/jj config mounts into compose template
- let compose = inject_vcs_config_mounts(COMPOSE_YML);
- fs_err::write(cache_dir.join("compose.yml"), &compose)?;
fs_err::write(cache_dir.join("Dockerfile"), DOCKERFILE)?;
- fs_err::write(cache_dir.join("ramekin.ts"), RAMEKIN_EXTENSION)?;
-
- let compose_file = cache_dir.join("compose.yml");
+ let extension_path = cache_dir.join("ramekin.ts");
+ fs_err::write(&extension_path, RAMEKIN_EXTENSION)?;
// Always build the base image first
info!("building base image");
@@ -101,17 +97,22 @@ fn run() -> Result<()> {
(base_dockerfile, cache_dir.clone())
};
+ let compose = generate_compose(
+ &workspace,
+ &dockerfile,
+ &build_context,
+ &pi_data_dir,
+ &pi_config_dir,
+ &extension_path,
+ );
+ let compose_file = cache_dir.join("compose.yml");
+ fs_err::write(&compose_file, &compose)?;
+
let docker_compose = |args: &[&str]| -> Result<Command> {
let mut cmd = Command::new("docker");
cmd.args(["compose", "-f"])
.arg(&compose_file)
- .args(args)
- .env("RAMEKIN_WORKSPACE", &workspace)
- .env("RAMEKIN_DATA_DIR", &pi_data_dir)
- .env("RAMEKIN_DOCKERFILE", &dockerfile)
- .env("RAMEKIN_BUILD_CONTEXT", &build_context)
- .env("RAMEKIN_CONFIG_DIR", &pi_config_dir)
- .env("RAMEKIN_EXTENSION", cache_dir.join("ramekin.ts"));
+ .args(args);
Ok(cmd)
};
@@ -146,31 +147,81 @@ fn run() -> Result<()> {
Ok(())
}
-/// Insert read-only volume mounts for host git/jj config into the compose YAML.
-fn inject_vcs_config_mounts(template: &str) -> String {
- let git_config_dir = xdg::BaseDirectories::with_prefix("git").get_config_home();
- let jj_config_dir = xdg::BaseDirectories::with_prefix("jj").get_config_home();
+#[derive(Serialize)]
+struct ComposeConfig {
+ services: Services,
+}
- if git_config_dir.is_none() && jj_config_dir.is_none() {
- return template.to_string();
- }
+#[derive(Serialize)]
+struct Services {
+ agent: AgentService,
+}
- let mut extra = String::new();
- if let Some(ref path) = git_config_dir {
- info!(path = %path.display(), "mounting git config dir");
- extra.push_str(&format!(
- " - \"{}:/root/.config/git:ro\"\n",
- path.display()
- ));
+#[derive(Serialize)]
+struct AgentService {
+ build: BuildConfig,
+ image: String,
+ stdin_open: bool,
+ tty: bool,
+ volumes: Vec<String>,
+}
+
+#[derive(Serialize)]
+struct BuildConfig {
+ context: String,
+ dockerfile: String,
+}
+
+/// Generate a Docker Compose config with all volume mounts baked in.
+fn generate_compose(
+ workspace: &Path,
+ dockerfile: &Path,
+ build_context: &Path,
+ data_dir: &Path,
+ config_dir: &Path,
+ extension_path: &Path,
+) -> String {
+ let mut volumes = vec![
+ format!("{}:/workspace", workspace.display()),
+ format!("{}:/root/.pi", data_dir.display()),
+ format!(
+ "{}/settings.json:/root/.pi/agent/settings.json",
+ config_dir.display()
+ ),
+ format!(
+ "{}/keybindings.json:/root/.pi/agent/keybindings.json",
+ config_dir.display()
+ ),
+ format!("{}/AGENTS.md:/root/.pi/agent/AGENTS.md", config_dir.display()),
+ format!(
+ "{}:/root/.pi/agent/extensions/ramekin.ts:ro",
+ extension_path.display()
+ ),
+ ];
+
+ if let Some(dir) = xdg::BaseDirectories::with_prefix("git").get_config_home() {
+ info!(path = %dir.display(), "mounting git config dir");
+ volumes.push(format!("{}:/root/.config/git:ro", dir.display()));
}
- if let Some(ref path) = jj_config_dir {
- info!(path = %path.display(), "mounting jj config dir");
- extra.push_str(&format!(
- " - \"{}:/root/.config/jj:ro\"\n",
- path.display()
- ));
+ if let Some(dir) = xdg::BaseDirectories::with_prefix("jj").get_config_home() {
+ info!(path = %dir.display(), "mounting jj config dir");
+ volumes.push(format!("{}:/root/.config/jj:ro", dir.display()));
}
- // Insert before the extension bind mount
- template.replace(" - type: bind", &format!("{extra} - type: bind"))
+ let config = ComposeConfig {
+ services: Services {
+ agent: AgentService {
+ build: BuildConfig {
+ context: build_context.display().to_string(),
+ dockerfile: dockerfile.display().to_string(),
+ },
+ image: "ramekin-agent".into(),
+ stdin_open: true,
+ tty: true,
+ volumes,
+ },
+ },
+ };
+
+ serde_yaml::to_string(&config).expect("failed to serialize compose config")
}