- README.md en crates/modules/shipote/ como entry point. - docs/ARCHITECTURE.md — 11 crates, capas, decisiones (O_CLOEXEC, dirty AtomicBool, pipeline restart entero, etc.) + snapshot versioning. - docs/CLI.md — referencia comando por comando, flags, env vars. - docs/RECIPES.md — specs TOML para workspaces y pipelines típicos. - docs/DEVELOPMENT.md — compilar, correr daemon/shell/CLI, tests, smoke E2E manual, debugging FDs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
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 abrahman_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 poryahweh-provider-fsynouser-core::cluster.crates/modules/shipote/shipote-core—WorkspaceManager+ módulospipeline,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 conyahweh_launcher. Dashboard: estado, workspaces, comandos, flows, quotas, sparkline.
Consumidores del discerner (externos a shipote pero usándolo)
crates/modules/ui_engine/libs/providers/fs—FileDataProviderahora pueblamime_typeconDiscernPipeline.crates/modules/nouser/core/src/cluster.rs—pick_lensusa el discerner como fallback cuando la extensión cae aLens::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.
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.