diff --git a/README.md b/README.md index 79f01c2..48a4e02 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,16 @@ cargo install cargo-component ./target/release/fabricksd ``` -**2. Build and deploy the example service:** +**2. Deploy the example service:** ```bash -# Build the WASM component -cd examples/hello-http -cargo component build --release -cd ../.. - -# Deploy via CLI +# Option A: Deploy from path (builds automatically if needed) ./target/release/fabricks service run examples/hello-http + +# Option B: Build first, then run by tag +./target/release/fabricks build examples/hello-http +./target/release/fabricks images # List stored modules +./target/release/fabricks service run hello-http:0.1.0 ``` **3. Test it:** @@ -73,8 +73,14 @@ curl http://localhost:8080/ # List services ./target/release/fabricks service ls +# View service details +./target/release/fabricks service inspect + # Stop the service ./target/release/fabricks service stop + +# Remove the service +./target/release/fabricks service rm ``` ### Create Your Own Service @@ -370,8 +376,10 @@ fabricks pull wasm://redis:7.2 - ✅ Service management (create, start, stop, scale, delete) - ✅ Network management and port binding - ✅ Fabrickfile and mortar file parsing -- ✅ OCI registry client +- ✅ OCI registry client with local storage - ✅ Health monitoring infrastructure +- ✅ Module storage integration (`fabricks images`, run by tag) +- ✅ Build-on-run (automatic build when running from path) **In Progress:** - 🔄 Auto-scaling diff --git a/fabricks/Cargo.toml b/fabricks/Cargo.toml index ffadabb..1bdf7d2 100644 --- a/fabricks/Cargo.toml +++ b/fabricks/Cargo.toml @@ -38,6 +38,6 @@ hyper.workspace = true hyper-util.workspace = true http-body-util.workspace = true tower.workspace = true +tempfile.workspace = true [dev-dependencies] -tempfile.workspace = true diff --git a/fabricks/src/cli.rs b/fabricks/src/cli.rs index d0b0cc0..9dcc597 100644 --- a/fabricks/src/cli.rs +++ b/fabricks/src/cli.rs @@ -82,6 +82,19 @@ pub enum Commands { /// /// Manage multi-service deployments. Mortar(MortarArgs), + + /// List locally stored modules. + /// + /// Shows all modules built or pulled to local storage. + Images(ImagesArgs), +} + +/// Arguments for the images command. +#[derive(Parser, Debug)] +pub struct ImagesArgs { + /// Output format. + #[arg(short, long, value_enum, default_value = "text")] + pub format: OutputFormat, } /// Arguments for the build command. @@ -303,17 +316,23 @@ pub enum ServiceCommands { format: OutputFormat, }, - /// Deploy and start a service from a Fabrickfile. + /// Deploy and start a service from a Fabrickfile or stored module. /// /// Creates the service and starts it immediately. + /// + /// The reference can be: + /// - A local file path (e.g., `./my-service` or `examples/hello-http`) + /// - A stored module tag (e.g., `hello-http:0.1.0`) + /// - A registry reference (e.g., `ghcr.io/user/module:1.0.0`) - requires `fabricks pull` first Run { - /// Path to the Fabrickfile or directory containing one. + /// Module reference (path, tag, or registry reference). + /// + /// Examples: + /// - ./my-service (local path) + /// - hello-http:0.1.0 (stored module tag) + /// - ghcr.io/user/module:latest (registry reference) #[arg(default_value = ".")] - path: PathBuf, - - /// Path to pre-built WASM module (skips build step). - #[arg(long)] - wasm: Option, + reference: String, /// Output format. #[arg(short, long, value_enum, default_value = "text")] diff --git a/fabricks/src/commands/images.rs b/fabricks/src/commands/images.rs new file mode 100644 index 0000000..048ea90 --- /dev/null +++ b/fabricks/src/commands/images.rs @@ -0,0 +1,180 @@ +//! Images command implementation. +//! +//! Lists all locally stored modules in OCI format. + +use anyhow::{Context, Result}; +use fabricks_oci::LocalStorage; + +use crate::cli::{ImagesArgs, OutputFormat}; +use crate::output; + +/// Information about a stored image. +#[derive(Debug, serde::Serialize)] +struct ImageInfo { + /// Image reference (e.g., "hello-http:0.1.0"). + reference: String, + /// Manifest digest. + digest: String, + /// WASM size in bytes. + wasm_size: u64, + /// Module name from config. + name: Option, + /// Module version from config. + version: Option, +} + +/// Run the images command. +/// +/// # Errors +/// +/// Returns an error if storage operations fail. +pub async fn run(args: &ImagesArgs) -> Result<()> { + let storage = get_local_storage()?; + let refs = storage.list_references().await.context("Failed to list references")?; + + if refs.is_empty() { + match args.format { + OutputFormat::Text => { + output::writeln("No images found.")?; + output::writeln("")?; + output::writeln("Build an image with: fabricks build ")?; + } + OutputFormat::Json => { + output::writeln("[]")?; + } + } + return Ok(()); + } + + let mut images = Vec::new(); + + for reference in &refs { + if let Ok(info) = get_image_info(&storage, reference).await { + images.push(info); + } + } + + match args.format { + OutputFormat::Text => { + // Print header + output::writeln(&format!( + "{:<30} {:<12} {:<10} {:<64}", + "REFERENCE", "SIZE", "VERSION", "DIGEST" + ))?; + output::writeln(&"-".repeat(116))?; + + for image in &images { + let size = format_size(image.wasm_size); + let version = image.version.as_deref().unwrap_or("-"); + let short_digest = if image.digest.len() > 19 { + &image.digest[..19] + } else { + &image.digest + }; + + output::writeln(&format!( + "{:<30} {:<12} {:<10} {}...", + image.reference, size, version, short_digest + ))?; + } + + output::writeln("")?; + output::writeln(&format!("Total: {} image(s)", images.len()))?; + } + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&images)?; + output::writeln(&json)?; + } + } + + Ok(()) +} + +/// Get image info from storage. +async fn get_image_info(storage: &LocalStorage, reference: &str) -> Result { + let manifest_digest = storage.get_manifest_digest(reference).await?; + let manifest_bytes = storage.get_blob(&manifest_digest).await?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes)?; + + // Extract WASM size from layers + let wasm_size = manifest["layers"] + .as_array() + .and_then(|layers| layers.first()) + .and_then(|layer| layer["size"].as_u64()) + .unwrap_or(0); + + // Extract name and version from annotations + let annotations = manifest["annotations"].as_object(); + let name = annotations + .and_then(|a| a.get("dev.fabricks.name")) + .and_then(|v| v.as_str()) + .map(String::from); + let version = annotations + .and_then(|a| a.get("dev.fabricks.module.version")) + .and_then(|v| v.as_str()) + .map(String::from); + + Ok(ImageInfo { + reference: reference.to_string(), + digest: manifest_digest, + wasm_size, + name, + version, + }) +} + +/// Get the default local storage location. +fn get_local_storage() -> Result { + let home = dirs::home_dir().context("Could not determine home directory")?; + let storage_path = home.join(".fabricks").join("storage"); + + // Check if storage exists + if !storage_path.exists() { + anyhow::bail!( + "No local storage found at {}\n\ + Build an image first with: fabricks build ", + storage_path.display() + ); + } + + LocalStorage::open(storage_path).context("Failed to open local storage") +} + +/// Format size in human-readable format. +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + let whole = bytes / GB; + let frac = (bytes % GB) * 10 / GB; + format!("{whole}.{frac} GB") + } else if bytes >= MB { + let whole = bytes / MB; + let frac = (bytes % MB) * 10 / MB; + format!("{whole}.{frac} MB") + } else if bytes >= KB { + let whole = bytes / KB; + let frac = (bytes % KB) * 10 / KB; + format!("{whole}.{frac} KB") + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_size() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(500), "500 B"); + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1536), "1.5 KB"); + assert_eq!(format_size(1048576), "1.0 MB"); + assert_eq!(format_size(1572864), "1.5 MB"); + assert_eq!(format_size(1073741824), "1.0 GB"); + } +} diff --git a/fabricks/src/commands/mod.rs b/fabricks/src/commands/mod.rs index bdce0ab..3ab567d 100644 --- a/fabricks/src/commands/mod.rs +++ b/fabricks/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod daemon; +pub mod images; pub mod inspect; pub mod login; pub mod logout; diff --git a/fabricks/src/commands/service.rs b/fabricks/src/commands/service.rs index 49f14e2..45088ce 100644 --- a/fabricks/src/commands/service.rs +++ b/fabricks/src/commands/service.rs @@ -1,13 +1,29 @@ //! Service command implementation. +use std::io::Write; use std::path::Path; +use std::process::Command; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use fabricks_common::{parse_fabrickfile, Fabrickfile}; +use fabricks_oci::{FabricksModule, LocalStorage}; +use tempfile::NamedTempFile; +use tracing::{debug, info}; use crate::cli::{OutputFormat, ServiceArgs, ServiceCommands}; use crate::daemon_client::{DaemonClient, RunFabrickfileRequest}; use crate::output; +/// Source of a resolved module. +enum ModuleSource { + /// A module loaded from local storage. + Storage { + tag: String, + fabrickfile: Fabrickfile, + wasm_bytes: Vec, + }, +} + /// Runs the service command. /// /// # Errors @@ -21,8 +37,8 @@ pub async fn run(args: &ServiceArgs) -> Result<()> { match &args.command { ServiceCommands::List { format } => list_services(&client, *format).await, - ServiceCommands::Run { path, wasm, format } => { - run_fabrickfile(&client, path, wasm.as_deref(), *format).await + ServiceCommands::Run { reference, format } => { + run_service(&client, reference, *format).await } ServiceCommands::Inspect { id, format } => inspect_service(&client, id, *format).await, ServiceCommands::Start { id } => start_service(&client, id).await, @@ -32,37 +48,308 @@ pub async fn run(args: &ServiceArgs) -> Result<()> { } } -async fn run_fabrickfile( - client: &DaemonClient, - path: &Path, - wasm: Option<&Path>, - format: OutputFormat, -) -> Result<()> { - // Resolve Fabrickfile path +/// Resolve a module reference to its source. +/// +/// Reference types: +/// - Local file path (exists on filesystem) -> build if needed, store, load from storage +/// - Registry reference (contains '/') -> not yet supported +/// - Local storage tag (e.g., "hello-http:0.1.0") -> load from storage +async fn resolve_module_reference(reference: &str) -> Result { + let path = Path::new(reference); + + // 1. Local file/directory path + if path.exists() { + return resolve_from_path(path).await; + } + + // 2. Registry reference (contains '/') + if reference.contains('/') { + bail!( + "Registry references not yet supported.\n\ + Pull the module first with: fabricks pull {reference}" + ); + } + + // 3. Local tag (e.g., "hello-http:0.1.0") + resolve_from_storage(reference).await +} + +/// Resolve module from a filesystem path. +/// +/// This will build the module if needed and store it in local storage. +async fn resolve_from_path(path: &Path) -> Result { let fabrickfile_path = if path.is_dir() { path.join("Fabrickfile") } else { path.to_path_buf() }; - // Make path absolute let fabrickfile_path = fabrickfile_path .canonicalize() .with_context(|| format!("Fabrickfile not found: {}", fabrickfile_path.display()))?; - // Make wasm path absolute if provided - let wasm_path = wasm - .map(Path::canonicalize) - .transpose() - .context("WASM path not found")?; + let workdir = fabrickfile_path + .parent() + .context("Fabrickfile has no parent directory")?; + + // Parse the Fabrickfile + let fabrickfile = parse_fabrickfile(&fabrickfile_path)?; + + // Determine tag + let tag = format!("{}:{}", fabrickfile.info.name, fabrickfile.info.version); + + // Check if already in storage + if let Ok(source) = resolve_from_storage(&tag).await { + info!("Using cached module: {tag}"); + return Ok(source); + } + + // Build and store the module + output::writeln(&format!("Building {tag}..."))?; + let wasm_bytes = build_module(&fabrickfile, workdir)?; + + // Store in local storage + let module = FabricksModule::new(fabrickfile.clone(), wasm_bytes.clone()); + store_module(&module, &tag).await?; + + output::writeln(&format!("Stored as: {tag}"))?; + + Ok(ModuleSource::Storage { + tag, + fabrickfile, + wasm_bytes, + }) +} + +/// Build a module using its build command. +fn build_module(fabrickfile: &Fabrickfile, workdir: &Path) -> Result> { + let build = fabrickfile + .build + .as_ref() + .context("Fabrickfile has no [build] section")?; + + // Determine the actual working directory + let actual_workdir = if let Some(ref build_workdir) = build.workdir { + workdir.join(build_workdir) + } else { + workdir.to_path_buf() + }; + + debug!("Running build command in {}", actual_workdir.display()); + debug!("Command: {}", build.command); + + // Build environment + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&build.command).current_dir(&actual_workdir); + + // Add build environment variables + if let Some(ref environment) = build.environment { + for (key, value) in environment { + cmd.env(key, value); + } + } + + let output = cmd + .output() + .context("Failed to execute build command")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + bail!( + "Build command failed with exit code {}\nstdout: {}\nstderr: {}", + output.status, + stdout, + stderr + ); + } + + info!("Build command completed successfully"); + + // Read the WASM output + let output_path = actual_workdir.join(&build.output); + + if !output_path.exists() { + bail!( + "Build output not found: {}\nMake sure the build command creates this file.", + output_path.display() + ); + } + + let wasm_bytes = + std::fs::read(&output_path).context("Failed to read WASM output file")?; + + debug!("Read {} bytes from {}", wasm_bytes.len(), output_path.display()); + Ok(wasm_bytes) +} + +/// Get or create local storage. +async fn get_or_create_local_storage() -> Result { + let home = dirs::home_dir().context("Could not determine home directory")?; + let storage_path = home.join(".fabricks").join("storage"); + LocalStorage::new(storage_path) + .await + .context("Failed to initialize local storage") +} + +/// Store a module in local storage. +async fn store_module(module: &FabricksModule, tag: &str) -> Result<()> { + let storage = get_or_create_local_storage().await?; + + // Store config blob + let config_bytes = module.config_bytes().context("Failed to serialize config")?; + let config_digest = storage + .store_blob(&config_bytes) + .await + .context("Failed to store config blob")?; + debug!("Stored config: {config_digest}"); + + // Store WASM blob + let wasm_digest = storage + .store_blob(module.wasm_bytes()) + .await + .context("Failed to store WASM blob")?; + debug!("Stored WASM: {wasm_digest}"); + + // Build and store manifest + let manifest = build_manifest(module, &config_digest, &wasm_digest); + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .context("Failed to serialize manifest")?; + let manifest_digest = storage + .store_blob(&manifest_bytes) + .await + .context("Failed to store manifest")?; + debug!("Stored manifest: {manifest_digest}"); + + // Add to index + storage + .add_to_index(tag, &manifest_digest, i64::try_from(manifest_bytes.len()).unwrap_or(i64::MAX)) + .await + .context("Failed to update storage index")?; + + Ok(()) +} + +/// Build a manifest for storage. +fn build_manifest( + module: &FabricksModule, + config_digest: &str, + wasm_digest: &str, +) -> serde_json::Value { + let config_bytes = module.config_bytes().unwrap_or_default(); + let annotations = module.build_annotations(); + + serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.fabricks.module.v1", + "config": { + "mediaType": "application/vnd.fabricks.config.v1+toml", + "digest": config_digest, + "size": config_bytes.len(), + }, + "layers": [{ + "mediaType": "application/vnd.fabricks.module.v1+wasm", + "digest": wasm_digest, + "size": module.wasm_size(), + }], + "annotations": annotations, + }) +} + +/// Resolve module from local OCI storage. +async fn resolve_from_storage(reference: &str) -> Result { + let storage = get_local_storage()?; + + // Get manifest digest for this reference + let manifest_digest = storage + .get_manifest_digest(reference) + .await + .with_context(|| format!("Module not found: {reference}"))?; + + // Load and parse manifest + let manifest_bytes = storage + .get_blob(&manifest_digest) + .await + .context("Failed to load manifest")?; + let manifest: serde_json::Value = + serde_json::from_slice(&manifest_bytes).context("Failed to parse manifest")?; + + // Extract config digest and load Fabrickfile + let config_digest = manifest["config"]["digest"] + .as_str() + .context("Manifest missing config digest")?; + let config_bytes = storage + .get_blob(config_digest) + .await + .context("Failed to load config blob")?; + let config_toml = + String::from_utf8(config_bytes).context("Config blob is not valid UTF-8")?; + let fabrickfile: Fabrickfile = + toml::from_str(&config_toml).context("Failed to parse Fabrickfile from storage")?; + + // Extract WASM layer digest and load bytes + let wasm_digest = manifest["layers"] + .as_array() + .and_then(|layers| layers.first()) + .and_then(|layer| layer["digest"].as_str()) + .context("Manifest missing WASM layer")?; + let wasm_bytes = storage + .get_blob(wasm_digest) + .await + .context("Failed to load WASM blob")?; + + Ok(ModuleSource::Storage { + tag: reference.to_string(), + fabrickfile, + wasm_bytes, + }) +} + +/// Get the local storage path. +fn get_local_storage() -> Result { + let home = dirs::home_dir().context("Could not determine home directory")?; + let storage_path = home.join(".fabricks").join("storage"); + + if !storage_path.exists() { + bail!( + "No local storage found at {}\n\ + Build a module first with: fabricks build \n\ + Or pull from a registry with: fabricks pull ", + storage_path.display() + ); + } + + LocalStorage::open(storage_path).context("Failed to open local storage") +} + +/// Run a service from a module reference. +async fn run_service(client: &DaemonClient, reference: &str, format: OutputFormat) -> Result<()> { + let ModuleSource::Storage { + tag, + fabrickfile, + wasm_bytes, + } = resolve_module_reference(reference).await?; + + // Write WASM to a temp file and create a temp Fabrickfile + // (daemon currently expects file paths) + let wasm_temp = write_temp_wasm(&wasm_bytes)?; + let fabrickfile_temp = write_temp_fabrickfile(&fabrickfile)?; + + output::writeln(&format!("Running module: {tag}"))?; let req = RunFabrickfileRequest { - fabrickfile_path, - wasm_path, + fabrickfile_path: fabrickfile_temp.path().to_path_buf(), + wasm_path: Some(wasm_temp.path().to_path_buf()), }; + // Keep temp files alive during the request let response = client.run_fabrickfile(req).await?; + // Temp files are dropped here after daemon has read them + drop(wasm_temp); + drop(fabrickfile_temp); + match format { OutputFormat::Json => { let json = serde_json::to_string_pretty(&response)?; @@ -80,6 +367,25 @@ async fn run_fabrickfile( Ok(()) } +/// Write WASM bytes to a temporary file. +fn write_temp_wasm(wasm_bytes: &[u8]) -> Result { + let mut temp = NamedTempFile::with_suffix(".wasm").context("Failed to create temp file")?; + temp.write_all(wasm_bytes) + .context("Failed to write WASM to temp file")?; + temp.flush().context("Failed to flush temp file")?; + Ok(temp) +} + +/// Write Fabrickfile to a temporary file. +fn write_temp_fabrickfile(fabrickfile: &Fabrickfile) -> Result { + let mut temp = NamedTempFile::with_suffix(".toml").context("Failed to create temp file")?; + let toml_content = toml::to_string_pretty(fabrickfile).context("Failed to serialize Fabrickfile")?; + temp.write_all(toml_content.as_bytes()) + .context("Failed to write Fabrickfile to temp file")?; + temp.flush().context("Failed to flush temp file")?; + Ok(temp) +} + async fn inspect_service(client: &DaemonClient, id: &str, format: OutputFormat) -> Result<()> { let detail = client.get_service(id).await?; diff --git a/fabricks/src/main.rs b/fabricks/src/main.rs index 6f69048..b390ca5 100644 --- a/fabricks/src/main.rs +++ b/fabricks/src/main.rs @@ -60,6 +60,7 @@ fn main() -> ExitCode { Commands::Daemon(args) => rt.block_on(commands::daemon::run(&args)), Commands::Service(args) => rt.block_on(commands::service::run(&args)), Commands::Mortar(args) => rt.block_on(commands::mortar::run(&args)), + Commands::Images(args) => rt.block_on(commands::images::run(&args)), }; match result {