refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# shipote
|
||||
|
||||
**Runtime de espacios aislados con flujo tipado** dentro del fractal brahman.
|
||||
|
||||
shipote no es un shell POSIX. Es un daemon long-running que:
|
||||
|
||||
- Aísla procesos en **Workspaces** (cada uno con su `SomaSpec`: namespaces, cgroup, rlimits).
|
||||
- Encadena comandos en **Pipelines** (DAG con fan-out, fan-in, taps, replay buffer).
|
||||
- **Discierne contenido** sobre cada flow (detecta `application/json`, `text/markdown`, magic bytes, hasta `brahman:card`).
|
||||
- Expone **data plane** real: subscribers externos se conectan a Unix sockets y reciben los bytes del stream.
|
||||
- Se integra al ecosistema vía `brahman-sidecar` (anuncia Cards al broker).
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ shipote │ daemon long-running
|
||||
│ -daemon │ $XDG_RUNTIME_DIR/shipote.sock
|
||||
└──────┬──────┘
|
||||
│ admin protocol (postcard)
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
|
||||
│ shipote │ │ shipote- │ │ HTTP / │
|
||||
│ (CLI) │ │ shell │ │ custom │
|
||||
│ │ │ (GUI) │ │ (futuro) │
|
||||
└───────────┘ └───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
# 1. Compilar
|
||||
cargo build -p shipote-daemon -p shipote-cli
|
||||
|
||||
# 2. Arrancar daemon (en una terminal):
|
||||
SHIPOTE_LOG=info ./target/debug/shipote-daemon
|
||||
|
||||
# 3. En otra terminal:
|
||||
./target/debug/shipote health
|
||||
./target/debug/shipote workspace create demo.toml
|
||||
./target/debug/shipote run -w <ws-id> /bin/echo -- hola
|
||||
```
|
||||
|
||||
## Estado actual
|
||||
|
||||
- **11 crates** en el monorepo. Ver [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
- **85 tests** pasando.
|
||||
- Compatible con cualquier supervisor (`ente-incarnate` es reusable — no requiere ser PID 1).
|
||||
|
||||
## Documentación
|
||||
|
||||
- [**ARCHITECTURE.md**](docs/ARCHITECTURE.md) — qué crates lo componen, cómo se conectan, decisiones clave.
|
||||
- [**CLI.md**](docs/CLI.md) — referencia de comandos del binario `shipote`.
|
||||
- [**RECIPES.md**](docs/RECIPES.md) — specs TOML de workspaces y pipelines con casos prácticos.
|
||||
- [**DEVELOPMENT.md**](docs/DEVELOPMENT.md) — cómo correr daemon, GUI, tests, snapshot.
|
||||
|
||||
## Acoplamiento con el resto del monorepo
|
||||
|
||||
| Reusa | Propósito |
|
||||
|---|---|
|
||||
| `ente-incarnate` (extraído de `ente-soma` en Fase 0) | clone(2) + namespaces + cgroup + rlimits |
|
||||
| `brahman-card` | tipos `Card`, `SomaSpec`, `Flow`, `TypeRef` |
|
||||
| `brahman-sidecar` + `brahman-handshake` | anuncio al broker |
|
||||
| `ente-snapshot` filosofía | persistencia (shipote tiene su propio `ShipoteSnapshot` v4) |
|
||||
| `yahweh-launcher` + widgets | GUI `shipote-shell` |
|
||||
| `shipote-discern` ← usado por | `yahweh-provider-fs` (mime per archivo) y `nouser-core::cluster::pick_lens` |
|
||||
|
||||
## Limitaciones declaradas
|
||||
|
||||
- **`mount/pid/net/uts/ipc/cgroup` namespaces** requieren `CAP_SYS_ADMIN` o combo con `CLONE_NEWUSER`.
|
||||
- **`user` namespace** puede estar bloqueado por sysctl o LSM (apparmor/selinux).
|
||||
- **`cgroup` v2 limits** (memory.max, pids.max) requieren delegation. Sin delegation, accounting funciona pero el kernel no enforce.
|
||||
- **Pipeline restart** preserva spec pero **no ULIDs** del pipeline_id (cada relaunch genera uno nuevo).
|
||||
- **Replay buffer broadcast**: subscribers tarde reciben el snapshot del replay (cap configurable por bytes o chunks), pero pueden perder chunks intermedios si el ring rota antes de conectarse.
|
||||
@@ -0,0 +1,36 @@
|
||||
# modules/shuma/ — Runtime de espacios aislados (era shipote)
|
||||
|
||||
**Propósito.** Cada Workspace = un proceso aislado (namespaces +
|
||||
cgroups + capabilities filtradas) que expone un wire protocol tipado.
|
||||
El daemon es dueño de los workspaces; los clientes (cli/shell/gateway)
|
||||
hablan postcard sobre Unix socket.
|
||||
|
||||
## Crates
|
||||
|
||||
| crate | tipo | rol |
|
||||
| ----------------- | ---- | ------------------------------------------------------- |
|
||||
| `shuma-card` | lib | Card del daemon + spec del Workspace |
|
||||
| `shuma-protocol` | lib | Wire types: requests/responses + framing |
|
||||
| `shuma-discern` | lib | Lookup de daemon vía broker brahman |
|
||||
| `shuma-core` | lib | Pipeline: parse spec → encarnar → supervisar → persist |
|
||||
|
||||
## Dependencias
|
||||
|
||||
- `shuma-core` ← `init/ente-incarnate` (encarnación real).
|
||||
- `shuma-protocol` ← `protocol/brahman-card`.
|
||||
- Apps: `shuma-daemon` (dueño), `shuma-cli`, `shuma-shell` (GUI),
|
||||
`shuma-gateway` (HTTP/JSON ↔ postcard).
|
||||
|
||||
## Docs
|
||||
|
||||
Documentación completa en `crates/modules/shuma/docs/`:
|
||||
- `ARCHITECTURE.md` — diseño + flow
|
||||
- `CLI.md` — referencia del cli
|
||||
- `DEVELOPMENT.md` — guía de contribución
|
||||
- `RECIPES.md` — specs ejemplo de Workspace
|
||||
|
||||
## Estado
|
||||
|
||||
LOC 6,907. Backend completo (daemon + cli funcionales). Tests sobre
|
||||
discern + protocol. 14 TODOs en core (supervisión avanzada). Ver
|
||||
`docs/changelog/shuma.md`.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Arquitectura de shipote
|
||||
|
||||
## Diagrama de capas
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Apps (binarios) │
|
||||
│ │
|
||||
│ shipote-daemon shipote (CLI) shipote-shell │
|
||||
│ crates/apps/... crates/apps/... crates/apps/... │
|
||||
└─────────┬─────────────────────┬──────────────────────┬───────────┘
|
||||
│ │ │
|
||||
│ postcard frames (length-prefixed)
|
||||
│ │ │
|
||||
└─────────────────────┴──────────────────────┘
|
||||
│
|
||||
shipote-protocol — wire types
|
||||
crates/modules/shipote/shipote-protocol
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ shipote-core — runtime │
|
||||
│ crates/modules/shipote/shipote-core │
|
||||
│ │
|
||||
│ WorkspaceManager (Arc<Mutex<Inner>>) │
|
||||
│ ├ workspaces: HashMap<WorkspaceId, WorkspaceState> │
|
||||
│ ├ saved_pipelines, pipeline_flows, pipeline_supervisors │
|
||||
│ ├ restart_specs, pending_pipeline_restarts │
|
||||
│ └ dirty: AtomicBool (snapshot incremental) │
|
||||
│ │
|
||||
│ Modules: │
|
||||
│ - pipeline.rs (run_pipeline, splitter, merger, TokenBucket) │
|
||||
│ - flow_channel.rs (Unix socket + tokio::broadcast + replay) │
|
||||
│ - logbuf.rs (ring buffer 64 KiB para stdout/stderr) │
|
||||
│ - stats.rs (WorkspaceStats: RSS/CPU/peak/cores) │
|
||||
│ - persist.rs (ShipoteSnapshot v4 con JSON atomic write) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Soporte compartido │
|
||||
│ │
|
||||
│ shipote-card shipote-discern │
|
||||
│ - WorkspaceSpec - DiscernPipeline │
|
||||
│ - PipelineSpec - MagicBytes, JsonProbe, etc. │
|
||||
│ - CommandRef - Detecta brahman:card │
|
||||
│ - DiscernPolicy │
|
||||
│ │
|
||||
│ ente-incarnate (crates/shared/) — extraído de ente-soma │
|
||||
│ - Incarnator { caps, env, stdio, pre_exec } │
|
||||
│ - CapabilitySet::detect (user_ns, cgroup, kernel version) │
|
||||
│ - ChildStdio, ChildSetup (NoNewPrivs, Chdir, ...) │
|
||||
│ - clone(2) + namespaces + cgroup move (post-clone setup) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
brahman-card · ente-incarnate · nix · libc
|
||||
```
|
||||
|
||||
## Crates (11 nuevos)
|
||||
|
||||
### Core del runtime
|
||||
- **`crates/shared/ente-incarnate`** — rutina extraída del Init para encarnar Cards en procesos aislados. Reusable por cualquier supervisor. NO requiere ser PID 1.
|
||||
- **`crates/modules/shipote/shipote-card`** — tipos: `WorkspaceSpec`, `PipelineSpec`, `CommandRef`, `FlowEdge`, `DiscernPolicy`, `QuotaEnforcement`. Compilan a `brahman_card::Card`.
|
||||
- **`crates/modules/shipote/shipote-protocol`** — wire postcard length-prefixed sobre Unix socket en `$XDG_RUNTIME_DIR/shipote.sock`.
|
||||
- **`crates/modules/shipote/shipote-discern`** — `DiscernPipeline` + discerners default. Reusado por `yahweh-provider-fs` y `nouser-core::cluster`.
|
||||
- **`crates/modules/shipote/shipote-core`** — `WorkspaceManager` + módulos `pipeline`, `flow_channel`, `logbuf`, `persist`, `stats`.
|
||||
|
||||
### Binarios
|
||||
- **`crates/apps/shipote-daemon`** — long-running. Escucha admin socket, ejecuta el dispatch, reaper periódico.
|
||||
- **`crates/apps/shipote-cli`** — `shipote` (clap). Para administración + scripting.
|
||||
- **`crates/apps/shipote-shell`** — GUI con `yahweh_launcher`. Dashboard: estado, workspaces, comandos, flows, quotas, sparkline.
|
||||
- **`crates/apps/shipote-gateway`** — HTTP/JSON adapter. `POST /rpc` con body JSON `Request` → traduce a postcard, round-trip al daemon, retorna JSON. Default `127.0.0.1:7378`.
|
||||
|
||||
### Consumidores del discerner (externos a shipote pero usándolo)
|
||||
- **`crates/modules/ui_engine/libs/providers/fs`** — `FileDataProvider` ahora puebla `mime_type` con `DiscernPipeline`.
|
||||
- **`crates/modules/nouser/core/src/cluster.rs`** — `pick_lens` usa el discerner como fallback cuando la extensión cae a `Lens::Grid`.
|
||||
|
||||
## Modelo conceptual
|
||||
|
||||
### Workspace
|
||||
Espacio aislado raíz. Una **Card con `kind: Ente`, `payload: Virtual`** que aloja N comandos hijos. Su `SomaSpec` define el sandbox compartido.
|
||||
|
||||
```toml
|
||||
label = "demo"
|
||||
on_exit = "reap"
|
||||
ttl = 60000 # opcional, ms
|
||||
|
||||
[soma.rlimits]
|
||||
mem_bytes = 10485760 # 10 MiB
|
||||
nproc = 4
|
||||
|
||||
[soma.cgroup]
|
||||
path = "shipote/demo"
|
||||
|
||||
[quota_enforce]
|
||||
mem = "kill" # None | Log | Kill
|
||||
```
|
||||
|
||||
### CommandRef
|
||||
Un comando dentro del workspace. Su `SomaSpec` se **intersecta** con el del padre (OR de namespaces, MIN de rlimits).
|
||||
|
||||
### PipelineSpec
|
||||
DAG de `CommandRef` con edges tipados. Soporta:
|
||||
- **1→1**: pipe directo (sin task intermedio).
|
||||
- **1→N (fan-out)**: splitter task replica.
|
||||
- **N→1 (fan-in)**: merger task con mpsc (una sub-task por reader → channel → escribir al consumer.stdin).
|
||||
|
||||
### Flow channel (data plane)
|
||||
Unix socket en `$XDG_RUNTIME_DIR/shipote-flow-<pipeline_id>-<edge_idx>.sock`. Cada subscriber recibe primero el replay (cap por chunks y/o bytes), después el broadcast live. Tokio `broadcast::Sender<Arc<Vec<u8>>>` — zero-copy entre subscribers.
|
||||
|
||||
### Discerner
|
||||
Pipeline ordenado: `MagicBytes` → `CardProbe` → `JsonProbe` → `TomlProbe` → `Utf8Probe`. Cada uno devuelve `Discernment { ty: TypeRef, confidence, mime, lens }`.
|
||||
|
||||
## Decisiones clave de arquitectura
|
||||
|
||||
### Por qué `ente-incarnate` es separado
|
||||
La rutina de aislamiento (clone+ns+cgroup+rlimits) vivía dentro del Init (`ente-soma`). Era global y atada a PID 1. La separamos en Fase 0 para que shipote (y futuros supervisores) puedan reusarla sin implicar privilegio.
|
||||
|
||||
Hoy `ente-soma` es un wrapper de ~30 líneas sobre `ente-incarnate` que preserva la semántica histórica (`set_bus_sock` global + `strict_caps=false`). `ente-zero` sigue funcionando intocado.
|
||||
|
||||
### Por qué pipeline-level restart relanza el pipeline ENTERO
|
||||
Los pipes intermedios de un comando que muere ya están cerrados. Restart parcial no funciona. La identidad ULID del pipeline cambia entre intentos; `restart_count` y `current_backoff_ms` se preservan en el supervisor.
|
||||
|
||||
### Por qué replay buffer guarda ANTES del broadcast
|
||||
Si guardas después, un subscriber que conecta entre `broadcast::send` y `replay.push_back` puede perder ese chunk. El orden importa: replay → broadcast.
|
||||
|
||||
### Por qué `pipe2(O_CLOEXEC)` siempre
|
||||
Sin `O_CLOEXEC`, el siguiente comando del pipeline hereda copias del write-end del pipe anterior. El read-end nunca ve EOF y el consumidor cuelga. Bug encontrado y arreglado en Fase F.
|
||||
|
||||
### Por qué AsyncFd + non-blocking en taps/mergers
|
||||
`tokio::fs::File::from_std` con un FD blocking bloquea el reactor entero. La solución: `fcntl(F_SETFL, O_NONBLOCK)` antes de envolver en `AsyncFd<OwnedFd>`. Patrón usado en splitter, merger y log drainer.
|
||||
|
||||
### Por qué token-bucket real reemplazó el sleep simple
|
||||
El sleep `chunk_size / rate` era uniforme y no permitía burst legítimo. `TokenBucket` con refill por wall time + `capacity=rate_bps` (1s burst) permite burst real y throttle suave.
|
||||
|
||||
### Por qué `dirty: AtomicBool` para snapshot incremental
|
||||
Cada SIGTERM hacía full re-serialize aunque no hubiera cambios. `Ordering::Relaxed` está bien porque el peor caso es un save innecesario. `restore_snapshot` resetea dirty=false (acabamos de hidratar, no es mutation).
|
||||
|
||||
### Por qué auth con SO_PEERCRED y `target: "audit"`
|
||||
SO_PEERCRED es automático en Unix sockets — leer `ucred.uid` del peer y comparar con el propio uid. Sin grupos, sin caps. `SHIPOTE_TRUST_ANYONE=1` es el escape hatch.
|
||||
|
||||
`target: "audit"` separa el log de mutaciones del log normal. Filtrable con `EnvFilter`: `SHIPOTE_LOG=warn,audit=info`.
|
||||
|
||||
## Versión de snapshot
|
||||
|
||||
| Versión | Campos | Forward-compat |
|
||||
|---------|--------|----------------|
|
||||
| v1 | `workspaces` | ✓ |
|
||||
| v2 | + `saved_pipelines` | ✓ (default vacío) |
|
||||
| v3 | + `live_pipelines` | ✓ (default vacío) |
|
||||
| v4 | + `stats_history` per workspace (cap 16) | ✓ (default vacío) |
|
||||
|
||||
`SNAPSHOT_VERSION = 4`. Reader rechaza versiones > 4 (forward-incompat). Todos los campos nuevos usan `#[serde(default)]`.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
Todos los mensajes son enums tagged externamente, serializados con `postcard`. Framing: u32 BE length-prefix + payload. Max frame: 1 MiB.
|
||||
|
||||
### Requests
|
||||
- Reads: `Ping`, `Health`, `Capabilities`, `WorkspaceList`, `WorkspaceStats`, `WorkspaceStatsHistory`, `WorkspaceFullSummary`, `WorkspaceQuota`, `CommandList`, `CommandLogs`, `PipelineSavedList`, `FlowList`, `FlowThroughput`, `Discern`.
|
||||
- Mutaciones (loguean audit): `WorkspaceCreate`, `WorkspaceStop`, `Run`, `PipelineRun`, `PipelineRunSaved`, `PipelineStop`, `PipelineSave`, `PipelineDrop`, `FlowDrop`.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Referencia CLI — `shipote`
|
||||
|
||||
El binario `shipote` (compilado por `cargo build -p shipote-cli`) se conecta al daemon vía Unix socket. Por default usa `$XDG_RUNTIME_DIR/shipote.sock`; override con `--socket <path>`.
|
||||
|
||||
```sh
|
||||
shipote [--socket PATH] <comando> [...]
|
||||
```
|
||||
|
||||
## Comandos globales
|
||||
|
||||
### `shipote ping`
|
||||
Health-check rápido. Imprime `pong` si el daemon responde.
|
||||
|
||||
### `shipote health`
|
||||
Health endpoint estructurado.
|
||||
```
|
||||
version: 0.1.0
|
||||
uptime: 11411 ms
|
||||
alive_workspaces: 1
|
||||
alive_commands: 1
|
||||
alive_pipelines: 0
|
||||
active_flows: 0
|
||||
dirty: true
|
||||
```
|
||||
|
||||
### `shipote caps`
|
||||
Capacidades runtime detectadas: kernel version, `user_ns` status, `cgroup_v2`, `cgroup_delegated`, `cap_sys_admin`.
|
||||
|
||||
### `shipote discern <path>`
|
||||
Discierne ad-hoc un archivo (no requiere workspace). Imprime `ty`, `confidence`, `mime`, `lens`.
|
||||
|
||||
## Workspaces
|
||||
|
||||
### `shipote workspace create <spec.toml|.json>`
|
||||
Crea workspace desde un spec. Imprime el ULID asignado. Si el cgroup está delegated y el spec declara rlimits, se aplica `memory.max`/`pids.max`.
|
||||
|
||||
### `shipote workspace list`
|
||||
Lista workspaces vivos.
|
||||
|
||||
### `shipote workspace stats <id>`
|
||||
Resource accounting:
|
||||
```
|
||||
commands: 1 alive / 1 total
|
||||
rss: 2.05 MiB
|
||||
rss_peak: 58.89 MiB
|
||||
cpu: 0.300 s
|
||||
cpu_pct: 98.7 % (24.7% total / 4 cores)
|
||||
source: proc
|
||||
uptime: 1002 ms
|
||||
```
|
||||
|
||||
### `shipote workspace quota <id>`
|
||||
Reporta breaches del `soma.rlimits`. Si `quota_enforce.{mem,nproc}=Kill`, el daemon mata automáticamente al detectar.
|
||||
|
||||
### `shipote workspace stop <id> [--grace-ms N]`
|
||||
Stop graceful. Default `grace_ms=1000`. `0` = SIGKILL inmediato.
|
||||
|
||||
## Comandos directos
|
||||
|
||||
### `shipote run -w <ws-id> [--restart-on-failure] <exec> -- <argv>...`
|
||||
One-shot dentro del workspace. Si `--restart-on-failure`, el reaper relanza con backoff exponencial cuando exit != 0.
|
||||
|
||||
**Nota**: usar `--` antes de los argv del comando para que clap no los confunda con flags suyos:
|
||||
```sh
|
||||
shipote run -w 01... /bin/sh -- -c "echo hola"
|
||||
```
|
||||
|
||||
### `shipote commands <ws-id>`
|
||||
Lista comandos del workspace (vivos + exited) con pid, status, bytes_log.
|
||||
|
||||
### `shipote logs <ws-id> <cmd-id> [--stream {stdout|stderr|both}] [--tail N] [--follow]`
|
||||
Tail del ring buffer de logs.
|
||||
- `--stream` default `stdout`. `both` concatena stderr-tras-stdout.
|
||||
- `--tail N`: últimos N bytes (0 = todo).
|
||||
- `--follow` (`-f`): poll cada 200ms, imprime suffix nuevo hasta que el comando termine.
|
||||
|
||||
## Pipelines
|
||||
|
||||
### `shipote pipeline run <spec.toml> [--tap] [--tail] [--var KEY=VAL ...]`
|
||||
Lanza pipeline.
|
||||
- `--tap`: crea flow channels (data plane) por cada edge.
|
||||
- `--tail`: auto-implica `--tap`, suscribe al primer flow socket y vuelca a stdout.
|
||||
- `--var KEY=VAL` (repetible): sustituye `${KEY}` en el spec antes de lanzar.
|
||||
|
||||
### `shipote pipeline stop <pipeline-id> [--grace-ms N]`
|
||||
Stop selectivo del pipeline (no afecta otros comandos del workspace).
|
||||
|
||||
### `shipote pipeline save <name> <spec.toml>`
|
||||
Persiste el spec bajo `name` (sobrevive restart vía snapshot).
|
||||
|
||||
### `shipote pipeline saved-list`
|
||||
Lista pipelines guardados.
|
||||
|
||||
### `shipote pipeline drop <name>`
|
||||
Elimina un saved pipeline.
|
||||
|
||||
### `shipote pipeline run-saved <name> [--tap] [--tail] [--var KEY=VAL ...]`
|
||||
Ejecuta un pipeline guardado con vars opcionales.
|
||||
|
||||
## Flows
|
||||
|
||||
### `shipote flow list`
|
||||
Lista pipelines vivos con sockets activos (uno por edge con `--tap`).
|
||||
|
||||
### `shipote flow throughput`
|
||||
Bytes acumulados + rate por socket.
|
||||
```
|
||||
shipote-flow-<ULID>-0.sock 0.1 KiB total 0.07 KiB/s
|
||||
```
|
||||
|
||||
### `shipote flow tail <socket-path>`
|
||||
Conecta directo al Unix socket y vuelca hasta EOF. Si el splitter tiene replay buffer, lo recibís primero.
|
||||
|
||||
### `shipote flow drop <pipeline-id>`
|
||||
Cierra el data plane de un pipeline (drop de FlowChannels).
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `SHIPOTE_LOG` — filtro `tracing-subscriber` (`info`, `warn`, `audit=info,warn`, ...).
|
||||
- `SHIPOTE_TRUST_ANYONE=1` — daemon acepta peers con cualquier uid (testing).
|
||||
- `XDG_RUNTIME_DIR` — dónde se crea el socket admin y los flow sockets.
|
||||
- `XDG_STATE_HOME` — dónde se guarda el snapshot. Fallback: `$HOME/.local/state/shipote/state.json`.
|
||||
|
||||
## Trucos del shell zsh
|
||||
|
||||
`shipote run` toma flags propios antes del `--`. Si el comando que ejecutás tiene un argumento que arranca con `-`, usá `--`:
|
||||
|
||||
```sh
|
||||
# Mal (clap intenta parsear `-c` como flag de shipote):
|
||||
shipote run -w <ws> /bin/sh -c "echo hi"
|
||||
|
||||
# Bien:
|
||||
shipote run -w <ws> /bin/sh -- -c "echo hi"
|
||||
```
|
||||
@@ -0,0 +1,244 @@
|
||||
# Development guide — shipote
|
||||
|
||||
Cómo compilar, correr, testear y debuggear shipote desde la raíz del monorepo (`/home/sergio/brahman`).
|
||||
|
||||
## Compilar
|
||||
|
||||
Todos los crates de shipote y su soporte:
|
||||
```sh
|
||||
cargo build -p shipote-daemon -p shipote-cli -p shipote-shell
|
||||
```
|
||||
|
||||
Sólo lo esencial (daemon + cli, sin GUI):
|
||||
```sh
|
||||
cargo build -p shipote-daemon -p shipote-cli
|
||||
```
|
||||
|
||||
Workspace completo:
|
||||
```sh
|
||||
cargo check --workspace
|
||||
```
|
||||
|
||||
## Correr el daemon
|
||||
|
||||
```sh
|
||||
SHIPOTE_LOG=info ./target/debug/shipote-daemon
|
||||
```
|
||||
|
||||
Filtros útiles:
|
||||
```sh
|
||||
SHIPOTE_LOG=warn,shipote_core=info ./target/debug/shipote-daemon # menos ruido
|
||||
SHIPOTE_LOG=warn,audit=info ./target/debug/shipote-daemon # sólo audit
|
||||
SHIPOTE_LOG=info,shipote_core::pipeline=debug ./target/debug/shipote-daemon # debug del pipeline
|
||||
```
|
||||
|
||||
El daemon:
|
||||
- Escucha `$XDG_RUNTIME_DIR/shipote.sock` (fallback `/run/user/$UID/shipote.sock`).
|
||||
- Restaura snapshot de `$XDG_STATE_HOME/shipote/state.json` (fallback `$HOME/.local/state/shipote/state.json`).
|
||||
- Relanza pipelines `live` (con `restart_on_failure=true`) automáticamente.
|
||||
- Reaper cada 500 ms (cosecha zombies + drena pending restarts + chequea quota breaches).
|
||||
- SIGTERM/SIGINT → snapshot + `stop_with_grace(1s)` de todos los workspaces + exit.
|
||||
|
||||
## Correr el shell (GUI)
|
||||
|
||||
```sh
|
||||
cargo run -p shipote-shell
|
||||
```
|
||||
|
||||
Polling cada 2 s al daemon. Si el daemon no está corriendo, muestra "Daemon DOWN" banner rojo. Sparkline de RSS por workspace. Cards: Estado, Capabilities, Workspaces, Comandos, Saved pipelines, Flow channels, Quota breaches, Live tail.
|
||||
|
||||
Requiere DISPLAY (X11/Wayland) — no corre en sandbox sin gráfico.
|
||||
|
||||
## Correr el CLI
|
||||
|
||||
```sh
|
||||
./target/debug/shipote ping
|
||||
./target/debug/shipote health
|
||||
./target/debug/shipote workspace create demo.toml
|
||||
```
|
||||
|
||||
Ver [CLI.md](CLI.md) para referencia completa.
|
||||
|
||||
## Tests
|
||||
|
||||
Suite completa (85 tests al cierre de Fase R):
|
||||
```sh
|
||||
cargo test -p ente-incarnate \
|
||||
-p shipote-card \
|
||||
-p shipote-protocol \
|
||||
-p shipote-discern \
|
||||
-p shipote-core \
|
||||
-p yahweh-provider-fs \
|
||||
-p nouser-core \
|
||||
--no-fail-fast
|
||||
```
|
||||
|
||||
Breakdown:
|
||||
| Crate | Tests |
|
||||
|---|---|
|
||||
| `ente-incarnate` | 16 (clone+ns, stdio, pre_exec, caps detect, cgroup paths) |
|
||||
| `nouser-core` | 27 (pre-existentes — discerner integrado sin regresiones) |
|
||||
| `shipote-card` | 8 (roundtrip TOML/JSON, compile-to-card, intersect_soma) |
|
||||
| `shipote-core` | 26 (workspaces, pipeline fan-in/out, flow_channel replay, stats history, restart, quota enforce, snapshot dirty-skip, ...) |
|
||||
| `shipote-discern` | 5 (MagicBytes, JsonProbe, CardProbe, TomlProbe, Utf8Probe) |
|
||||
| `yahweh-provider-fs` | 3 (discern_head + integración) |
|
||||
|
||||
Tests específicos útiles:
|
||||
```sh
|
||||
# Fan-in (merger):
|
||||
cargo test -p shipote-core --lib pipeline_fanin -- --nocapture
|
||||
|
||||
# Replay buffer:
|
||||
cargo test -p shipote-core --lib replay -- --nocapture
|
||||
|
||||
# Quota enforcement:
|
||||
cargo test -p shipote-core --lib quota_enforce -- --nocapture
|
||||
|
||||
# Snapshot incremental:
|
||||
cargo test -p shipote-core --lib save_snapshot_skips
|
||||
```
|
||||
|
||||
> Algunos tests del pipeline (fan-in/fan-out) son tokio-async y necesitan `--test-threads=1` cuando todos los tests corren juntos. La suite por crate ya funciona bien.
|
||||
|
||||
## Smoke E2E manual
|
||||
|
||||
Demo end-to-end de los features principales:
|
||||
|
||||
```sh
|
||||
# 1. Limpiar estado previo
|
||||
rm -f $HOME/.local/state/shipote/state.json
|
||||
pkill -f shipote-daemon
|
||||
|
||||
# 2. Arrancar daemon (en background o terminal aparte)
|
||||
SHIPOTE_LOG=info ./target/debug/shipote-daemon &
|
||||
sleep 0.3
|
||||
|
||||
# 3. Crear workspace
|
||||
cat > /tmp/ws.toml <<'EOF'
|
||||
label = "demo"
|
||||
on_exit = "reap"
|
||||
EOF
|
||||
WS=$(./target/debug/shipote workspace create /tmp/ws.toml)
|
||||
echo "workspace: $WS"
|
||||
|
||||
# 4. Health check
|
||||
./target/debug/shipote health
|
||||
|
||||
# 5. Run comando con log capture
|
||||
./target/debug/shipote run -w $WS /bin/echo -- "hello shipote"
|
||||
sleep 0.3
|
||||
CMD=$(./target/debug/shipote commands $WS | head -1 | awk '{print $1}')
|
||||
./target/debug/shipote logs $WS $CMD
|
||||
|
||||
# 6. Pipeline con tap (data plane real)
|
||||
cat > /tmp/pipe.toml <<EOF
|
||||
label = "demo-pipe"
|
||||
workspace = "$WS"
|
||||
discern = { sample_bytes = 4096, enrich_producer = true }
|
||||
|
||||
[[nodes]]
|
||||
label = "p1"
|
||||
payload.Native = { exec = "/bin/echo", argv = ['{"hello": 1}'], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "p2"
|
||||
payload.Native = { exec = "/bin/cat", argv = [], envp = [] }
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 1
|
||||
to_input = "stdin"
|
||||
EOF
|
||||
./target/debug/shipote pipeline run /tmp/pipe.toml --tap
|
||||
|
||||
# 7. Flow throughput
|
||||
./target/debug/shipote flow throughput
|
||||
|
||||
# 8. Stats con CPU%
|
||||
./target/debug/shipote workspace stats $WS
|
||||
|
||||
# 9. SIGTERM (drain + snapshot)
|
||||
pkill -TERM -f shipote-daemon
|
||||
```
|
||||
|
||||
## Debug del daemon
|
||||
|
||||
### Logs del daemon en archivo
|
||||
```sh
|
||||
SHIPOTE_LOG=info ./target/debug/shipote-daemon 2>&1 | tee /tmp/shipote-daemon.log
|
||||
```
|
||||
|
||||
### Filtrar audit log
|
||||
```sh
|
||||
SHIPOTE_LOG=warn,audit=info ./target/debug/shipote-daemon 2>&1 | grep audit
|
||||
```
|
||||
|
||||
### Verificar caps runtime
|
||||
```sh
|
||||
shipote caps
|
||||
```
|
||||
Te dice si `user_ns` está bloqueado por sysctl/LSM y si tu cgroup está delegado.
|
||||
|
||||
### Inspeccionar el snapshot
|
||||
```sh
|
||||
cat $HOME/.local/state/shipote/state.json | jq .
|
||||
```
|
||||
|
||||
JSON con: version, timestamp_ms, workspaces, saved_pipelines, live_pipelines, stats_history persistida.
|
||||
|
||||
### Debugging FDs
|
||||
```sh
|
||||
ls -la /proc/$(pgrep -x shipote-daemon)/fd | head -20
|
||||
```
|
||||
Si ves muchos `pipe:[N]` o `socket:[N]` huérfanos, hay leak en algún spawn.
|
||||
|
||||
## Arquitectura del repositorio
|
||||
|
||||
```
|
||||
crates/
|
||||
├── apps/
|
||||
│ ├── shipote-daemon/ ← binario long-running
|
||||
│ ├── shipote-cli/ ← binario `shipote`
|
||||
│ └── shipote-shell/ ← GUI GPUI
|
||||
├── modules/shipote/
|
||||
│ ├── shipote-card/ ← tipos WorkspaceSpec/PipelineSpec/...
|
||||
│ ├── shipote-protocol/ ← wire postcard
|
||||
│ ├── shipote-discern/ ← MagicBytes/Json/Toml/Card/Utf8
|
||||
│ ├── shipote-core/ ← WorkspaceManager + pipeline + flow_channel + ...
|
||||
│ ├── README.md ← entry point (este archivo es vecino)
|
||||
│ └── docs/
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── CLI.md
|
||||
│ ├── RECIPES.md
|
||||
│ └── DEVELOPMENT.md ← estás acá
|
||||
└── shared/
|
||||
└── ente-incarnate/ ← extraído de ente-soma; reusable por shipote y otros
|
||||
```
|
||||
|
||||
## Memoria del proyecto
|
||||
|
||||
Toda la historia de fases (F a R) está documentada en:
|
||||
```
|
||||
/home/sergio/.claude/projects/-home-sergio-brahman/memory/project_shipote.md
|
||||
```
|
||||
|
||||
Esa memoria persiste entre conversaciones con Claude Code. Si arrancás una sesión nueva, Claude la consulta automáticamente.
|
||||
|
||||
## Issues conocidos
|
||||
|
||||
### El remote `origin` está mal configurado
|
||||
`https:/sergio:...` con un solo `/`. Para subir los 10 commits locales:
|
||||
```sh
|
||||
git remote set-url origin https://sergio:<password>@gitea.gioser.net/sergio/brahman
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### El daemon no se conecta al broker
|
||||
Si el Init (`brahman-init.sock`) no está corriendo, el sidecar loguea `no conectado` y el daemon sigue standalone. Esto es **diseñado** (graceful degradation), no un bug. Los announcements de edge-card al broker tampoco llegan en este modo.
|
||||
|
||||
### Pipeline restart pierde ULIDs
|
||||
Cada relaunch genera un pipeline_id nuevo. Trackers externos que dependen del ULID se rompen. Workaround: usar el `label` del pipeline (estable entre restarts).
|
||||
|
||||
### `tokio::test` + `waitpid` síncrono
|
||||
Algunos tests del pipeline necesitan `--test-threads=1` porque el waitpid síncrono dentro de un `current_thread` runtime bloquea el reactor antes de que las tareas spawneadas corran.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Recetas de specs
|
||||
|
||||
Specs TOML para casos comunes. Todas asumen que `<WS>` ya existe (creado con `shipote workspace create <ws-spec>`).
|
||||
|
||||
## Workspaces
|
||||
|
||||
### Workspace mínimo
|
||||
```toml
|
||||
label = "demo"
|
||||
on_exit = "reap"
|
||||
```
|
||||
|
||||
### Workspace con TTL (auto-stop tras N ms)
|
||||
```toml
|
||||
label = "ephemeral"
|
||||
on_exit = "reap"
|
||||
ttl = 30000 # 30 s
|
||||
```
|
||||
|
||||
### Workspace con rlimits (sólo accounting)
|
||||
```toml
|
||||
label = "bounded"
|
||||
on_exit = "reap"
|
||||
|
||||
[soma.rlimits]
|
||||
mem_bytes = 10485760 # 10 MiB — visible en `shipote workspace quota`
|
||||
nproc = 4
|
||||
```
|
||||
|
||||
### Workspace con enforcement automático
|
||||
```toml
|
||||
label = "strict"
|
||||
on_exit = "reap"
|
||||
|
||||
[soma.rlimits]
|
||||
mem_bytes = 5242880
|
||||
nproc = 2
|
||||
|
||||
[quota_enforce]
|
||||
mem = "kill" # None | Log | Kill
|
||||
nproc = "kill"
|
||||
```
|
||||
|
||||
### Workspace con cgroup delegado (kernel enforces)
|
||||
```toml
|
||||
label = "cgroup-enforced"
|
||||
on_exit = "reap"
|
||||
|
||||
[soma.rlimits]
|
||||
mem_bytes = 10485760
|
||||
nproc = 4
|
||||
|
||||
[soma.cgroup]
|
||||
path = "shipote/bounded" # bajo $cgroup_actual/shipote/bounded
|
||||
```
|
||||
> Requiere `cgroup_delegated: true` en `shipote caps`. Sino el accounting funciona pero el kernel no enforces.
|
||||
|
||||
### Workspace con namespacing real
|
||||
```toml
|
||||
label = "isolated"
|
||||
on_exit = "reap"
|
||||
|
||||
[soma.namespaces]
|
||||
user = true
|
||||
pid = true
|
||||
mount = true
|
||||
net = false
|
||||
uts = false
|
||||
ipc = false
|
||||
cgroup = false
|
||||
|
||||
[soma.rlimits]
|
||||
mem_bytes = 0
|
||||
nproc = 0
|
||||
nofile = 0
|
||||
|
||||
[soma.cgroup]
|
||||
path = ""
|
||||
```
|
||||
> Requiere `user_ns: Allowed` (o `cap_sys_admin: true`).
|
||||
|
||||
## Pipelines
|
||||
|
||||
### Pipeline lineal con tap (data plane)
|
||||
```toml
|
||||
label = "echo-cat"
|
||||
workspace = "<WS>"
|
||||
discern = { sample_bytes = 4096, enrich_producer = true }
|
||||
|
||||
[[nodes]]
|
||||
label = "producer"
|
||||
payload.Native = { exec = "/bin/echo", argv = ['{"hello": 1}'], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "consumer"
|
||||
payload.Native = { exec = "/bin/cat", argv = [], envp = [] }
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 1
|
||||
to_input = "stdin"
|
||||
```
|
||||
|
||||
Run:
|
||||
```sh
|
||||
shipote pipeline run echo-cat.toml --tap
|
||||
# imprime: edge ty=json mime=application/json conf=0.95
|
||||
```
|
||||
|
||||
### Pipeline con fan-out (1 → N)
|
||||
```toml
|
||||
label = "broadcast"
|
||||
workspace = "<WS>"
|
||||
discern = { sample_bytes = 4096, enrich_producer = true }
|
||||
|
||||
[[nodes]]
|
||||
label = "src"
|
||||
payload.Native = { exec = "/bin/echo", argv = ["mensaje compartido"], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "wc-c"
|
||||
payload.Native = { exec = "/usr/bin/wc", argv = ["-c"], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "wc-l"
|
||||
payload.Native = { exec = "/usr/bin/wc", argv = ["-l"], envp = [] }
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 1
|
||||
to_input = "stdin"
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 2
|
||||
to_input = "stdin"
|
||||
```
|
||||
|
||||
### Pipeline con fan-in (N → 1)
|
||||
```toml
|
||||
label = "merge"
|
||||
workspace = "<WS>"
|
||||
|
||||
[[nodes]]
|
||||
label = "p1"
|
||||
payload.Native = { exec = "/bin/echo", argv = ["from-p1"], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "p2"
|
||||
payload.Native = { exec = "/bin/echo", argv = ["from-p2"], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "merge-sink"
|
||||
payload.Native = { exec = "/bin/cat", argv = [], envp = [] }
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 2
|
||||
to_input = "stdin"
|
||||
|
||||
[[edges]]
|
||||
from = 1
|
||||
from_output = "stdout"
|
||||
to = 2
|
||||
to_input = "stdin"
|
||||
```
|
||||
|
||||
### Pipeline con replay y rate-limit
|
||||
```toml
|
||||
label = "throttled"
|
||||
workspace = "<WS>"
|
||||
|
||||
[discern]
|
||||
sample_bytes = 4096
|
||||
enrich_producer = true
|
||||
replay_chunks = 32 # default
|
||||
replay_bytes = 65536 # cap adicional por bytes (0 = sólo chunks)
|
||||
max_bytes_per_sec = 1024 # token-bucket con burst de 1s
|
||||
|
||||
[[nodes]]
|
||||
label = "fast"
|
||||
payload.Native = { exec = "/bin/sh", argv = ["-c", "for i in 1 2 3 4 5; do echo line-$i; done"], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "sink"
|
||||
payload.Native = { exec = "/bin/cat", argv = [], envp = [] }
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 1
|
||||
to_input = "stdin"
|
||||
```
|
||||
|
||||
### Pipeline supervisado (restart on failure con backoff)
|
||||
```toml
|
||||
label = "supervised"
|
||||
workspace = "<WS>"
|
||||
restart_on_failure = true
|
||||
restart_backoff_ms = 200 # inicial; escala x2
|
||||
restart_max_backoff_ms = 30000 # cap
|
||||
restart_max = 5 # 0 = infinito
|
||||
|
||||
[[nodes]]
|
||||
label = "flaky"
|
||||
payload.Native = { exec = "/bin/false", argv = [], envp = [] }
|
||||
```
|
||||
|
||||
Después de 5 restarts (`/bin/false` siempre exit=1), el daemon loguea `restart_max reached — giving up` y el supervisor se descarta.
|
||||
|
||||
### Pipeline con templating
|
||||
Spec con placeholders:
|
||||
```toml
|
||||
label = "tmpl-${VARIANT}"
|
||||
workspace = "<WS>"
|
||||
discern = { sample_bytes = 4096, enrich_producer = true }
|
||||
|
||||
[[nodes]]
|
||||
label = "gen-${VARIANT}"
|
||||
payload.Native = { exec = "/bin/echo", argv = ["greeting from ${VARIANT}"], envp = [] }
|
||||
|
||||
[[nodes]]
|
||||
label = "sink"
|
||||
payload.Native = { exec = "/bin/cat", argv = [], envp = [] }
|
||||
|
||||
[[edges]]
|
||||
from = 0
|
||||
from_output = "stdout"
|
||||
to = 1
|
||||
to_input = "stdin"
|
||||
```
|
||||
|
||||
Run con vars:
|
||||
```sh
|
||||
shipote pipeline run tmpl.toml --var VARIANT=alpha
|
||||
shipote pipeline run tmpl.toml --var VARIANT=beta
|
||||
```
|
||||
|
||||
Variables sin match quedan intactas (útil para detectar olvidos).
|
||||
|
||||
## Subscribers externos
|
||||
|
||||
### Tail directo a un flow socket
|
||||
```sh
|
||||
shipote pipeline run mypipe.toml --tap &
|
||||
sleep 0.3
|
||||
SOCK=$(shipote flow list | grep shipote-flow | xargs)
|
||||
shipote flow tail "$SOCK"
|
||||
```
|
||||
|
||||
Si conectás tarde, el replay buffer te entrega los últimos N chunks (según `replay_chunks` y `replay_bytes` del spec).
|
||||
|
||||
### Modo live-tail integrado
|
||||
```sh
|
||||
shipote pipeline run mypipe.toml --tail
|
||||
# vuelca el primer flow_socket a stdout hasta que el productor termine.
|
||||
```
|
||||
|
||||
## Combinatorias útiles
|
||||
|
||||
### Workspace con cleanup automático
|
||||
```toml
|
||||
label = "burst-and-die"
|
||||
on_exit = "reap"
|
||||
ttl = 10000 # auto-stop a los 10s
|
||||
|
||||
[soma.rlimits]
|
||||
mem_bytes = 5242880
|
||||
```
|
||||
|
||||
### Pipeline JSON-aware con discern enriched
|
||||
- Producer escribe JSON.
|
||||
- Discern detecta `application/json` con confidence 0.95.
|
||||
- Card efímera anunciada al broker (si está corriendo): `shipote.flow.<id>.<from>.<output>.json`.
|
||||
- Subscribers downstream pueden filtrar por TypeRef en el broker.
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "shuma-card"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Tipos de shipote: WorkspaceSpec, PipelineSpec, CommandRef, FlowEdge. Compilan a Cards de brahman-card."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
@@ -0,0 +1,655 @@
|
||||
//! `shuma-card` — tipos del runtime shuma.
|
||||
//!
|
||||
//! 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 shuma. 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,
|
||||
|
||||
/// 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)]
|
||||
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,
|
||||
/// 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,
|
||||
/// Backoff inicial entre restarts (ms). Crece exponencialmente
|
||||
/// hasta `restart_max_backoff_ms`. Default 200ms = ~5 restarts/s
|
||||
/// inicial, escalando rápido.
|
||||
#[serde(default = "default_restart_backoff")]
|
||||
pub restart_backoff_ms: u64,
|
||||
/// Backoff máximo (ms). Default 30s. El backoff no crece más allá.
|
||||
#[serde(default = "default_restart_max_backoff")]
|
||||
pub restart_max_backoff_ms: u64,
|
||||
/// Máximo de restarts antes de dar up. `0` = infinito. Default 0.
|
||||
/// Útil para fail-loud cuando un pipeline siempre falla.
|
||||
#[serde(default)]
|
||||
pub restart_max: u32,
|
||||
}
|
||||
|
||||
fn default_restart_backoff() -> u64 {
|
||||
200
|
||||
}
|
||||
fn default_restart_max_backoff() -> u64 {
|
||||
30_000
|
||||
}
|
||||
|
||||
#[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, 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,
|
||||
/// Chunks que el FlowChannel guarda en replay buffer para subscribers
|
||||
/// tarde. Default 32. Subir si los productores escriben en ráfagas y
|
||||
/// querés que los consumidores tardíos vean toda la salida.
|
||||
#[serde(default = "default_replay_chunks")]
|
||||
pub replay_chunks: usize,
|
||||
/// Tope adicional por **bytes** acumulados en el replay buffer. Lo
|
||||
/// que se exceda primero (chunks o bytes) drop-ea el chunk más viejo.
|
||||
/// `0` = sin tope por bytes (sólo aplica `replay_chunks`). Útil para
|
||||
/// productores con chunks de tamaño variable.
|
||||
#[serde(default)]
|
||||
pub replay_bytes: usize,
|
||||
/// Rate-limit del flow channel (bytes/s). `0` = sin límite. Si está
|
||||
/// definido, el splitter sleeps proporcional al tamaño del chunk
|
||||
/// antes de re-broadcastear. Protege subscribers lentos.
|
||||
#[serde(default)]
|
||||
pub max_bytes_per_sec: u64,
|
||||
}
|
||||
|
||||
impl Default for DiscernPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sample_bytes: default_sample_bytes(),
|
||||
enrich_producer: default_true(),
|
||||
replay_chunks: default_replay_chunks(),
|
||||
replay_bytes: 0,
|
||||
max_bytes_per_sec: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sample_bytes() -> usize {
|
||||
4096
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_replay_chunks() -> usize {
|
||||
32
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 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!("shuma.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!("shuma.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!("shuma.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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sustituye `${KEY}` en todos los strings del spec por el valor de
|
||||
/// `vars["KEY"]`. Variables sin match quedan intactas (no se borra el
|
||||
/// placeholder — útil para detectar olvidos).
|
||||
///
|
||||
/// Walk recursivo sobre la representación JSON intermedia para cubrir
|
||||
/// labels, argv, envp, paths y cualquier String del schema.
|
||||
pub fn substitute_vars(
|
||||
spec: &PipelineSpec,
|
||||
vars: &std::collections::HashMap<String, String>,
|
||||
) -> Result<PipelineSpec, serde_json::Error> {
|
||||
if vars.is_empty() {
|
||||
return Ok(spec.clone());
|
||||
}
|
||||
let mut v = serde_json::to_value(spec)?;
|
||||
walk_subst(&mut v, vars);
|
||||
serde_json::from_value(v)
|
||||
}
|
||||
|
||||
fn walk_subst(v: &mut serde_json::Value, vars: &std::collections::HashMap<String, String>) {
|
||||
match v {
|
||||
serde_json::Value::String(s) => {
|
||||
*s = subst_str(s, vars);
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for item in arr {
|
||||
walk_subst(item, vars);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
for (_, val) in obj.iter_mut() {
|
||||
walk_subst(val, vars);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn subst_str(s: &str, vars: &std::collections::HashMap<String, String>) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
|
||||
// Buscar el cierre `}`.
|
||||
if let Some(close) = bytes[i + 2..].iter().position(|&b| b == b'}') {
|
||||
let key = std::str::from_utf8(&bytes[i + 2..i + 2 + close]).unwrap_or("");
|
||||
if let Some(val) = vars.get(key) {
|
||||
out.push_str(val);
|
||||
i += 2 + close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod subst_tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn substitute_in_argv_and_label() {
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("MSG".into(), "hola-mundo".into());
|
||||
vars.insert("LABEL".into(), "renamed".into());
|
||||
let spec = PipelineSpec {
|
||||
label: "p-${LABEL}".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![CommandRef {
|
||||
label: "node-${LABEL}".into(),
|
||||
payload: Payload::Native {
|
||||
exec: "/bin/echo".into(),
|
||||
argv: vec!["${MSG}".into()],
|
||||
envp: vec![],
|
||||
},
|
||||
soma: Default::default(),
|
||||
flows: Default::default(),
|
||||
supervision: Supervision::OneShot,
|
||||
}],
|
||||
edges: vec![],
|
||||
discern: DiscernPolicy::default(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
let out = substitute_vars(&spec, &vars).unwrap();
|
||||
assert_eq!(out.label, "p-renamed");
|
||||
assert_eq!(out.nodes[0].label, "node-renamed");
|
||||
match &out.nodes[0].payload {
|
||||
Payload::Native { argv, .. } => assert_eq!(argv[0], "hola-mundo"),
|
||||
_ => panic!("wrong payload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_var_left_intact() {
|
||||
let vars = HashMap::new();
|
||||
let spec = PipelineSpec {
|
||||
label: "p-${UNDEFINED}".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![],
|
||||
edges: vec![],
|
||||
discern: DiscernPolicy::default(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
let out = substitute_vars(&spec, &vars).unwrap();
|
||||
assert_eq!(out.label, "p-${UNDEFINED}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
quota_enforce: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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("shuma.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(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "shuma-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Runtime de shipote: WorkspaceManager sobre ente-incarnate. Estado in-memory, lifecycle, reaping."
|
||||
|
||||
[dependencies]
|
||||
shuma-card = { path = "../shuma-card" }
|
||||
shuma-discern = { path = "../shuma-discern" }
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
ente-incarnate = { path = "../../../init/ente-incarnate" }
|
||||
nix = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Flow channels: data plane sobre Unix socket por edge enriquecido.
|
||||
//!
|
||||
//! Cuando un splitter detecta el TypeRef de un edge, además de replicar a
|
||||
//! los consumers internos del pipeline, se levanta un FlowChannel que
|
||||
//! expone los bytes a subscribers externos (otros módulos del fractal).
|
||||
//!
|
||||
//! ## Diseño
|
||||
//!
|
||||
//! - `tokio::sync::broadcast::channel` para fan-out lock-less entre el
|
||||
//! splitter (sender) y los N subscribers conectados.
|
||||
//! - `UnixListener` accept-loop: por cada cliente nuevo, spawn una task
|
||||
//! que drena el receiver y escribe al socket.
|
||||
//! - Subscribers lentos pueden perder mensajes (broadcast::Receiver::Lagged)
|
||||
//! — se loguea warn y se sigue. Esto es deliberado para no bloquear el
|
||||
//! splitter en consumers lentos.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//!
|
||||
//! `FlowChannel` se construye con `new(path)`. Cuando se drop:
|
||||
//! - El `accept_task` se cancela (vía drop del `tokio::task::JoinHandle`
|
||||
//! que tenemos abort-on-drop).
|
||||
//! - El socket file se borra del FS (`Drop` impl).
|
||||
//!
|
||||
//! Sender clones son baratos; los subscribers conectados se enteran del
|
||||
//! cierre cuando todos los senders se dropean.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::UnixListener;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::AbortHandle;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Capacidad del broadcast channel. Si un subscriber está más de N chunks
|
||||
/// atrasado, queda `Lagged` y empieza a perder mensajes.
|
||||
const BROADCAST_CAP: usize = 64;
|
||||
|
||||
/// Chunks default del replay buffer. Cuando un cliente nuevo se conecta,
|
||||
/// recibe hasta estos N chunks antes de iniciar el broadcast live.
|
||||
/// Override via `FlowChannel::with_replay_cap`.
|
||||
pub const DEFAULT_REPLAY_CHUNKS: usize = 32;
|
||||
|
||||
pub struct FlowChannel {
|
||||
sender: broadcast::Sender<Arc<Vec<u8>>>,
|
||||
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
|
||||
replay_caps: ReplayCaps,
|
||||
socket_path: PathBuf,
|
||||
meter: Arc<FlowMeter>,
|
||||
_accept_handle: AbortOnDrop,
|
||||
}
|
||||
|
||||
/// Contador de bytes y rate (bytes/s ventana 1s).
|
||||
#[derive(Debug)]
|
||||
pub struct FlowMeter {
|
||||
/// Bytes acumulados desde la creación del FlowChannel.
|
||||
total_bytes: std::sync::atomic::AtomicU64,
|
||||
/// Ring buffer de (timestamp_ms, bytes_acumulados) para calcular
|
||||
/// el rate sobre los últimos N samples.
|
||||
rate_window: Mutex<VecDeque<(u64, u64)>>,
|
||||
}
|
||||
|
||||
const RATE_WINDOW_SAMPLES: usize = 32;
|
||||
|
||||
impl FlowMeter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_bytes: std::sync::atomic::AtomicU64::new(0),
|
||||
rate_window: Mutex::new(VecDeque::with_capacity(RATE_WINDOW_SAMPLES)),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&self, delta: u64) {
|
||||
let now = self.total_bytes
|
||||
.fetch_add(delta, std::sync::atomic::Ordering::Relaxed)
|
||||
+ delta;
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
if let Ok(mut w) = self.rate_window.lock() {
|
||||
if w.len() >= RATE_WINDOW_SAMPLES {
|
||||
w.pop_front();
|
||||
}
|
||||
w.push_back((ts, now));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes totales acumulados desde la creación.
|
||||
pub fn total_bytes(&self) -> u64 {
|
||||
self.total_bytes.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bytes por segundo (rolling sobre la ventana). 0 si no hay
|
||||
/// historia suficiente o si el último sample es muy viejo (>5s).
|
||||
pub fn bytes_per_sec(&self) -> f64 {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let w = match self.rate_window.lock() {
|
||||
Ok(w) => w,
|
||||
Err(_) => return 0.0,
|
||||
};
|
||||
if w.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let last = w.back().copied().unwrap();
|
||||
// Si el último sample tiene >5s, asumimos idle.
|
||||
if now_ms.saturating_sub(last.0) > 5000 {
|
||||
return 0.0;
|
||||
}
|
||||
let first = w.front().copied().unwrap();
|
||||
let dt_ms = last.0.saturating_sub(first.0).max(1);
|
||||
let d_bytes = last.1.saturating_sub(first.1);
|
||||
(d_bytes as f64 * 1000.0) / dt_ms as f64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ReplayCaps {
|
||||
/// Máximo de chunks retenidos.
|
||||
pub chunks: usize,
|
||||
/// Máximo de bytes (sumando len de chunks). `0` = sin tope.
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
impl ReplayCaps {
|
||||
pub fn chunks_only(chunks: usize) -> Self {
|
||||
Self { chunks: chunks.max(1), bytes: 0 }
|
||||
}
|
||||
pub fn new(chunks: usize, bytes: usize) -> Self {
|
||||
Self { chunks: chunks.max(1), bytes }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FlowSender {
|
||||
sender: broadcast::Sender<Arc<Vec<u8>>>,
|
||||
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
|
||||
replay_caps: ReplayCaps,
|
||||
meter: Arc<FlowMeter>,
|
||||
}
|
||||
|
||||
impl FlowSender {
|
||||
/// Pushea al broadcast y al replay buffer. Si no hay subscribers,
|
||||
/// el broadcast::send retorna Err pero igual guardamos en replay
|
||||
/// (subscribers tarde verán los chunks pasados).
|
||||
pub fn send(&self, data: Arc<Vec<u8>>) {
|
||||
let incoming = data.len();
|
||||
let caps = self.replay_caps;
|
||||
if let Ok(mut g) = self.replay.lock() {
|
||||
evict_for_incoming(&mut g, caps, incoming);
|
||||
g.push_back(data.clone());
|
||||
}
|
||||
self.meter.record(incoming as u64);
|
||||
let _ = self.sender.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict los chunks más viejos para hacer espacio a un chunk entrante de
|
||||
/// `incoming` bytes — el buffer post-push queda dentro de los caps.
|
||||
fn evict_for_incoming(buf: &mut VecDeque<Arc<Vec<u8>>>, caps: ReplayCaps, incoming: usize) {
|
||||
// 1) chunks: dejar lugar para 1 más.
|
||||
while buf.len() + 1 > caps.chunks {
|
||||
if buf.pop_front().is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 2) bytes (si está activado).
|
||||
if caps.bytes > 0 {
|
||||
let mut current: usize = buf.iter().map(|a| a.len()).sum();
|
||||
while current + incoming > caps.bytes {
|
||||
match buf.pop_front() {
|
||||
Some(c) => current = current.saturating_sub(c.len()),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FlowChannel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FlowChannel")
|
||||
.field("socket_path", &self.socket_path)
|
||||
.field("subscribers", &self.sender.receiver_count())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FlowChannel {
|
||||
/// Crea un FlowChannel atado al path `socket_path`. Si el path ya
|
||||
/// existe, lo borra antes de bind (asume restart limpio).
|
||||
pub fn new(socket_path: PathBuf) -> std::io::Result<Self> {
|
||||
Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(DEFAULT_REPLAY_CHUNKS))
|
||||
}
|
||||
|
||||
pub fn with_replay_cap(socket_path: PathBuf, chunks: usize) -> std::io::Result<Self> {
|
||||
Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(chunks))
|
||||
}
|
||||
|
||||
pub fn with_replay_caps(socket_path: PathBuf, caps: ReplayCaps) -> std::io::Result<Self> {
|
||||
if socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
}
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let listener = UnixListener::bind(&socket_path)?;
|
||||
let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP);
|
||||
let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> =
|
||||
Arc::new(Mutex::new(VecDeque::with_capacity(caps.chunks)));
|
||||
let tx_for_accept = tx.clone();
|
||||
let replay_for_accept = replay.clone();
|
||||
let path_for_log = socket_path.clone();
|
||||
|
||||
let join = tokio::spawn(async move {
|
||||
debug!(path = %path_for_log.display(), "flow channel listening");
|
||||
loop {
|
||||
let (mut stream, _addr) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(?e, "flow channel accept failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Snapshot del replay buffer Y subscribe al broadcast.
|
||||
// El orden es crítico: subscribe ANTES de drenar el replay
|
||||
// para no perder chunks que llegan justo en el medio.
|
||||
let mut rx = tx_for_accept.subscribe();
|
||||
let snapshot: Vec<Arc<Vec<u8>>> = {
|
||||
let g = replay_for_accept.lock().expect("replay lock");
|
||||
g.iter().cloned().collect()
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
// Fase 1: drenar replay snapshot al subscriber.
|
||||
for chunk in &snapshot {
|
||||
if let Err(e) = stream.write_all(chunk).await {
|
||||
debug!(?e, "flow subscriber dropped during replay");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fase 2: live broadcast.
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(chunk) => {
|
||||
if let Err(e) = stream.write_all(&chunk).await {
|
||||
debug!(?e, "flow subscriber dropped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(skipped = n, "flow subscriber lagged");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
sender: tx,
|
||||
replay,
|
||||
replay_caps: caps,
|
||||
socket_path,
|
||||
meter: Arc::new(FlowMeter::new()),
|
||||
_accept_handle: AbortOnDrop(join.abort_handle()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn meter(&self) -> &FlowMeter {
|
||||
&self.meter
|
||||
}
|
||||
|
||||
/// Push un chunk al channel. Si no hay subscribers, drop silencioso.
|
||||
/// Siempre se guarda en el replay buffer (con cap rotation por chunks
|
||||
/// y opcionalmente por bytes).
|
||||
pub fn send(&self, data: Vec<u8>) {
|
||||
let incoming = data.len();
|
||||
let arc = Arc::new(data);
|
||||
let caps = self.replay_caps;
|
||||
if let Ok(mut g) = self.replay.lock() {
|
||||
evict_for_incoming(&mut g, caps, incoming);
|
||||
g.push_back(arc.clone());
|
||||
}
|
||||
self.meter.record(incoming as u64);
|
||||
let _ = self.sender.send(arc);
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
/// Handle clone-able para que tasks externas (splitter) pushen al
|
||||
/// channel sin tener ownership del FlowChannel. Cada push se guarda
|
||||
/// también en el replay buffer y se contabiliza en el meter.
|
||||
pub fn sender_handle(&self) -> FlowSender {
|
||||
FlowSender {
|
||||
sender: self.sender.clone(),
|
||||
replay: self.replay.clone(),
|
||||
replay_caps: self.replay_caps,
|
||||
meter: self.meter.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.sender.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FlowChannel {
|
||||
fn drop(&mut self) {
|
||||
// El AbortOnDrop cancela el accept loop; sólo nos queda limpiar el
|
||||
// socket file.
|
||||
let _ = std::fs::remove_file(&self.socket_path);
|
||||
}
|
||||
}
|
||||
|
||||
struct AbortOnDrop(AbortHandle);
|
||||
impl Drop for AbortOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Path canónico para un flow channel: `$XDG_RUNTIME_DIR/shuma-flow-<id>.sock`.
|
||||
pub fn default_flow_socket_path(id: &str) -> PathBuf {
|
||||
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||
let uid = nix::unistd::getuid().as_raw();
|
||||
let p = format!("/run/user/{uid}");
|
||||
if std::path::Path::new(&p).exists() {
|
||||
p
|
||||
} else {
|
||||
"/tmp".into()
|
||||
}
|
||||
});
|
||||
PathBuf::from(base).join(format!("shuma-flow-{id}.sock"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_delivers_to_subscriber() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("flow.sock");
|
||||
let ch = FlowChannel::new(path.clone()).unwrap();
|
||||
|
||||
// Subscriber se conecta.
|
||||
let path_clone = path.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
|
||||
let mut buf = vec![0u8; 64];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
buf.truncate(n);
|
||||
buf
|
||||
});
|
||||
|
||||
// Damos tiempo al accept.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
// Hasta que haya 1 receiver_count, el send no llega.
|
||||
for _ in 0..50 {
|
||||
if ch.subscriber_count() >= 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
ch.send(b"hello-flow".to_vec());
|
||||
|
||||
let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
|
||||
.await
|
||||
.expect("timeout")
|
||||
.unwrap();
|
||||
assert_eq!(received, b"hello-flow");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_buffer_serves_late_subscriber() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("flow.sock");
|
||||
let ch = FlowChannel::new(path.clone()).unwrap();
|
||||
|
||||
// Pushes ANTES de cualquier subscriber: van solo al replay.
|
||||
ch.send(b"chunk-1".to_vec());
|
||||
ch.send(b"chunk-2".to_vec());
|
||||
ch.send(b"chunk-3".to_vec());
|
||||
|
||||
// Subscriber LATE — debe recibir los 3 chunks del replay.
|
||||
let path_clone = path.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
|
||||
let mut buf = vec![0u8; 256];
|
||||
// Leemos hasta recibir los 3 chunks (21 bytes esperados).
|
||||
let mut total = Vec::new();
|
||||
for _ in 0..20 {
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
total.extend_from_slice(&buf[..n]);
|
||||
if total.len() >= 21 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
total
|
||||
});
|
||||
|
||||
let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
|
||||
.await
|
||||
.expect("timeout")
|
||||
.unwrap();
|
||||
let s = String::from_utf8_lossy(&received);
|
||||
assert!(s.contains("chunk-1"), "got: {s:?}");
|
||||
assert!(s.contains("chunk-2"), "got: {s:?}");
|
||||
assert!(s.contains("chunk-3"), "got: {s:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_evicts_by_bytes_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("flow.sock");
|
||||
// chunks=100 (no limita), bytes=20: deberíamos retener sólo los
|
||||
// últimos chunks cuyos bytes sumen ≤ 20.
|
||||
let ch = FlowChannel::with_replay_caps(path.clone(), ReplayCaps::new(100, 20)).unwrap();
|
||||
ch.send(b"AAAAAAAA".to_vec()); // 8 bytes
|
||||
ch.send(b"BBBBBBBB".to_vec()); // 8 → total 16
|
||||
ch.send(b"CCCCCCCC".to_vec()); // 8 → total 24 > 20, evict A → 16
|
||||
ch.send(b"DDDDDDDD".to_vec()); // 8 → total 24 > 20, evict B → 16
|
||||
|
||||
let path_clone = path.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
|
||||
let mut buf = vec![0u8; 64];
|
||||
let mut total = Vec::new();
|
||||
for _ in 0..20 {
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
total.extend_from_slice(&buf[..n]);
|
||||
if total.len() >= 16 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
total
|
||||
});
|
||||
let got = tokio::time::timeout(std::time::Duration::from_secs(2), task)
|
||||
.await
|
||||
.expect("timeout")
|
||||
.unwrap();
|
||||
let s = String::from_utf8_lossy(&got);
|
||||
// Sólo C y D (los más viejos A y B fueron evicted).
|
||||
assert!(!s.contains("AAAA"), "should have evicted A: {s:?}");
|
||||
assert!(!s.contains("BBBB"), "should have evicted B: {s:?}");
|
||||
assert!(s.contains("CCCC"), "should keep C: {s:?}");
|
||||
assert!(s.contains("DDDD"), "should keep D: {s:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_removes_socket() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("flow.sock");
|
||||
{
|
||||
let _ch = FlowChannel::new(path.clone()).unwrap();
|
||||
assert!(path.exists());
|
||||
}
|
||||
// Después del drop, el socket file no debe quedar.
|
||||
// Damos un pelín de tiempo al runtime para que el drop corra
|
||||
// mientras estamos en task.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
//! Ring buffer en memoria para capturar stdout/stderr de comandos.
|
||||
//!
|
||||
//! Tamaño fijo por comando (config: `MAX_LOG_BYTES`). Cuando se llena,
|
||||
//! descarta los bytes más viejos. Pensado para diagnostico rápido, no
|
||||
//! para retención histórica — eso es trabajo de un journald-like aparte.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Bytes máximos retenidos por comando. 64 KiB cubre logs típicos sin
|
||||
/// abusar de memoria si el daemon tiene cientos de comandos vivos.
|
||||
pub const MAX_LOG_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogBuf {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
/// Bytes raw. Cuando se acerca al cap, descartamos head para mantener
|
||||
/// el tail.
|
||||
buf: Vec<u8>,
|
||||
cap: usize,
|
||||
/// Total escrito alguna vez (no decrementado al recortar).
|
||||
written_total: u64,
|
||||
}
|
||||
|
||||
impl LogBuf {
|
||||
pub fn new() -> Self {
|
||||
Self::with_cap(MAX_LOG_BYTES)
|
||||
}
|
||||
|
||||
pub fn with_cap(cap: usize) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner {
|
||||
buf: Vec::with_capacity(cap.min(4096)),
|
||||
cap,
|
||||
written_total: 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(&self, data: &[u8]) {
|
||||
let Ok(mut g) = self.inner.lock() else { return };
|
||||
g.written_total += data.len() as u64;
|
||||
g.buf.extend_from_slice(data);
|
||||
// Recorte cuando excede cap (con un pequeño slack para evitar
|
||||
// shift en cada append). El usuario ve sólo el tail.
|
||||
if g.buf.len() > g.cap + 1024 {
|
||||
let drop = g.buf.len() - g.cap;
|
||||
g.buf.drain(..drop);
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve el tail de hasta `n` bytes (o todo si `n=0`).
|
||||
pub fn tail(&self, n: usize) -> Vec<u8> {
|
||||
let g = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
if n == 0 || n >= g.buf.len() {
|
||||
return g.buf.clone();
|
||||
}
|
||||
g.buf[g.buf.len() - n..].to_vec()
|
||||
}
|
||||
|
||||
/// Cuántos bytes hay actualmente en el buffer.
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.lock().map(|g| g.buf.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn written_total(&self) -> u64 {
|
||||
self.inner.lock().map(|g| g.written_total).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LogBuf {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn append_and_tail_basic() {
|
||||
let lb = LogBuf::with_cap(100);
|
||||
lb.append(b"hello ");
|
||||
lb.append(b"world\n");
|
||||
let t = lb.tail(0);
|
||||
assert_eq!(t, b"hello world\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_drops_oldest() {
|
||||
let lb = LogBuf::with_cap(10);
|
||||
lb.append(&[b'a'; 8]);
|
||||
lb.append(&[b'b'; 8]);
|
||||
// Después del recorte, debe quedar ~10 bytes pero el slack
|
||||
// permite hasta 10+1024. Como pasamos slack, no se recorta aún
|
||||
// en este caso (16 bytes < 10+1024). Forzamos un append grande.
|
||||
lb.append(&[b'c'; 2048]);
|
||||
assert!(lb.len() <= 10 + 1024);
|
||||
let t = lb.tail(0);
|
||||
// El tail debe contener 'c's (los más recientes).
|
||||
assert!(t.iter().filter(|&&b| b == b'c').count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_total_tracks_all() {
|
||||
let lb = LogBuf::with_cap(10);
|
||||
lb.append(b"abcdef");
|
||||
lb.append(b"ghijkl");
|
||||
assert_eq!(lb.written_total(), 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Persistencia del estado del WorkspaceManager.
|
||||
//!
|
||||
//! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten —
|
||||
//! el kernel los mata al cerrar el daemon. Sólo la *intención declarada*
|
||||
//! (Workspaces creados con su spec) sobrevive a un reboot del daemon.
|
||||
|
||||
use crate::WorkspaceManager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shuma_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// v2 agregó `saved_pipelines`. v3 agrega `live_pipelines`. v4 agrega
|
||||
/// `stats_history` por workspace (sparkline survives daemon restart).
|
||||
/// Versiones inferiores leen campos ausentes como vacío.
|
||||
pub const SNAPSHOT_VERSION: u16 = 4;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShipoteSnapshot {
|
||||
pub version: u16,
|
||||
pub timestamp_ms: u64,
|
||||
pub workspaces: Vec<WorkspaceEntry>,
|
||||
#[serde(default)]
|
||||
pub saved_pipelines: Vec<PipelineEntry>,
|
||||
/// Pipelines vivos con supervisor (`restart_on_failure=true`) al
|
||||
/// momento del snapshot. El daemon los relanza al restore.
|
||||
#[serde(default)]
|
||||
pub live_pipelines: Vec<LivePipelineEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceEntry {
|
||||
pub id: WorkspaceId,
|
||||
pub spec: WorkspaceSpec,
|
||||
/// Stats history persistida — cap reasonable para no inflar el JSON.
|
||||
/// Sólo se guardan campos serializables (no Instant).
|
||||
#[serde(default)]
|
||||
pub stats_history: Vec<PersistedStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedStats {
|
||||
pub commands_alive: u32,
|
||||
pub commands_total: u32,
|
||||
pub rss_bytes: Option<u64>,
|
||||
pub rss_peak_bytes: Option<u64>,
|
||||
pub cpu_usec: Option<u64>,
|
||||
pub cpu_percent: Option<f32>,
|
||||
pub cpu_cores: u32,
|
||||
pub uptime_ms: u64,
|
||||
}
|
||||
|
||||
impl From<&crate::stats::WorkspaceStats> for PersistedStats {
|
||||
fn from(s: &crate::stats::WorkspaceStats) -> Self {
|
||||
Self {
|
||||
commands_alive: s.commands_alive,
|
||||
commands_total: s.commands_total,
|
||||
rss_bytes: s.rss_bytes,
|
||||
rss_peak_bytes: s.rss_peak_bytes,
|
||||
cpu_usec: s.cpu_usec,
|
||||
cpu_percent: s.cpu_percent,
|
||||
cpu_cores: s.cpu_cores,
|
||||
uptime_ms: s.uptime_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipelineEntry {
|
||||
pub name: String,
|
||||
pub spec: PipelineSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LivePipelineEntry {
|
||||
pub workspace: WorkspaceId,
|
||||
pub spec: PipelineSpec,
|
||||
pub tap: bool,
|
||||
}
|
||||
|
||||
impl ShipoteSnapshot {
|
||||
pub fn write(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(self)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> anyhow::Result<Self> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?;
|
||||
// v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío).
|
||||
if snap.version > SNAPSHOT_VERSION {
|
||||
anyhow::bail!(
|
||||
"snapshot version {} no soportada (esperada ≤ {})",
|
||||
snap.version,
|
||||
SNAPSHOT_VERSION
|
||||
);
|
||||
}
|
||||
Ok(snap)
|
||||
}
|
||||
}
|
||||
|
||||
/// Path canónico del snapshot: `$XDG_STATE_HOME/shuma/state.json`,
|
||||
/// fallback `$HOME/.local/state/shuma/state.json`,
|
||||
/// fallback `/tmp/shuma-state-$UID.json`.
|
||||
pub fn default_snapshot_path() -> PathBuf {
|
||||
if let Ok(state) = std::env::var("XDG_STATE_HOME") {
|
||||
return PathBuf::from(state).join("shuma/state.json");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(".local/state/shuma/state.json");
|
||||
}
|
||||
let uid = nix::unistd::getuid().as_raw();
|
||||
PathBuf::from(format!("/tmp/shuma-state-{uid}.json"))
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl WorkspaceManager {
|
||||
/// Toma snapshot del estado actual.
|
||||
pub async fn snapshot(&self) -> ShipoteSnapshot {
|
||||
const PERSIST_STATS_CAP: usize = 16;
|
||||
let g = self.inner.lock().await;
|
||||
let workspaces = g
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|(id, ws)| {
|
||||
// Persist sólo los últimos N samples — el resto crece
|
||||
// y el JSON se infla.
|
||||
let take = ws.stats_history.len().min(PERSIST_STATS_CAP);
|
||||
let skip = ws.stats_history.len() - take;
|
||||
let stats_history: Vec<PersistedStats> = ws
|
||||
.stats_history
|
||||
.iter()
|
||||
.skip(skip)
|
||||
.map(PersistedStats::from)
|
||||
.collect();
|
||||
WorkspaceEntry {
|
||||
id: *id,
|
||||
spec: ws.spec.clone(),
|
||||
stats_history,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let saved_pipelines = g
|
||||
.saved_pipelines
|
||||
.iter()
|
||||
.map(|(name, spec)| PipelineEntry {
|
||||
name: name.clone(),
|
||||
spec: spec.clone(),
|
||||
})
|
||||
.collect();
|
||||
// Pipelines vivos con supervisor — preserva la intención. Los
|
||||
// pids/sockets/discernments son ephemeral y se regeneran al
|
||||
// restore (relaunch desde cero).
|
||||
let live_pipelines = g
|
||||
.pipeline_supervisors
|
||||
.values()
|
||||
.map(|sup| LivePipelineEntry {
|
||||
workspace: sup.workspace,
|
||||
spec: sup.spec.clone(),
|
||||
tap: sup.tap,
|
||||
})
|
||||
.collect();
|
||||
ShipoteSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
timestamp_ms: now_ms(),
|
||||
workspaces,
|
||||
saved_pipelines,
|
||||
live_pipelines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Escribe snapshot a disco. Si `is_dirty()` es false **y** el path
|
||||
/// existe (snapshot previo válido), skip la escritura.
|
||||
pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> {
|
||||
if !self.is_dirty() && path.exists() {
|
||||
info!(path = %path.display(), "snapshot SKIPPED (clean)");
|
||||
return Ok(());
|
||||
}
|
||||
let snap = self.snapshot().await;
|
||||
snap.write(path)?;
|
||||
// Clear dirty: lo que está en disco es el current state.
|
||||
self.dirty
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carga snapshot desde disco y restaura los Workspaces + saved
|
||||
/// pipelines. Devuelve los `live_pipelines` para que el caller
|
||||
/// (daemon) los relance — no podemos relanzarlos desde acá porque
|
||||
/// `run_pipeline` necesita `Incarnator` + `DiscernPipeline`.
|
||||
/// Errores no-fatales (workspaces inválidos) se loguean y se saltan.
|
||||
pub async fn restore_snapshot(
|
||||
self: &std::sync::Arc<Self>,
|
||||
path: &Path,
|
||||
) -> anyhow::Result<RestoreOutcome> {
|
||||
let snap = match ShipoteSnapshot::read(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(?e, path = %path.display(), "no snapshot — start fresh");
|
||||
return Ok(RestoreOutcome::default());
|
||||
}
|
||||
};
|
||||
let mut out = RestoreOutcome::default();
|
||||
for entry in snap.workspaces {
|
||||
// v2+: reusamos el id original así clients que tracking
|
||||
// workspace_id no se rompen al restart.
|
||||
let label = entry.spec.label.clone();
|
||||
let id = entry.id;
|
||||
let history = entry.stats_history;
|
||||
match self.create_with_id(id, entry.spec).await {
|
||||
Ok(_) => {
|
||||
out.workspaces_restored += 1;
|
||||
// Hidratar history persistida. Convertimos
|
||||
// PersistedStats → WorkspaceStats (perdemos
|
||||
// los campos no serializables como `source`).
|
||||
if !history.is_empty() {
|
||||
let mut g = self.inner.lock().await;
|
||||
if let Some(ws) = g.workspaces.get_mut(&id) {
|
||||
for ps in history {
|
||||
ws.stats_history.push_back(crate::stats::WorkspaceStats {
|
||||
commands_alive: ps.commands_alive,
|
||||
commands_total: ps.commands_total,
|
||||
rss_bytes: ps.rss_bytes,
|
||||
rss_peak_bytes: ps.rss_peak_bytes,
|
||||
cpu_usec: ps.cpu_usec,
|
||||
cpu_percent: ps.cpu_percent,
|
||||
cpu_cores: ps.cpu_cores,
|
||||
source: "persisted".into(),
|
||||
uptime_ms: ps.uptime_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(?e, %label, "skipped workspace en restore"),
|
||||
}
|
||||
}
|
||||
for entry in snap.saved_pipelines {
|
||||
self.save_pipeline(entry.name, entry.spec).await;
|
||||
out.saved_pipelines_restored += 1;
|
||||
}
|
||||
out.live_pipelines = snap.live_pipelines;
|
||||
// Restore no cuenta como mutación — lo que está en disco es lo
|
||||
// que acabamos de cargar. Sin esto, el próximo SIGTERM siempre
|
||||
// re-escribiría aunque no hubiese cambios reales.
|
||||
self.dirty
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
info!(
|
||||
workspaces = out.workspaces_restored,
|
||||
saved_pipelines = out.saved_pipelines_restored,
|
||||
live_pipelines = out.live_pipelines.len(),
|
||||
"snapshot restored"
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lo que el caller del restore obtiene. Las `live_pipelines` requieren
|
||||
/// `Incarnator + DiscernPipeline` para relanzarlas → el caller las
|
||||
/// procesa (típicamente el daemon).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RestoreOutcome {
|
||||
pub workspaces_restored: usize,
|
||||
pub saved_pipelines_restored: usize,
|
||||
pub live_pipelines: Vec<LivePipelineEntry>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::WorkspaceManager;
|
||||
use ente_incarnate::IncarnatorConfig;
|
||||
use shuma_card::{ExitPolicy, WorkspaceSpec};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn sample_ws(label: &str) -> WorkspaceSpec {
|
||||
WorkspaceSpec {
|
||||
label: label.into(),
|
||||
soma: Default::default(),
|
||||
permissions: Default::default(),
|
||||
ttl: None,
|
||||
flow_dirs: vec![],
|
||||
on_exit: ExitPolicy::Reap,
|
||||
quota_enforce: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roundtrip_snapshot_preserves_ulids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("state.json");
|
||||
|
||||
let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||
let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap();
|
||||
let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap();
|
||||
mgr1.save_snapshot(&path).await.unwrap();
|
||||
|
||||
let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||
let out = mgr2.restore_snapshot(&path).await.unwrap();
|
||||
assert_eq!(out.workspaces_restored, 2);
|
||||
let listed = mgr2.list().await;
|
||||
let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect();
|
||||
assert!(restored_ids.contains(&id1));
|
||||
assert!(restored_ids.contains(&id2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_snapshot_skips_when_clean() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("state.json");
|
||||
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||
let _ = mgr.create(sample_ws("dirty-test")).await.unwrap();
|
||||
assert!(mgr.is_dirty(), "create debería marcar dirty");
|
||||
mgr.save_snapshot(&path).await.unwrap();
|
||||
assert!(!mgr.is_dirty(), "save_snapshot debería limpiar dirty");
|
||||
let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
// Esperamos un pelín para que mtime cambie si fuera re-escrito.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
// Segundo save sin mutación → skip.
|
||||
mgr.save_snapshot(&path).await.unwrap();
|
||||
let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
assert_eq!(mtime1, mtime2, "skip cuando clean — mtime no cambia");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_includes_saved_pipelines() {
|
||||
use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("state.json");
|
||||
|
||||
let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||
let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap();
|
||||
let spec = PipelineSpec {
|
||||
label: "echo-cat".into(),
|
||||
workspace: ws_id,
|
||||
nodes: vec![CommandRef {
|
||||
label: "n1".into(),
|
||||
payload: brahman_card::Payload::Native {
|
||||
exec: "/bin/echo".into(),
|
||||
argv: vec!["hi".into()],
|
||||
envp: vec![],
|
||||
},
|
||||
soma: Default::default(),
|
||||
flows: Default::default(),
|
||||
supervision: brahman_card::Supervision::OneShot,
|
||||
}],
|
||||
edges: vec![],
|
||||
discern: DiscernPolicy::default(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
mgr1.save_pipeline("daily".into(), spec).await;
|
||||
mgr1.save_snapshot(&path).await.unwrap();
|
||||
|
||||
let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
|
||||
mgr2.restore_snapshot(&path).await.unwrap();
|
||||
let saved = mgr2.list_saved_pipelines().await;
|
||||
assert_eq!(saved, vec!["daily".to_string()]);
|
||||
let got = mgr2.get_saved_pipeline("daily").await.expect("saved");
|
||||
assert_eq!(got.label, "echo-cat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_path_ends_with_state_json() {
|
||||
let p = default_snapshot_path();
|
||||
assert!(p.to_string_lossy().ends_with("state.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
//! Pipeline runtime: encadena nodos con pipes y opcionalmente intercepta
|
||||
//! cada flow para discernir su contenido.
|
||||
//!
|
||||
//! Cada nodo se encarna via [`ente_incarnate::Incarnator`] — eso significa
|
||||
//! que **cada comando puede tener su propio SomaSpec** (namespaces, cgroup,
|
||||
//! rlimits) heredado del workspace. La conexión stdin↔stdout se hace con
|
||||
//! `pipe2(2)` + `ChildStdio` declarativo: el callback de clone(2) hace los
|
||||
//! `dup2` pre-execve sin romper la regla async-signal-safe.
|
||||
|
||||
use crate::CoreError;
|
||||
use brahman_card::Payload;
|
||||
use ente_incarnate::{ChildStdio, Incarnator};
|
||||
use nix::fcntl::OFlag;
|
||||
use nix::unistd::pipe2;
|
||||
use shuma_card::PipelineSpec;
|
||||
use shuma_discern::{DiscernPipeline, Discernment, Hint};
|
||||
use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::io::Interest;
|
||||
use tracing::{debug, info, warn};
|
||||
use ulid::Ulid;
|
||||
|
||||
/// Resultado de lanzar un pipeline.
|
||||
#[derive(Debug)]
|
||||
pub struct PipelineLaunch {
|
||||
pub pipeline: Ulid,
|
||||
pub command_pids: Vec<(String, i32)>,
|
||||
/// Discernments por edge, en el mismo orden que `spec.edges`.
|
||||
pub edge_discernments: Vec<EdgeDiscernment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EdgeDiscernment {
|
||||
pub from_label: String,
|
||||
pub from_output: String,
|
||||
pub to_label: String,
|
||||
pub to_input: String,
|
||||
pub discernment: Option<Discernment>,
|
||||
/// Path del Unix socket donde otros módulos pueden suscribirse al
|
||||
/// stream replicado por este edge. `None` cuando tap=false (no hay
|
||||
/// data plane porque no hay sampling).
|
||||
pub flow_socket: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
|
||||
/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
|
||||
///
|
||||
/// Soporta:
|
||||
/// - Pipeline lineal (1 producer → 1 consumer).
|
||||
/// - **Fan-out** (1 producer → N consumers): shuma interpone un
|
||||
/// splitter que duplica bytes a cada destino. Cuando `tap=true`, el
|
||||
/// splitter además samplea para discernir.
|
||||
/// - Múltiples predecessors por nodo NO se soporta aún (fan-in): sólo se
|
||||
/// honra el primer edge entrante.
|
||||
pub async fn run_pipeline(
|
||||
spec: &PipelineSpec,
|
||||
workspace_label: &str,
|
||||
tap: bool,
|
||||
discerner: Arc<DiscernPipeline>,
|
||||
incarnator: Arc<Incarnator>,
|
||||
manager: Option<Arc<crate::WorkspaceManager>>,
|
||||
) -> Result<PipelineLaunch, CoreError> {
|
||||
spec.validate()?;
|
||||
let n = spec.nodes.len();
|
||||
info!(
|
||||
nodes = n,
|
||||
edges = spec.edges.len(),
|
||||
tap,
|
||||
"launching pipeline (incarnated)"
|
||||
);
|
||||
|
||||
// Pre-compute grafo:
|
||||
// - `consumers[i]` = índices de edges salientes de `i`.
|
||||
// - `predecessors[j]` = índices de edges entrantes a `j`.
|
||||
let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
let mut predecessors: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for (idx, e) in spec.edges.iter().enumerate() {
|
||||
consumers[e.from].push(idx);
|
||||
predecessors[e.to].push(idx);
|
||||
}
|
||||
|
||||
// Por cada edge: par (r_to_consumer, w_from_producer_side).
|
||||
// El consumer recibe r_to_consumer; el producer escribe a w_from_producer_side
|
||||
// (directa o vía splitter).
|
||||
let mut edge_r: Vec<RawFd> = vec![-1; spec.edges.len()];
|
||||
let mut edge_w: Vec<RawFd> = vec![-1; spec.edges.len()];
|
||||
for i in 0..spec.edges.len() {
|
||||
let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
||||
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||
})?;
|
||||
edge_r[i] = r.into_raw_fd();
|
||||
edge_w[i] = w.into_raw_fd();
|
||||
}
|
||||
|
||||
let mut consumer_stdin_fd: Vec<Option<RawFd>> = vec![None; n];
|
||||
let mut producer_stdout_fd: Vec<Option<RawFd>> = vec![None; n];
|
||||
let mut splitter_specs: Vec<SplitterSpec> = Vec::new();
|
||||
let mut merger_specs: Vec<MergerSpec> = Vec::new();
|
||||
|
||||
// Stdout del producer: directo a edge_w[único] si tiene 1 consumer y NO tap;
|
||||
// sino, pipe propio que va al splitter task.
|
||||
for i in 0..n {
|
||||
if consumers[i].is_empty() {
|
||||
continue;
|
||||
}
|
||||
if consumers[i].len() == 1 && !tap {
|
||||
producer_stdout_fd[i] = Some(edge_w[consumers[i][0]]);
|
||||
continue;
|
||||
}
|
||||
// Splitter: pipe propio para el productor → splitter lee y replica a edge_w[*].
|
||||
let (prod_r, prod_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
||||
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||
})?;
|
||||
producer_stdout_fd[i] = Some(prod_w.into_raw_fd());
|
||||
let prod_r_fd = prod_r.into_raw_fd();
|
||||
let mut consumer_writes: Vec<RawFd> = Vec::with_capacity(consumers[i].len());
|
||||
let mut edge_meta: Vec<EdgeMeta> = Vec::with_capacity(consumers[i].len());
|
||||
for edge_idx in &consumers[i] {
|
||||
let edge = &spec.edges[*edge_idx];
|
||||
consumer_writes.push(edge_w[*edge_idx]);
|
||||
edge_meta.push(EdgeMeta {
|
||||
from_label: spec.nodes[edge.from].label.clone(),
|
||||
from_output: edge.from_output.clone(),
|
||||
to_label: spec.nodes[edge.to].label.clone(),
|
||||
to_input: edge.to_input.clone(),
|
||||
});
|
||||
}
|
||||
splitter_specs.push(SplitterSpec {
|
||||
producer_r_fd: prod_r_fd,
|
||||
consumer_w_fds: consumer_writes,
|
||||
edges: edge_meta,
|
||||
tap,
|
||||
sample_bytes: spec.discern.sample_bytes,
|
||||
max_bytes_per_sec: spec.discern.max_bytes_per_sec,
|
||||
});
|
||||
}
|
||||
|
||||
// Stdin del consumer: edge_r[único] si tiene 1 predecessor; sino, merger.
|
||||
for j in 0..n {
|
||||
match predecessors[j].len() {
|
||||
0 => {}
|
||||
1 => {
|
||||
consumer_stdin_fd[j] = Some(edge_r[predecessors[j][0]]);
|
||||
}
|
||||
_ => {
|
||||
// Merger: lee de N edge_r y escribe a un nuevo pipe cuyo
|
||||
// read end es el stdin del consumer.
|
||||
let (cons_r, cons_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
|
||||
CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
|
||||
})?;
|
||||
consumer_stdin_fd[j] = Some(cons_r.into_raw_fd());
|
||||
let inputs: Vec<RawFd> = predecessors[j]
|
||||
.iter()
|
||||
.map(|eidx| edge_r[*eidx])
|
||||
.collect();
|
||||
merger_specs.push(MergerSpec {
|
||||
producer_r_fds: inputs,
|
||||
consumer_w_fd: cons_w.into_raw_fd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encarnamos cada nodo con su stdin/stdout fd asignado.
|
||||
let mut pids = Vec::with_capacity(n);
|
||||
for (i, node) in spec.nodes.iter().enumerate() {
|
||||
match &node.payload {
|
||||
Payload::Native { .. } | Payload::Legacy { .. } => {}
|
||||
_ => {
|
||||
return Err(CoreError::Incarnate(
|
||||
ente_incarnate::IncarnateError::NonExecutablePayload,
|
||||
))
|
||||
}
|
||||
}
|
||||
let card = node.to_card(i, workspace_label)?;
|
||||
let stdio = ChildStdio {
|
||||
stdin_fd: consumer_stdin_fd[i],
|
||||
stdout_fd: producer_stdout_fd[i],
|
||||
stderr_fd: None,
|
||||
};
|
||||
let outcome = incarnator
|
||||
.incarnate_with(&card, stdio)
|
||||
.map_err(CoreError::Incarnate)?;
|
||||
let pid = outcome.pid;
|
||||
pids.push((node.label.clone(), pid.as_raw()));
|
||||
debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
|
||||
}
|
||||
|
||||
let pipeline_id_for_flows = Ulid::new();
|
||||
// Si tap=true, creamos un FlowChannel por edge para el data plane.
|
||||
// Cada splitter pushea al sender del channel correspondiente.
|
||||
let pipeline_id = pipeline_id_for_flows;
|
||||
let mut flow_channels: Vec<crate::flow_channel::FlowChannel> = Vec::new();
|
||||
let mut splitter_channels: Vec<Vec<Option<crate::flow_channel::FlowSender>>> =
|
||||
Vec::with_capacity(splitter_specs.len());
|
||||
let mut edge_socket_for_splitter: Vec<Vec<Option<std::path::PathBuf>>> = Vec::new();
|
||||
for s in &splitter_specs {
|
||||
let mut senders_per_edge = Vec::with_capacity(s.edges.len());
|
||||
let mut paths_per_edge = Vec::with_capacity(s.edges.len());
|
||||
for (i, _em) in s.edges.iter().enumerate() {
|
||||
if !s.tap {
|
||||
senders_per_edge.push(None);
|
||||
paths_per_edge.push(None);
|
||||
continue;
|
||||
}
|
||||
// Socket name = pipeline_id full (26 chars ULID) + edge_idx.
|
||||
// ULID es único globalmente → cero colisiones entre runs.
|
||||
// Edge_idx desambigua múltiples sockets del mismo pipeline.
|
||||
// No incluimos from_label en el name (puede tener chars que
|
||||
// no van en paths Unix — los hints van en `EdgeDiscernment`).
|
||||
let id = format!("{}-{}", pipeline_id, i);
|
||||
let mut socket = crate::flow_channel::default_flow_socket_path(&id);
|
||||
// Fallback: si el path existe (raro — daemon crashed sin
|
||||
// cleanup), agregar suffix numérico hasta encontrar libre.
|
||||
let mut suffix = 1u32;
|
||||
while socket.exists() {
|
||||
let alt = format!("{id}-{suffix}");
|
||||
socket = crate::flow_channel::default_flow_socket_path(&alt);
|
||||
suffix += 1;
|
||||
if suffix > 1000 {
|
||||
warn!(orig = id, "flow socket collision: 1000 retries — using as-is");
|
||||
break;
|
||||
}
|
||||
}
|
||||
match crate::flow_channel::FlowChannel::with_replay_caps(
|
||||
socket.clone(),
|
||||
crate::flow_channel::ReplayCaps::new(spec.discern.replay_chunks, spec.discern.replay_bytes),
|
||||
) {
|
||||
Ok(fc) => {
|
||||
senders_per_edge.push(Some(fc.sender_handle()));
|
||||
paths_per_edge.push(Some(socket));
|
||||
flow_channels.push(fc);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(?e, "flow channel new failed");
|
||||
senders_per_edge.push(None);
|
||||
paths_per_edge.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
splitter_channels.push(senders_per_edge);
|
||||
edge_socket_for_splitter.push(paths_per_edge);
|
||||
}
|
||||
|
||||
// Registramos los flow_channels en el manager AHORA, antes de await
|
||||
// las tasks. Esto permite que clientes externos hagan `flow list` y
|
||||
// se suscriban mientras el pipeline aún produce data.
|
||||
if let Some(mgr) = &manager {
|
||||
if !flow_channels.is_empty() {
|
||||
let drained: Vec<crate::flow_channel::FlowChannel> = flow_channels.drain(..).collect();
|
||||
mgr.retain_pipeline_flows(pipeline_id, drained).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn mergers + splitters después del incarnate. Cada task posee
|
||||
// sus fds y los cierra al terminar (via Drop de OwnedFd).
|
||||
let mut merger_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
||||
for m in merger_specs {
|
||||
merger_handles.push(spawn_merger(m));
|
||||
}
|
||||
let mut tap_handles: Vec<SplitterHandle> = Vec::new();
|
||||
for (s, senders) in splitter_specs.into_iter().zip(splitter_channels.into_iter()) {
|
||||
tap_handles.push(spawn_splitter(s, discerner.clone(), senders));
|
||||
}
|
||||
|
||||
let mut edge_discernments = Vec::new();
|
||||
for (h, paths) in tap_handles.into_iter().zip(edge_socket_for_splitter.into_iter()) {
|
||||
match h.handle.await {
|
||||
Ok(eds) => {
|
||||
for (mut ed, path) in eds.into_iter().zip(paths.into_iter()) {
|
||||
ed.flow_socket = path;
|
||||
edge_discernments.push(ed);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(?e, "splitter handle joined with error"),
|
||||
}
|
||||
}
|
||||
for h in merger_handles {
|
||||
if let Err(e) = h.await {
|
||||
warn!(?e, "merger handle joined with error");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PipelineLaunch {
|
||||
pipeline: pipeline_id,
|
||||
command_pids: pids,
|
||||
edge_discernments,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn short_ulid(u: &Ulid) -> String {
|
||||
let s = u.to_string();
|
||||
s[s.len() - 6..].to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EdgeMeta {
|
||||
from_label: String,
|
||||
from_output: String,
|
||||
to_label: String,
|
||||
to_input: String,
|
||||
}
|
||||
|
||||
struct SplitterSpec {
|
||||
producer_r_fd: RawFd,
|
||||
consumer_w_fds: Vec<RawFd>,
|
||||
edges: Vec<EdgeMeta>,
|
||||
tap: bool,
|
||||
sample_bytes: usize,
|
||||
/// Rate-limit en bytes/s (0 = sin limit). Tras cada chunk de `n`
|
||||
/// bytes, splitter sleeps `n / max_bytes_per_sec` segundos.
|
||||
max_bytes_per_sec: u64,
|
||||
}
|
||||
|
||||
struct SplitterHandle {
|
||||
handle: tokio::task::JoinHandle<Vec<EdgeDiscernment>>,
|
||||
}
|
||||
|
||||
struct MergerSpec {
|
||||
producer_r_fds: Vec<RawFd>,
|
||||
consumer_w_fd: RawFd,
|
||||
}
|
||||
|
||||
fn spawn_merger(spec: MergerSpec) -> tokio::task::JoinHandle<()> {
|
||||
for fd in &spec.producer_r_fds {
|
||||
set_nonblocking(*fd);
|
||||
}
|
||||
set_nonblocking(spec.consumer_w_fd);
|
||||
// Patrón: una task lectora por cada producer reenvía bytes a un mpsc.
|
||||
// El merger principal consume del mpsc y escribe al consumer.
|
||||
// Esto evita el "block en reader idle" del enfoque round-robin sobre
|
||||
// AsyncFd::ready() (los readers idle nunca dejan turno).
|
||||
tokio::spawn(async move {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
|
||||
let nr = spec.producer_r_fds.len();
|
||||
for fd in spec.producer_r_fds {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// SAFETY: ownership transferida.
|
||||
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
|
||||
let r = match AsyncFd::with_interest(owned, Interest::READABLE) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
warn!(?e, "merger reader AsyncFd");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match async_read(&r, &mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buf[..n].to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
// Drop de tx → cuando todos los readers cerraron, el rx
|
||||
// recibe None y el merger termina.
|
||||
});
|
||||
}
|
||||
drop(tx); // sólo los reader tasks tienen sus clones ahora.
|
||||
|
||||
// SAFETY: ownership transferida al task.
|
||||
let w_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.consumer_w_fd) };
|
||||
let w = match AsyncFd::with_interest(w_owned, Interest::WRITABLE) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
warn!(?e, "merger AsyncFd w");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut total: u64 = 0;
|
||||
while let Some(chunk) = rx.recv().await {
|
||||
if async_write_all(&w, &chunk).await.is_err() {
|
||||
return;
|
||||
}
|
||||
total += chunk.len() as u64;
|
||||
}
|
||||
debug!(bytes = total, readers = nr, "merger finished");
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_splitter(
|
||||
spec: SplitterSpec,
|
||||
discerner: Arc<DiscernPipeline>,
|
||||
edge_senders: Vec<Option<crate::flow_channel::FlowSender>>,
|
||||
) -> SplitterHandle {
|
||||
set_nonblocking(spec.producer_r_fd);
|
||||
for fd in &spec.consumer_w_fds {
|
||||
set_nonblocking(*fd);
|
||||
}
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
// SAFETY: ownership transferida al task.
|
||||
let r_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.producer_r_fd) };
|
||||
let r = match AsyncFd::with_interest(r_owned, Interest::READABLE) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
warn!(?e, "splitter AsyncFd r");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let mut writers: Vec<AsyncFd<std::os::fd::OwnedFd>> = Vec::with_capacity(spec.consumer_w_fds.len());
|
||||
for fd in spec.consumer_w_fds {
|
||||
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
|
||||
match AsyncFd::with_interest(owned, Interest::WRITABLE) {
|
||||
Ok(a) => writers.push(a),
|
||||
Err(e) => warn!(?e, "splitter AsyncFd w"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut sample: Vec<u8> = Vec::with_capacity(spec.sample_bytes);
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut total: u64 = 0;
|
||||
let mut eof = false;
|
||||
let mut bucket = if spec.max_bytes_per_sec > 0 {
|
||||
Some(TokenBucket::new(spec.max_bytes_per_sec))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Fase 1: sampling (sólo si tap=true) + replicación.
|
||||
while !eof && (spec.tap && sample.len() < spec.sample_bytes) {
|
||||
let n = match async_read(&r, &mut buf).await {
|
||||
Ok(0) => { eof = true; 0 }
|
||||
Ok(n) => n,
|
||||
Err(e) => { warn!(?e, "splitter read"); break; }
|
||||
};
|
||||
if n == 0 { break; }
|
||||
if spec.tap {
|
||||
let take = n.min(spec.sample_bytes - sample.len());
|
||||
sample.extend_from_slice(&buf[..take]);
|
||||
}
|
||||
// Token bucket: reserva ANTES de broadcast — si hay debt,
|
||||
// sleep antes de mandar al subscriber.
|
||||
if let Some(b) = bucket.as_mut() {
|
||||
let wait = b.reserve(n as u64);
|
||||
if !wait.is_zero() {
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
|
||||
total += n as u64;
|
||||
}
|
||||
|
||||
let d = if spec.tap {
|
||||
discerner.discern(&sample, &Hint { path: None, size_total: None })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Fase 2: replicación pura.
|
||||
while !eof {
|
||||
let n = match async_read(&r, &mut buf).await {
|
||||
Ok(0) => { eof = true; 0 }
|
||||
Ok(n) => n,
|
||||
Err(_) => break,
|
||||
};
|
||||
if n == 0 { break; }
|
||||
if let Some(b) = bucket.as_mut() {
|
||||
let wait = b.reserve(n as u64);
|
||||
if !wait.is_zero() {
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
|
||||
total += n as u64;
|
||||
}
|
||||
debug!(bytes = total, consumers = writers.len(), "splitter finished");
|
||||
|
||||
// Mismo discernment para todos los edges del splitter (es el mismo
|
||||
// stream replicado). Devolvemos N entries (una por edge) para que
|
||||
// la UI/CLI los liste todos. flow_socket lo rellena el caller.
|
||||
spec.edges
|
||||
.into_iter()
|
||||
.map(|em| EdgeDiscernment {
|
||||
from_label: em.from_label,
|
||||
from_output: em.from_output,
|
||||
to_label: em.to_label,
|
||||
to_input: em.to_input,
|
||||
discernment: d.clone(),
|
||||
flow_socket: None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
SplitterHandle { handle }
|
||||
}
|
||||
|
||||
/// Token-bucket real con capacidad de burst.
|
||||
/// - `rate_bps`: tokens (bytes) por segundo de refill.
|
||||
/// - `capacity`: máx tokens acumulables. Default = 1 segundo de rate.
|
||||
/// - `tokens`: tokens disponibles (puede negativos para "debt").
|
||||
/// - `last_refill`: para calcular cuántos refill desde la última call.
|
||||
struct TokenBucket {
|
||||
rate_bps: u64,
|
||||
capacity: u64,
|
||||
tokens: f64,
|
||||
last_refill: std::time::Instant,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
fn new(rate_bps: u64) -> Self {
|
||||
Self {
|
||||
rate_bps,
|
||||
capacity: rate_bps, // 1 second worth of burst.
|
||||
tokens: rate_bps as f64,
|
||||
last_refill: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refill desde la última call según wall time. Reserva `cost`
|
||||
/// tokens; si no alcanza, retorna el sleep necesario.
|
||||
fn reserve(&mut self, cost: u64) -> std::time::Duration {
|
||||
let now = std::time::Instant::now();
|
||||
let elapsed_secs = now.duration_since(self.last_refill).as_secs_f64();
|
||||
self.tokens = (self.tokens + elapsed_secs * self.rate_bps as f64)
|
||||
.min(self.capacity as f64);
|
||||
self.last_refill = now;
|
||||
|
||||
self.tokens -= cost as f64;
|
||||
if self.tokens >= 0.0 {
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
// Debt: tiempo para recuperar a 0 tokens.
|
||||
let secs_needed = -self.tokens / self.rate_bps as f64;
|
||||
std::time::Duration::from_secs_f64(secs_needed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn broadcast_chunk(
|
||||
writers: &[AsyncFd<std::os::fd::OwnedFd>],
|
||||
edge_senders: &[Option<crate::flow_channel::FlowSender>],
|
||||
data: &[u8],
|
||||
) {
|
||||
// Internal pipes a los consumers del pipeline.
|
||||
for w in writers {
|
||||
let _ = async_write_all(w, data).await;
|
||||
}
|
||||
// Externos: broadcast a subscribers vía FlowChannel.
|
||||
// Cada edge tiene su propio sender (mismo data — el sample/discernment
|
||||
// viaja por broadcast separados para que un subscriber por edge vea su
|
||||
// stream específico).
|
||||
if edge_senders.iter().any(|s| s.is_some()) {
|
||||
let shared = std::sync::Arc::new(data.to_vec());
|
||||
for s in edge_senders {
|
||||
if let Some(s) = s {
|
||||
let _ = s.send(shared.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn async_read(
|
||||
afd: &AsyncFd<std::os::fd::OwnedFd>,
|
||||
buf: &mut [u8],
|
||||
) -> std::io::Result<usize> {
|
||||
loop {
|
||||
let mut guard = afd.readable().await?;
|
||||
let fd = afd.as_raw_fd();
|
||||
// SAFETY: lectura sobre fd válido propiedad del AsyncFd.
|
||||
let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
if r >= 0 {
|
||||
return Ok(r as usize);
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
guard.clear_ready();
|
||||
continue;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
async fn async_write_all(
|
||||
afd: &AsyncFd<std::os::fd::OwnedFd>,
|
||||
mut buf: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
while !buf.is_empty() {
|
||||
let mut guard = afd.writable().await?;
|
||||
let fd = afd.as_raw_fd();
|
||||
// SAFETY: escritura sobre fd válido propiedad del AsyncFd.
|
||||
let r = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
|
||||
if r > 0 {
|
||||
buf = &buf[r as usize..];
|
||||
continue;
|
||||
}
|
||||
if r == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::WriteZero,
|
||||
"write 0",
|
||||
));
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
guard.clear_ready();
|
||||
continue;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_nonblocking(fd: RawFd) {
|
||||
// SAFETY: fcntl con F_SETFL es seguro para fds válidos.
|
||||
unsafe {
|
||||
let flags = libc::fcntl(fd, libc::F_GETFL, 0);
|
||||
if flags >= 0 {
|
||||
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension trait para abstraer la API de OwnedFd entre versiones (compat).
|
||||
trait OwnedFdFromRawCompat: Sized {
|
||||
unsafe fn from_raw_fd_compat(fd: RawFd) -> Self;
|
||||
}
|
||||
|
||||
impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
|
||||
unsafe fn from_raw_fd_compat(fd: RawFd) -> Self {
|
||||
use std::os::fd::FromRawFd;
|
||||
// SAFETY: el caller transfiere ownership de `fd` a la `OwnedFd`.
|
||||
unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export para que el unused warning del AsRawFd se calle si no se usa.
|
||||
#[allow(dead_code)]
|
||||
fn _keep_raw(_: &dyn AsRawFd) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brahman_card::Payload;
|
||||
use ente_incarnate::IncarnatorConfig;
|
||||
use shuma_card::{CommandRef, DiscernPolicy, FlowEdge, PipelineSpec, WorkspaceId};
|
||||
|
||||
fn cmd(label: &str, exec: &str, argv: &[&str]) -> CommandRef {
|
||||
CommandRef {
|
||||
label: label.into(),
|
||||
payload: Payload::Native {
|
||||
exec: exec.into(),
|
||||
argv: argv.iter().map(|s| s.to_string()).collect(),
|
||||
envp: vec![],
|
||||
},
|
||||
soma: Default::default(),
|
||||
flows: Default::default(),
|
||||
supervision: brahman_card::Supervision::OneShot,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_isolated_echo_to_cat_runs() {
|
||||
let spec = PipelineSpec {
|
||||
label: "echo-cat".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![
|
||||
cmd("p1", "/bin/echo", &["hola pipeline aislado"]),
|
||||
cmd("p2", "/bin/cat", &[]),
|
||||
],
|
||||
edges: vec![FlowEdge {
|
||||
from: 0,
|
||||
from_output: "stdout".into(),
|
||||
to: 1,
|
||||
to_input: "stdin".into(),
|
||||
}],
|
||||
discern: DiscernPolicy::default(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
|
||||
assert_eq!(launch.command_pids.len(), 2);
|
||||
// Cosecha.
|
||||
for (_, pid) in &launch.command_pids {
|
||||
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_fanin_two_to_one() {
|
||||
// 2 productores → 1 consumer (cat). El merger multiplexa.
|
||||
let spec = PipelineSpec {
|
||||
label: "fanin".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![
|
||||
cmd("p1", "/bin/echo", &["from-p1"]),
|
||||
cmd("p2", "/bin/echo", &["from-p2"]),
|
||||
cmd("c", "/bin/cat", &[]),
|
||||
],
|
||||
edges: vec![
|
||||
FlowEdge {
|
||||
from: 0,
|
||||
from_output: "stdout".into(),
|
||||
to: 2,
|
||||
to_input: "stdin".into(),
|
||||
},
|
||||
FlowEdge {
|
||||
from: 1,
|
||||
from_output: "stdout".into(),
|
||||
to: 2,
|
||||
to_input: "stdin".into(),
|
||||
},
|
||||
],
|
||||
discern: DiscernPolicy::default(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
|
||||
assert_eq!(launch.command_pids.len(), 3);
|
||||
for (_, pid) in &launch.command_pids {
|
||||
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_fanout_one_to_two() {
|
||||
// 1 productor (echo) → 2 consumers (wc -c). Splitter replica.
|
||||
let spec = PipelineSpec {
|
||||
label: "fanout".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![
|
||||
cmd("p", "/bin/echo", &["fanout-test"]),
|
||||
cmd("c1", "/bin/cat", &[]),
|
||||
cmd("c2", "/bin/cat", &[]),
|
||||
],
|
||||
edges: vec![
|
||||
FlowEdge {
|
||||
from: 0,
|
||||
from_output: "stdout".into(),
|
||||
to: 1,
|
||||
to_input: "stdin".into(),
|
||||
},
|
||||
FlowEdge {
|
||||
from: 0,
|
||||
from_output: "stdout".into(),
|
||||
to: 2,
|
||||
to_input: "stdin".into(),
|
||||
},
|
||||
],
|
||||
discern: DiscernPolicy::default(),
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
|
||||
assert_eq!(launch.command_pids.len(), 3);
|
||||
for (_, pid) in &launch.command_pids {
|
||||
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_isolated_with_tap_captures_discernment() {
|
||||
let spec = PipelineSpec {
|
||||
label: "json-cat".into(),
|
||||
workspace: WorkspaceId::new(),
|
||||
nodes: vec![
|
||||
cmd("p1", "/bin/echo", &["{\"hello\": 1}"]),
|
||||
cmd("p2", "/bin/cat", &[]),
|
||||
],
|
||||
edges: vec![FlowEdge {
|
||||
from: 0,
|
||||
from_output: "stdout".into(),
|
||||
to: 1,
|
||||
to_input: "stdin".into(),
|
||||
}],
|
||||
discern: DiscernPolicy {
|
||||
sample_bytes: 4096,
|
||||
enrich_producer: true,
|
||||
replay_chunks: 32,
|
||||
replay_bytes: 0,
|
||||
max_bytes_per_sec: 0,
|
||||
},
|
||||
restart_on_failure: false,
|
||||
restart_backoff_ms: 200,
|
||||
restart_max_backoff_ms: 30_000,
|
||||
restart_max: 0,
|
||||
};
|
||||
let disc = Arc::new(DiscernPipeline::default_pipeline());
|
||||
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
|
||||
let launch = run_pipeline(&spec, "ws", true, disc, inc, None).await.unwrap();
|
||||
assert_eq!(launch.edge_discernments.len(), 1);
|
||||
let d = &launch.edge_discernments[0];
|
||||
let dis = d.discernment.as_ref().expect("discernment present");
|
||||
assert_eq!(dis.mime.as_deref(), Some("application/json"));
|
||||
// Cosecha.
|
||||
for (_, pid) in &launch.command_pids {
|
||||
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Resource accounting por workspace.
|
||||
//!
|
||||
//! Dos fuentes:
|
||||
//! - **Per-proc** (`/proc/<pid>/status` + `stat`): suma RSS y CPU ticks de
|
||||
//! los comandos vivos del workspace. Siempre disponible. Costo: O(N pids).
|
||||
//! - **Cgroup v2** (`memory.current`, `cpu.stat`): un read por workspace si
|
||||
//! `SomaSpec.cgroup.path` está y es leíble. Más preciso (incluye descendants).
|
||||
//!
|
||||
//! Si ambos están disponibles, devolvemos el cgroup (más preciso) y dejamos
|
||||
//! el per-proc como `sample_via_proc`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkspaceStats {
|
||||
pub commands_alive: u32,
|
||||
pub commands_total: u32,
|
||||
/// RSS sumado en bytes. `None` si no se pudo medir.
|
||||
pub rss_bytes: Option<u64>,
|
||||
/// High-water mark de RSS (peak alguna vez observado). Cgroup v2:
|
||||
/// `memory.peak` (≥6.5). Per-proc: suma de `VmHWM` de cada pid.
|
||||
pub rss_peak_bytes: Option<u64>,
|
||||
/// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
|
||||
pub cpu_usec: Option<u64>,
|
||||
/// %CPU instantáneo derivado entre dos samples consecutivos. `None`
|
||||
/// en el primer sample (no hay baseline). `100.0` = 1 core saturado.
|
||||
/// `400.0` con 4 cores activos = la máquina al 100%.
|
||||
pub cpu_percent: Option<f32>,
|
||||
/// Cores online detectados (sysconf `_SC_NPROCESSORS_ONLN`). Útil
|
||||
/// para normalizar `cpu_percent / cpu_cores` → 0..100 absoluto.
|
||||
pub cpu_cores: u32,
|
||||
/// Fuente del dato: "proc" | "cgroup" | "mixed".
|
||||
pub source: String,
|
||||
/// Wall-clock uptime del workspace en milisegundos.
|
||||
pub uptime_ms: u64,
|
||||
}
|
||||
|
||||
impl WorkspaceStats {
|
||||
/// CPU% normalizado al 100% total de la máquina (no por core).
|
||||
/// Útil para comparar workspaces independiente del paralelismo.
|
||||
pub fn cpu_percent_total(&self) -> Option<f32> {
|
||||
self.cpu_percent
|
||||
.map(|p| if self.cpu_cores == 0 { p } else { p / self.cpu_cores as f32 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Reporte de quotas: comparación entre el accounting real y los
|
||||
/// `rlimits` declarados en `SomaSpec`. NO hace enforcement automático
|
||||
/// en v1 — sólo accounting + reporting. El caller decide qué hacer.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QuotaReport {
|
||||
/// Límite de memoria declarado (bytes). None = sin límite.
|
||||
pub mem_limit: Option<u64>,
|
||||
/// Límite de procesos declarado.
|
||||
pub nproc_limit: Option<u32>,
|
||||
/// Lista de violaciones detectadas (strings humano-legibles).
|
||||
/// Empty = todo dentro de quota.
|
||||
pub breaches: Vec<String>,
|
||||
}
|
||||
|
||||
/// Detecta cores online runtime. Cacheado vía OnceLock — el valor no
|
||||
/// cambia salvo hotplug, que es raro y aceptamos sample stale.
|
||||
fn online_cores() -> u32 {
|
||||
static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
|
||||
*CACHED.get_or_init(|| {
|
||||
let n = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
|
||||
if n > 0 { n as u32 } else { 1 }
|
||||
})
|
||||
}
|
||||
|
||||
/// Mide stats para un set de PIDs vivos + un path de cgroup opcional.
|
||||
pub fn measure(
|
||||
alive_pids: &[i32],
|
||||
cgroup_path: Option<&Path>,
|
||||
workspace_started: Instant,
|
||||
) -> WorkspaceStats {
|
||||
let mut rss_proc: u64 = 0;
|
||||
let mut rss_peak_proc: u64 = 0;
|
||||
let mut cpu_proc: u64 = 0;
|
||||
let mut proc_ok = false;
|
||||
for &pid in alive_pids {
|
||||
if let Some((rss, peak, cpu)) = read_proc_pid(pid) {
|
||||
rss_proc += rss;
|
||||
rss_peak_proc += peak;
|
||||
cpu_proc += cpu;
|
||||
proc_ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
let cgroup = cgroup_path.and_then(read_cgroup_stats);
|
||||
|
||||
let (rss, rss_peak, cpu, source) = match (cgroup, proc_ok) {
|
||||
(Some(cg), _) => (Some(cg.rss), cg.rss_peak, Some(cg.cpu_usec), "cgroup".to_string()),
|
||||
(None, true) => (
|
||||
Some(rss_proc),
|
||||
Some(rss_peak_proc),
|
||||
Some(cpu_proc),
|
||||
"proc".to_string(),
|
||||
),
|
||||
(None, false) => (None, None, None, "none".to_string()),
|
||||
};
|
||||
|
||||
WorkspaceStats {
|
||||
commands_alive: alive_pids.len() as u32,
|
||||
commands_total: 0,
|
||||
rss_bytes: rss,
|
||||
rss_peak_bytes: rss_peak,
|
||||
cpu_usec: cpu,
|
||||
cpu_percent: None, // El caller lo rellena con el diff vs prev sample.
|
||||
cpu_cores: online_cores(),
|
||||
source,
|
||||
uptime_ms: workspace_started.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
struct CgroupStats {
|
||||
rss: u64,
|
||||
rss_peak: Option<u64>,
|
||||
cpu_usec: u64,
|
||||
}
|
||||
|
||||
/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc/<pid>/`. None si el proc desapareció.
|
||||
fn read_proc_pid(pid: i32) -> Option<(u64, u64, u64)> {
|
||||
let (rss_kb, hwm_kb) = {
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let mut rss = 0u64;
|
||||
let mut hwm = 0u64;
|
||||
for l in status.lines() {
|
||||
if let Some(rest) = l.strip_prefix("VmRSS:") {
|
||||
rss = rest
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
} else if let Some(rest) = l.strip_prefix("VmHWM:") {
|
||||
hwm = rest
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
(rss, hwm)
|
||||
};
|
||||
let cpu_usec = {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
// formato: pid (comm) state ppid pgrp ... utime stime cutime cstime
|
||||
// Cuidado: comm puede tener espacios y paréntesis. Buscamos la última `)`.
|
||||
let end_comm = stat.rfind(')')?;
|
||||
let after = &stat[end_comm + 1..];
|
||||
let fields: Vec<&str> = after.split_whitespace().collect();
|
||||
// Tras `)`, índice 0 = state, índice 11 = utime, 12 = stime.
|
||||
let utime = fields.get(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let stime = fields.get(12).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let ticks = utime + stime;
|
||||
// Convertimos ticks → microsegundos. SC_CLK_TCK típicamente 100.
|
||||
let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as u64;
|
||||
ticks * 1_000_000 / clk_tck
|
||||
};
|
||||
Some((rss_kb * 1024, hwm_kb * 1024, cpu_usec))
|
||||
}
|
||||
|
||||
/// Lee `CgroupStats` del cgroup. None si no existe o no es leíble.
|
||||
/// `memory.peak` requiere kernel ≥6.5; si falta, `rss_peak` queda None.
|
||||
fn read_cgroup_stats(cgroup_path: &Path) -> Option<CgroupStats> {
|
||||
let mem = std::fs::read_to_string(cgroup_path.join("memory.current"))
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())?;
|
||||
let cpu_stat = std::fs::read_to_string(cgroup_path.join("cpu.stat")).ok()?;
|
||||
let cpu_usec = cpu_stat
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("usage_usec"))
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let peak = std::fs::read_to_string(cgroup_path.join("memory.peak"))
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok());
|
||||
Some(CgroupStats {
|
||||
rss: mem,
|
||||
rss_peak: peak,
|
||||
cpu_usec,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn measure_with_no_pids_returns_zero() {
|
||||
let stats = measure(&[], None, Instant::now());
|
||||
assert_eq!(stats.commands_alive, 0);
|
||||
assert_eq!(stats.rss_bytes, None);
|
||||
assert_eq!(stats.source, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_self_pid_returns_data() {
|
||||
let me = std::process::id() as i32;
|
||||
let stats = measure(&[me], None, Instant::now());
|
||||
assert_eq!(stats.commands_alive, 1);
|
||||
// Nuestro propio RSS debería ser > 0.
|
||||
assert!(stats.rss_bytes.unwrap_or(0) > 0);
|
||||
assert_eq!(stats.source, "proc");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "shuma-discern"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Discernidor de contenido sobre buffers: MIME, codificación, parser hints. Compartible con file_explorer y nouser."
|
||||
|
||||
[dependencies]
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -0,0 +1,307 @@
|
||||
//! `shuma-discern` — detección de tipo de contenido sobre buffers.
|
||||
//!
|
||||
//! Trait + pipeline + discerners default. Devuelve un [`Discernment`] con
|
||||
//! `TypeRef` consistente con el broker, confidence, MIME y un `lens` hint
|
||||
//! para UIs (reusa el espíritu del `dominant_lens` de akasha).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use brahman_card::TypeRef;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hint<'a> {
|
||||
pub path: Option<&'a str>,
|
||||
pub size_total: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Discernment {
|
||||
pub ty: TypeRef,
|
||||
pub confidence: f32,
|
||||
pub mime: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
}
|
||||
|
||||
pub trait Discerner: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment>;
|
||||
}
|
||||
|
||||
pub struct DiscernPipeline {
|
||||
discerners: Vec<Box<dyn Discerner>>,
|
||||
}
|
||||
|
||||
impl DiscernPipeline {
|
||||
pub fn new() -> Self {
|
||||
Self { discerners: Vec::new() }
|
||||
}
|
||||
|
||||
/// Pipeline con los discerners default. Orden importa: el primer match
|
||||
/// con confidence ≥ `accept_threshold` corta.
|
||||
pub fn default_pipeline() -> Self {
|
||||
let mut p = Self::new();
|
||||
p.push(Box::new(MagicBytes));
|
||||
// CardProbe antes que JsonProbe: una Card es JSON, pero queremos el
|
||||
// TypeRef más específico cuando aplique.
|
||||
p.push(Box::new(CardProbe));
|
||||
p.push(Box::new(JsonProbe));
|
||||
p.push(Box::new(TomlProbe));
|
||||
p.push(Box::new(Utf8Probe));
|
||||
p
|
||||
}
|
||||
|
||||
pub fn push(&mut self, d: Box<dyn Discerner>) {
|
||||
self.discerners.push(d);
|
||||
}
|
||||
|
||||
/// Recorre los discerners y devuelve el primer Discernment con
|
||||
/// confidence ≥ 0.5, o el más confidente si ninguno alcanza el umbral.
|
||||
pub fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment> {
|
||||
let mut best: Option<Discernment> = None;
|
||||
for d in &self.discerners {
|
||||
if let Some(r) = d.discern(sample, hint) {
|
||||
if r.confidence >= 0.9 {
|
||||
return Some(r);
|
||||
}
|
||||
best = match best {
|
||||
Some(prev) if prev.confidence >= r.confidence => Some(prev),
|
||||
_ => Some(r),
|
||||
};
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscernPipeline {
|
||||
fn default() -> Self {
|
||||
Self::default_pipeline()
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Discerners
|
||||
// =====================================================================
|
||||
|
||||
/// Magic-bytes para formatos comunes. Confidence alta cuando hay match.
|
||||
pub struct MagicBytes;
|
||||
|
||||
impl Discerner for MagicBytes {
|
||||
fn name(&self) -> &str { "magic-bytes" }
|
||||
|
||||
fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
|
||||
let d = |ty: &str, mime: &str, lens: Option<&str>| Discernment {
|
||||
ty: TypeRef::Primitive { name: ty.into() },
|
||||
confidence: 0.99,
|
||||
mime: Some(mime.into()),
|
||||
lens: lens.map(String::from),
|
||||
};
|
||||
match s {
|
||||
x if x.starts_with(&[0x89, b'P', b'N', b'G']) => Some(d("png", "image/png", Some("gallery"))),
|
||||
x if x.starts_with(&[0xFF, 0xD8, 0xFF]) => Some(d("jpeg", "image/jpeg", Some("gallery"))),
|
||||
x if x.starts_with(b"%PDF-") => Some(d("pdf", "application/pdf", Some("reader"))),
|
||||
x if x.starts_with(&[0x7F, b'E', b'L', b'F']) => Some(d("elf", "application/x-executable", None)),
|
||||
x if x.starts_with(&[0x00, 0x61, 0x73, 0x6D]) => Some(d("wasm", "application/wasm", None)),
|
||||
x if x.starts_with(&[0x1F, 0x8B]) => Some(d("gzip", "application/gzip", None)),
|
||||
x if x.starts_with(b"PK\x03\x04") || x.starts_with(b"PK\x05\x06") => {
|
||||
Some(d("zip", "application/zip", None))
|
||||
}
|
||||
x if x.starts_with(b"GIF87a") || x.starts_with(b"GIF89a") => {
|
||||
Some(d("gif", "image/gif", Some("gallery")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON: parsea el inicio. No requiere parsearlo entero; con que arranque
|
||||
/// con `{`/`[` y haga progreso cuenta.
|
||||
pub struct JsonProbe;
|
||||
|
||||
impl Discerner for JsonProbe {
|
||||
fn name(&self) -> &str { "json" }
|
||||
|
||||
fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
|
||||
let trimmed = trim_left(s);
|
||||
let first = *trimmed.first()?;
|
||||
if first != b'{' && first != b'[' {
|
||||
return None;
|
||||
}
|
||||
// Intento parsear tal cual; si falla por truncated, igualmente confidence media.
|
||||
let txt = std::str::from_utf8(trimmed).ok()?;
|
||||
match serde_json::from_str::<serde_json::Value>(txt) {
|
||||
Ok(_) => Some(Discernment {
|
||||
ty: TypeRef::Primitive { name: "json".into() },
|
||||
confidence: 0.95,
|
||||
mime: Some("application/json".into()),
|
||||
lens: Some("tree".into()),
|
||||
}),
|
||||
Err(_) => Some(Discernment {
|
||||
ty: TypeRef::Primitive { name: "json".into() },
|
||||
confidence: 0.6, // sample truncado
|
||||
mime: Some("application/json".into()),
|
||||
lens: Some("tree".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TomlProbe;
|
||||
|
||||
impl Discerner for TomlProbe {
|
||||
fn name(&self) -> &str { "toml" }
|
||||
|
||||
fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option<Discernment> {
|
||||
let txt = std::str::from_utf8(s).ok()?;
|
||||
// Heurística: presencia de `[seccion]` y/o `clave = valor` y extensión.
|
||||
let looks_like = txt.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.starts_with('[') && l.ends_with(']')
|
||||
}) || txt.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
!l.starts_with('#') && l.contains(" = ")
|
||||
});
|
||||
if !looks_like {
|
||||
return None;
|
||||
}
|
||||
let confidence = if h.path.map_or(false, |p| p.ends_with(".toml")) {
|
||||
0.95
|
||||
} else {
|
||||
0.55
|
||||
};
|
||||
// Si parsea, sube confidence.
|
||||
let parsed = toml::from_str::<toml::Value>(txt).is_ok();
|
||||
Some(Discernment {
|
||||
ty: TypeRef::Primitive { name: "toml".into() },
|
||||
confidence: if parsed { 0.93 } else { confidence },
|
||||
mime: Some("application/toml".into()),
|
||||
lens: Some("tree".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Si el JSON parsea como Card, lo emite como Wit { brahman:card }.
|
||||
pub struct CardProbe;
|
||||
|
||||
impl Discerner for CardProbe {
|
||||
fn name(&self) -> &str { "card" }
|
||||
|
||||
fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
|
||||
let trimmed = trim_left(s);
|
||||
if trimmed.first()? != &b'{' {
|
||||
return None;
|
||||
}
|
||||
let txt = std::str::from_utf8(trimmed).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(txt).ok()?;
|
||||
let obj = v.as_object()?;
|
||||
if obj.contains_key("schema_version") && obj.contains_key("id") && obj.contains_key("payload") {
|
||||
Some(Discernment {
|
||||
ty: TypeRef::Wit {
|
||||
package: "brahman:card".into(),
|
||||
interface: None,
|
||||
name: "card".into(),
|
||||
},
|
||||
confidence: 0.97,
|
||||
mime: Some("application/json".into()),
|
||||
lens: Some("card".into()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Texto UTF-8 plano. Fallback de baja confidence.
|
||||
pub struct Utf8Probe;
|
||||
|
||||
impl Discerner for Utf8Probe {
|
||||
fn name(&self) -> &str { "utf8" }
|
||||
|
||||
fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option<Discernment> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let valid = std::str::from_utf8(s).is_ok();
|
||||
if !valid {
|
||||
return None;
|
||||
}
|
||||
// Detectar binario disfrazado: bytes de control fuera de \t\n\r.
|
||||
let suspicious = s.iter().filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20)).count();
|
||||
if suspicious * 100 / s.len().max(1) > 5 {
|
||||
return None;
|
||||
}
|
||||
let lens = h.path.and_then(|p| {
|
||||
if p.ends_with(".md") { Some("markdown") }
|
||||
else if p.ends_with(".rs") || p.ends_with(".py") || p.ends_with(".go") || p.ends_with(".js") || p.ends_with(".ts") {
|
||||
Some("code")
|
||||
} else { None }
|
||||
}).map(String::from);
|
||||
Some(Discernment {
|
||||
ty: TypeRef::Primitive { name: "text".into() },
|
||||
confidence: 0.5,
|
||||
mime: Some("text/plain; charset=utf-8".into()),
|
||||
lens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_left(s: &[u8]) -> &[u8] {
|
||||
let mut i = 0;
|
||||
while i < s.len() && (s[i] == b' ' || s[i] == b'\t' || s[i] == b'\n' || s[i] == b'\r') {
|
||||
i += 1;
|
||||
}
|
||||
&s[i..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn discern(sample: &[u8]) -> Option<Discernment> {
|
||||
DiscernPipeline::default_pipeline().discern(sample, &Hint { path: None, size_total: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_detected() {
|
||||
let r = discern(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]).unwrap();
|
||||
assert_eq!(r.mime.as_deref(), Some("image/png"));
|
||||
assert!(r.confidence > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_detected() {
|
||||
let r = discern(b"{\"hello\": 1}").unwrap();
|
||||
assert_eq!(r.mime.as_deref(), Some("application/json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_wins_over_plain_json() {
|
||||
let payload = br#"{"schema_version":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","label":"x","payload":{"Virtual":null},"supervision":"OneShot"}"#;
|
||||
let r = discern(payload).unwrap();
|
||||
match r.ty {
|
||||
TypeRef::Wit { ref package, .. } => assert_eq!(package, "brahman:card"),
|
||||
_ => panic!("expected card"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_text_fallback() {
|
||||
let r = discern(b"hello world\nthis is text").unwrap();
|
||||
// Puede ser detected as toml (= heurística) o text. Ambos son aceptables, sólo aseguro algo razonable.
|
||||
assert!(r.mime.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_rejected_by_utf8() {
|
||||
let mut bytes = vec![0u8; 100];
|
||||
bytes[0] = 0x00;
|
||||
bytes[1] = 0x01;
|
||||
bytes[2] = 0x02;
|
||||
let r = DiscernPipeline::default_pipeline().discern(&bytes, &Hint { path: None, size_total: None });
|
||||
// Tras Utf8Probe rechazar, no hay match → None.
|
||||
// Si por casualidad otro discerner mata antes, también es OK.
|
||||
if let Some(r) = r {
|
||||
assert_ne!(r.mime.as_deref(), Some("text/plain; charset=utf-8"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "shuma-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Wire protocol entre shipote-daemon y clientes (cli/gui). Postcard length-prefixed sobre Unix socket."
|
||||
|
||||
[dependencies]
|
||||
shuma-card = { path = "../shuma-card" }
|
||||
brahman-card = { path = "../../../protocol/brahman-card" }
|
||||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
@@ -0,0 +1,444 @@
|
||||
//! `shuma-protocol` — wire daemon ↔ cliente (cli/gui).
|
||||
//!
|
||||
//! Framing: u32 BE length-prefix + payload postcard. Mismo patrón que
|
||||
//! `ente-bus`/`brahman-handshake` para que clientes existentes compartan
|
||||
//! reader/writer helpers si quieren.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shuma_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixStream;
|
||||
use ulid::Ulid;
|
||||
|
||||
pub const DEFAULT_SOCK_NAME: &str = "shuma.sock";
|
||||
pub const MAX_FRAME: usize = 1 << 20;
|
||||
|
||||
fn default_grace_ms() -> u64 {
|
||||
1000
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Mensajes
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
/// Health-check.
|
||||
Ping,
|
||||
|
||||
/// Health endpoint estructurado: versión + uptime + counts.
|
||||
Health,
|
||||
|
||||
/// Crear un workspace nuevo.
|
||||
WorkspaceCreate { spec: WorkspaceSpec },
|
||||
|
||||
/// Listar todos los workspaces vivos.
|
||||
WorkspaceList,
|
||||
|
||||
/// Detener un workspace y reapear sus comandos. `grace_ms`: tiempo
|
||||
/// que se espera tras SIGTERM antes de SIGKILL. 0 = SIGKILL inmediato.
|
||||
WorkspaceStop {
|
||||
id: WorkspaceId,
|
||||
#[serde(default = "default_grace_ms")]
|
||||
grace_ms: u64,
|
||||
},
|
||||
|
||||
/// Ejecutar un comando one-shot dentro de un workspace existente.
|
||||
Run {
|
||||
workspace: WorkspaceId,
|
||||
exec: String,
|
||||
argv: Vec<String>,
|
||||
envp: Vec<(String, String)>,
|
||||
/// Si `true` y el comando muere con exit_status != 0, el reaper
|
||||
/// lo relaunch con backoff exponencial.
|
||||
#[serde(default)]
|
||||
restart_on_failure: bool,
|
||||
},
|
||||
|
||||
/// Lanzar un Pipeline completo dentro de un workspace.
|
||||
PipelineRun {
|
||||
spec: PipelineSpec,
|
||||
/// Si `true`, el daemon interpone un tap entre productor y
|
||||
/// consumidor de cada FlowEdge, sampleando los primeros bytes
|
||||
/// y discerniendo el TypeRef.
|
||||
tap: bool,
|
||||
/// Variables para sustitución `${KEY}` en strings del spec
|
||||
/// antes de spawn (templating).
|
||||
#[serde(default)]
|
||||
vars: std::collections::BTreeMap<String, String>,
|
||||
},
|
||||
|
||||
/// Discernir un buffer ad-hoc (sin workspace). Útil para `shuma discern <file>`.
|
||||
Discern { sample: Vec<u8>, hint_path: Option<PathBuf> },
|
||||
|
||||
/// Capacidades runtime del kernel/proceso del daemon.
|
||||
Capabilities,
|
||||
|
||||
/// Listar comandos vivos+pasados de un workspace.
|
||||
CommandList { workspace: shuma_card::WorkspaceId },
|
||||
|
||||
/// Tail del log capturado para un comando.
|
||||
CommandLogs {
|
||||
workspace: shuma_card::WorkspaceId,
|
||||
command: Ulid,
|
||||
tail_bytes: usize,
|
||||
/// "stdout" | "stderr" | "both" (default "both" si vacío).
|
||||
stream: String,
|
||||
},
|
||||
|
||||
/// Guardar (o reemplazar) un PipelineSpec bajo un nombre.
|
||||
PipelineSave { name: String, spec: PipelineSpec },
|
||||
|
||||
/// Listar nombres de pipelines guardados.
|
||||
PipelineSavedList,
|
||||
|
||||
/// Eliminar un pipeline guardado.
|
||||
PipelineDrop { name: String },
|
||||
|
||||
/// Ejecutar un pipeline guardado.
|
||||
PipelineRunSaved {
|
||||
name: String,
|
||||
tap: bool,
|
||||
#[serde(default)]
|
||||
vars: std::collections::BTreeMap<String, String>,
|
||||
},
|
||||
|
||||
/// Resource accounting de un workspace.
|
||||
WorkspaceStats { workspace: shuma_card::WorkspaceId },
|
||||
|
||||
/// Reporte de quotas (rlimits declarados vs uso actual).
|
||||
WorkspaceQuota { workspace: shuma_card::WorkspaceId },
|
||||
|
||||
/// History de samples del workspace (server-side). Sobrevive
|
||||
/// restart del shell. `tail`: cantidad de samples desde el final
|
||||
/// (0 = todo).
|
||||
WorkspaceStatsHistory {
|
||||
workspace: shuma_card::WorkspaceId,
|
||||
tail: usize,
|
||||
},
|
||||
|
||||
/// Resumen completo de un workspace: stats + quota + commands +
|
||||
/// flow sockets en una sola roundtrip. Reduce N×4 requests del
|
||||
/// shell a N×1.
|
||||
WorkspaceFullSummary { workspace: shuma_card::WorkspaceId },
|
||||
|
||||
/// Detener selectivamente los comandos de un pipeline (no el workspace
|
||||
/// entero). `grace_ms`: SIGTERM → wait → SIGKILL.
|
||||
PipelineStop {
|
||||
pipeline: Ulid,
|
||||
#[serde(default = "default_grace_ms")]
|
||||
grace_ms: u64,
|
||||
},
|
||||
|
||||
/// Listar pipelines activos con sus flow channels (data plane).
|
||||
FlowList,
|
||||
|
||||
/// Throughput por flow socket: bytes_total + bytes_per_sec.
|
||||
FlowThroughput,
|
||||
|
||||
/// Cerrar el data plane de un pipeline (drop sockets + canales).
|
||||
FlowDrop { pipeline: Ulid },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
|
||||
Health {
|
||||
version: String,
|
||||
uptime_ms: u64,
|
||||
alive_workspaces: u32,
|
||||
alive_commands: u32,
|
||||
alive_pipelines: u32,
|
||||
active_flows: u32,
|
||||
dirty: bool,
|
||||
},
|
||||
|
||||
WorkspaceCreated {
|
||||
id: WorkspaceId,
|
||||
warnings: Vec<String>,
|
||||
},
|
||||
|
||||
WorkspaceList {
|
||||
items: Vec<WorkspaceSummary>,
|
||||
},
|
||||
|
||||
WorkspaceStopped {
|
||||
id: WorkspaceId,
|
||||
reaped: u32,
|
||||
},
|
||||
|
||||
RunStarted {
|
||||
workspace: WorkspaceId,
|
||||
command_id: Ulid,
|
||||
pid: i32,
|
||||
},
|
||||
|
||||
PipelineStarted {
|
||||
pipeline: Ulid,
|
||||
command_pids: Vec<(String, i32)>,
|
||||
/// Discernments por edge cuando tap=true. Vacío sin tap.
|
||||
edges: Vec<EdgeDiscernmentInfo>,
|
||||
},
|
||||
|
||||
Discernment {
|
||||
ty: String,
|
||||
confidence: f32,
|
||||
mime: Option<String>,
|
||||
lens: Option<String>,
|
||||
},
|
||||
|
||||
Capabilities {
|
||||
kernel_version: (u32, u32, u32),
|
||||
user_ns: String,
|
||||
cgroup_v2: String,
|
||||
cgroup_delegated: bool,
|
||||
has_cap_sys_admin: bool,
|
||||
},
|
||||
|
||||
CommandList {
|
||||
items: Vec<CommandInfo>,
|
||||
},
|
||||
|
||||
CommandLogs {
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
|
||||
PipelineSaved {
|
||||
name: String,
|
||||
},
|
||||
|
||||
PipelineSavedList {
|
||||
names: Vec<String>,
|
||||
},
|
||||
|
||||
PipelineDropped {
|
||||
name: String,
|
||||
existed: bool,
|
||||
},
|
||||
|
||||
PipelineStopped {
|
||||
pipeline: Ulid,
|
||||
reaped: u32,
|
||||
},
|
||||
|
||||
WorkspaceStats {
|
||||
info: WorkspaceStatsInfo,
|
||||
},
|
||||
|
||||
WorkspaceQuota {
|
||||
info: QuotaReportInfo,
|
||||
},
|
||||
|
||||
WorkspaceStatsHistory {
|
||||
samples: Vec<WorkspaceStatsInfo>,
|
||||
},
|
||||
|
||||
WorkspaceFullSummary {
|
||||
stats: WorkspaceStatsInfo,
|
||||
quota: QuotaReportInfo,
|
||||
commands: Vec<CommandInfo>,
|
||||
flow_sockets: Vec<PathBuf>,
|
||||
},
|
||||
|
||||
FlowList {
|
||||
items: Vec<FlowInfo>,
|
||||
},
|
||||
|
||||
FlowThroughput {
|
||||
items: Vec<FlowThroughputInfo>,
|
||||
},
|
||||
|
||||
FlowDropped {
|
||||
pipeline: Ulid,
|
||||
existed: bool,
|
||||
},
|
||||
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuotaReportInfo {
|
||||
pub mem_limit: Option<u64>,
|
||||
pub nproc_limit: Option<u32>,
|
||||
pub breaches: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceStatsInfo {
|
||||
pub commands_alive: u32,
|
||||
pub commands_total: u32,
|
||||
pub rss_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub rss_peak_bytes: Option<u64>,
|
||||
pub cpu_usec: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub cpu_percent: Option<f32>,
|
||||
#[serde(default = "default_cpu_cores")]
|
||||
pub cpu_cores: u32,
|
||||
pub source: String,
|
||||
pub uptime_ms: u64,
|
||||
}
|
||||
|
||||
fn default_cpu_cores() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowThroughputInfo {
|
||||
pub socket: PathBuf,
|
||||
pub bytes_total: u64,
|
||||
pub bytes_per_sec: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowInfo {
|
||||
pub pipeline: Ulid,
|
||||
pub sockets: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandInfo {
|
||||
pub id: Ulid,
|
||||
pub label: String,
|
||||
pub pid: i32,
|
||||
pub alive: bool,
|
||||
pub exit_status: Option<i32>,
|
||||
pub log_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EdgeDiscernmentInfo {
|
||||
pub from_label: String,
|
||||
pub from_output: String,
|
||||
pub to_label: String,
|
||||
pub to_input: String,
|
||||
/// `Some(ty)` si el discerner detectó algo. `None` si no hubo data
|
||||
/// suficiente o no matcheó ningún discerner.
|
||||
pub ty: Option<String>,
|
||||
pub mime: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
pub confidence: f32,
|
||||
/// Path del Unix socket donde otros módulos pueden suscribirse a los
|
||||
/// bytes replicados de este edge (data plane). `None` si tap=false.
|
||||
pub flow_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSummary {
|
||||
pub id: WorkspaceId,
|
||||
pub label: String,
|
||||
pub commands: u32,
|
||||
pub uptime_ms: u64,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Errores
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProtocolError {
|
||||
#[error("frame oversize: {0} bytes (max {MAX_FRAME})")]
|
||||
FrameOversize(usize),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("postcard: {0}")]
|
||||
Postcard(#[from] postcard::Error),
|
||||
#[error("connection closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Framing helpers
|
||||
// =====================================================================
|
||||
|
||||
pub async fn write_frame<T: Serialize>(stream: &mut UnixStream, msg: &T) -> Result<(), ProtocolError> {
|
||||
let bytes = postcard::to_allocvec(msg)?;
|
||||
if bytes.len() > MAX_FRAME {
|
||||
return Err(ProtocolError::FrameOversize(bytes.len()));
|
||||
}
|
||||
let len = (bytes.len() as u32).to_be_bytes();
|
||||
stream.write_all(&len).await?;
|
||||
stream.write_all(&bytes).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_frame<T: for<'de> Deserialize<'de>>(
|
||||
stream: &mut UnixStream,
|
||||
) -> Result<T, ProtocolError> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
ProtocolError::Closed
|
||||
} else {
|
||||
ProtocolError::Io(e)
|
||||
}
|
||||
})?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME {
|
||||
return Err(ProtocolError::FrameOversize(len));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stream.read_exact(&mut buf).await?;
|
||||
Ok(postcard::from_bytes(&buf)?)
|
||||
}
|
||||
|
||||
/// Path canónico del socket del daemon: `$XDG_RUNTIME_DIR/shuma.sock`,
|
||||
/// fallback `/run/user/$UID/shuma.sock`, fallback `/tmp/shuma-$UID.sock`.
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(xdg).join(DEFAULT_SOCK_NAME);
|
||||
}
|
||||
let uid = nix::unistd::getuid().as_raw();
|
||||
let p = PathBuf::from(format!("/run/user/{uid}"));
|
||||
if p.exists() {
|
||||
return p.join(DEFAULT_SOCK_NAME);
|
||||
}
|
||||
PathBuf::from(format!("/tmp/shuma-{uid}.sock"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ping_roundtrip() {
|
||||
let bytes = postcard::to_allocvec(&Request::Ping).unwrap();
|
||||
let back: Request = postcard::from_bytes(&bytes).unwrap();
|
||||
assert!(matches!(back, Request::Ping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_create_roundtrip() {
|
||||
let req = Request::WorkspaceCreate {
|
||||
spec: WorkspaceSpec {
|
||||
label: "demo".into(),
|
||||
soma: Default::default(),
|
||||
permissions: Default::default(),
|
||||
ttl: None,
|
||||
flow_dirs: vec![],
|
||||
on_exit: shuma_card::ExitPolicy::Reap,
|
||||
quota_enforce: Default::default(),
|
||||
},
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&req).unwrap();
|
||||
let back: Request = postcard::from_bytes(&bytes).unwrap();
|
||||
match back {
|
||||
Request::WorkspaceCreate { spec } => assert_eq!(spec.label, "demo"),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_socket_path_uses_runtime_dir() {
|
||||
let p = default_socket_path();
|
||||
assert!(p.to_string_lossy().ends_with("shuma.sock"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user