aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-07-15 01:28:02 +0100
committerNikolay Govorov <me@govorov.online>2026-07-15 01:28:02 +0100
commit353e3b6fe64659b33a6ca376f8c1e9aa4ae752d3 (patch)
tree91772d2b81668e0716f945ffe85ddbe19630a996
parentfb93355d37d5009f34fa9fba210f4d80d9d76ac6 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Split CLI to image/machine namespaces
Diffstat
-rw-r--r--Cargo.lock115+115 −0
-rw-r--r--crates/hule/Cargo.toml1+1 −0
-rw-r--r--crates/hule/src/main.rs124+75 −49
-rw-r--r--docs/whitepaper.md22+13 −9
4 files changed, 204 insertions, 58 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 38d3abe..cff42e0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -21,6 +21,56 @@ dependencies = [
]
[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -159,6 +209,46 @@ dependencies = [
]
[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -168,6 +258,12 @@ dependencies = [
]
[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -570,6 +666,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
name = "hule"
version = "0.1.0"
dependencies = [
+ "clap",
"hule-hmi",
"hule-oci",
"hule-vmm",
@@ -816,6 +913,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1040,6 +1143,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1870,6 +1979,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
name = "uuid"
version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/hule/Cargo.toml b/crates/hule/Cargo.toml
index 12f361d..354290b 100644
--- a/crates/hule/Cargo.toml
+++ b/crates/hule/Cargo.toml
@@ -13,6 +13,7 @@ authors.workspace = true
repository.workspace = true
[dependencies]
+clap = { version = "4.6.1", features = ["derive"] }
hule-hmi.workspace = true
hule-oci.workspace = true
hule-vmm.workspace = true
diff --git a/crates/hule/src/main.rs b/crates/hule/src/main.rs
index 3f316f5..644545b 100644
--- a/crates/hule/src/main.rs
+++ b/crates/hule/src/main.rs
@@ -4,6 +4,7 @@
use std::path::Path;
use std::process::exit;
+use clap::{Parser, Subcommand};
use hule_hmi::{Access, MachineImage};
use hule_vmm::backend::qemu::QemuHypervisor;
use hule_vmm::{Hypervisor, MachineId, Settings};
@@ -11,6 +12,54 @@ use uuid::Uuid;
type R<T> = std::result::Result<T, String>;
+#[derive(Parser)]
+#[command(version, about)]
+#[command(propagate_version = true)]
+struct Cli {
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ Image {
+ #[command(subcommand)]
+ command: ImageCommands,
+ },
+
+ Machine {
+ #[command(subcommand)]
+ command: MachineCommands,
+ },
+}
+
+#[derive(Subcommand)]
+enum ImageCommands {
+ Pull {
+ name: String,
+ },
+
+ Push {
+ name: String,
+ },
+
+ Load {
+ image: String,
+
+ reference: Option<String>,
+ },
+}
+
+#[derive(Subcommand)]
+enum MachineCommands {
+ Run {
+ image: String,
+
+ #[arg(default_value_t = 8022)]
+ port: u16,
+ },
+}
+
fn die(msg: impl AsRef<str>) -> ! {
eprintln!("hule: {}", msg.as_ref());
exit(1);
@@ -74,60 +123,37 @@ async fn cmd_run(reference: &str, port: u16) -> R<()> {
#[tokio::main]
async fn main() {
- let args: Vec<String> = std::env::args().collect();
- let usage = |prog: &str| -> ! {
- eprintln!("usage:");
- eprintln!(" {prog} import <image-dir> [ref]");
- eprintln!(" {prog} push <ref>");
- eprintln!(" {prog} pull <ref>");
- eprintln!(" {prog} run <ref> [port]");
- exit(2);
- };
+ let cli = Cli::parse();
- if args.len() < 2 {
- usage(&args[0]);
- }
+ match &cli.command {
+ Commands::Image { command } => match &command {
+ ImageCommands::Pull { name } => {
+ let digest = hule_oci::pull(name).await.unwrap_or_else(|e| die(e));
+ eprintln!("hule: pulled {} ({digest})", name);
+ }
- match args[1].as_str() {
- "import" => {
- if args.len() < 3 {
- usage(&args[0]);
+ ImageCommands::Push { name } => {
+ hule_oci::push(name).await.unwrap_or_else(|e| die(e));
+ eprintln!("hule: pushed {}", name);
}
- let reference = args.get(3).map(String::as_str);
- let digest = hule_oci::import(Path::new(&args[2]), reference)
- .await
- .unwrap_or_else(|e| die(e));
- match reference {
- Some(reference) => {
- eprintln!("hule: imported {} as {reference} ({digest})", args[2])
+
+ ImageCommands::Load { reference, image } => {
+ let digest = hule_oci::import(Path::new(image), reference.as_deref())
+ .await
+ .unwrap_or_else(|e| die(e));
+
+ match reference {
+ Some(reference) => {
+ eprintln!("hule: loaded {} as {reference} ({digest})", image)
+ }
+ None => eprintln!("hule: loaded {} ({digest}, untagged)", image),
}
- None => eprintln!("hule: imported {} ({digest}, untagged)", args[2]),
- }
- }
- "push" => {
- if args.len() < 3 {
- usage(&args[0]);
- }
- hule_oci::push(&args[2]).await.unwrap_or_else(|e| die(e));
- eprintln!("hule: pushed {}", args[2]);
- }
- "pull" => {
- if args.len() < 3 {
- usage(&args[0]);
}
- let digest = hule_oci::pull(&args[2]).await.unwrap_or_else(|e| die(e));
- eprintln!("hule: pulled {} ({digest})", args[2]);
- }
- "run" => {
- if args.len() < 3 {
- usage(&args[0]);
+ },
+ Commands::Machine { command } => match &command {
+ MachineCommands::Run { image, port } => {
+ cmd_run(image, *port).await.unwrap_or_else(|e| die(e));
}
- let port = args
- .get(3)
- .map_or(Ok(8022), |s| s.parse::<u16>())
- .unwrap_or_else(|_| die(format!("invalid port '{}'", args[3])));
- cmd_run(&args[2], port).await.unwrap_or_else(|e| die(e));
- }
- _ => usage(&args[0]),
+ },
}
}
diff --git a/docs/whitepaper.md b/docs/whitepaper.md
index 948430e..2661601 100644
--- a/docs/whitepaper.md
+++ b/docs/whitepaper.md
@@ -106,7 +106,7 @@ chunks so they can be transferred and verified in parallel.
It is responsible for:
-- importing an already prepared machine;
+- loading and saving already prepared Hule machines;
- pushing and pulling OCI artifacts;
- maintaining a content-addressed local cache;
- resolving complete backing chains;
@@ -142,12 +142,12 @@ builder, an exported VM, or a directory copied by the user.
A Hulefile is a recipe for deriving one Hule image from another. Unlike an
external image builder, it does not install an operating system from scratch.
-Its input is an already imported Hule machine that can be started and controlled
+Its input is an already loaded Hule machine that can be started and controlled
through an `exec`-capable access method.
Every Hulefile has exactly one parent image. There is no empty base and no
equivalent of `FROM scratch`: creating the first bootable machine always happens
-outside the Hulefile workflow and enters Hule through import.
+outside the Hulefile workflow and enters Hule through load or import.
The intended execution model is deliberately simple:
@@ -172,12 +172,15 @@ implicitly portable across operating systems.
## Image Lifecycle
-### Prepare and Import
+### Prepare, Import, and Load
Preparing a base guest operating system is outside Hule. Users may use Packer, a
-`genimg` script, an unattended installer, or a manually configured VM. Hule
-imports the finished, powered-off machine, which can then be distributed or used
-as the parent of a Hulefile.
+`genimg` script, an unattended installer, or a manually configured VM. A
+finished Hule machine with HMI disks and a `config.json` enters the local store
+through load and can be exported again through save. A qcow2 or raw disk image
+instead enters through import, which converts it to HMI and constructs the
+machine configuration where possible. The resulting Hule image can then be
+distributed or used as the parent of a Hulefile.
This boundary is important for systems whose prebuilt images cannot be freely
redistributed. A project can publish a recipe that downloads official
@@ -186,8 +189,9 @@ to the user's own OCI registry.
### Publish
-The prepared machine is imported, validated, split into OCI blobs, and pushed to
-a registry. Tags provide convenient names; digests identify immutable versions.
+The prepared Hule machine is loaded, validated, split into OCI blobs, and pushed
+to a registry. Tags provide convenient names; digests identify immutable
+versions.
### Run