shell
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
//! `shipote-card` — tipos del runtime shipote.
|
||||
//!
|
||||
//! Tres entidades nuevas encima del `brahman-card::Card`:
|
||||
//!
|
||||
//! - [`WorkspaceSpec`] — espacio aislado raíz con su propio `SomaSpec`.
|
||||
//! - [`CommandRef`] — un comando dentro de un workspace.
|
||||
//! - [`PipelineSpec`] — DAG de `CommandRef` conectados por `FlowEdge`.
|
||||
//!
|
||||
//! Cada `WorkspaceSpec`/`CommandRef` se **compila** a una o varias
|
||||
//! [`brahman_card::Card`] que el daemon entrega al [`Incarnator`] de
|
||||
//! `ente-incarnate`. Esto preserva el contrato canónico del fractal.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use brahman_card::{Card, Payload, Permissions, SomaSpec, Supervision};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
// =====================================================================
|
||||
// Identidades
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WorkspaceId(pub Ulid);
|
||||
|
||||
impl WorkspaceId {
|
||||
pub fn new() -> Self {
|
||||
Self(Ulid::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkspaceId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorkspaceId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PipelineId(pub Ulid);
|
||||
|
||||
impl PipelineId {
|
||||
pub fn new() -> Self {
|
||||
Self(Ulid::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PipelineId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PipelineId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Workspace
|
||||
// =====================================================================
|
||||
|
||||
/// Espacio aislado de shipote. Es la raíz de aislamiento — cualquier comando
|
||||
/// que corre dentro hereda restricciones y no puede aflojarlas.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSpec {
|
||||
pub label: String,
|
||||
|
||||
/// Aislamiento del workspace mismo (cuando se materializa como Card raíz).
|
||||
#[serde(default)]
|
||||
pub soma: SomaSpec,
|
||||
|
||||
/// Permisos máximos para hijas. Hijas pueden bajar pero no subir.
|
||||
#[serde(default)]
|
||||
pub permissions: Permissions,
|
||||
|
||||
/// `None` = vive hasta `stop`. `Some(d)` = el daemon lo termina tras d.
|
||||
#[serde(default, with = "opt_duration_millis")]
|
||||
pub ttl: Option<Duration>,
|
||||
|
||||
/// Slots de flow pre-declarados. Limitan qué consumidores externos al
|
||||
/// workspace pueden empatar contra los productores internos.
|
||||
#[serde(default)]
|
||||
pub flow_dirs: Vec<FlowSlot>,
|
||||
|
||||
/// Política al terminar el workspace.
|
||||
#[serde(default)]
|
||||
pub on_exit: ExitPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowSlot {
|
||||
pub name: String,
|
||||
pub direction: FlowDirection,
|
||||
/// Si `Workspace`, sólo otros nodos del mismo workspace pueden empatar.
|
||||
/// Si `Public`, el broker global puede emparejar.
|
||||
#[serde(default)]
|
||||
pub scope: FlowScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlowDirection {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlowScope {
|
||||
#[default]
|
||||
Workspace,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ExitPolicy {
|
||||
/// Reapear procesos hijos y descartar estado.
|
||||
#[default]
|
||||
Reap,
|
||||
/// Mantener el workspace en `stopped` para inspección.
|
||||
Keep,
|
||||
/// Tomar snapshot del estado (para restart posterior).
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
mod opt_duration_millis {
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
|
||||
d.map(|x| x.as_millis() as u64).serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
|
||||
let v: Option<u64> = Option::deserialize(d)?;
|
||||
Ok(v.map(Duration::from_millis))
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CommandRef
|
||||
// =====================================================================
|
||||
|
||||
/// Un comando que vive dentro de un workspace. Se compila a una `Card` con
|
||||
/// `pin_to` apuntando al workspace padre (label) y su `SomaSpec`
|
||||
/// intersectado con el del workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandRef {
|
||||
pub label: String,
|
||||
pub payload: Payload,
|
||||
|
||||
/// SomaSpec del comando. El compilador lo intersecta con el del workspace.
|
||||
#[serde(default)]
|
||||
pub soma: SomaSpec,
|
||||
|
||||
/// Inputs/outputs tipados (mismos `Flow` de brahman-card).
|
||||
#[serde(default)]
|
||||
pub flows: brahman_card::Flows,
|
||||
|
||||
/// Política de supervisión. Default `OneShot` (un comando se ejecuta y muere).
|
||||
#[serde(default = "default_oneshot")]
|
||||
pub supervision: Supervision,
|
||||
}
|
||||
|
||||
fn default_oneshot() -> Supervision {
|
||||
Supervision::OneShot
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pipeline
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipelineSpec {
|
||||
pub label: String,
|
||||
pub workspace: WorkspaceId,
|
||||
pub nodes: Vec<CommandRef>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<FlowEdge>,
|
||||
#[serde(default)]
|
||||
pub discern: DiscernPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowEdge {
|
||||
/// Índice en `PipelineSpec.nodes` del productor.
|
||||
pub from: usize,
|
||||
/// Nombre del Flow output del productor.
|
||||
pub from_output: String,
|
||||
/// Índice en `PipelineSpec.nodes` del consumidor.
|
||||
pub to: usize,
|
||||
/// Nombre del Flow input del consumidor.
|
||||
pub to_input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DiscernPolicy {
|
||||
/// Bytes a samplear por flow para el discernidor. Default 4 KiB.
|
||||
#[serde(default = "default_sample_bytes")]
|
||||
pub sample_bytes: usize,
|
||||
/// Si `true`, enriquece la Card del producer con el TypeRef detectado.
|
||||
#[serde(default = "default_true")]
|
||||
pub enrich_producer: bool,
|
||||
}
|
||||
|
||||
fn default_sample_bytes() -> usize {
|
||||
4096
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Compilación a Card
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CompileError {
|
||||
#[error("workspace label vacío")]
|
||||
EmptyWorkspaceLabel,
|
||||
#[error("comando con label vacío en posición {0}")]
|
||||
EmptyCommandLabel(usize),
|
||||
#[error("edge fuera de rango: from={from}, to={to}, nodes={nodes}")]
|
||||
EdgeOutOfBounds { from: usize, to: usize, nodes: usize },
|
||||
}
|
||||
|
||||
impl WorkspaceSpec {
|
||||
/// Compila el WorkspaceSpec a una Card raíz que el Incarnator puede
|
||||
/// encarnar. Usa `Payload::Virtual` (el workspace no es un proceso por
|
||||
/// sí solo; sólo aloja hijos).
|
||||
pub fn to_card(&self, id: WorkspaceId) -> Result<Card, CompileError> {
|
||||
if self.label.trim().is_empty() {
|
||||
return Err(CompileError::EmptyWorkspaceLabel);
|
||||
}
|
||||
let mut c = Card::new(format!("shipote.workspace.{}", self.label));
|
||||
c.id = id.0;
|
||||
c.soma = self.soma.clone();
|
||||
c.permissions = self.permissions.clone();
|
||||
c.payload = Payload::Virtual;
|
||||
c.supervision = Supervision::OneShot;
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRef {
|
||||
/// Compila un CommandRef a Card hija de un workspace. La Card resultante
|
||||
/// referencia al workspace por label en `pin_to` de cada Flow.
|
||||
pub fn to_card(&self, idx: usize, workspace_label: &str) -> Result<Card, CompileError> {
|
||||
if self.label.trim().is_empty() {
|
||||
return Err(CompileError::EmptyCommandLabel(idx));
|
||||
}
|
||||
let mut c = Card::new(format!("shipote.cmd.{}.{}", workspace_label, self.label));
|
||||
c.payload = self.payload.clone();
|
||||
c.soma = intersect_soma(&self.soma, /*workspace*/ &SomaSpec::default());
|
||||
c.supervision = self.supervision.clone();
|
||||
c.flow = self.flows.clone();
|
||||
// pin_to del workspace en cada Flow input/output → el broker prefiere
|
||||
// resolver dentro del mismo workspace cuando hay candidatos múltiples.
|
||||
let pin = format!("shipote.workspace.{}", workspace_label);
|
||||
for f in c.flow.input.iter_mut().chain(c.flow.output.iter_mut()) {
|
||||
if f.pin_to.is_none() {
|
||||
f.pin_to = Some(pin.clone());
|
||||
}
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersección conservadora: si el workspace pidió aislamiento, la hija
|
||||
/// también lo tiene (no puede aflojar). Si la hija pidió aislamiento extra,
|
||||
/// se respeta.
|
||||
fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec {
|
||||
let mut out = child.clone();
|
||||
out.namespaces.mount |= ws.namespaces.mount;
|
||||
out.namespaces.pid |= ws.namespaces.pid;
|
||||
out.namespaces.net |= ws.namespaces.net;
|
||||
out.namespaces.uts |= ws.namespaces.uts;
|
||||
out.namespaces.ipc |= ws.namespaces.ipc;
|
||||
out.namespaces.user |= ws.namespaces.user;
|
||||
out.namespaces.cgroup |= ws.namespaces.cgroup;
|
||||
// rlimits: el menor (más restrictivo) gana.
|
||||
out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes);
|
||||
out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc);
|
||||
out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile);
|
||||
out
|
||||
}
|
||||
|
||||
fn min_opt<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) => Some(x.min(y)),
|
||||
(Some(x), None) | (None, Some(x)) => Some(x),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl PipelineSpec {
|
||||
pub fn validate(&self) -> Result<(), CompileError> {
|
||||
let n = self.nodes.len();
|
||||
for (i, c) in self.nodes.iter().enumerate() {
|
||||
if c.label.trim().is_empty() {
|
||||
return Err(CompileError::EmptyCommandLabel(i));
|
||||
}
|
||||
}
|
||||
for e in &self.edges {
|
||||
if e.from >= n || e.to >= n {
|
||||
return Err(CompileError::EdgeOutOfBounds {
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
nodes: n,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// I/O conveniencia (TOML + JSON)
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoadError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("toml: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("formato desconocido (esperado .toml o .json)")]
|
||||
UnknownFormat,
|
||||
}
|
||||
|
||||
pub fn load_workspace_spec(path: &std::path::Path) -> Result<WorkspaceSpec, LoadError> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
match path.extension().and_then(|s| s.to_str()) {
|
||||
Some("toml") => Ok(toml::from_str(&raw)?),
|
||||
Some("json") => Ok(serde_json::from_str(&raw)?),
|
||||
_ => Err(LoadError::UnknownFormat),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_pipeline_spec(path: &std::path::Path) -> Result<PipelineSpec, LoadError> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
match path.extension().and_then(|s| s.to_str()) {
|
||||
Some("toml") => Ok(toml::from_str(&raw)?),
|
||||
Some("json") => Ok(serde_json::from_str(&raw)?),
|
||||
_ => Err(LoadError::UnknownFormat),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_workspace() -> WorkspaceSpec {
|
||||
WorkspaceSpec {
|
||||
label: "demo".into(),
|
||||
soma: SomaSpec::default(),
|
||||
permissions: Permissions::default(),
|
||||
ttl: Some(Duration::from_secs(60)),
|
||||
flow_dirs: vec![FlowSlot {
|
||||
name: "out".into(),
|
||||
direction: FlowDirection::Output,
|
||||
scope: FlowScope::Public,
|
||||
}],
|
||||
on_exit: ExitPolicy::Reap,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_toml_roundtrip() {
|
||||
let ws = sample_workspace();
|
||||
let s = toml::to_string(&ws).unwrap();
|
||||
let back: WorkspaceSpec = toml::from_str(&s).unwrap();
|
||||
assert_eq!(back.label, ws.label);
|
||||
assert_eq!(back.ttl, ws.ttl);
|
||||
assert_eq!(back.flow_dirs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_json_roundtrip() {
|
||||
let ws = sample_workspace();
|
||||
let s = serde_json::to_string(&ws).unwrap();
|
||||
let back: WorkspaceSpec = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back.label, ws.label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_compiles_to_card() {
|
||||
let ws = sample_workspace();
|
||||
let id = WorkspaceId::new();
|
||||
let c = ws.to_card(id).unwrap();
|
||||
assert_eq!(c.id, id.0);
|
||||
assert!(c.label.starts_with("shipote.workspace."));
|
||||
assert!(matches!(c.payload, Payload::Virtual));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_label_rejected() {
|
||||
let mut ws = sample_workspace();
|
||||
ws.label = String::new();
|
||||
assert!(ws.to_card(WorkspaceId::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_validates_edges() {
|
||||
let p = PipelineSpec {
|
||||
label: "p".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![CommandRef {
|
||||
label: "a".into(),
|
||||
payload: Payload::Virtual,
|
||||
soma: SomaSpec::default(),
|
||||
flows: brahman_card::Flows::default(),
|
||||
supervision: Supervision::OneShot,
|
||||
}],
|
||||
edges: vec![FlowEdge {
|
||||
from: 0,
|
||||
from_output: "x".into(),
|
||||
to: 5,
|
||||
to_input: "y".into(),
|
||||
}],
|
||||
discern: DiscernPolicy::default(),
|
||||
};
|
||||
assert!(p.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_soma_takes_more_restrictive() {
|
||||
let mut child = SomaSpec::default();
|
||||
child.rlimits.mem_bytes = Some(1_000_000);
|
||||
let mut ws = SomaSpec::default();
|
||||
ws.rlimits.mem_bytes = Some(500_000);
|
||||
ws.namespaces.user = true;
|
||||
let r = intersect_soma(&child, &ws);
|
||||
assert_eq!(r.rlimits.mem_bytes, Some(500_000));
|
||||
assert!(r.namespaces.user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user