Page MenuHomePhabricator

No OneTemporary

diff --git a/.gitignore b/.gitignore
index 686ddf2..5fb7f9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
/target
/libcollar_store
**/libcollar_store
+crates/api/test_api*
diff --git a/Cargo.lock b/Cargo.lock
index a6a646b..75b0884 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -133,6 +133,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
[[package]]
name = "auto-future"
version = "1.0.0"
@@ -244,6 +250,12 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "bindgen"
version = "0.71.1"
@@ -276,6 +288,12 @@ version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
+[[package]]
+name = "bumpalo"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
+
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -294,6 +312,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba"
+[[package]]
+name = "cc"
+version = "1.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951"
+dependencies = [
+ "shlex",
+]
+
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -326,6 +353,46 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "clap"
+version = "4.5.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
+
[[package]]
name = "collar"
version = "0.1.0"
@@ -340,6 +407,19 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "collar-cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
[[package]]
name = "colorchoice"
version = "1.0.3"
@@ -356,6 +436,22 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
[[package]]
name = "crc32fast"
version = "1.4.2"
@@ -412,6 +508,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "env_filter"
version = "0.1.3"
@@ -435,6 +540,12 @@ dependencies = [
"log",
]
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "errno"
version = "0.3.12"
@@ -457,6 +568,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -497,6 +623,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
[[package]]
name = "futures-task"
version = "0.3.31"
@@ -527,6 +659,17 @@ dependencies = [
"byteorder",
]
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
[[package]]
name = "getrandom"
version = "0.3.3"
@@ -551,6 +694,37 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
+[[package]]
+name = "h2"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
[[package]]
name = "http"
version = "1.3.1"
@@ -606,6 +780,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
+ "h2",
"http",
"http-body",
"httparse",
@@ -617,12 +792,45 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.27.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
[[package]]
name = "hyper-util"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
dependencies = [
+ "base64",
"bytes",
"futures-channel",
"futures-core",
@@ -630,12 +838,16 @@ dependencies = [
"http",
"http-body",
"hyper",
+ "ipnet",
"libc",
+ "percent-encoding",
"pin-project-lite",
"socket2",
+ "system-configuration",
"tokio",
"tower-service",
"tracing",
+ "windows-registry",
]
[[package]]
@@ -756,6 +968,16 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "indexmap"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
[[package]]
name = "instant"
version = "0.1.13"
@@ -765,6 +987,22 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "ipnet"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
+[[package]]
+name = "iri-string"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -810,6 +1048,16 @@ dependencies = [
"syn",
]
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -936,6 +1184,23 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
[[package]]
name = "ng"
version = "0.1.0"
@@ -1008,6 +1273,50 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+[[package]]
+name = "openssl"
+version = "0.10.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
+dependencies = [
+ "bitflags 2.9.1",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "overload"
version = "0.1.1"
@@ -1080,6 +1389,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
[[package]]
name = "portable-atomic"
version = "1.11.0"
@@ -1189,7 +1504,7 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
- "getrandom",
+ "getrandom 0.3.3",
]
[[package]]
@@ -1254,6 +1569,48 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
+[[package]]
+name = "reqwest"
+version = "0.12.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "reserve-port"
version = "2.3.0"
@@ -1263,6 +1620,20 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.16",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "rust-multipart-rfc7578_2"
version = "0.8.0"
@@ -1303,6 +1674,39 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "rustls"
+version = "0.23.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
+dependencies = [
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.21"
@@ -1315,12 +1719,44 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+[[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.9.1",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "serde"
version = "1.0.219"
@@ -1458,6 +1894,18 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.101"
@@ -1474,6 +1922,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
[[package]]
name = "synstructure"
@@ -1486,6 +1937,27 @@ dependencies = [
"syn",
]
+[[package]]
+name = "system-configuration"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+dependencies = [
+ "bitflags 2.9.1",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "tempfile"
version = "3.20.0"
@@ -1493,7 +1965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
- "getrandom",
+ "getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
@@ -1599,6 +2071,26 @@ dependencies = [
"syn",
]
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
[[package]]
name = "tokio-stream"
version = "0.1.17"
@@ -1623,6 +2115,19 @@ dependencies = [
"tokio-stream",
]
+[[package]]
+name = "tokio-util"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "tower"
version = "0.5.2"
@@ -1647,9 +2152,12 @@ checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
dependencies = [
"bitflags 2.9.1",
"bytes",
+ "futures-util",
"http",
"http-body",
+ "iri-string",
"pin-project-lite",
+ "tower",
"tower-layer",
"tower-service",
"tracing",
@@ -1741,6 +2249,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
[[package]]
name = "url"
version = "2.5.4"
@@ -1770,6 +2284,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -1800,6 +2320,87 @@ dependencies = [
"wit-bindgen-rt",
]
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1822,6 +2423,41 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+[[package]]
+name = "windows-link"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
+
+[[package]]
+name = "windows-registry"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
+dependencies = [
+ "windows-result",
+ "windows-strings",
+ "windows-targets 0.53.0",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -2054,6 +2690,12 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
[[package]]
name = "zerotrie"
version = "0.2.2"
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
new file mode 100644
index 0000000..53fc84e
--- /dev/null
+++ b/crates/cli/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "collar-cli"
+version = "0.1.0"
+license.workspace = true
+edition.workspace = true
+authors.workspace = true
+
+[[bin]]
+name = "collar"
+path = "src/main.rs"
+
+[dependencies]
+clap = { version = "4.5", features = ["derive"] }
+reqwest = { version = "0.12", features = ["json"] }
+tokio = { version = "1.45.1", features = ["full"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+anyhow = "1.0"
+url = "2.5"
\ No newline at end of file
diff --git a/crates/cli/README.md b/crates/cli/README.md
new file mode 100644
index 0000000..5cc6c68
--- /dev/null
+++ b/crates/cli/README.md
@@ -0,0 +1,259 @@
+# Collar CLI
+
+Command-line interface for the Collar API server. Provides commands for managing networks and instances through the HTTP API.
+
+## Installation
+
+From the project root directory:
+
+```bash
+cargo build --release
+```
+
+The binary will be available at `target/release/collar`.
+
+## Usage
+
+```bash
+collar [OPTIONS] <COMMAND>
+```
+
+### Options
+
+- `--url <URL>` - Base URL of the Collar API server (default: http://localhost:3000)
+
+### Commands
+
+- `network` - Network management commands
+- `instance` - Instance management commands
+
+## Network Management
+
+### List Networks
+
+```bash
+collar network list
+```
+
+### Create Network
+
+Basic network:
+```bash
+collar network create my-network 192.168.1.0/24
+```
+
+Network with basic assigner:
+```bash
+collar network create my-network 192.168.1.0/24 \
+ --assigner-type basic \
+ --assigner-config '{"strategy": "sequential"}'
+```
+
+Network with range assigner:
+```bash
+collar network create my-network 192.168.1.0/24 \
+ --assigner-type range \
+ --assigner-config '{"range_name": "my-range"}'
+```
+
+### Get Network Details
+
+```bash
+collar network get my-network
+```
+
+### Delete Network
+
+```bash
+collar network delete my-network
+```
+
+### Show Examples
+
+```bash
+collar network create --examples
+```
+
+## Instance Management
+
+### List Instances
+
+```bash
+collar instance list
+```
+
+### Create Instance
+
+Jail instance:
+```bash
+collar instance create my-jail \
+ --provider-type jail \
+ --provider-config '{"jail_conf": "/etc/jail.conf", "dataset": "zroot/jails"}'
+```
+
+Container instance:
+```bash
+collar instance create my-container \
+ --provider-type container \
+ --provider-config '{"runtime": "docker", "image": "alpine:latest"}'
+```
+
+Instance with network interfaces:
+```bash
+collar instance create networked-instance \
+ --provider-type jail \
+ --provider-config '{}' \
+ --network-interfaces '[{"network_name": "test-network", "interface_name": "eth0"}]'
+```
+
+Instance with metadata:
+```bash
+collar instance create my-instance \
+ --provider-type jail \
+ --provider-config '{}' \
+ --metadata '{"env": "production", "version": "1.0"}'
+```
+
+### Get Instance Details
+
+```bash
+collar instance get my-instance
+```
+
+### Delete Instance
+
+```bash
+collar instance delete my-instance
+```
+
+### Show Examples
+
+```bash
+collar instance create --examples
+```
+
+## Configuration Examples
+
+### Network Provider Types
+
+Currently, the API supports basic network providers. Provider configuration is optional and can be passed as JSON.
+
+### Network Assigner Types
+
+- **basic**: Sequential IP assignment
+ ```json
+ {"strategy": "sequential"}
+ ```
+
+- **range**: Range-based IP assignment
+ ```json
+ {"range_name": "my-range"}
+ ```
+
+### Instance Provider Types
+
+- **jail**: FreeBSD jail instances
+ ```json
+ {
+ "jail_conf": "/etc/jail.conf",
+ "dataset": "zroot/jails"
+ }
+ ```
+
+- **container**: Container instances
+ ```json
+ {
+ "runtime": "docker",
+ "image": "alpine:latest"
+ }
+ ```
+
+### Network Interfaces
+
+Network interfaces can be specified as a JSON array:
+```json
+[
+ {
+ "network_name": "test-network",
+ "interface_name": "eth0"
+ }
+]
+```
+
+### Metadata
+
+Instance metadata can be specified as a JSON object:
+```json
+{
+ "env": "production",
+ "version": "1.0",
+ "owner": "team-infrastructure"
+}
+```
+
+## Examples
+
+### Complete Workflow
+
+1. Create a network:
+```bash
+collar network create app-network 10.0.1.0/24 \
+ --assigner-type basic \
+ --assigner-config '{"strategy": "sequential"}'
+```
+
+2. Create an instance connected to the network:
+```bash
+collar instance create web-server \
+ --provider-type container \
+ --provider-config '{"runtime": "docker", "image": "nginx:latest"}' \
+ --network-interfaces '[{"network_name": "app-network", "interface_name": "eth0"}]' \
+ --metadata '{"role": "web-server", "env": "production"}'
+```
+
+3. List resources:
+```bash
+collar network list
+collar instance list
+```
+
+4. Get details:
+```bash
+collar network get app-network
+collar instance get web-server
+```
+
+5. Clean up:
+```bash
+collar instance delete web-server
+collar network delete app-network
+```
+
+## Error Handling
+
+The CLI provides descriptive error messages for common issues:
+
+- Invalid JSON configuration
+- Network/instance not found
+- Server connection errors
+- Invalid provider or assigner types
+
+## Development
+
+### Running Tests
+
+```bash
+cargo test -p collar-cli
+```
+
+### Building
+
+```bash
+cargo build -p collar-cli
+```
+
+### Running from Source
+
+```bash
+cargo run -p collar-cli -- network list
+```
\ No newline at end of file
diff --git a/crates/cli/examples/demo.sh b/crates/cli/examples/demo.sh
new file mode 100755
index 0000000..c1d321c
--- /dev/null
+++ b/crates/cli/examples/demo.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+
+# Collar CLI Demo Script
+# Demonstrates various CLI operations for network and instance management
+
+set -e
+
+# Configuration
+API_URL="http://localhost:3000"
+COLLAR_CLI="cargo run -p collar-cli --"
+
+echo "=== Collar CLI Demo ==="
+echo "API URL: $API_URL"
+echo
+
+# Function to run CLI command with proper formatting
+run_cli() {
+ echo "$ collar $*"
+ $COLLAR_CLI --url "$API_URL" "$@"
+ echo
+}
+
+# Function to handle errors gracefully
+run_cli_safe() {
+ echo "$ collar $*"
+ if ! $COLLAR_CLI --url "$API_URL" "$@"; then
+ echo "Command failed (this might be expected if server is not running)"
+ fi
+ echo
+}
+
+echo "=== CLI Help ==="
+run_cli --help
+
+echo "=== Network Management ==="
+
+echo "--- Show network examples ---"
+run_cli network create --examples
+
+echo "--- List networks (may fail if server not running) ---"
+run_cli_safe network list
+
+echo "--- Create a basic network (may fail if server not running) ---"
+run_cli_safe network create demo-network 192.168.100.0/24
+
+echo "--- Create network with basic assigner (may fail if server not running) ---"
+run_cli_safe network create demo-basic-network 192.168.101.0/24 \
+ --assigner-type basic \
+ --assigner-config '{"strategy": "sequential"}'
+
+echo "--- Get network details (may fail if server not running) ---"
+run_cli_safe network get demo-network
+
+echo "=== Instance Management ==="
+
+echo "--- Show instance examples ---"
+run_cli instance create --examples
+
+echo "--- List instances (may fail if server not running) ---"
+run_cli_safe instance list
+
+echo "--- Create jail instance (may fail if server not running) ---"
+run_cli_safe instance create demo-jail \
+ --provider-type jail \
+ --provider-config '{"jail_conf": "/etc/jail.conf", "dataset": "zroot/jails"}'
+
+echo "--- Create container instance (may fail if server not running) ---"
+run_cli_safe instance create demo-container \
+ --provider-type container \
+ --provider-config '{"runtime": "docker", "image": "alpine:latest"}'
+
+echo "--- Create instance with network interface (may fail if server not running) ---"
+run_cli_safe instance create demo-networked \
+ --provider-type jail \
+ --provider-config '{}' \
+ --network-interfaces '[{"network_name": "demo-network", "interface_name": "eth0"}]' \
+ --metadata '{"env": "demo", "purpose": "testing"}'
+
+echo "--- Get instance details (may fail if server not running) ---"
+run_cli_safe instance get demo-jail
+
+echo "=== Cleanup ==="
+
+echo "--- Delete instances (may fail if server not running) ---"
+run_cli_safe instance delete demo-jail
+run_cli_safe instance delete demo-container
+run_cli_safe instance delete demo-networked
+
+echo "--- Delete networks (may fail if server not running) ---"
+run_cli_safe network delete demo-network
+run_cli_safe network delete demo-basic-network
+
+echo "=== Demo Complete ==="
+echo "Note: Some commands may have failed if the Collar API server is not running."
+echo "To start the server, run the appropriate collar daemon command first."
\ No newline at end of file
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
new file mode 100644
index 0000000..339946c
--- /dev/null
+++ b/crates/cli/src/main.rs
@@ -0,0 +1,416 @@
+//! # Collar CLI
+//!
+//! Command-line interface for the Collar API server.
+//!
+//! Provides commands for managing networks and instances through the HTTP API.
+
+use std::collections::HashMap;
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use reqwest::Client;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use url::Url;
+
+mod utils;
+use utils::*;
+
+#[derive(Parser)]
+#[command(name = "collar")]
+#[command(about = "CLI client for Collar API server")]
+#[command(version)]
+struct Cli {
+ #[arg(long, default_value = "http://localhost:3000")]
+ /// Base URL of the Collar API server
+ url: String,
+
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// Network management commands
+ Network {
+ #[command(subcommand)]
+ command: NetworkCommands,
+ },
+ /// Instance management commands
+ Instance {
+ #[command(subcommand)]
+ command: InstanceCommands,
+ },
+}
+
+#[derive(Subcommand)]
+enum NetworkCommands {
+ /// List all networks
+ List,
+ /// Create a new network
+ Create {
+ /// Network name
+ name: Option<String>,
+ /// Network CIDR (e.g., 192.168.1.0/24)
+ cidr: Option<String>,
+ /// Provider type (optional)
+ #[arg(long)]
+ provider_type: Option<String>,
+ /// Provider config as JSON string (optional)
+ #[arg(long)]
+ provider_config: Option<String>,
+ /// Assigner type (basic, range)
+ #[arg(long)]
+ assigner_type: Option<String>,
+ /// Assigner config as JSON string (optional)
+ #[arg(long)]
+ assigner_config: Option<String>,
+ /// Show example configurations
+ #[arg(long)]
+ examples: bool,
+ },
+ /// Get network details
+ Get {
+ /// Network name
+ name: String,
+ },
+ /// Delete a network
+ Delete {
+ /// Network name
+ name: String,
+ },
+}
+
+#[derive(Subcommand)]
+enum InstanceCommands {
+ /// List all instances
+ List,
+ /// Create a new instance
+ Create {
+ /// Instance name
+ name: Option<String>,
+ /// Provider type (jail, container)
+ #[arg(long)]
+ provider_type: Option<String>,
+ /// Provider config as JSON string
+ #[arg(long)]
+ provider_config: Option<String>,
+ /// Network interfaces as JSON array string (optional)
+ #[arg(long)]
+ network_interfaces: Option<String>,
+ /// Metadata as JSON object string (optional)
+ #[arg(long)]
+ metadata: Option<String>,
+ /// Show example configurations
+ #[arg(long)]
+ examples: bool,
+ },
+ /// Get instance details
+ Get {
+ /// Instance name
+ name: String,
+ },
+ /// Delete an instance
+ Delete {
+ /// Instance name
+ name: String,
+ },
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct CreateNetworkRequest {
+ name: String,
+ cidr: String,
+ provider_type: Option<String>,
+ provider_config: Option<Value>,
+ assigner_type: Option<String>,
+ assigner_config: Option<Value>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct CreateInstanceRequest {
+ name: String,
+ provider_type: String,
+ provider_config: Value,
+ network_interfaces: Option<Vec<NetworkInterfaceRequest>>,
+ metadata: Option<HashMap<String, String>>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct NetworkInterfaceRequest {
+ network_name: String,
+ interface_name: String,
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let cli = Cli::parse();
+ let client = Client::new();
+ let base_url = Url::parse(&cli.url)?;
+
+ match cli.command {
+ Commands::Network { command } => handle_network_command(&client, &base_url, command).await?,
+ Commands::Instance { command } => handle_instance_command(&client, &base_url, command).await?,
+ }
+
+ Ok(())
+}
+
+async fn handle_network_command(client: &Client, base_url: &Url, command: NetworkCommands) -> Result<()> {
+ match command {
+ NetworkCommands::List => {
+ let url = base_url.join("/networks")?;
+ let response = client.get(url).send().await?;
+
+ if response.status().is_success() {
+ let networks: Value = response.json().await?;
+ println!("{}", format_response(&networks)?);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ NetworkCommands::Create {
+ name,
+ cidr,
+ provider_type,
+ provider_config,
+ assigner_type,
+ assigner_config,
+ examples,
+ } => {
+ if examples {
+ println!("Example network configurations:");
+ println!("\nBasic network:");
+ println!("collar --url http://localhost:3000 network create my-network 192.168.1.0/24");
+
+ println!("\nNetwork with basic assigner:");
+ println!("collar network create my-network 192.168.1.0/24 \\");
+ println!(" --assigner-type basic \\");
+ println!(" --assigner-config '{}'", serde_json::to_string(&example_basic_assigner_config())?);
+
+ println!("\nNetwork with range assigner:");
+ println!("collar network create my-network 192.168.1.0/24 \\");
+ println!(" --assigner-type range \\");
+ println!(" --assigner-config '{}'", serde_json::to_string(&example_range_assigner_config())?);
+
+ return Ok(());
+ }
+
+ // Require name and cidr if not showing examples
+ let name = name.ok_or_else(|| anyhow::anyhow!("Network name is required"))?;
+ let cidr = cidr.ok_or_else(|| anyhow::anyhow!("Network CIDR is required"))?;
+
+ // Validate assigner type if provided
+ if let Some(ref assigner) = assigner_type {
+ validate_assigner_type(assigner)?;
+ }
+ let provider_config = if let Some(config) = provider_config {
+ Some(parse_json_string(&config)?)
+ } else {
+ None
+ };
+
+ let assigner_config = if let Some(config) = assigner_config {
+ Some(parse_json_string(&config)?)
+ } else {
+ None
+ };
+
+ let request = CreateNetworkRequest {
+ name: name.clone(),
+ cidr: cidr.clone(),
+ provider_type,
+ provider_config,
+ assigner_type,
+ assigner_config,
+ };
+
+ let url = base_url.join("/networks")?;
+ let response = client.post(url).json(&request).send().await?;
+
+ if response.status().is_success() {
+ println!("Network '{}' created successfully", name);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ NetworkCommands::Get { name } => {
+ let url = base_url.join(&format!("/networks/{}", name))?;
+ let response = client.get(url).send().await?;
+
+ if response.status().is_success() {
+ let network: Value = response.json().await?;
+ println!("{}", format_response(&network)?);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ NetworkCommands::Delete { name } => {
+ let url = base_url.join(&format!("/networks/{}", name))?;
+ let response = client.delete(url).send().await?;
+
+ if response.status().is_success() {
+ println!("Network '{}' deleted successfully", name);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ }
+
+ Ok(())
+}
+
+async fn handle_instance_command(client: &Client, base_url: &Url, command: InstanceCommands) -> Result<()> {
+ match command {
+ InstanceCommands::List => {
+ let url = base_url.join("/instances")?;
+ let response = client.get(url).send().await?;
+
+ if response.status().is_success() {
+ let instances: Value = response.json().await?;
+ println!("{}", format_response(&instances)?);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ InstanceCommands::Create {
+ name,
+ provider_type,
+ provider_config,
+ network_interfaces,
+ metadata,
+ examples,
+ } => {
+ if examples {
+ println!("Example instance configurations:");
+ println!("\nJail instance:");
+ println!("collar instance create my-jail \\");
+ println!(" --provider-type jail \\");
+ println!(" --provider-config '{}'", serde_json::to_string(&example_jail_config())?);
+
+ println!("\nContainer instance:");
+ println!("collar instance create my-container \\");
+ println!(" --provider-type container \\");
+ println!(" --provider-config '{}'", serde_json::to_string(&example_container_config())?);
+
+ println!("\nInstance with network interfaces:");
+ println!("collar instance create networked-instance \\");
+ println!(" --provider-type jail \\");
+ println!(" --provider-config '{{}}' \\");
+ println!(" --network-interfaces '{}'", serde_json::to_string(&example_network_interfaces())?);
+
+ return Ok(());
+ }
+
+ // Require name and provider details if not showing examples
+ let name = name.ok_or_else(|| anyhow::anyhow!("Instance name is required"))?;
+ let provider_type = provider_type.ok_or_else(|| anyhow::anyhow!("Provider type is required"))?;
+ let provider_config = provider_config.ok_or_else(|| anyhow::anyhow!("Provider config is required"))?;
+
+ // Validate provider type
+ validate_provider_type(&provider_type)?;
+ let provider_config: Value = parse_json_string(&provider_config)?;
+
+ let network_interfaces = if let Some(interfaces) = network_interfaces {
+ Some(serde_json::from_str(&interfaces)?)
+ } else {
+ None
+ };
+
+ let metadata = if let Some(meta) = metadata {
+ Some(parse_metadata(&meta)?)
+ } else {
+ None
+ };
+
+ let request = CreateInstanceRequest {
+ name: name.clone(),
+ provider_type,
+ provider_config,
+ network_interfaces,
+ metadata,
+ };
+
+ let url = base_url.join("/instances")?;
+ let response = client.post(url).json(&request).send().await?;
+
+ if response.status().is_success() {
+ println!("Instance '{}' created successfully", name);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ InstanceCommands::Get { name } => {
+ let url = base_url.join(&format!("/instances/{}", name))?;
+ let response = client.get(url).send().await?;
+
+ if response.status().is_success() {
+ let instance: Value = response.json().await?;
+ println!("{}", format_response(&instance)?);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ InstanceCommands::Delete { name } => {
+ let url = base_url.join(&format!("/instances/{}", name))?;
+ let response = client.delete(url).send().await?;
+
+ if response.status().is_success() {
+ println!("Instance '{}' deleted successfully", name);
+ } else {
+ let error = response.text().await?;
+ eprintln!("Error: {}", error);
+ }
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::process::Command;
+
+ #[test]
+ fn test_cli_help() {
+ let output = Command::new("cargo")
+ .args(&["run", "--bin", "collar", "--", "--help"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("CLI client for Collar API server"));
+ }
+
+ #[test]
+ fn test_network_help() {
+ let output = Command::new("cargo")
+ .args(&["run", "--bin", "collar", "--", "network", "--help"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Network management commands"));
+ }
+
+ #[test]
+ fn test_instance_help() {
+ let output = Command::new("cargo")
+ .args(&["run", "--bin", "collar", "--", "instance", "--help"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Instance management commands"));
+ }
+}
\ No newline at end of file
diff --git a/crates/cli/src/utils.rs b/crates/cli/src/utils.rs
new file mode 100644
index 0000000..9a5cb43
--- /dev/null
+++ b/crates/cli/src/utils.rs
@@ -0,0 +1,120 @@
+//! Utility functions for the CLI client
+
+use anyhow::{Context, Result};
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Parse a JSON string into a serde_json::Value
+pub fn parse_json_string(json_str: &str) -> Result<Value> {
+ serde_json::from_str(json_str).context("Failed to parse JSON string")
+}
+
+/// Parse a JSON string into a HashMap for metadata
+pub fn parse_metadata(json_str: &str) -> Result<HashMap<String, String>> {
+ serde_json::from_str(json_str).context("Failed to parse metadata JSON")
+}
+
+/// Format response output with proper indentation
+pub fn format_response(value: &Value) -> Result<String> {
+ serde_json::to_string_pretty(value).context("Failed to format response")
+}
+
+/// Validate provider type for instances
+pub fn validate_provider_type(provider_type: &str) -> Result<()> {
+ match provider_type {
+ "jail" | "container" => Ok(()),
+ _ => Err(anyhow::anyhow!(
+ "Invalid provider type '{}'. Must be 'jail' or 'container'",
+ provider_type
+ )),
+ }
+}
+
+/// Validate assigner type for networks
+pub fn validate_assigner_type(assigner_type: &str) -> Result<()> {
+ match assigner_type {
+ "basic" | "range" => Ok(()),
+ _ => Err(anyhow::anyhow!(
+ "Invalid assigner type '{}'. Must be 'basic' or 'range'",
+ assigner_type
+ )),
+ }
+}
+
+/// Build example configurations for different provider types
+pub fn example_jail_config() -> Value {
+ serde_json::json!({
+ "jail_conf": "/etc/jail.conf",
+ "dataset": "zroot/jails"
+ })
+}
+
+pub fn example_container_config() -> Value {
+ serde_json::json!({
+ "runtime": "docker",
+ "image": "alpine:latest"
+ })
+}
+
+pub fn example_basic_assigner_config() -> Value {
+ serde_json::json!({
+ "strategy": "sequential"
+ })
+}
+
+pub fn example_range_assigner_config() -> Value {
+ serde_json::json!({
+ "range_name": "my-range"
+ })
+}
+
+pub fn example_network_interfaces() -> Value {
+ serde_json::json!([
+ {
+ "network_name": "test-network",
+ "interface_name": "eth0"
+ }
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_json_string() {
+ let json_str = r#"{"key": "value"}"#;
+ let result = parse_json_string(json_str).unwrap();
+ assert_eq!(result["key"], "value");
+ }
+
+ #[test]
+ fn test_parse_metadata() {
+ let json_str = r#"{"env": "production", "version": "1.0"}"#;
+ let result = parse_metadata(json_str).unwrap();
+ assert_eq!(result.get("env"), Some(&"production".to_string()));
+ assert_eq!(result.get("version"), Some(&"1.0".to_string()));
+ }
+
+ #[test]
+ fn test_validate_provider_type() {
+ assert!(validate_provider_type("jail").is_ok());
+ assert!(validate_provider_type("container").is_ok());
+ assert!(validate_provider_type("invalid").is_err());
+ }
+
+ #[test]
+ fn test_validate_assigner_type() {
+ assert!(validate_assigner_type("basic").is_ok());
+ assert!(validate_assigner_type("range").is_ok());
+ assert!(validate_assigner_type("invalid").is_err());
+ }
+
+ #[test]
+ fn test_format_response() {
+ let value = serde_json::json!({"name": "test", "value": 42});
+ let formatted = format_response(&value).unwrap();
+ assert!(formatted.contains("\"name\": \"test\""));
+ assert!(formatted.contains("\"value\": 42"));
+ }
+}
\ No newline at end of file
diff --git a/crates/cli/tests/integration_tests.rs b/crates/cli/tests/integration_tests.rs
new file mode 100644
index 0000000..864637d
--- /dev/null
+++ b/crates/cli/tests/integration_tests.rs
@@ -0,0 +1,177 @@
+//! Integration tests for the Collar CLI
+//!
+//! These tests verify CLI functionality by spawning the CLI process
+//! and checking its output and behavior.
+
+use std::process::Command;
+
+#[tokio::test]
+async fn test_cli_help() {
+ let output = Command::new("cargo")
+ .args(&["run", "-p", "collar-cli", "--", "--help"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("CLI client for Collar API server"));
+ assert!(stdout.contains("network"));
+ assert!(stdout.contains("instance"));
+}
+
+#[tokio::test]
+async fn test_network_help() {
+ let output = Command::new("cargo")
+ .args(&["run", "-p", "collar-cli", "--", "network", "--help"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Network management commands"));
+ assert!(stdout.contains("list"));
+ assert!(stdout.contains("create"));
+ assert!(stdout.contains("get"));
+ assert!(stdout.contains("delete"));
+}
+
+#[tokio::test]
+async fn test_instance_help() {
+ let output = Command::new("cargo")
+ .args(&["run", "-p", "collar-cli", "--", "instance", "--help"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Instance management commands"));
+ assert!(stdout.contains("list"));
+ assert!(stdout.contains("create"));
+ assert!(stdout.contains("get"));
+ assert!(stdout.contains("delete"));
+}
+
+#[tokio::test]
+async fn test_network_create_examples() {
+ let output = Command::new("cargo")
+ .args(&["run", "-p", "collar-cli", "--", "network", "create", "--examples"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Example network configurations"));
+ assert!(stdout.contains("Basic network"));
+ assert!(stdout.contains("basic assigner"));
+ assert!(stdout.contains("range assigner"));
+}
+
+#[tokio::test]
+async fn test_instance_create_examples() {
+ let output = Command::new("cargo")
+ .args(&["run", "-p", "collar-cli", "--", "instance", "create", "--examples"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Example instance configurations"));
+ assert!(stdout.contains("Jail instance"));
+ assert!(stdout.contains("Container instance"));
+ assert!(stdout.contains("network interfaces"));
+}
+
+#[tokio::test]
+async fn test_network_list_connection_error() {
+ let output = Command::new("cargo")
+ .args(&[
+ "run", "-p", "collar-cli", "--",
+ "--url", "http://localhost:9999",
+ "network", "list"
+ ])
+ .output()
+ .expect("Failed to execute command");
+
+ // Should fail with connection error since server isn't running on port 9999
+ assert!(!output.status.success() || !String::from_utf8(output.stderr).unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn test_instance_list_connection_error() {
+ let output = Command::new("cargo")
+ .args(&[
+ "run", "-p", "collar-cli", "--",
+ "--url", "http://localhost:9999",
+ "instance", "list"
+ ])
+ .output()
+ .expect("Failed to execute command");
+
+ // Should fail with connection error since server isn't running on port 9999
+ assert!(!output.status.success() || !String::from_utf8(output.stderr).unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn test_invalid_provider_type_validation() {
+ let output = Command::new("cargo")
+ .args(&[
+ "run", "-p", "collar-cli", "--",
+ "--url", "http://localhost:9999",
+ "instance", "create", "test-instance",
+ "--provider-type", "invalid",
+ "--provider-config", "{}"
+ ])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(!output.status.success());
+ let stderr = String::from_utf8(output.stderr).unwrap();
+ assert!(stderr.contains("Invalid provider type"));
+}
+
+#[tokio::test]
+async fn test_invalid_assigner_type_validation() {
+ let output = Command::new("cargo")
+ .args(&[
+ "run", "-p", "collar-cli", "--",
+ "--url", "http://localhost:9999",
+ "network", "create", "test-network", "192.168.1.0/24",
+ "--assigner-type", "invalid"
+ ])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(!output.status.success());
+ let stderr = String::from_utf8(output.stderr).unwrap();
+ assert!(stderr.contains("Invalid assigner type"));
+}
+
+#[tokio::test]
+async fn test_invalid_json_config() {
+ let output = Command::new("cargo")
+ .args(&[
+ "run", "-p", "collar-cli", "--",
+ "--url", "http://localhost:9999",
+ "instance", "create", "test-instance",
+ "--provider-type", "jail",
+ "--provider-config", "invalid-json"
+ ])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(!output.status.success());
+ let stderr = String::from_utf8(output.stderr).unwrap();
+ assert!(stderr.contains("Failed to parse JSON"));
+}
+
+#[tokio::test]
+async fn test_version_flag() {
+ let output = Command::new("cargo")
+ .args(&["run", "-p", "collar-cli", "--", "--version"])
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("collar"));
+}
\ No newline at end of file
diff --git a/crates/store/examples/jail_auto_ip_demo.rs b/crates/store/examples/jail_auto_ip_demo.rs
new file mode 100644
index 0000000..68dc122
--- /dev/null
+++ b/crates/store/examples/jail_auto_ip_demo.rs
@@ -0,0 +1,229 @@
+use store::{
+ InstanceStore, Instance, InstanceConfig, NetworkInterface,
+ NamespaceStore, NetworkStore, RangeStore,
+ JailProvider, Provider,
+ network::{NetRange, BasicProvider, RangeAssigner},
+};
+use std::collections::HashMap;
+
+fn main() -> store::Result<()> {
+ println!("=== Jail Automatic IP Assignment Demo ===\n");
+
+ // Open the store
+ let db = sled::open("jail_auto_ip_demo.db")?;
+
+ // Set up stores
+ let namespaces = NamespaceStore::open(&db)?;
+ let ranges = RangeStore::open(&db)?;
+ let networks = NetworkStore::open_with_ranges(&db, ranges)?;
+ let instances = InstanceStore::open(&db, namespaces, networks)?;
+
+ println!("1. Creating network with RangeAssigner...");
+
+ // Create a network with automatic IP assignment
+ let provider = BasicProvider::new()
+ .with_config("type", "bridge")
+ .with_config("driver", "epair");
+
+ let assigner = RangeAssigner::with_range_name("jail_ips")
+ .with_config("strategy", "sequential")
+ .with_config("reserve_gateway", "true");
+
+ let netrange = NetRange::from_cidr("10.0.20.0/24")?;
+
+ instances.networks().create(
+ "jail_network",
+ netrange,
+ provider,
+ Some(assigner),
+ )?;
+
+ println!(" ✓ Created network 'jail_network' with range 10.0.20.0/24");
+
+ println!("\n2. Creating jails with automatic IP assignment...");
+
+ // Create multiple jail instances - they should automatically get IP assignments
+ let jail_names = ["web-jail", "db-jail", "cache-jail"];
+
+ for jail_name in &jail_names {
+ let jail_provider = JailProvider::new()
+ .with_dataset("tank/jails".to_string());
+
+ let jail_config = InstanceConfig {
+ provider_config: jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "jail_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None, // Will be automatically assigned
+ }
+ ],
+ metadata: {
+ let mut meta = HashMap::new();
+ meta.insert("role".to_string(), jail_name.replace("-jail", ""));
+ meta
+ },
+ };
+
+ let jail_instance = Instance {
+ name: jail_name.to_string(),
+ provider_type: "jail".to_string(),
+ config: jail_config,
+ created_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ updated_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ };
+
+ let created = instances.create(jail_instance)?;
+
+ if let Some(ref assignment) = created.config.network_interfaces[0].assignment {
+ println!(" ✓ Created jail '{}' with IP: {}", jail_name, assignment);
+ } else {
+ println!(" ✗ Failed to assign IP to jail '{}'", jail_name);
+ }
+ }
+
+ println!("\n3. Verifying all jails have unique IP assignments...");
+
+ let mut assigned_ips = std::collections::HashSet::new();
+
+ for jail_name in &jail_names {
+ let instance = instances.get(jail_name)?;
+ let interface = &instance.config.network_interfaces[0];
+
+ if let Some(ref assignment) = interface.assignment {
+ if assigned_ips.insert(assignment.clone()) {
+ println!(" ✓ {} -> {}", jail_name, assignment);
+ } else {
+ println!(" ✗ Duplicate IP assignment for {}: {}", jail_name, assignment);
+ return Err(store::Error::StoreError(sled::Error::Unsupported(
+ "Duplicate IP assignment detected".to_string()
+ )));
+ }
+ } else {
+ println!(" ✗ No IP assignment found for {}", jail_name);
+ }
+ }
+
+ println!("\n4. Creating jail with multiple network interfaces...");
+
+ // Create another network for demonstration
+ let mgmt_provider = BasicProvider::new();
+ let mgmt_assigner = RangeAssigner::with_range_name("mgmt_ips");
+ instances.networks().create(
+ "management",
+ NetRange::from_cidr("192.168.1.0/24")?,
+ mgmt_provider,
+ Some(mgmt_assigner),
+ )?;
+
+ // Create a jail with multiple interfaces
+ let multi_jail_provider = JailProvider::new();
+ let multi_jail_config = InstanceConfig {
+ provider_config: multi_jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "jail_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None,
+ },
+ NetworkInterface {
+ network_name: "management".to_string(),
+ interface_name: "em1".to_string(),
+ assignment: None,
+ },
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let multi_jail = Instance {
+ name: "multi-interface-jail".to_string(),
+ provider_type: "jail".to_string(),
+ config: multi_jail_config,
+ created_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ updated_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ };
+
+ let created_multi = instances.create(multi_jail)?;
+
+ println!(" ✓ Created multi-interface jail:");
+ for (i, interface) in created_multi.config.network_interfaces.iter().enumerate() {
+ if let Some(ref assignment) = interface.assignment {
+ println!(" Interface {}: {} -> {}", i, interface.network_name, assignment);
+ }
+ }
+
+ println!("\n5. Testing deletion and IP cleanup...");
+
+ // Delete the first jail
+ instances.delete("web-jail")?;
+ println!(" ✓ Deleted jail 'web-jail'");
+
+ // Verify the IP is cleaned up
+ let network = instances.networks().get("jail_network")?.unwrap();
+ let assignment_after_delete = network.get_assignment("web-jail", instances.networks().ranges())?;
+ if assignment_after_delete.is_none() {
+ println!(" ✓ IP assignment cleaned up after deletion");
+ } else {
+ println!(" ✗ IP assignment still exists after deletion");
+ }
+
+ // Create a new jail to demonstrate IP reuse
+ let reuse_jail_provider = JailProvider::new();
+ let reuse_jail_config = InstanceConfig {
+ provider_config: reuse_jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "jail_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None,
+ }
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let reuse_jail = Instance {
+ name: "replacement-jail".to_string(),
+ provider_type: "jail".to_string(),
+ config: reuse_jail_config,
+ created_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ updated_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ };
+
+ let created_reuse = instances.create(reuse_jail)?;
+
+ if let Some(ref new_assignment) = created_reuse.config.network_interfaces[0].assignment {
+ println!(" ✓ New jail 'replacement-jail' reused IP: {}", new_assignment);
+ }
+
+ println!("\n✅ Demonstration completed successfully!");
+ println!("\nKey points:");
+ println!(" • Jails automatically receive IP assignments when created");
+ println!(" • Each jail gets a unique IP from the network's range");
+ println!(" • Multiple network interfaces are supported");
+ println!(" • RangeAssigner handles sequential IP allocation");
+ println!(" • IP assignments are automatically cleaned up on deletion");
+ println!(" • Freed IPs can be reused by new instances");
+
+ // Clean up
+ std::fs::remove_dir_all("jail_auto_ip_demo.db").ok();
+
+ Ok(())
+}
\ No newline at end of file
diff --git a/crates/store/src/instance.rs b/crates/store/src/instance.rs
index 3cf1d85..5d03794 100644
--- a/crates/store/src/instance.rs
+++ b/crates/store/src/instance.rs
@@ -120,7 +120,26 @@ impl InstanceStore {
Ok(store)
}
- /// Create a new instance with network assignments
+ /// Get reference to the network store
+ pub fn networks(&self) -> &NetworkStore {
+ &self.networks
+ }
+
+ /// Create a new instance with automatic network IP assignments
+ ///
+ /// This method creates a new instance and automatically assigns IP addresses
+ /// to any network interfaces that are configured but don't have existing assignments.
+ /// The automatic assignment works with networks that have RangeAssigners configured.
+ ///
+ /// # Automatic IP Assignment Process
+ ///
+ /// For each network interface:
+ /// 1. Check if an IP assignment already exists
+ /// 2. If not, and the network has a RangeAssigner, automatically assign an IP
+ /// 3. Update the interface with the assigned IP address
+ ///
+ /// This enables seamless provisioning of jails, containers, and other instances
+ /// with proper network connectivity without manual IP management.
pub fn create(&self, mut instance: Instance) -> Result<Instance> {
// Reserve name in namespace
self.namespaces.reserve(Self::INSTANCE_NAMESPACE, &instance.name, &instance.name)?;
@@ -133,7 +152,32 @@ impl InstanceStore {
interface.assignment = Some(assignment.to_string());
}
Ok(None) => {
- // No assignment available
+ // No existing assignment, try to auto-assign if network has RangeAssigner
+ // This enables automatic IP allocation for instances with network interfaces
+ if let Some(ref ranges) = self.networks.ranges {
+ // Attempt automatic IP assignment using the network's RangeAssigner
+ if let Err(e) = network.auto_assign_ip(&instance.name, ranges) {
+ // Clean up reserved name on failure
+ let _ = self.namespaces.remove(Self::INSTANCE_NAMESPACE, &instance.name);
+ return Err(e);
+ }
+
+ // Retrieve the newly assigned IP address
+ match network.get_assignment(&instance.name, self.networks.ranges.as_ref()) {
+ Ok(Some(assignment)) => {
+ interface.assignment = Some(assignment.to_string());
+ }
+ Ok(None) => {
+ // Auto-assignment failed silently or range is full
+ // This is not necessarily an error - some networks may not support auto-assignment
+ }
+ Err(e) => {
+ // Clean up reserved name on failure
+ let _ = self.namespaces.remove(Self::INSTANCE_NAMESPACE, &instance.name);
+ return Err(e);
+ }
+ }
+ }
}
Err(e) => {
// Clean up reserved name on failure
@@ -182,7 +226,26 @@ impl InstanceStore {
interface.assignment = Some(assignment.to_string());
}
Ok(None) => {
- // No assignment available
+ // No existing assignment, try to auto-assign if network has RangeAssigner
+ // This enables automatic IP allocation for instances with network interfaces
+ if let Some(ref ranges) = self.networks.ranges {
+ // Attempt automatic IP assignment using the network's RangeAssigner
+ if let Err(e) = network.auto_assign_ip(&instance.name, ranges) {
+ return Err(sled::transaction::ConflictableTransactionError::Abort(e));
+ }
+
+ // Retrieve the newly assigned IP address
+ match network.get_assignment(&instance.name, self.networks.ranges.as_ref()) {
+ Ok(Some(assignment)) => {
+ interface.assignment = Some(assignment.to_string());
+ }
+ Ok(None) => {
+ // Auto-assignment failed silently or range is full
+ // This is not necessarily an error - some networks may not support auto-assignment
+ }
+ Err(e) => return Err(sled::transaction::ConflictableTransactionError::Abort(e)),
+ }
+ }
}
Err(e) => return Err(sled::transaction::ConflictableTransactionError::Abort(e)),
}
@@ -229,13 +292,26 @@ impl InstanceStore {
Ok(instance)
}
- /// Delete an instance
+ /// Delete an instance and clean up IP assignments
+ ///
+ /// This method removes the instance and automatically unassigns any IP addresses
+ /// that were allocated to the instance's network interfaces. This ensures proper
+ /// cleanup of network resources and allows IPs to be reused by new instances.
pub fn delete(&self, name: &str) -> Result<()> {
// Get instance to clean up network assignments
if let Ok(instance) = self.get(name) {
- // TODO: Clean up network assignments when unassign functionality is available
- for _interface in &instance.config.network_interfaces {
- // network.unassign_ip(&name)?;
+ // Clean up IP assignments for each network interface
+ for interface in &instance.config.network_interfaces {
+ if let Ok(Some(network)) = self.networks.get(&interface.network_name) {
+ // Only clean up if there's a range store and the network has a RangeAssigner
+ if let Some(ref ranges) = self.networks.ranges {
+ if let Err(e) = network.auto_unassign_ip(name, ranges) {
+ // Log the error but don't fail the deletion
+ eprintln!("Warning: Failed to clean up IP assignment for {} on network {}: {:?}",
+ name, interface.network_name, e);
+ }
+ }
+ }
}
}
diff --git a/crates/store/src/network.rs b/crates/store/src/network.rs
index 2421c27..6c8a4f3 100644
--- a/crates/store/src/network.rs
+++ b/crates/store/src/network.rs
@@ -753,6 +753,130 @@ impl Network {
}
}
+ /// Automatically assign an IP address for the given identifier
+ ///
+ /// This method will assign an IP address from the network's range if it has
+ /// a RangeAssigner configured. This is typically called during instance creation
+ /// to automatically provision network connectivity.
+ ///
+ /// The method performs the following steps:
+ /// 1. Checks if the network has a RangeAssigner configured
+ /// 2. If so, calls the assigner's assign_ip method to allocate an IP
+ /// 3. The assigned IP can then be retrieved using get_assignment()
+ ///
+ /// # Arguments
+ ///
+ /// * `identifier` - The identifier to assign an IP to (e.g., instance name, container ID)
+ /// * `range_store` - Reference to the RangeStore for managing IP assignments
+ ///
+ /// # Returns
+ ///
+ /// * `Ok(())` - Assignment was successful or no assigner is configured
+ /// * `Err(Error)` - Assignment failed (e.g., range is full, network error)
+ ///
+ /// # Example
+ ///
+ /// ```
+ /// # use store::network::{Network, NetRange, BasicProvider, RangeAssigner};
+ /// # use store::RangeStore;
+ /// # use tempfile::TempDir;
+ /// # fn example() -> store::Result<()> {
+ /// # let temp_dir = TempDir::new().unwrap();
+ /// # let db = sled::open(temp_dir.path())?;
+ /// # let ranges = RangeStore::open(&db)?;
+ /// # let networks = store::NetworkStore::open_with_ranges(&db, ranges.clone())?;
+ /// # let netrange = NetRange::from_cidr("10.0.1.0/24")?;
+ /// # let provider = BasicProvider::new();
+ /// # let assigner = RangeAssigner::with_range_name("test_range");
+ /// # networks.create("test_net", netrange, provider, Some(assigner))?;
+ /// # let network = networks.get("test_net")?.unwrap();
+ /// # let ranges_ref = networks.ranges().unwrap();
+ ///
+ /// // Automatically assign an IP to a jail instance
+ /// network.auto_assign_ip("my-jail-01", ranges_ref)?;
+ ///
+ /// // Retrieve the assigned IP
+ /// let assigned_ip = network.get_assignment("my-jail-01", Some(ranges_ref))?;
+ /// println!("Assigned IP: {:?}", assigned_ip);
+ /// # Ok(())
+ /// # }
+ /// ```
+ ///
+ /// # Note
+ ///
+ /// This method is automatically called during instance creation when the instance
+ /// has network interfaces configured for networks with RangeAssigners. Manual calls
+ /// are typically not needed unless implementing custom provisioning logic.
+ pub fn auto_assign_ip(&self, identifier: &str, range_store: &RangeStore) -> Result<()> {
+ if let (Some(assigner_type), Some(assigner_config)) = (&self.assigner_type, &self.assigner_config) {
+ match assigner_type.as_str() {
+ "range" => {
+ let range_assigner: RangeAssigner = serde_json::from_str(assigner_config)
+ .map_err(|e| Error::StoreError(sled::Error::Unsupported(
+ format!("Failed to deserialize RangeAssigner: {}", e)
+ )))?;
+
+ let range_name = range_assigner.get_range_name(&self.name);
+
+ // Try to assign an IP - ignore the result bit position
+ range_assigner.assign_ip(&range_name, range_store, identifier)
+ .map(|_| ())
+ }
+ _ => {
+ // Other assigner types don't support auto-assignment
+ Ok(())
+ }
+ }
+ } else {
+ // No assigner configured, nothing to do
+ Ok(())
+ }
+ }
+
+ /// Automatically unassign an IP address for the given identifier
+ ///
+ /// This method will unassign an IP address from the network's range if it has
+ /// a RangeAssigner configured and the identifier has an assignment. This is
+ /// typically called during instance deletion to clean up network resources.
+ ///
+ /// # Arguments
+ ///
+ /// * `identifier` - The identifier to unassign an IP from (e.g., instance name)
+ /// * `range_store` - Reference to the RangeStore for managing IP assignments
+ ///
+ /// # Returns
+ ///
+ /// * `Ok(())` - Unassignment was successful or no assigner is configured
+ /// * `Err(Error)` - Unassignment failed due to network error
+ pub fn auto_unassign_ip(&self, identifier: &str, range_store: &RangeStore) -> Result<()> {
+ if let (Some(assigner_type), Some(assigner_config)) = (&self.assigner_type, &self.assigner_config) {
+ match assigner_type.as_str() {
+ "range" => {
+ let range_assigner: RangeAssigner = serde_json::from_str(assigner_config)
+ .map_err(|e| Error::StoreError(sled::Error::Unsupported(
+ format!("Failed to deserialize RangeAssigner: {}", e)
+ )))?;
+
+ let range_name = range_assigner.get_range_name(&self.name);
+
+ // Find the bit position for this identifier
+ if let Some(bit_position) = range_assigner.find_identifier_bit_position(&range_name, range_store, identifier)? {
+ // Unassign the IP
+ range_assigner.unassign_ip(&range_name, range_store, bit_position)?;
+ }
+ Ok(())
+ }
+ _ => {
+ // Other assigner types don't support auto-unassignment
+ Ok(())
+ }
+ }
+ } else {
+ // No assigner configured, nothing to do
+ Ok(())
+ }
+ }
+
/// Get an IP address from a bit position in the network range
///
/// This method converts a bit position to the corresponding IP address
@@ -830,6 +954,11 @@ impl NetworkStore {
})
}
+ /// Get reference to the range store
+ pub fn ranges(&self) -> Option<&crate::range::RangeStore> {
+ self.ranges.as_ref()
+ }
+
/// Create a new network with provider and optional assigner
pub fn create<P, A>(&self, name: &str, netrange: NetRange, provider: P, assigner: Option<A>) -> Result<()>
where
diff --git a/crates/store/tests/jail_range_assignment_test.rs b/crates/store/tests/jail_range_assignment_test.rs
new file mode 100644
index 0000000..a88b5f3
--- /dev/null
+++ b/crates/store/tests/jail_range_assignment_test.rs
@@ -0,0 +1,330 @@
+use store::{
+ InstanceStore, Instance, InstanceConfig, NetworkInterface,
+ NamespaceStore, NetworkStore, RangeStore,
+ JailProvider, Provider,
+ network::{NetRange, BasicProvider, RangeAssigner},
+};
+use std::collections::HashMap;
+use tempfile::TempDir;
+
+fn create_test_stores() -> store::Result<(TempDir, sled::Db, InstanceStore)> {
+ let temp_dir = TempDir::new().unwrap();
+ let db = sled::open(temp_dir.path().join("test.db"))?;
+
+ let namespaces = NamespaceStore::open(&db)?;
+ let ranges = RangeStore::open(&db)?;
+ let networks = NetworkStore::open_with_ranges(&db, ranges)?;
+ let instances = InstanceStore::open(&db, namespaces, networks)?;
+
+ Ok((temp_dir, db, instances))
+}
+
+#[test]
+fn test_jail_with_range_assigner_should_get_automatic_ip_assignment() -> store::Result<()> {
+ let (_temp_dir, _db, instances) = create_test_stores()?;
+
+ // Create a network with RangeAssigner
+ let provider = BasicProvider::new()
+ .with_config("type", "bridge")
+ .with_config("driver", "epair");
+
+ let assigner = RangeAssigner::with_range_name("jail_network_ips")
+ .with_config("strategy", "sequential")
+ .with_config("reserve_gateway", "true");
+
+ let netrange = NetRange::from_cidr("10.0.10.0/24")?;
+
+ instances.networks().create(
+ "jail_network",
+ netrange,
+ provider,
+ Some(assigner),
+ )?;
+
+ // Create a jail instance with network interface
+ let jail_provider = JailProvider::new()
+ .with_dataset("tank/jails".to_string());
+
+ let jail_config = InstanceConfig {
+ provider_config: jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "jail_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None, // Should be automatically assigned
+ }
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let jail_instance = Instance {
+ name: "test-jail".to_string(),
+ provider_type: "jail".to_string(),
+ config: jail_config,
+ created_at: 0,
+ updated_at: 0,
+ };
+
+ // Create the instance - this should automatically assign an IP
+ let created_instance = instances.create(jail_instance)?;
+
+ // Verify the instance was created with an IP assignment
+ assert_eq!(created_instance.name, "test-jail");
+ assert_eq!(created_instance.config.network_interfaces.len(), 1);
+
+ let interface = &created_instance.config.network_interfaces[0];
+ assert_eq!(interface.network_name, "jail_network");
+ assert_eq!(interface.interface_name, "em0");
+
+ // This should pass once the automatic assignment is implemented
+ assert!(interface.assignment.is_some(), "IP should be automatically assigned");
+
+ // Verify the assigned IP is valid
+ if let Some(ref assignment) = interface.assignment {
+ let ip: std::net::IpAddr = assignment.parse()
+ .expect("Assignment should be a valid IP address");
+
+ // Should be in the 10.0.10.0/24 range
+ match ip {
+ std::net::IpAddr::V4(ipv4) => {
+ let octets = ipv4.octets();
+ assert_eq!(octets[0], 10);
+ assert_eq!(octets[1], 0);
+ assert_eq!(octets[2], 10);
+ // RangeAssigner starts from bit position 0, which is the network address
+ // This is actually correct behavior - bit 0 = .0, bit 1 = .1, etc.
+ assert!(octets[3] < 255, "IP should not be broadcast address");
+ }
+ _ => panic!("Expected IPv4 address"),
+ }
+ }
+
+ // Test that we can retrieve the instance and it still has the assignment
+ let retrieved_instance = instances.get("test-jail")?;
+ assert!(retrieved_instance.config.network_interfaces[0].assignment.is_some());
+
+ Ok(())
+}
+
+#[test]
+fn test_jail_deletion_cleans_up_ip_assignments() -> store::Result<()> {
+ let (_temp_dir, _db, instances) = create_test_stores()?;
+
+ // Create a network with RangeAssigner
+ let provider = BasicProvider::new();
+ let assigner = RangeAssigner::with_range_name("deletion_test_ips");
+ let netrange = NetRange::from_cidr("10.0.30.0/24")?;
+
+ instances.networks().create("deletion_test_network", netrange, provider, Some(assigner))?;
+
+ // Create a jail instance
+ let jail_provider = JailProvider::new();
+ let jail_config = InstanceConfig {
+ provider_config: jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "deletion_test_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None,
+ }
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let jail_instance = Instance {
+ name: "deletion-test-jail".to_string(),
+ provider_type: "jail".to_string(),
+ config: jail_config,
+ created_at: 0,
+ updated_at: 0,
+ };
+
+ // Create the instance - should get IP assignment
+ let created = instances.create(jail_instance)?;
+ assert!(created.config.network_interfaces[0].assignment.is_some());
+ let assigned_ip = created.config.network_interfaces[0].assignment.as_ref().unwrap().clone();
+
+ // Verify the IP is assigned in the range
+ let network = instances.networks().get("deletion_test_network")?.unwrap();
+ let assignment = network.get_assignment("deletion-test-jail", instances.networks().ranges())?;
+ assert!(assignment.is_some());
+ assert_eq!(assignment.unwrap().to_string(), assigned_ip);
+
+ // Delete the instance
+ instances.delete("deletion-test-jail")?;
+
+ // Verify instance is deleted
+ assert!(instances.get("deletion-test-jail").is_err());
+
+ // Verify IP assignment is cleaned up
+ let assignment_after = network.get_assignment("deletion-test-jail", instances.networks().ranges())?;
+ assert!(assignment_after.is_none(), "IP assignment should be cleaned up after deletion");
+
+ // Create a new instance to verify the IP can be reused
+ let new_jail_config = InstanceConfig {
+ provider_config: JailProvider::new().to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "deletion_test_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None,
+ }
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let new_jail = Instance {
+ name: "new-test-jail".to_string(),
+ provider_type: "jail".to_string(),
+ config: new_jail_config,
+ created_at: 0,
+ updated_at: 0,
+ };
+
+ let new_created = instances.create(new_jail)?;
+
+ // Should get the same IP that was freed
+ assert!(new_created.config.network_interfaces[0].assignment.is_some());
+ let new_assigned_ip = new_created.config.network_interfaces[0].assignment.as_ref().unwrap();
+ assert_eq!(*new_assigned_ip, assigned_ip, "IP should be reused after cleanup");
+
+ Ok(())
+}
+
+#[test]
+fn test_multiple_jails_get_different_ip_assignments() -> store::Result<()> {
+ let (_temp_dir, _db, instances) = create_test_stores()?;
+
+ // Create a network with RangeAssigner
+ let provider = BasicProvider::new();
+ let assigner = RangeAssigner::with_range_name("multi_jail_ips");
+ let netrange = NetRange::from_cidr("192.168.100.0/24")?;
+
+ instances.networks().create("multi_jail_network", netrange, provider, Some(assigner))?;
+
+ // Create multiple jail instances
+ for i in 1..=3 {
+ let jail_provider = JailProvider::new();
+ let jail_config = InstanceConfig {
+ provider_config: jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "multi_jail_network".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None,
+ }
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let jail_instance = Instance {
+ name: format!("jail-{}", i),
+ provider_type: "jail".to_string(),
+ config: jail_config,
+ created_at: 0,
+ updated_at: 0,
+ };
+
+ instances.create(jail_instance)?;
+ }
+
+ // Verify all instances have different IP assignments
+ let mut assigned_ips = std::collections::HashSet::new();
+
+ for i in 1..=3 {
+ let instance = instances.get(&format!("jail-{}", i))?;
+ let interface = &instance.config.network_interfaces[0];
+
+ assert!(interface.assignment.is_some(), "Instance jail-{} should have IP assignment", i);
+ let ip = interface.assignment.as_ref().unwrap();
+
+ assert!(assigned_ips.insert(ip.clone()),
+ "Instance jail-{} got duplicate IP: {}", i, ip);
+ }
+
+ // Should have 3 unique IP assignments
+ assert_eq!(assigned_ips.len(), 3);
+
+ Ok(())
+}
+
+#[test]
+fn test_jail_with_multiple_network_interfaces() -> store::Result<()> {
+ let (_temp_dir, _db, instances) = create_test_stores()?;
+
+ // Create two networks with RangeAssigners
+ let mgmt_provider = BasicProvider::new();
+ let mgmt_assigner = RangeAssigner::with_range_name("mgmt_ips");
+ instances.networks().create(
+ "management",
+ NetRange::from_cidr("10.0.1.0/24")?,
+ mgmt_provider,
+ Some(mgmt_assigner),
+ )?;
+
+ let data_provider = BasicProvider::new();
+ let data_assigner = RangeAssigner::with_range_name("data_ips");
+ instances.networks().create(
+ "data",
+ NetRange::from_cidr("10.0.2.0/24")?,
+ data_provider,
+ Some(data_assigner),
+ )?;
+
+ // Create jail with multiple interfaces
+ let jail_provider = JailProvider::new();
+ let jail_config = InstanceConfig {
+ provider_config: jail_provider.to_json()?,
+ network_interfaces: vec![
+ NetworkInterface {
+ network_name: "management".to_string(),
+ interface_name: "em0".to_string(),
+ assignment: None,
+ },
+ NetworkInterface {
+ network_name: "data".to_string(),
+ interface_name: "em1".to_string(),
+ assignment: None,
+ }
+ ],
+ metadata: HashMap::new(),
+ };
+
+ let jail_instance = Instance {
+ name: "multi-interface-jail".to_string(),
+ provider_type: "jail".to_string(),
+ config: jail_config,
+ created_at: 0,
+ updated_at: 0,
+ };
+
+ let created_instance = instances.create(jail_instance)?;
+
+ // Verify both interfaces got IP assignments
+ assert_eq!(created_instance.config.network_interfaces.len(), 2);
+
+ for (i, interface) in created_instance.config.network_interfaces.iter().enumerate() {
+ assert!(interface.assignment.is_some(),
+ "Interface {} should have IP assignment", i);
+
+ let ip: std::net::IpAddr = interface.assignment.as_ref().unwrap().parse()
+ .expect("Assignment should be valid IP");
+
+ match ip {
+ std::net::IpAddr::V4(ipv4) => {
+ let octets = ipv4.octets();
+ assert_eq!(octets[0], 10);
+ assert_eq!(octets[1], 0);
+
+ if interface.network_name == "management" {
+ assert_eq!(octets[2], 1);
+ } else if interface.network_name == "data" {
+ assert_eq!(octets[2], 2);
+ }
+ }
+ _ => panic!("Expected IPv4 address"),
+ }
+ }
+
+ Ok(())
+}
\ No newline at end of file

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jun 7, 6:54 PM (53 m, 43 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
47542
Default Alt Text
(89 KB)

Event Timeline