This commit is contained in:
sergio
2026-05-10 21:58:16 +00:00
parent 3d55f189c0
commit c22d2480b9
36 changed files with 5158 additions and 363 deletions
+391
View File
@@ -0,0 +1,391 @@
//! `shipote` — CLI de administración del daemon.
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use shipote_card::{load_pipeline_spec, load_workspace_spec, WorkspaceId};
use shipote_protocol::{default_socket_path, read_frame, write_frame, Request, Response};
use std::path::PathBuf;
use tokio::net::UnixStream;
use ulid::Ulid;
#[derive(Parser, Debug)]
#[command(name = "shipote", version, about = "Administración de shipote-daemon")]
struct Cli {
/// Path al socket del daemon. Default: $XDG_RUNTIME_DIR/shipote.sock.
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
/// Health-check del daemon.
Ping,
/// Capacidades runtime detectadas por el daemon.
Caps,
/// Operaciones sobre Workspaces.
#[command(subcommand)]
Workspace(WsCmd),
/// Ejecutar un comando one-shot dentro de un workspace.
Run {
/// ULID del workspace destino.
#[arg(short = 'w', long)]
workspace: String,
/// Path del ejecutable.
exec: String,
/// Argumentos del comando.
argv: Vec<String>,
},
/// Discernir el tipo de un archivo (ad-hoc, sin workspace).
Discern {
/// Path al archivo a discernir.
path: PathBuf,
},
/// Listar comandos de un workspace.
Commands {
/// ULID del workspace.
workspace: String,
},
/// Mostrar tail del log capturado de un comando.
Logs {
/// ULID del workspace.
workspace: String,
/// ULID del comando.
command: String,
/// Bytes desde el final (0 = todo).
#[arg(long, default_value_t = 0)]
tail: usize,
},
/// Pipeline DAG con flujo tipado.
#[command(subcommand)]
Pipeline(PipeCmd),
}
#[derive(Subcommand, Debug)]
enum PipeCmd {
/// Lanzar un Pipeline desde un spec TOML/JSON.
Run {
/// Path al spec del pipeline.
spec: PathBuf,
/// Interponer un tap entre productor↔consumidor de cada edge para
/// discernir el TypeRef del flujo.
#[arg(long)]
tap: bool,
},
/// Guardar un pipeline bajo un nombre (persiste con el snapshot).
Save {
/// Nombre simbólico.
name: String,
/// Path al spec.
spec: PathBuf,
},
/// Listar nombres de pipelines guardados.
SavedList,
/// Eliminar un pipeline guardado.
Drop { name: String },
/// Ejecutar un pipeline guardado por nombre.
RunSaved {
name: String,
#[arg(long)]
tap: bool,
},
}
#[derive(Subcommand, Debug)]
enum WsCmd {
/// Crear un workspace desde un spec TOML/JSON.
Create {
/// Path al spec del workspace.
spec: PathBuf,
},
/// Listar workspaces vivos.
List,
/// Detener un workspace por ID.
Stop {
id: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let socket = cli.socket.unwrap_or_else(default_socket_path);
let mut stream = UnixStream::connect(&socket)
.await
.with_context(|| format!("connect {}", socket.display()))?;
match cli.cmd {
Cmd::Ping => {
let resp = round_trip(&mut stream, Request::Ping).await?;
match resp {
Response::Pong => println!("pong"),
other => print_unexpected(&other),
}
}
Cmd::Caps => {
let resp = round_trip(&mut stream, Request::Capabilities).await?;
match resp {
Response::Capabilities {
kernel_version,
user_ns,
cgroup_v2,
cgroup_delegated,
has_cap_sys_admin,
} => {
println!("kernel: {}.{}.{}", kernel_version.0, kernel_version.1, kernel_version.2);
println!("user_ns: {user_ns}");
println!("cgroup_v2: {cgroup_v2}");
println!("cgroup_delegated: {cgroup_delegated}");
println!("cap_sys_admin: {has_cap_sys_admin}");
}
other => print_unexpected(&other),
}
}
Cmd::Workspace(WsCmd::Create { spec }) => {
let ws = load_workspace_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
let resp = round_trip(&mut stream, Request::WorkspaceCreate { spec: ws }).await?;
match resp {
Response::WorkspaceCreated { id, warnings } => {
println!("{id}");
for w in warnings {
eprintln!("warning: {w}");
}
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}
Cmd::Workspace(WsCmd::List) => {
let resp = round_trip(&mut stream, Request::WorkspaceList).await?;
match resp {
Response::WorkspaceList { items } => {
if items.is_empty() {
println!("(no workspaces)");
}
for it in items {
println!(
"{} {:<20} cmds={} uptime={}ms",
it.id, it.label, it.commands, it.uptime_ms
);
}
}
other => print_unexpected(&other),
}
}
Cmd::Workspace(WsCmd::Stop { id }) => {
let id = parse_ws_id(&id)?;
let resp = round_trip(&mut stream, Request::WorkspaceStop { id }).await?;
match resp {
Response::WorkspaceStopped { id, reaped } => {
println!("stopped {id} (reaped {reaped})");
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}
Cmd::Run { workspace, exec, argv } => {
let id = parse_ws_id(&workspace)?;
let resp = round_trip(
&mut stream,
Request::Run {
workspace: id,
exec,
argv,
envp: vec![],
},
)
.await?;
match resp {
Response::RunStarted { command_id, pid, .. } => {
println!("{command_id} pid={pid}");
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}
Cmd::Pipeline(PipeCmd::Run { spec, tap }) => {
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
let resp = round_trip(&mut stream, Request::PipelineRun { spec: p, tap }).await?;
print_pipeline_started(resp)?;
}
Cmd::Pipeline(PipeCmd::Save { name, spec }) => {
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
let resp = round_trip(&mut stream, Request::PipelineSave { name: name.clone(), spec: p }).await?;
match resp {
Response::PipelineSaved { name } => println!("saved {name}"),
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}
Cmd::Pipeline(PipeCmd::SavedList) => {
let resp = round_trip(&mut stream, Request::PipelineSavedList).await?;
match resp {
Response::PipelineSavedList { names } => {
if names.is_empty() {
println!("(no saved pipelines)");
}
for n in names {
println!("{n}");
}
}
other => print_unexpected(&other),
}
}
Cmd::Pipeline(PipeCmd::Drop { name }) => {
let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?;
match resp {
Response::PipelineDropped { name, existed } => {
if existed {
println!("dropped {name}");
} else {
eprintln!("no existía: {name}");
}
}
other => print_unexpected(&other),
}
}
Cmd::Pipeline(PipeCmd::RunSaved { name, tap }) => {
let resp = round_trip(&mut stream, Request::PipelineRunSaved { name, tap }).await?;
print_pipeline_started(resp)?;
}
Cmd::Commands { workspace } => {
let ws = parse_ws_id(&workspace)?;
let resp = round_trip(&mut stream, Request::CommandList { workspace: ws }).await?;
match resp {
Response::CommandList { items } => {
if items.is_empty() {
println!("(no commands)");
}
for c in items {
let alive = if c.alive { "alive" } else { "exited" };
let exit = c
.exit_status
.map(|s| format!("exit={s}"))
.unwrap_or_default();
println!(
"{} {:<24} pid={:<7} {:<8} logs={} {}",
c.id, c.label, c.pid, alive, c.log_bytes, exit
);
}
}
other => print_unexpected(&other),
}
}
Cmd::Logs { workspace, command, tail } => {
let ws = parse_ws_id(&workspace)?;
let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
let resp = round_trip(
&mut stream,
Request::CommandLogs {
workspace: ws,
command: cmd_id,
tail_bytes: tail,
},
)
.await?;
match resp {
Response::CommandLogs { bytes } => {
// stdout raw, sin decoding — el log puede tener bytes binarios.
use std::io::Write;
let _ = std::io::stdout().write_all(&bytes);
let _ = std::io::stdout().flush();
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}
Cmd::Discern { path } => {
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
// Sample: hasta 4 KiB.
let sample = bytes.into_iter().take(4096).collect();
let resp = round_trip(
&mut stream,
Request::Discern {
sample,
hint_path: Some(path),
},
)
.await?;
match resp {
Response::Discernment { ty, confidence, mime, lens } => {
println!("type: {ty}");
println!("confidence: {confidence:.2}");
if let Some(m) = mime {
println!("mime: {m}");
}
if let Some(l) = lens {
println!("lens: {l}");
}
}
Response::Error { message } => return Err(anyhow!(message)),
other => print_unexpected(&other),
}
}
}
Ok(())
}
async fn round_trip(stream: &mut UnixStream, req: Request) -> Result<Response> {
write_frame(stream, &req).await?;
let resp: Response = read_frame(stream).await?;
Ok(resp)
}
fn parse_ws_id(s: &str) -> Result<WorkspaceId> {
let u = Ulid::from_string(s).map_err(|e| anyhow!("invalid workspace id: {e}"))?;
Ok(WorkspaceId(u))
}
fn print_unexpected(r: &Response) {
eprintln!("unexpected response: {r:?}");
}
fn print_pipeline_started(resp: Response) -> Result<()> {
match resp {
Response::PipelineStarted { pipeline, command_pids, edges } => {
println!("pipeline {pipeline}");
for (label, pid) in command_pids {
println!(" {:<20} pid={pid}", label);
}
if !edges.is_empty() {
println!("edges:");
for e in edges {
println!(
" {}.{}{}.{} ty={:?} mime={:?} conf={:.2}",
e.from_label, e.from_output, e.to_label, e.to_input,
e.ty, e.mime, e.confidence,
);
}
}
Ok(())
}
Response::Error { message } => Err(anyhow!(message)),
other => {
print_unexpected(&other);
Ok(())
}
}
}