Skip to content

Commit c6166d1

Browse files
committed
Mim per-program health, env injection, and eval
- Fix doctor falsely reporting podman as unreachable (use {{.Version.Version}} for podman) - Add GET /health/<program> endpoint with per-program daemon status - Enhance GET /health to return aggregated per-program breakdown - Add --env/--env-file flags to start for secret/config injection into containers - Add --env to freeze for recording expected env var names in manifest - Add eval CLI command that POSTs to a running serve container's /eval endpoint - Add engine fallback warning on unfreeze when defaulting to global config
1 parent 790dcd5 commit c6166d1

6 files changed

Lines changed: 223 additions & 13 deletions

File tree

data/rust/morloc-manager/src/doctor.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,12 @@ pub fn doctor(
105105

106106
fn check_engine(c: &mut Counts, engine: ContainerEngine) {
107107
let exe = engine_executable(engine);
108+
let fmt = match engine {
109+
ContainerEngine::Podman => "{{.Version.Version}}",
110+
ContainerEngine::Docker => "{{.ServerVersion}}",
111+
};
108112
let output = Command::new(exe)
109-
.args(["info", "--format", "{{.ServerVersion}}"])
113+
.args(["info", "--format", fmt])
110114
.output();
111115
match output {
112116
Ok(o) if o.status.success() => {

data/rust/morloc-manager/src/freeze.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub fn freeze_from_dir(
1616
v_data_dir: &str,
1717
output_dir: &str,
1818
verbose: bool,
19+
env_vars: &[String],
1920
) -> Result<()> {
2021
fs::create_dir_all(output_dir)
2122
.map_err(|e| ManagerError::FreezeError(format!("Failed to create output dir: {e}")))?;
@@ -112,11 +113,16 @@ pub fn freeze_from_dir(
112113
programs,
113114
base_image: base_img,
114115
env_layer,
116+
env_vars: env_vars.to_vec(),
115117
};
116118
let manifest_path = Path::new(output_dir).join("freeze-manifest.json");
117119
let manifest_path = manifest_path.to_string_lossy();
118120
write_freeze_manifest(&manifest_path, &manifest)?;
119121
eprintln!("Wrote {manifest_path}");
122+
if !env_vars.is_empty() {
123+
eprintln!("Expected env vars: {}", env_vars.join(", "));
124+
eprintln!(" Pass at start time with: morloc-manager start --env KEY=VALUE");
125+
}
120126
eprintln!("Frozen state written to {output_dir}");
121127
Ok(())
122128
}

data/rust/morloc-manager/src/main.rs

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,12 @@ Without --, flags like --version are interpreted by morloc-manager itself.")]
260260
/// Port mapping HOST:CONTAINER (default: 8080:8080)
261261
#[arg(short, long, value_parser = parse_port)]
262262
port: Vec<(u16, u16)>,
263+
/// Pass environment variable to the container (KEY=VALUE)
264+
#[arg(short, long = "env")]
265+
env_vars: Vec<String>,
266+
/// Read environment variables from a file (one KEY=VALUE per line)
267+
#[arg(long)]
268+
env_file: Option<String>,
263269
},
264270
/// Stop a running serve container
265271
#[command(display_order = 21)]
@@ -288,6 +294,9 @@ Without --, flags like --version are interpreted by morloc-manager itself.")]
288294
/// Overwrite existing output directory
289295
#[arg(long)]
290296
force: bool,
297+
/// Declare an expected environment variable name (recorded in manifest, no value stored)
298+
#[arg(short, long = "env")]
299+
env_vars: Vec<String>,
291300
},
292301
/// Build a serve image from frozen state
293302
#[command(display_order = 24)]
@@ -310,8 +319,20 @@ Without --, flags like --version are interpreted by morloc-manager itself.")]
310319
#[arg(long)]
311320
rebuild: bool,
312321
},
313-
/// List running serve containers
322+
/// Evaluate a morloc expression against a running serve container
314323
#[command(display_order = 25)]
324+
#[command(after_help = "Examples:\n morloc-manager eval 'add 1 2'\n morloc-manager eval myenv 'map (add 1) [1,2,3]'\n morloc-manager eval -p 9090 'greet \"world\"'")]
325+
Eval {
326+
/// Expression to evaluate (or environment name if two positional args)
327+
first: String,
328+
/// Expression to evaluate (when first arg is environment name)
329+
second: Option<String>,
330+
/// Port of the serve container (default: 8080)
331+
#[arg(short, long, default_value = "8080")]
332+
port: u16,
333+
},
334+
/// List running serve containers
335+
#[command(display_order = 26)]
315336
#[command(after_help = "Examples:\n morloc-manager status")]
316337
Status,
317338
/// Check environment health and diagnose issues
@@ -358,6 +379,44 @@ fn parse_port(s: &str) -> std::result::Result<(u16, u16), String> {
358379
Ok((host, container))
359380
}
360381

382+
/// Parse env vars from --env flags and --env-file, returning (key, value) pairs.
383+
fn collect_env_vars(
384+
env_flags: &[String],
385+
env_file: Option<&str>,
386+
) -> Result<Vec<(String, String)>> {
387+
let mut result = Vec::new();
388+
389+
if let Some(path) = env_file {
390+
let contents = std::fs::read_to_string(path).map_err(|e| {
391+
ManagerError::EnvError(format!("Cannot read env file {path}: {e}"))
392+
})?;
393+
for line in contents.lines() {
394+
let trimmed = line.trim();
395+
if trimmed.is_empty() || trimmed.starts_with('#') {
396+
continue;
397+
}
398+
if let Some((k, v)) = trimmed.split_once('=') {
399+
result.push((k.to_string(), v.to_string()));
400+
}
401+
}
402+
}
403+
404+
for entry in env_flags {
405+
if let Some((k, v)) = entry.split_once('=') {
406+
result.push((k.to_string(), v.to_string()));
407+
} else {
408+
// Bare key — pass through from host environment
409+
if let Ok(v) = std::env::var(entry) {
410+
result.push((entry.clone(), v));
411+
} else {
412+
eprintln!("Warning: env var '{entry}' not set in host environment, skipping");
413+
}
414+
}
415+
}
416+
417+
Ok(result)
418+
}
419+
361420
// ======================================================================
362421
// Main
363422
// ======================================================================
@@ -1293,7 +1352,7 @@ fn dispatch(verbose: bool, cmd: Cmd) -> Result<()> {
12931352
}
12941353

12951354
// ---- freeze ----
1296-
Cmd::Freeze { output, force } => {
1355+
Cmd::Freeze { output, force, env_vars } => {
12971356
let output_dir = output.as_deref().unwrap_or("./morloc-freeze");
12981357
// Protect against silently overwriting a previous freeze
12991358
let existing_tar = std::path::Path::new(output_dir).join("state.tar.gz");
@@ -1330,7 +1389,7 @@ fn dispatch(verbose: bool, cmd: Cmd) -> Result<()> {
13301389
};
13311390
let data_dir = cfg::env_data_dir(env_scope, &env_name);
13321391
let image = ec.active_image().to_string();
1333-
let result = freeze::freeze_from_dir(env_scope, ver.clone(), engine, &image, &data_dir.to_string_lossy(), output_dir, verbose);
1392+
let result = freeze::freeze_from_dir(env_scope, ver.clone(), engine, &image, &data_dir.to_string_lossy(), output_dir, verbose, &env_vars);
13341393
if result.is_ok() && ec.morloc_version.as_ref() != Some(&ver) {
13351394
let mut updated = ec.clone();
13361395
updated.morloc_version = Some(ver);
@@ -1360,13 +1419,27 @@ fn dispatch(verbose: bool, cmd: Cmd) -> Result<()> {
13601419
let engine = match engine_override {
13611420
Some(EngineArg::Docker) => ContainerEngine::Docker,
13621421
Some(EngineArg::Podman) => ContainerEngine::Podman,
1363-
None => ensure_engine()?,
1422+
None => {
1423+
let e = ensure_engine()?;
1424+
eprintln!(
1425+
"Note: using {} engine from global config. Override with --engine if needed.",
1426+
display_engine(e)
1427+
);
1428+
e
1429+
}
13641430
};
1431+
if !manifest.env_vars.is_empty() {
1432+
eprintln!(
1433+
"Note: frozen state expects env vars: {}",
1434+
manifest.env_vars.join(", ")
1435+
);
1436+
eprintln!(" Pass at runtime with: -e KEY=VALUE when running the image");
1437+
}
13651438
serve::build_serve_image(engine, verbose, &from, &tag, manifest.morloc_version, base.as_deref(), rebuild, &manifest.programs)
13661439
}
13671440

13681441
// ---- start ----
1369-
Cmd::Start { name, port } => {
1442+
Cmd::Start { name, port, env_vars, env_file } => {
13701443
let (env_name, env_scope, ec) = resolve_env_or_active(name)?;
13711444
let image = ec.active_image().to_string();
13721445
let data_dir = cfg::env_data_dir(env_scope, &env_name);
@@ -1382,10 +1455,12 @@ fn dispatch(verbose: bool, cmd: Cmd) -> Result<()> {
13821455
};
13831456
let flags_path = cfg::env_flags_path(env_scope, &env_name);
13841457
let extra_flags = cfg::read_flags_file(&flags_path);
1458+
let user_env = collect_env_vars(&env_vars, env_file.as_deref())?;
13851459
serve::serve_environment(
13861460
ec.engine, verbose, &image,
13871461
&data_dir.to_string_lossy(), &container_name,
13881462
&port_mappings, &extra_flags, &Some(ec.shm_size.clone()),
1463+
&user_env,
13891464
)
13901465
}
13911466

@@ -1439,6 +1514,50 @@ fn dispatch(verbose: bool, cmd: Cmd) -> Result<()> {
14391514
Ok(())
14401515
}
14411516

1517+
// ---- eval ----
1518+
Cmd::Eval { first, second, port } => {
1519+
let expr = if let Some(ref expr_arg) = second {
1520+
// first is env name — validate it exists and its serve container is running
1521+
let (env_name, _, ec) = resolve_env_or_active(Some(first))?;
1522+
let container_name = format!("morloc-serve-{env_name}");
1523+
if !container::container_exists(ec.engine, &container_name) {
1524+
return Err(ManagerError::EnvError(format!(
1525+
"No serve container running for '{env_name}'. Start with: morloc-manager start {env_name}"
1526+
)));
1527+
}
1528+
expr_arg.clone()
1529+
} else {
1530+
first
1531+
};
1532+
use std::io::{Read as IoRead, Write as IoWrite};
1533+
let body = format!("{{\"expr\":{}}}", serde_json::to_string(&expr).unwrap_or_default());
1534+
let request = format!(
1535+
"POST /eval HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1536+
body.len(), body
1537+
);
1538+
let addr = format!("127.0.0.1:{port}");
1539+
let mut stream = std::net::TcpStream::connect(&addr).map_err(|e| {
1540+
ManagerError::EnvError(format!(
1541+
"Cannot connect to serve container on {addr}: {e}\n Is a serve container running? Start with: morloc-manager start"
1542+
))
1543+
})?;
1544+
stream.write_all(request.as_bytes()).map_err(|e| {
1545+
ManagerError::EnvError(format!("Failed to send request: {e}"))
1546+
})?;
1547+
let mut response = String::new();
1548+
stream.read_to_string(&mut response).map_err(|e| {
1549+
ManagerError::EnvError(format!("Failed to read response: {e}"))
1550+
})?;
1551+
// Extract body from HTTP response (after \r\n\r\n)
1552+
if let Some(pos) = response.find("\r\n\r\n") {
1553+
let body = &response[pos + 4..];
1554+
println!("{body}");
1555+
} else {
1556+
println!("{response}");
1557+
}
1558+
Ok(())
1559+
}
1560+
14421561
// ---- status ----
14431562
Cmd::Status => {
14441563
// Only show engines that have running/stopped serve containers.
@@ -1907,6 +2026,7 @@ mod tests {
19072026
content_hash: "abc".to_string(),
19082027
image_digest: None,
19092028
}),
2029+
env_vars: vec!["API_KEY".to_string(), "DB_URL".to_string()],
19102030
};
19112031
cfg::write_config(&path, &fm).unwrap();
19122032
let fm2: FreezeManifest = cfg::read_config(&path).unwrap();

data/rust/morloc-manager/src/serve.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ pub fn serve_environment(
286286
ports: &[(u16, u16)],
287287
extra_flags: &[String],
288288
shm_size: &Option<String>,
289+
user_env: &[(String, String)],
289290
) -> Result<()> {
290291
// Clean up any existing dead container with this name (silently)
291292
let _ = crate::container::container_remove_quiet(engine, container_name);
@@ -310,6 +311,7 @@ pub fn serve_environment(
310311
("PATH".to_string(), format!("{mh}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")),
311312
("MORLOC_HOME".to_string(), mh.to_string()),
312313
];
314+
cfg.env.extend(user_env.iter().cloned());
313315
cfg.command = Some(vec![
314316
"morloc-nexus".to_string(),
315317
"--router".to_string(),

data/rust/morloc-manager/src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ pub struct FreezeManifest {
239239
pub programs: Vec<ProgramEntry>,
240240
pub base_image: String,
241241
pub env_layer: Option<FrozenEnvLayer>,
242+
/// Expected environment variable names (no values — injected at start/run time).
243+
#[serde(default)]
244+
pub env_vars: Vec<String>,
242245
}
243246

244247
#[derive(Debug, Clone, Serialize, Deserialize)]

data/rust/morloc-runtime/src/router_ffi.rs

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -833,9 +833,16 @@ unsafe fn router_http_to_request(
833833
""
834834
};
835835

836-
// GET /health
837-
if method == HttpMethod::Get && path == "/health" {
836+
// GET /health or GET /health/<program>
837+
if method == HttpMethod::Get && (path == "/health" || path.starts_with("/health/")) {
838838
(*dreq).method = DaemonMethod::Health;
839+
if path.starts_with("/health/") {
840+
let prog_name = &path[8..];
841+
if !prog_name.is_empty() {
842+
let c = CString::new(prog_name).unwrap_or_default();
843+
*out_program = libc::strdup(c.as_ptr());
844+
}
845+
}
839846
return dreq;
840847
}
841848

@@ -1170,13 +1177,37 @@ pub unsafe extern "C" fn router_run(config: *mut DaemonConfig, router: *mut Rout
11701177
// Router-level requests
11711178
if target_program.is_null() {
11721179
if (*dreq).method == DaemonMethod::Health {
1173-
let body = b"{\"status\":\"ok\"}\0";
1180+
// Aggregate per-program health
1181+
let mut all_ok = true;
1182+
let mut prog_entries = Vec::new();
1183+
for i in 0..(*router).n_programs {
1184+
let prog = &*(*router).programs.add(i);
1185+
let name = CStr::from_ptr(prog.name).to_string_lossy();
1186+
let alive =
1187+
prog.daemon_pid > 0 && libc::kill(prog.daemon_pid, 0) == 0;
1188+
if !alive {
1189+
all_ok = false;
1190+
}
1191+
let status_str = if alive { "ok" } else { "error" };
1192+
prog_entries.push(serde_json::json!({
1193+
"program": name.as_ref(),
1194+
"status": status_str,
1195+
}));
1196+
}
1197+
let overall = if all_ok { "ok" } else { "degraded" };
1198+
let body = serde_json::json!({
1199+
"status": overall,
1200+
"programs": prog_entries,
1201+
}).to_string();
1202+
let status_code = if all_ok { 200 } else { 503 };
1203+
resp_status = status_code;
1204+
let c = CString::new(body.as_str()).unwrap_or_default();
11741205
http_write_response(
11751206
client_fd,
1176-
200,
1207+
status_code,
11771208
ct.as_ptr() as *const c_char,
1178-
body.as_ptr() as *const c_char,
1179-
body.len() - 1,
1209+
c.as_ptr(),
1210+
body.len(),
11801211
);
11811212
} else if (*dreq).method == DaemonMethod::Discover {
11821213
let disco = router_build_discovery(router);
@@ -1214,7 +1245,51 @@ pub unsafe extern "C" fn router_run(config: *mut DaemonConfig, router: *mut Rout
12141245
}
12151246

12161247
// Per-program request
1217-
if (*dreq).method == DaemonMethod::Discover {
1248+
if (*dreq).method == DaemonMethod::Health {
1249+
let mut found = false;
1250+
for p in 0..(*router).n_programs {
1251+
let rprog = &*(*router).programs.add(p);
1252+
if CStr::from_ptr(rprog.name) == CStr::from_ptr(target_program) {
1253+
found = true;
1254+
let alive =
1255+
rprog.daemon_pid > 0 && libc::kill(rprog.daemon_pid, 0) == 0;
1256+
let prog_str = CStr::from_ptr(rprog.name).to_string_lossy();
1257+
let body = if alive {
1258+
serde_json::json!({
1259+
"status": "ok",
1260+
"program": prog_str.as_ref(),
1261+
}).to_string()
1262+
} else {
1263+
resp_status = 503;
1264+
serde_json::json!({
1265+
"status": "error",
1266+
"program": prog_str.as_ref(),
1267+
"error": "daemon not running",
1268+
}).to_string()
1269+
};
1270+
let c = CString::new(body.as_str()).unwrap_or_default();
1271+
http_write_response(
1272+
client_fd,
1273+
resp_status,
1274+
ct.as_ptr() as *const c_char,
1275+
c.as_ptr(),
1276+
body.len(),
1277+
);
1278+
break;
1279+
}
1280+
}
1281+
if !found {
1282+
resp_status = 404;
1283+
let body = b"{\"status\":\"error\",\"error\":\"Unknown program\"}\0";
1284+
http_write_response(
1285+
client_fd,
1286+
404,
1287+
ct.as_ptr() as *const c_char,
1288+
body.as_ptr() as *const c_char,
1289+
body.len() - 1,
1290+
);
1291+
}
1292+
} else if (*dreq).method == DaemonMethod::Discover {
12181293
let mut found = false;
12191294
for p in 0..(*router).n_programs {
12201295
let rprog = &*(*router).programs.add(p);

0 commit comments

Comments
 (0)