mod cli {
use anyhow::{bail, Context};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
fn cuttlefishd_endpoint() -> PathBuf {
cuttlefish_core::endpoint::default_endpoint()
}
#[derive(Parser)]
#[command(
name = "cuttlefish",
version,
about = "Client for the cuttlefish daemon"
)]
pub struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Run {
#[arg(long, alias = "socket", default_value_os_t = cuttlefishd_endpoint())]
endpoint: PathBuf,
#[arg(long)]
spec: String,
#[arg(long)]
input: String,
},
Specs {
#[arg(long, alias = "socket", default_value_os_t = cuttlefishd_endpoint())]
endpoint: PathBuf,
},
Catalog {
#[command(subcommand)]
action: CatalogCmd,
},
Build {
spec: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum CatalogCmd {
Add {
name_version: String,
path: PathBuf,
},
List,
Show {
name_version: String,
},
Rm {
name_version: String,
},
}
pub async fn main() -> anyhow::Result<()> {
match Cli::parse().command {
Cmd::Specs { endpoint } => crate::daemon::specs(&endpoint).await,
Cmd::Run {
endpoint,
spec,
input,
} => crate::daemon::run(&endpoint, &spec, &input).await,
Cmd::Catalog { action } => catalog_cmd(action),
Cmd::Build { spec, output } => build_cmd(&spec, output),
}
}
fn catalog_cmd(action: CatalogCmd) -> anyhow::Result<()> {
use cuttlefish_host::catalog::Catalog;
let catalog_root = cuttlefish_host::catalog::default_root()
.context("could not determine home directory; set CUTTLEFISH_HOME")?;
let catalog = Catalog::open(catalog_root);
match action {
CatalogCmd::Add { name_version, path } => {
let engine = wasmtime::Engine::default();
let outcome = catalog.add(&name_version, &path, &engine)?;
println!(
"catalogued {} ({})",
outcome.name_version, outcome.signature
);
if outcome.is_permissive_default {
println!(
"warning: {} did not declare a signature (no cf_signature export \
present) — cached as the permissive default, which means \
pipeline::check will accept it next to almost anything. Add a \
signature() impl (see cuttlefish-sdk's Block trait) if this block \
has a real input/output shape.",
outcome.name_version
);
}
Ok(())
}
CatalogCmd::List => {
const NAME_COLUMN: usize = 24;
const MIN_GAP: usize = 2;
for (name_version, entry) in catalog.list()? {
let gap = NAME_COLUMN
.saturating_sub(name_version.chars().count())
.max(MIN_GAP);
println!("{name_version}{:gap$}{}", "", entry.signature);
}
Ok(())
}
CatalogCmd::Show { name_version } => {
let entry = catalog.show(&name_version)?;
println!("{name_version}");
println!(" kind: {:?}", entry.kind);
println!(" signature: {}", entry.signature);
println!(" hash: {}", entry.hash);
println!(" created: {}", entry.created_at);
Ok(())
}
CatalogCmd::Rm { name_version } => {
catalog.rm(&name_version)?;
println!("removed {name_version}");
Ok(())
}
}
}
fn build_cmd(spec_path: &Path, output: Option<PathBuf>) -> anyhow::Result<()> {
let src = std::fs::read_to_string(spec_path)
.with_context(|| format!("reading {}", spec_path.display()))?;
let spec = cuttlefish_core::spec::parse_spec(&src)
.with_context(|| format!("parsing {}", spec_path.display()))?;
let spec_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
let out_path = output.unwrap_or_else(|| spec_path.with_extension("cfbundle"));
if std::fs::canonicalize(&out_path).ok() == std::fs::canonicalize(spec_path).ok() {
bail!(
"refusing to build: output path {} is the same file as the spec being built",
out_path.display()
);
}
let catalog_root = cuttlefish_host::catalog::default_root()
.context("could not determine home directory; set CUTTLEFISH_HOME")?;
let catalog = cuttlefish_host::catalog::Catalog::open(catalog_root);
let engine = wasmtime::Engine::default();
if !cuttlefish_core::graph::is_simple_chain(&spec.nodes, &spec.branches) {
bail!(
"`{}`'s graph isn't a simple linear chain (it has fan-in, a repeat_until \
loop, or conditional dispatch) — `cuttlefish build` doesn't yet support \
packaging that into a bundle. Run it via cuttlefishd instead.",
spec.name
);
}
let resolved: Vec<_> = spec
.nodes
.nodes
.iter()
.map(|(_, node)| {
cuttlefish_host::pipeline::resolve_and_load(
&catalog,
spec_dir,
&node.block.to_string_lossy(),
cuttlefish_host::catalog::ResolutionContext::Interactive,
)
})
.collect::<Result<_, _>>()
.with_context(|| format!("resolving the pipeline for `{}`", spec.name))?;
let checked = cuttlefish_host::pipeline::check(&engine, &resolved)
.with_context(|| format!("checking the pipeline for `{}`", spec.name))?;
for stage in checked.stages() {
println!(
"checking node `{}` ... ok ({})",
stage.name, stage.signature
);
}
let bytes = cuttlefish_host::bundle::build(&checked);
std::fs::write(&out_path, &bytes)
.with_context(|| format!("writing {}", out_path.display()))?;
println!(
"built: {} ({} nodes, accepts {}, produces {})",
out_path.display(),
checked.stages().len(),
checked.input(),
checked.output()
);
Ok(())
}
}
mod daemon {
use anyhow::{bail, Context};
use std::path::Path;
use std::time::Duration;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
fn client(endpoint: &Path) -> anyhow::Result<reqwest::Client> {
let builder = reqwest::Client::builder();
#[cfg(unix)]
let builder = builder.unix_socket(endpoint);
#[cfg(windows)]
let builder = builder.windows_named_pipe(endpoint);
builder.build().context("building the daemon client")
}
pub async fn specs(socket: &Path) -> anyhow::Result<()> {
let body: serde_json::Value = client(socket)?
.get("http://localhost/specs")
.send()
.await
.with_context(|| format!("connecting to daemon at {}", socket.display()))?
.json()
.await?;
println!("{}", serde_json::to_string_pretty(&body)?);
Ok(())
}
pub async fn run(socket: &Path, spec: &str, input: &str) -> anyhow::Result<()> {
let input: serde_json::Value =
serde_json::from_str(input).context("--input must be JSON")?;
let client = client(socket)?;
let submitted = client
.post("http://localhost/jobs")
.json(&serde_json::json!({ "spec": spec, "input": input }))
.send()
.await
.with_context(|| format!("connecting to daemon at {}", socket.display()))?;
if !submitted.status().is_success() {
let status = submitted.status();
bail!(
"daemon rejected the job: {status} {}",
submitted.text().await?
);
}
let job_id = submitted.json::<serde_json::Value>().await?["job_id"]
.as_str()
.context("daemon response had no job_id")?
.to_string();
loop {
let body: serde_json::Value = client
.get(format!("http://localhost/jobs/{job_id}"))
.send()
.await?
.json()
.await?;
let status = body["status"].as_str().unwrap_or("running");
match status {
"completed" | "failed" | "cancelled" => {
println!("{}", serde_json::to_string_pretty(&body["envelope"])?);
std::process::exit(match status {
"completed" => 0,
"failed" => 1,
_ => 2,
});
}
_ => tokio::time::sleep(POLL_INTERVAL).await,
}
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
cli::main().await
}