shell
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user