feat(shipote): quota enforce + cgroup memory.max + pipeline restart (fase L)
- WorkspaceSpec.quota_enforce: QuotaAction (None|Log|Kill) por recurso (mem, nproc). reap_dead aplica policy; Kill usa stop_with_grace(ZERO). - ente_incarnate::cgroup::apply_rlimits_to_cgroup escribe memory.max y pids.max. WorkspaceManager::create_with_id lo invoca si soma.cgroup.path y delegation. Kernel hace OOM kill al exceder; falla silenciosa si no hay delegation. - PipelineSpec.restart_on_failure: bool. register_pipeline_supervisor retiene spec; reap_dead detecta all-dead + any-failed → push a queue; daemon reaper drena y relanza pipeline ENTERO (los pipes intermedios no permiten restart parcial). 82 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8, shipote-core 24, shipote-discern 5, yahweh-provider-fs 3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let discerner = Arc::new(DiscernPipeline::default_pipeline());
|
let discerner = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
|
|
||||||
// Reaper periódico cada 500 ms.
|
// Reaper periódico cada 500 ms. Además drena pipelines pendientes
|
||||||
|
// de restart (supervisión a nivel pipeline).
|
||||||
{
|
{
|
||||||
let mgr = mgr.clone();
|
let mgr = mgr.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -101,6 +102,55 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
mgr.reap_dead().await;
|
mgr.reap_dead().await;
|
||||||
|
let pending = mgr.take_pending_restarts().await;
|
||||||
|
for sup in pending {
|
||||||
|
info!(
|
||||||
|
label = %sup.spec.label,
|
||||||
|
restart_count = sup.restart_count,
|
||||||
|
"pipeline restart: relaunching"
|
||||||
|
);
|
||||||
|
let inc = mgr.incarnator_handle();
|
||||||
|
let disc = std::sync::Arc::new(DiscernPipeline::default_pipeline());
|
||||||
|
let ws_label = mgr
|
||||||
|
.workspace_label(sup.spec.workspace)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let restart_count = sup.restart_count;
|
||||||
|
let workspace = sup.spec.workspace;
|
||||||
|
let tap = sup.tap;
|
||||||
|
let mut new_spec = sup.spec.clone();
|
||||||
|
// Mantener restart_on_failure para futuras fallas.
|
||||||
|
new_spec.restart_on_failure = true;
|
||||||
|
match shipote_core::pipeline::run_pipeline(
|
||||||
|
&new_spec,
|
||||||
|
&ws_label,
|
||||||
|
tap,
|
||||||
|
disc,
|
||||||
|
inc,
|
||||||
|
Some(mgr.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(launch) => {
|
||||||
|
mgr.register_pipeline_commands(workspace, launch.pipeline, launch.command_pids.clone())
|
||||||
|
.await;
|
||||||
|
// Re-registrar supervisor con el nuevo pipeline_id,
|
||||||
|
// preservando restart_count.
|
||||||
|
let mut s = shipote_core::PipelineSupervisor {
|
||||||
|
workspace,
|
||||||
|
spec: new_spec,
|
||||||
|
tap,
|
||||||
|
restart_count,
|
||||||
|
};
|
||||||
|
s.restart_count = restart_count;
|
||||||
|
mgr.register_pipeline_supervisor(launch.pipeline, workspace, s.spec.clone(), tap)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "pipeline restart failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -219,6 +269,7 @@ async fn dispatch(
|
|||||||
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
||||||
let cmds = launch.command_pids;
|
let cmds = launch.command_pids;
|
||||||
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
||||||
|
mgr.register_pipeline_supervisor(pipeline_id, spec.workspace, spec.clone(), tap).await;
|
||||||
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
||||||
Response::PipelineStarted {
|
Response::PipelineStarted {
|
||||||
pipeline: pipeline_id,
|
pipeline: pipeline_id,
|
||||||
@@ -318,6 +369,7 @@ async fn dispatch(
|
|||||||
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
||||||
let cmds = launch.command_pids;
|
let cmds = launch.command_pids;
|
||||||
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
||||||
|
mgr.register_pipeline_supervisor(pipeline_id, spec.workspace, spec.clone(), tap).await;
|
||||||
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
||||||
Response::PipelineStarted {
|
Response::PipelineStarted {
|
||||||
pipeline: pipeline_id,
|
pipeline: pipeline_id,
|
||||||
|
|||||||
@@ -94,6 +94,34 @@ pub struct WorkspaceSpec {
|
|||||||
/// Política al terminar el workspace.
|
/// Política al terminar el workspace.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub on_exit: ExitPolicy,
|
pub on_exit: ExitPolicy,
|
||||||
|
|
||||||
|
/// Política de enforcement automático cuando un recurso excede su
|
||||||
|
/// rlimit declarado en `soma.rlimits`. Default = sólo accounting
|
||||||
|
/// (None) — el quota report sigue funcionando, pero no hay kill.
|
||||||
|
#[serde(default)]
|
||||||
|
pub quota_enforce: QuotaEnforcement,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acción cuando un recurso excede su límite. Aplica por recurso (mem,
|
||||||
|
/// nproc, ...).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum QuotaAction {
|
||||||
|
/// Sólo accounting: la breach aparece en `workspace_quota`.
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
/// Loguear la breach (info-level del daemon).
|
||||||
|
Log,
|
||||||
|
/// Matar todos los comandos vivos del workspace (SIGKILL, sin grace).
|
||||||
|
Kill,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct QuotaEnforcement {
|
||||||
|
#[serde(default)]
|
||||||
|
pub mem: QuotaAction,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nproc: QuotaAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -189,6 +217,11 @@ pub struct PipelineSpec {
|
|||||||
pub edges: Vec<FlowEdge>,
|
pub edges: Vec<FlowEdge>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub discern: DiscernPolicy,
|
pub discern: DiscernPolicy,
|
||||||
|
/// Si `true` y cualquier comando del pipeline termina con exit!=0,
|
||||||
|
/// el daemon relaunch el pipeline ENTERO (stop + nuevo run_pipeline).
|
||||||
|
/// Útil para pipelines de procesamiento continuo.
|
||||||
|
#[serde(default)]
|
||||||
|
pub restart_on_failure: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -468,6 +501,7 @@ mod subst_tests {
|
|||||||
}],
|
}],
|
||||||
edges: vec![],
|
edges: vec![],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
let out = substitute_vars(&spec, &vars).unwrap();
|
let out = substitute_vars(&spec, &vars).unwrap();
|
||||||
assert_eq!(out.label, "p-renamed");
|
assert_eq!(out.label, "p-renamed");
|
||||||
@@ -487,6 +521,7 @@ mod subst_tests {
|
|||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
edges: vec![],
|
edges: vec![],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
let out = substitute_vars(&spec, &vars).unwrap();
|
let out = substitute_vars(&spec, &vars).unwrap();
|
||||||
assert_eq!(out.label, "p-${UNDEFINED}");
|
assert_eq!(out.label, "p-${UNDEFINED}");
|
||||||
@@ -509,6 +544,7 @@ mod tests {
|
|||||||
scope: FlowScope::Public,
|
scope: FlowScope::Public,
|
||||||
}],
|
}],
|
||||||
on_exit: ExitPolicy::Reap,
|
on_exit: ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,6 +602,7 @@ mod tests {
|
|||||||
to_input: "y".into(),
|
to_input: "y".into(),
|
||||||
}],
|
}],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
assert!(p.validate().is_err());
|
assert!(p.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ struct Inner {
|
|||||||
/// con la misma spec (nuevo pid y nuevo command_id se asigna por
|
/// con la misma spec (nuevo pid y nuevo command_id se asigna por
|
||||||
/// el nuevo state pero el restart_spec sigue ligado al original).
|
/// el nuevo state pero el restart_spec sigue ligado al original).
|
||||||
restart_specs: HashMap<Ulid, RestartSpec>,
|
restart_specs: HashMap<Ulid, RestartSpec>,
|
||||||
|
/// Supervisores de pipelines con `restart_on_failure`. Indexed por
|
||||||
|
/// pipeline_id. Cuando `reap_dead` detecta que el pipeline tuvo
|
||||||
|
/// algún command failure, agrega un entry a `pending_pipeline_restarts`.
|
||||||
|
pipeline_supervisors: HashMap<Ulid, PipelineSupervisor>,
|
||||||
|
/// Cola de pipelines pendientes de restart. El daemon la drena en
|
||||||
|
/// cada loop del reaper, hace stop + run_pipeline.
|
||||||
|
pending_pipeline_restarts: Vec<Ulid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PipelineSupervisor {
|
||||||
|
pub workspace: WorkspaceId,
|
||||||
|
pub spec: PipelineSpec,
|
||||||
|
pub tap: bool,
|
||||||
|
pub restart_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -131,6 +146,19 @@ pub struct CommandInfo {
|
|||||||
pub log_bytes: u64,
|
pub log_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lee VmRSS (bytes) de `/proc/<pid>/status`. Helper local para
|
||||||
|
/// reap_dead que no necesita el full stats. Devuelve 0 si el proc no
|
||||||
|
/// existe o el campo no aparece.
|
||||||
|
fn read_proc_rss(pid: i32) -> Option<u64> {
|
||||||
|
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("VmRSS:").map(str::trim))
|
||||||
|
.and_then(|s| s.split_whitespace().next())
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.map(|kb| kb * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
|
fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
|
||||||
// Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
|
// Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
|
||||||
// SAFETY: F_SETFL sobre fd válido.
|
// SAFETY: F_SETFL sobre fd válido.
|
||||||
@@ -198,11 +226,54 @@ impl WorkspaceManager {
|
|||||||
saved_pipelines: HashMap::new(),
|
saved_pipelines: HashMap::new(),
|
||||||
pipeline_flows: HashMap::new(),
|
pipeline_flows: HashMap::new(),
|
||||||
restart_specs: HashMap::new(),
|
restart_specs: HashMap::new(),
|
||||||
|
pipeline_supervisors: HashMap::new(),
|
||||||
|
pending_pipeline_restarts: Vec::new(),
|
||||||
})),
|
})),
|
||||||
incarnator: Arc::new(Incarnator::new(cfg)),
|
incarnator: Arc::new(Incarnator::new(cfg)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registra un supervisor para un pipeline con `restart_on_failure=true`.
|
||||||
|
/// El daemon llama esto tras `run_pipeline` para que `reap_dead` agregue
|
||||||
|
/// el pipeline a la cola de restart cuando algún command falle.
|
||||||
|
pub async fn register_pipeline_supervisor(
|
||||||
|
&self,
|
||||||
|
pipeline_id: Ulid,
|
||||||
|
workspace: WorkspaceId,
|
||||||
|
spec: PipelineSpec,
|
||||||
|
tap: bool,
|
||||||
|
) {
|
||||||
|
if !spec.restart_on_failure {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::debug!(%pipeline_id, label = %spec.label, "pipeline supervisor registered");
|
||||||
|
let mut g = self.inner.lock().await;
|
||||||
|
g.pipeline_supervisors.insert(
|
||||||
|
pipeline_id,
|
||||||
|
PipelineSupervisor {
|
||||||
|
workspace,
|
||||||
|
spec,
|
||||||
|
tap,
|
||||||
|
restart_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drena la cola de pipelines pendientes de restart y retorna las
|
||||||
|
/// specs a relaunch. El daemon lo llama tras cada `reap_dead`.
|
||||||
|
pub async fn take_pending_restarts(&self) -> Vec<PipelineSupervisor> {
|
||||||
|
let mut g = self.inner.lock().await;
|
||||||
|
let pending = std::mem::take(&mut g.pending_pipeline_restarts);
|
||||||
|
let mut out = Vec::with_capacity(pending.len());
|
||||||
|
for old_id in pending {
|
||||||
|
if let Some(mut sup) = g.pipeline_supervisors.remove(&old_id) {
|
||||||
|
sup.restart_count += 1;
|
||||||
|
out.push(sup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Registra los comandos lanzados por un pipeline en el workspace.
|
/// Registra los comandos lanzados por un pipeline en el workspace.
|
||||||
/// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
|
/// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
|
||||||
/// de un pipeline). `pipeline_id` se setea en cada CommandState.
|
/// de un pipeline). `pipeline_id` se setea en cada CommandState.
|
||||||
@@ -465,8 +536,22 @@ impl WorkspaceManager {
|
|||||||
spec: WorkspaceSpec,
|
spec: WorkspaceSpec,
|
||||||
) -> Result<(WorkspaceId, Vec<String>), CoreError> {
|
) -> Result<(WorkspaceId, Vec<String>), CoreError> {
|
||||||
let card = spec.to_card(id)?;
|
let card = spec.to_card(id)?;
|
||||||
let warnings = self.incarnator.dry_run(&card).warnings;
|
let mut warnings = self.incarnator.dry_run(&card).warnings;
|
||||||
let ttl = spec.ttl;
|
let ttl = spec.ttl;
|
||||||
|
|
||||||
|
// Si el workspace declara cgroup path Y rlimits, intentamos
|
||||||
|
// crear el cgroup y escribir memory.max/pids.max. El kernel
|
||||||
|
// hace OOM kill al exceder memory.max — enforcement automático
|
||||||
|
// sin policy adicional. Falla silenciosa si no hay delegation.
|
||||||
|
if !spec.soma.cgroup.path.is_empty() {
|
||||||
|
if let Ok(abs) = ente_incarnate::cgroup::ensure_cgroup(&spec.soma.cgroup) {
|
||||||
|
let applied =
|
||||||
|
ente_incarnate::cgroup::apply_rlimits_to_cgroup(&abs, &spec.soma.rlimits);
|
||||||
|
if !applied.is_empty() {
|
||||||
|
warnings.push(format!("cgroup limits applied: {}", applied.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let state = WorkspaceState {
|
let state = WorkspaceState {
|
||||||
id,
|
id,
|
||||||
spec,
|
spec,
|
||||||
@@ -778,6 +863,7 @@ impl WorkspaceManager {
|
|||||||
/// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
|
/// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
|
||||||
pub async fn reap_dead(self: &Arc<Self>) {
|
pub async fn reap_dead(self: &Arc<Self>) {
|
||||||
let mut to_restart: Vec<RestartSpec> = Vec::new();
|
let mut to_restart: Vec<RestartSpec> = Vec::new();
|
||||||
|
let mut to_enforce_kill: Vec<WorkspaceId> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut g = self.inner.lock().await;
|
let mut g = self.inner.lock().await;
|
||||||
for ws in g.workspaces.values_mut() {
|
for ws in g.workspaces.values_mut() {
|
||||||
@@ -798,6 +884,93 @@ impl WorkspaceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Quota enforcement: chequear breach por workspace y aplicar policy.
|
||||||
|
// Lo hacemos dentro del mismo lock para tener una lectura
|
||||||
|
// consistente; el kill real va fuera del lock.
|
||||||
|
for (ws_id, ws) in g.workspaces.iter() {
|
||||||
|
let rl = &ws.spec.soma.rlimits;
|
||||||
|
let qe = &ws.spec.quota_enforce;
|
||||||
|
// Sólo aplicamos si hay al menos una action != None.
|
||||||
|
if qe.mem == shipote_card::QuotaAction::None
|
||||||
|
&& qe.nproc == shipote_card::QuotaAction::None
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Medir RSS y nproc vivos sin pasar por workspace_stats
|
||||||
|
// (que tomaría el lock recursivo). Hacemos un read directo.
|
||||||
|
let alive: Vec<i32> = ws
|
||||||
|
.commands
|
||||||
|
.values()
|
||||||
|
.filter(|c| c.alive)
|
||||||
|
.map(|c| c.pid.as_raw())
|
||||||
|
.collect();
|
||||||
|
let nproc_alive = alive.len() as u32;
|
||||||
|
let mem_used: u64 = alive
|
||||||
|
.iter()
|
||||||
|
.filter_map(|pid| read_proc_rss(*pid))
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
let mem_breach = matches!(rl.mem_bytes, Some(limit) if mem_used > limit);
|
||||||
|
let nproc_breach = matches!(rl.nproc, Some(limit) if nproc_alive > limit);
|
||||||
|
|
||||||
|
let mut kill_needed = false;
|
||||||
|
if mem_breach {
|
||||||
|
match qe.mem {
|
||||||
|
shipote_card::QuotaAction::Log => {
|
||||||
|
warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: memory");
|
||||||
|
}
|
||||||
|
shipote_card::QuotaAction::Kill => {
|
||||||
|
warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: KILLING");
|
||||||
|
kill_needed = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nproc_breach {
|
||||||
|
match qe.nproc {
|
||||||
|
shipote_card::QuotaAction::Log => {
|
||||||
|
warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: nproc");
|
||||||
|
}
|
||||||
|
shipote_card::QuotaAction::Kill => {
|
||||||
|
warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: KILLING");
|
||||||
|
kill_needed = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kill_needed {
|
||||||
|
to_enforce_kill.push(*ws_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pipeline supervisor: detectar pipelines cuyos comandos tienen
|
||||||
|
// failure. Marca para restart si tiene supervisor.
|
||||||
|
// Esto se hace cuando TODOS los comandos del pipeline están
|
||||||
|
// dead Y al menos uno tiene exit!=0 (sino podría disparar
|
||||||
|
// restart mientras otros comandos aún corren — incorrecto).
|
||||||
|
let supervisor_ids: Vec<Ulid> = g.pipeline_supervisors.keys().copied().collect();
|
||||||
|
for pipe_id in supervisor_ids {
|
||||||
|
// ¿Hay algún comando vivo de este pipeline?
|
||||||
|
let mut all_dead = true;
|
||||||
|
let mut any_failed = false;
|
||||||
|
for ws in g.workspaces.values() {
|
||||||
|
for cmd in ws.commands.values() {
|
||||||
|
if cmd.pipeline_id != Some(pipe_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if cmd.alive {
|
||||||
|
all_dead = false;
|
||||||
|
} else if cmd.exit_status.map_or(false, |s| s != 0) {
|
||||||
|
any_failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_dead && any_failed {
|
||||||
|
// Push a queue si no estaba ya.
|
||||||
|
if !g.pending_pipeline_restarts.contains(&pipe_id) {
|
||||||
|
g.pending_pipeline_restarts.push(pipe_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Detectar restart_specs cuyo command_id ya está dead con exit!=0.
|
// Detectar restart_specs cuyo command_id ya está dead con exit!=0.
|
||||||
let mut to_remove: Vec<Ulid> = Vec::new();
|
let mut to_remove: Vec<Ulid> = Vec::new();
|
||||||
for (cmd_id, spec) in g.restart_specs.iter() {
|
for (cmd_id, spec) in g.restart_specs.iter() {
|
||||||
@@ -826,6 +999,10 @@ impl WorkspaceManager {
|
|||||||
g.restart_specs.remove(&id);
|
g.restart_specs.remove(&id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Quota enforcement: kill workspaces fuera del lock.
|
||||||
|
for ws_id in to_enforce_kill {
|
||||||
|
let _ = self.stop_with_grace(ws_id, std::time::Duration::ZERO).await;
|
||||||
|
}
|
||||||
// Schedule restart fuera del lock.
|
// Schedule restart fuera del lock.
|
||||||
for mut spec in to_restart {
|
for mut spec in to_restart {
|
||||||
let mgr = self.clone();
|
let mgr = self.clone();
|
||||||
@@ -900,6 +1077,7 @@ mod tests {
|
|||||||
ttl: Some(std::time::Duration::from_millis(120)),
|
ttl: Some(std::time::Duration::from_millis(120)),
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
};
|
};
|
||||||
let (id, _) = mgr.create(spec).await.unwrap();
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
assert_eq!(mgr.list().await.len(), 1);
|
assert_eq!(mgr.list().await.len(), 1);
|
||||||
@@ -922,6 +1100,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
};
|
};
|
||||||
let (id, _w) = mgr.create(spec).await.unwrap();
|
let (id, _w) = mgr.create(spec).await.unwrap();
|
||||||
let list = mgr.list().await;
|
let list = mgr.list().await;
|
||||||
@@ -939,6 +1118,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
};
|
};
|
||||||
let (id, _) = mgr.create(spec).await.unwrap();
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
let summary = mgr
|
let summary = mgr
|
||||||
@@ -971,6 +1151,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
};
|
};
|
||||||
let (id, _) = mgr.create(spec).await.unwrap();
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
// sh -c "echo OUT; echo ERR >&2"
|
// sh -c "echo OUT; echo ERR >&2"
|
||||||
@@ -1017,6 +1198,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
};
|
};
|
||||||
let (id, _) = mgr.create(spec).await.unwrap();
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
// /bin/false sale con exit=1. Con restart_on_failure=true debería
|
// /bin/false sale con exit=1. Con restart_on_failure=true debería
|
||||||
@@ -1042,6 +1224,98 @@ mod tests {
|
|||||||
panic!("restart never launched a new command");
|
panic!("restart never launched a new command");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_supervisor_queues_restart_on_failure() {
|
||||||
|
use shipote_card::{CommandRef, DiscernPolicy, PipelineSpec};
|
||||||
|
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||||
|
let (ws_id, _) = mgr.create(WorkspaceSpec {
|
||||||
|
label: "psup".into(),
|
||||||
|
soma: Default::default(),
|
||||||
|
permissions: Default::default(),
|
||||||
|
ttl: None,
|
||||||
|
flow_dirs: vec![],
|
||||||
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
|
}).await.unwrap();
|
||||||
|
let spec = PipelineSpec {
|
||||||
|
label: "fail-pipeline".into(),
|
||||||
|
workspace: ws_id,
|
||||||
|
nodes: vec![CommandRef {
|
||||||
|
label: "boom".into(),
|
||||||
|
payload: brahman_card::Payload::Native {
|
||||||
|
exec: "/bin/false".into(),
|
||||||
|
argv: vec![],
|
||||||
|
envp: vec![],
|
||||||
|
},
|
||||||
|
soma: Default::default(),
|
||||||
|
flows: Default::default(),
|
||||||
|
supervision: brahman_card::Supervision::OneShot,
|
||||||
|
}],
|
||||||
|
edges: vec![],
|
||||||
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: true,
|
||||||
|
};
|
||||||
|
let pipeline_id = ulid::Ulid::new();
|
||||||
|
// Simulamos lo que haría el daemon: registramos un comando como
|
||||||
|
// si fuera de pipeline. Usamos `register_pipeline_commands` con
|
||||||
|
// un pid fake — pero como reaper hace waitpid, mejor lanzar de verdad.
|
||||||
|
// Hack: usar /bin/false via run() y manualmente marcar pipeline_id.
|
||||||
|
let summary = mgr.run(ws_id, "/bin/false".into(), vec![], vec![]).await.unwrap();
|
||||||
|
// Marcar el comando con pipeline_id manualmente.
|
||||||
|
{
|
||||||
|
let mut g = mgr.inner.lock().await;
|
||||||
|
if let Some(ws) = g.workspaces.get_mut(&ws_id) {
|
||||||
|
if let Some(cmd) = ws.commands.get_mut(&summary.id) {
|
||||||
|
cmd.pipeline_id = Some(pipeline_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mgr.register_pipeline_supervisor(pipeline_id, ws_id, spec, true).await;
|
||||||
|
// Esperamos que reap detecte la falla y push a pending.
|
||||||
|
for _ in 0..40 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
mgr.reap_dead().await;
|
||||||
|
let pending = mgr.take_pending_restarts().await;
|
||||||
|
if !pending.is_empty() {
|
||||||
|
assert_eq!(pending[0].spec.label, "fail-pipeline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("supervisor never queued a restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn quota_enforce_nproc_kill_terminates_commands() {
|
||||||
|
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||||
|
let mut spec = WorkspaceSpec {
|
||||||
|
label: "qenforce".into(),
|
||||||
|
soma: Default::default(),
|
||||||
|
permissions: Default::default(),
|
||||||
|
ttl: None,
|
||||||
|
flow_dirs: vec![],
|
||||||
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: shipote_card::QuotaEnforcement {
|
||||||
|
mem: shipote_card::QuotaAction::None,
|
||||||
|
nproc: shipote_card::QuotaAction::Kill,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
spec.soma.rlimits.nproc = Some(1);
|
||||||
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
|
// Lanzo 2 procesos (cada uno sleep). nproc_limit=1 → breach inmediato.
|
||||||
|
let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
|
||||||
|
let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
|
||||||
|
// Reaper detecta breach y mata workspace.
|
||||||
|
for _ in 0..30 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
mgr.reap_dead().await;
|
||||||
|
let alive = mgr.list().await;
|
||||||
|
if alive.is_empty() {
|
||||||
|
return; // workspace removido por stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("quota enforce kill never triggered");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_true_in_workspace() {
|
async fn run_true_in_workspace() {
|
||||||
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||||
@@ -1052,6 +1326,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
};
|
};
|
||||||
let (id, _) = mgr.create(spec).await.unwrap();
|
let (id, _) = mgr.create(spec).await.unwrap();
|
||||||
let summary = mgr
|
let summary = mgr
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: ExitPolicy::Reap,
|
on_exit: ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +209,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
edges: vec![],
|
edges: vec![],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
mgr1.save_pipeline("daily".into(), spec).await;
|
mgr1.save_pipeline("daily".into(), spec).await;
|
||||||
mgr1.save_snapshot(&path).await.unwrap();
|
mgr1.save_snapshot(&path).await.unwrap();
|
||||||
|
|||||||
@@ -594,6 +594,7 @@ mod tests {
|
|||||||
to_input: "stdin".into(),
|
to_input: "stdin".into(),
|
||||||
}],
|
}],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
@@ -631,6 +632,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
@@ -667,6 +669,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
discern: DiscernPolicy::default(),
|
discern: DiscernPolicy::default(),
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
@@ -698,6 +701,7 @@ mod tests {
|
|||||||
replay_chunks: 32,
|
replay_chunks: 32,
|
||||||
replay_bytes: 0,
|
replay_bytes: 0,
|
||||||
},
|
},
|
||||||
|
restart_on_failure: false,
|
||||||
};
|
};
|
||||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ mod tests {
|
|||||||
ttl: None,
|
ttl: None,
|
||||||
flow_dirs: vec![],
|
flow_dirs: vec![],
|
||||||
on_exit: shipote_card::ExitPolicy::Reap,
|
on_exit: shipote_card::ExitPolicy::Reap,
|
||||||
|
quota_enforce: Default::default(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let bytes = postcard::to_allocvec(&req).unwrap();
|
let bytes = postcard::to_allocvec(&req).unwrap();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Resolución y creación de cgroups v2 para el hijo.
|
//! Resolución y creación de cgroups v2 para el hijo.
|
||||||
|
|
||||||
use crate::error::IncarnateError;
|
use crate::error::IncarnateError;
|
||||||
use brahman_card::CgroupSpec;
|
use brahman_card::{CgroupSpec, ResourceLimits};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Cgroup actual del proceso que llama. Lo usamos como prefijo para paths
|
/// Cgroup actual del proceso que llama. Lo usamos como prefijo para paths
|
||||||
/// declarados relativos en `CgroupSpec.path`.
|
/// declarados relativos en `CgroupSpec.path`.
|
||||||
@@ -58,8 +58,33 @@ pub fn ensure_cgroup(spec: &CgroupSpec) -> Result<PathBuf, IncarnateError> {
|
|||||||
Ok(abs)
|
Ok(abs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escribe `memory.max` y `pids.max` al cgroup según `rlimits`. Falla
|
||||||
|
/// silenciosamente si los archivos no son escribibles (cgroup no
|
||||||
|
/// delegated). El kernel hace OOM kill cuando `memory.max` se excede,
|
||||||
|
/// y bloquea forks cuando `pids.max` se alcanza.
|
||||||
|
///
|
||||||
|
/// `memory.max` acepta `max` o un número en bytes. `pids.max` igual.
|
||||||
|
pub fn apply_rlimits_to_cgroup(cgroup_abs: &Path, rlimits: &ResourceLimits) -> Vec<String> {
|
||||||
|
let mut applied = Vec::new();
|
||||||
|
if let Some(mem) = rlimits.mem_bytes {
|
||||||
|
let path = cgroup_abs.join("memory.max");
|
||||||
|
match std::fs::write(&path, format!("{mem}\n")) {
|
||||||
|
Ok(_) => applied.push(format!("memory.max={mem}")),
|
||||||
|
Err(e) => tracing::warn!(?e, path = %path.display(), "memory.max write failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(np) = rlimits.nproc {
|
||||||
|
let path = cgroup_abs.join("pids.max");
|
||||||
|
match std::fs::write(&path, format!("{np}\n")) {
|
||||||
|
Ok(_) => applied.push(format!("pids.max={np}")),
|
||||||
|
Err(e) => tracing::warn!(?e, path = %path.display(), "pids.max write failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
|
||||||
/// Mueve `pid` a `cgroup_abs/cgroup.procs`.
|
/// Mueve `pid` a `cgroup_abs/cgroup.procs`.
|
||||||
pub fn move_to_cgroup(cgroup_abs: &std::path::Path, pid: nix::unistd::Pid) -> Result<(), IncarnateError> {
|
pub fn move_to_cgroup(cgroup_abs: &Path, pid: nix::unistd::Pid) -> Result<(), IncarnateError> {
|
||||||
let procs = cgroup_abs.join("cgroup.procs");
|
let procs = cgroup_abs.join("cgroup.procs");
|
||||||
std::fs::write(&procs, format!("{}\n", pid.as_raw())).map_err(|e| match e.kind() {
|
std::fs::write(&procs, format!("{}\n", pid.as_raw())).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable {
|
std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable {
|
||||||
|
|||||||
Reference in New Issue
Block a user