Add zmgr: illumos zone manager with IPAM and flat-file registry

Rust CLI that creates/destroys/imports illumos zones from KDL template
configs with automatic IP allocation from named pools. Registry lives
under /etc/zmgr as flat KDL files — zone entries double as the IPAM
ledger. Includes default templates for ipkg (OI) and nlipkg (OFL)
brands, matching the existing shell scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-03-22 12:14:09 +01:00
commit abdce9c927
No known key found for this signature in database
22 changed files with 2864 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

819
Cargo.lock generated Normal file
View file

@ -0,0 +1,819 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[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 = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "backtrace-ext"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
dependencies = [
"backtrace",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cc"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
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.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
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 = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "kdl"
version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
dependencies = [
"miette",
"num",
"winnow",
]
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"backtrace",
"backtrace-ext",
"cfg-if",
"miette-derive",
"owo-colors",
"supports-color",
"supports-hyperlinks",
"supports-unicode",
"terminal_size",
"textwrap",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
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 = "owo-colors"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]]
name = "supports-hyperlinks"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
[[package]]
name = "supports-unicode"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "terminal_size"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
dependencies = [
"rustix",
"windows-sys 0.60.2",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"unicode-linebreak",
"unicode-width 0.2.2",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]]
name = "zmgr"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"ipnet",
"kdl",
"miette",
"thiserror",
]

12
Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "zmgr"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.44"
clap = { version = "4.6.0", features = ["derive"] }
ipnet = "2.12.0"
kdl = "6.5.0"
miette = { version = "7.6.0", features = ["fancy"] }
thiserror = "2.0.18"

66
create_ipxe_vm.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/bash
set -ex
ZONE=$1
IP_ADDRESS_CIDR="${2}/16"
NICNAME="${ZONE}0"
INTERNALSTUB="oinetint0"
GATEWAY="10.1.0.1"
RAM="16G"
VCPUS="8"
DISK="60G"
ISO="/vm/iso/ipxe-oi-internal.iso"
if ! dladm show-vnic $NICNAME > /dev/null; then
dladm create-vnic -l $INTERNALSTUB $NICNAME
fi
if ! zfs get name rpool/vm/${ZONE}d0 > /dev/null;then
zfs create -V ${DISK} rpool/vm/${ZONE}d0
fi
cat <<EOF | zonecfg -z $ZONE
create -b
set zonepath=/zones/$ZONE
set brand=bhyve
set autoboot=true
set ip-type=exclusive
add fs
set dir="/vm/iso"
set special="/vm/iso"
set type="lofs"
add options ro
add options nodevices
end
add net
set physical="$NICNAME"
end
add device
set match="/dev/zvol/rdsk/rpool/vm/${ZONE}d0"
end
add attr
set name="bootdisk"
set type="string"
set value="rpool/vm/${ZONE}d0"
end
add attr
set name="ram"
set type="string"
set value="${RAM}"
end
add attr
set name="vcpus"
set type="string"
set value="${VCPUS}"
end
add attr
set name="cdrom"
set type="string"
set value="${ISO}"
end
EOF
zoneadm -z $ZONE install
zoneadm -z $ZONE boot
zlogin -C $ZONE

29
create_ofl_zone.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/bash
set -ex
ZONE=$1
ADDRESS="${2}/24"
NICNAME="${ZONE}0"
INTERNALSTUB="oflint0"
GATEWAY="192.168.10.1"
if ! dladm show-vnic $NICNAME > /dev/null; then
dladm create-vnic -l $INTERNALSTUB $NICNAME
fi
cat <<EOF | zonecfg -z $ZONE
create -b
set zonepath=/zones/$ZONE
set brand=nlipkg
set autoboot=true
set ip-type=exclusive
add net
set physical=$NICNAME
set allowed-address=$ADDRESS
set defrouter=$GATEWAY
end
verify
commit
EOF
zoneadm -z $ZONE install

121
create_oi_vm.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/bash
set -ex
ZONE=$1
IP_ADDRESS_CIDR="${2}/16"
NICNAME="${ZONE}0"
INTERNALSTUB="oinetint0"
GATEWAY="10.1.0.1"
RAM="16G"
VCPUS="8"
DISK="60G"
CIDATA_DIR="/vm/iso/${ZONE}-cidata"
ISO="${CIDATA_DIR}.iso"
if ! dladm show-vnic $NICNAME > /dev/null; then
dladm create-vnic -l $INTERNALSTUB $NICNAME
fi
if ! zfs get name rpool/vm/${ZONE}d0 > /dev/null;then
zfs create -V ${DISK} rpool/vm/${ZONE}d0
fi
dd if=/zones/oinetentry/root/var/www/dlc/hipster/cloudimage-ttya-openindiana-hipster.raw of=/dev/zvol/rdsk/rpool/vm/${ZONE}d0 bs=1M status=progress
cat <<EOF | zonecfg -z $ZONE
create -b
set zonepath=/zones/$ZONE
set brand=bhyve
set autoboot=true
set ip-type=exclusive
add fs
set dir="/vm/iso"
set special="/vm/iso"
set type="lofs"
add options ro
add options nodevices
end
add net
set physical="$NICNAME"
end
add device
set match="/dev/zvol/rdsk/rpool/vm/${ZONE}d0"
end
add attr
set name="bootdisk"
set type="string"
set value="rpool/vm/${ZONE}d0"
end
add attr
set name="acpi"
set type="string"
set value="off"
end
add attr
set name="ram"
set type="string"
set value="${RAM}"
end
add attr
set name="vcpus"
set type="string"
set value="${VCPUS}"
end
add attr
set name="cdrom"
set type="string"
set value="${ISO}"
end
EOF
mkdir -p $CIDATA_DIR
cat<<EOF > $CIDATA_DIR/user-data
#cloud-config
users:
- name: root
ssh_authorized_keys:
- "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQBmEXyfBHRiQogrAEgFp8Lr1TDEKdRRcpwqHOiujzTAFqszxutMPzuFlyU0Ljh28VqeDD9cIyS6VGfKG3iASQbIZd61yE0NHPNC6IPTFTggGK+3PzKt4T+t1ZdIhdrCGcsrq8c+44kSyIutvbruB0UmRjq4XqqkCDFEZMzBF/KUcu0M7oSEawFuzbtl7xoaDY83F7+HXTxbBKuRwSJw58d8MqsI7KlW6zUKJi0bgmYJOFjjDLPVTo7lqdNYfTRAHZYaCUADfrUqBCOLHSDET1Zrs3ibr6l8oMqOvctfwvuGpNT8vs05Z0P/ETUiZ/SzTyvoRyxXG0JTKlNSRmxcYA2J toasty_rsa"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILcp4Wgea5V8N750xcRGil1rrdd+mCpwYBspNwwb45SN toasty_ed25519"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBrXXxL7WUfh5UjSGV2uANb9E4LP9jl/cEt3NpC87g6h a.wacknitz@gmx.de"
EOF
ZONE_UUID=$(zoneadm -z ${ZONE} list -p | cut -d ":" -f 5)
cat<<EOF > $CIDATA_DIR/meta-data
instance-id: "${ZONE_UUID}"
hostname: "${ZONE}"
EOF
MAC_ADDRESS=$(dladm show-vnic ${NICNAME} -p -o macaddress)
cat<<EOF > $CIDATA_DIR/network-config
network:
version: 1
config:
- type: physical
name: ${NICNAME}
mac_address: '${MAC_ADDRESS}'
subnets:
- type: static
address: ${IP_ADDRESS_CIDR}
gateway: ${GATEWAY}
dns_nameservers:
- 9.9.9.9
- 149.112.112.112
- 2620:fe::fe
- 2620:fe::9
dns_search:
- openindiana.org
EOF
vim $CIDATA_DIR/network-config
(cd ${CIDATA_DIR}; mkisofs -graft-points -dlrDJN -relaxed-filenames -o ${ISO} -V cidata user-data meta-data network-config)
zoneadm -z $ZONE install
zoneadm -z $ZONE boot
zlogin -C $ZONE

29
create_oi_zone.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/bash
set -ex
ZONE=$1
ADDRESS="${2}/24"
NICNAME="${ZONE}0"
INTERNALSTUB="oinetint0"
GATEWAY="10.1.0.1"
if ! dladm show-vnic $NICNAME > /dev/null; then
dladm create-vnic -l $INTERNALSTUB $NICNAME
fi
cat <<EOF | zonecfg -z $ZONE
create -b
set zonepath=/zones/$ZONE
set brand=ipkg
set autoboot=false
set ip-type=exclusive
add net
set physical=$NICNAME
set allowed-address=$ADDRESS
set defrouter=$GATEWAY
end
verify
commit
EOF
zoneadm -z $ZONE install

63
docs/ai/architecture.md Normal file
View file

@ -0,0 +1,63 @@
# Architecture
## Overview
zmgr is a single Rust binary that manages illumos zones via flat KDL config files under `/etc/zmgr`. No daemon, no database — just a CLI that reads configs, calls system commands (`zonecfg`, `zoneadm`, `dladm`), and writes registry entries.
## Module Layout
```
src/
main.rs CLI entry point (clap), command dispatch
config.rs Global config (/etc/zmgr/config.kdl)
template.rs Zone templates (/etc/zmgr/templates/*.kdl)
pool.rs IPAM pools (/etc/zmgr/pools/*.kdl)
zone.rs Zone registry (/etc/zmgr/zones/*.kdl)
publisher.rs IPS publishers (/etc/zmgr/publishers/*.kdl)
exec.rs System command wrappers (zonecfg, zoneadm, dladm)
import.rs Import existing zones into registry
kdl_util.rs KDL document parsing helpers
error.rs Error types (miette diagnostics)
```
## Data Flow
### Zone Creation (`zmgr create <name>`)
1. Load global config → get default template name
2. Load template → get brand, autoboot, pool reference
3. Load pool → get network, gateway, stub, IP range
4. Scan zone registry → find allocated IPs
5. Allocate next free IP from pool range
6. `dladm create-vnic` → create VNIC on stub
7. `zonecfg -z <name>` → pipe zone configuration
8. `zoneadm -z <name> install` → install zone
9. Write zone registry entry (KDL file)
10. If autoboot, `zoneadm boot`
### Zone Import (`zmgr import`)
1. `zoneadm list -cp` → get system zones
2. Filter out already-registered and bhyve zones
3. `zonecfg -z <name> info` → extract config
4. Match brand → template, IP → pool
5. Write zone registry entry
## Dependency Graph
```
main.rs
├── config.rs ← kdl_util.rs, error.rs
├── template.rs ← kdl_util.rs, error.rs
├── pool.rs ← kdl_util.rs, error.rs, zone.rs
├── zone.rs ← kdl_util.rs, error.rs
├── publisher.rs ← kdl_util.rs, error.rs
├── exec.rs ← error.rs
└── import.rs ← exec.rs, pool.rs, template.rs, zone.rs
```
## Boundary with vm-manager
zmgr manages **zone brands** (ipkg, nlipkg, etc.) — native illumos zones.
vm-manager manages **bhyve** brand zones — virtual machines.
Import logic skips bhyve zones to avoid overlap.

49
docs/ai/decisions.md Normal file
View file

@ -0,0 +1,49 @@
# Design Decisions
## KDL over TOML/JSON
KDL (v2) was chosen for all config files because:
- Node-based structure maps naturally to zone/pool/template semantics
- More readable than TOML for nested structures
- `kdl` crate v6.5 supports KDL v2 spec
- Note: KDL v2 uses `#true`/`#false` for booleans, not bare `true`/`false`
`knuffel` (typed derive) was evaluated but targets KDL v1, incompatible with `kdl` v6. Manual extraction via `kdl_util.rs` helpers is sufficient for our simple schemas.
## Flat Files, No Database
All state lives in `/etc/zmgr/` as individual KDL files:
- One file per zone, pool, template, publisher
- Human-editable with any text editor
- Works on minimal illumos installs (no SQLite, no sled)
- Easy to back up, version control, or inspect
## IPAM: Zone Files Are the Ledger
No separate allocation table. To find allocated IPs:
1. Scan `/etc/zmgr/zones/*.kdl`
2. Parse each zone's `address` field
3. Check which IPs fall within a pool's network
This avoids consistency issues between a ledger and registry.
## Global Pools, Template References
Pools are defined independently (not inside templates) so multiple templates can share a pool. Templates reference pools by name. This matches the user's preference for a "global pool config" pattern.
## bhyve Exclusion
Import logic explicitly skips `brand=bhyve` zones. These are vm-manager's responsibility. This boundary is enforced at the import level, not at the config level.
## Error Handling: miette Diagnostics
Every error variant includes:
- A descriptive message
- A diagnostic code (e.g., `zmgr::pool::exhausted`)
- A help message telling the user what to do next
This follows the miette diagnostic pattern per project conventions.
## No Daemon, No State Machine
zmgr is a one-shot CLI. Each invocation reads config, acts, writes results. No background process, no lock files, no PID management. Zone lifecycle is delegated to the OS (`zoneadm`).

40
docs/ai/gap-analysis.md Normal file
View file

@ -0,0 +1,40 @@
# Gap Analysis
## Current Gaps
### Publishers Not Applied During Install
Publishers are stored and listed but not yet passed to `zonecfg`/`zoneadm` during zone creation. Future: configure IPS publishers inside the zone after install via `zlogin` or `sysding`.
### No Zone Boot/Halt Commands
`zmgr` doesn't expose boot/halt/reboot as subcommands. Users must use `zoneadm -z <name> boot` directly. Could add `zmgr boot <name>` / `zmgr halt <name>` as thin wrappers.
### No Template Create/Edit via CLI
Templates must be edited as KDL files directly. Could add `zmgr template create` / `zmgr template edit` commands.
### No Pool Create/Edit via CLI
Same as templates — pools are managed by editing files. Could add CLI commands.
### No ZFS Dataset Management
The original VM scripts create ZFS volumes (`zfs create -V`). zmgr doesn't manage ZFS datasets. For zone brands, `zoneadm install` handles the zonepath ZFS dataset automatically.
### No Cloud-Init / Sysding Integration
The VM scripts generate cloud-init configs (user-data, meta-data, network-config). Zones don't use cloud-init but could benefit from sysding config generation for first-boot setup (hostname, SSH keys, networking).
### No Dry-Run Mode
`zmgr create --dry-run` could show what would happen without executing system commands. Useful for validation.
### No VNIC Naming Customization
VNICs are always `<zonename>0`. Could support custom VNIC naming patterns.
### Import Matching is Best-Effort
Import matches zones to templates by brand and IPs to pools by network containment. Zones with unusual configs may get poor matches. Manual editing of the resulting KDL files may be needed.
### No IPv6 Support
IPAM only handles IPv4 pools. Could extend to dual-stack.
## Future Considerations
- **Zone ordering**: Dependencies between zones (e.g., start DNS zone before app zones)
- **Snapshots**: ZFS snapshot management for zone rollback
- **Migration**: Move zones between hosts
- **Monitoring**: Health checks, resource usage

View file

@ -0,0 +1,37 @@
# Implementation State
## Completed
- [x] Project scaffold (Cargo.toml, module structure)
- [x] KDL parsing helpers (`kdl_util.rs`)
- [x] Error types with miette diagnostics (`error.rs`)
- [x] Global config loading (`config.rs`)
- [x] Template loading + defaults (`template.rs`)
- [x] IPAM pool loading, allocation, defaults (`pool.rs`)
- [x] Zone registry CRUD (`zone.rs`)
- [x] Publisher management (`publisher.rs`)
- [x] Exec layer for system commands (`exec.rs`)
- [x] Import logic from existing zones (`import.rs`)
- [x] CLI commands: init, create, destroy, list, status, import
- [x] CLI commands: template list/show, pool list/show
- [x] CLI commands: publisher list/add/remove
- [x] Clean build (zero warnings)
- [x] Tested: init, template list, pool list/show, publisher list, list
## Not Yet Tested on illumos
- [ ] `zmgr create` (requires illumos with zonecfg/zoneadm/dladm)
- [ ] `zmgr destroy`
- [ ] `zmgr import`
- [ ] `zmgr status` (requires zoneadm)
## Dependencies
| Crate | Version | Purpose |
|---|---|---|
| clap | 4.6.0 | CLI parsing (derive) |
| kdl | 6.5.0 | KDL v2 document parsing |
| miette | 7.6.0 | Diagnostic error reporting |
| thiserror | 2.0.18 | Error type derives |
| ipnet | 2.12.0 | IP network arithmetic |
| chrono | 0.4.44 | Date formatting |

View file

@ -0,0 +1,78 @@
# Registry Format
All files use KDL v2 syntax. The registry root defaults to `/etc/zmgr/` but can be overridden with `--registry`.
## config.kdl
```kdl
zonepath-prefix "/zones"
default-template "oi"
```
| Field | Type | Default | Description |
|---|---|---|---|
| `zonepath-prefix` | string | `/zones` | Parent directory for zone roots |
| `default-template` | string | `oi` | Template used when `--template` is omitted |
## templates/*.kdl
```kdl
template "oi" {
brand "ipkg"
autoboot #false
ip-type "exclusive"
pool "internal"
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `brand` | string | yes | Zone brand (ipkg, nlipkg, etc.) |
| `autoboot` | bool | no | Boot zone after install (default: `#false`) |
| `ip-type` | string | no | IP type (default: `exclusive`) |
| `pool` | string | yes | IPAM pool name to allocate from |
## pools/*.kdl
```kdl
pool "internal" {
network "10.1.0.0/24"
gateway "10.1.0.1"
stub "oinetint0"
range-start "10.1.0.10"
range-end "10.1.0.250"
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `network` | CIDR | yes | Network in CIDR notation |
| `gateway` | IPv4 | yes | Default router for zones in this pool |
| `stub` | string | yes | Etherstub/VNIC parent for zone VNICs |
| `range-start` | IPv4 | yes | First allocatable address |
| `range-end` | IPv4 | yes | Last allocatable address |
## zones/*.kdl
```kdl
zone "myzone" {
template "oi"
address "10.1.0.10/24"
gateway "10.1.0.1"
vnic "myzone0"
stub "oinetint0"
created "2026-03-22"
}
```
These files are created by `zmgr create` and `zmgr import`. They serve as the IPAM allocation ledger — scanning all zone files determines which IPs are in use.
## publishers/*.kdl
```kdl
publisher "openindiana.org" {
origin "https://pkg.openindiana.org/hipster"
}
```
IPS publisher configurations. Currently informational — future work could apply these during zone install.

46
src/config.rs Normal file
View file

@ -0,0 +1,46 @@
use std::path::Path;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
/// Global zmgr configuration.
pub struct Config {
pub zonepath_prefix: String,
pub default_template: String,
}
impl Config {
pub fn load(registry: &Path) -> Result<Self> {
let path = registry.join("config.kdl");
let doc = kdl_util::read_document(&path)?;
let zonepath_prefix = kdl_util::get_string(&doc, "zonepath-prefix")
.unwrap_or_else(|| "/zones".to_string());
let default_template = kdl_util::get_string(&doc, "default-template")
.unwrap_or_else(|| "oi".to_string());
Ok(Config {
zonepath_prefix,
default_template,
})
}
pub fn write_default(registry: &Path) -> Result<()> {
let content = r#"zonepath-prefix "/zones"
default-template "oi"
"#;
let path = registry.join("config.kdl");
std::fs::write(&path, content).map_err(ZmgrError::Io)?;
Ok(())
}
}
pub fn ensure_initialized(registry: &Path) -> Result<()> {
if !registry.join("config.kdl").exists() {
return Err(ZmgrError::NotInitialized {
path: registry.display().to_string(),
}
.into());
}
Ok(())
}

90
src/error.rs Normal file
View file

@ -0,0 +1,90 @@
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
#[allow(unused_assignments, dead_code)]
pub enum ZmgrError {
#[error("Registry not initialized at {path}")]
#[diagnostic(
code(zmgr::not_initialized),
help("Run `zmgr init` to create the registry structure at {path}")
)]
NotInitialized { path: String },
#[error("Registry already exists at {path}")]
#[diagnostic(
code(zmgr::already_initialized),
help("The registry is already set up. Use `zmgr init --force` to reinitialize.")
)]
AlreadyInitialized { path: String },
#[error("Zone '{name}' already exists in registry")]
#[diagnostic(
code(zmgr::zone::exists),
help("Use `zmgr destroy {name}` first, or choose a different name.")
)]
ZoneExists { name: String },
#[error("Zone '{name}' not found in registry")]
#[diagnostic(
code(zmgr::zone::not_found),
help("Run `zmgr list` to see managed zones, or `zmgr import` to import existing zones.")
)]
ZoneNotFound { name: String },
#[error("Template '{name}' not found")]
#[diagnostic(
code(zmgr::template::not_found),
help("Run `zmgr template list` to see available templates.")
)]
TemplateNotFound { name: String },
#[error("Pool '{name}' not found")]
#[diagnostic(
code(zmgr::pool::not_found),
help("Run `zmgr pool list` to see available pools.")
)]
PoolNotFound { name: String },
#[error("No free addresses in pool '{pool}'")]
#[diagnostic(
code(zmgr::ipam::exhausted),
help("All addresses in pool '{pool}' ({range_start} - {range_end}) are allocated. \
Destroy unused zones or expand the pool range.")
)]
PoolExhausted {
pool: String,
range_start: String,
range_end: String,
},
#[error("Failed to parse KDL file: {path}")]
#[diagnostic(code(zmgr::kdl::parse))]
KdlParse {
path: String,
#[source]
source: kdl::KdlError,
},
#[error("Missing required field '{field}' in {context}")]
#[diagnostic(
code(zmgr::kdl::missing_field),
help("Add `{field} \"<value>\"` to {context}")
)]
MissingField { field: String, context: String },
#[error("Command failed: {command}")]
#[diagnostic(code(zmgr::exec::failed))]
CommandFailed {
command: String,
stderr: String,
#[help]
help_msg: String,
},
#[error(transparent)]
#[diagnostic(code(zmgr::io))]
Io(#[from] std::io::Error),
}
pub type Result<T> = miette::Result<T>;

158
src/exec.rs Normal file
View file

@ -0,0 +1,158 @@
use std::process::Command;
use crate::error::{Result, ZmgrError};
/// Run a command and return stdout, or a diagnostic error.
fn run(cmd: &str, args: &[&str]) -> Result<String> {
let output = Command::new(cmd)
.args(args)
.output()
.map_err(ZmgrError::Io)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(ZmgrError::CommandFailed {
command: format!("{cmd} {}", args.join(" ")),
stderr: stderr.clone(),
help_msg: format!("Command exited with {}. stderr: {stderr}", output.status),
}
.into())
}
}
/// Pipe input to a command's stdin.
fn run_with_stdin(cmd: &str, args: &[&str], input: &str) -> Result<String> {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(cmd)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(ZmgrError::Io)?;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(input.as_bytes()).map_err(ZmgrError::Io)?;
}
let output = child.wait_with_output().map_err(ZmgrError::Io)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(ZmgrError::CommandFailed {
command: format!("{cmd} {}", args.join(" ")),
stderr: stderr.clone(),
help_msg: format!("Command exited with {}. stderr: {stderr}", output.status),
}
.into())
}
}
// --- dladm ---
pub fn dladm_create_vnic(name: &str, stub: &str) -> Result<()> {
// Check if VNIC already exists
if dladm_vnic_exists(name)? {
return Ok(());
}
run("dladm", &["create-vnic", "-l", stub, name])?;
Ok(())
}
pub fn dladm_delete_vnic(name: &str) -> Result<()> {
if !dladm_vnic_exists(name)? {
return Ok(());
}
run("dladm", &["delete-vnic", name])?;
Ok(())
}
fn dladm_vnic_exists(name: &str) -> Result<bool> {
let result = Command::new("dladm")
.args(["show-vnic", name])
.output()
.map_err(ZmgrError::Io)?;
Ok(result.status.success())
}
// --- zonecfg ---
/// Configure a zone using zonecfg commands piped to stdin.
pub fn zonecfg_create(zone_name: &str, zonecfg_commands: &str) -> Result<()> {
run_with_stdin("zonecfg", &["-z", zone_name], zonecfg_commands)?;
Ok(())
}
/// Get zone configuration info.
pub fn zonecfg_info(zone_name: &str) -> Result<String> {
run("zonecfg", &["-z", zone_name, "info"])
}
/// Delete zone configuration.
pub fn zonecfg_delete(zone_name: &str) -> Result<()> {
run_with_stdin("zonecfg", &["-z", zone_name], "delete -F\n")?;
Ok(())
}
// --- zoneadm ---
pub fn zoneadm_install(zone_name: &str) -> Result<()> {
run("zoneadm", &["-z", zone_name, "install"])?;
Ok(())
}
pub fn zoneadm_uninstall(zone_name: &str) -> Result<()> {
run("zoneadm", &["-z", zone_name, "uninstall", "-F"])?;
Ok(())
}
pub fn zoneadm_boot(zone_name: &str) -> Result<()> {
run("zoneadm", &["-z", zone_name, "boot"])?;
Ok(())
}
pub fn zoneadm_halt(zone_name: &str) -> Result<()> {
run("zoneadm", &["-z", zone_name, "halt"])?;
Ok(())
}
/// Parsed entry from `zoneadm list -cp`.
#[allow(dead_code)]
pub struct ZoneadmEntry {
pub id: String,
pub name: String,
pub state: String,
pub path: String,
pub uuid: String,
pub brand: String,
pub ip_type: String,
}
/// List all zones via `zoneadm list -cp`.
pub fn zoneadm_list() -> Result<Vec<ZoneadmEntry>> {
let output = run("zoneadm", &["list", "-cp"])?;
let mut entries = Vec::new();
for line in output.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 7 && fields[1] != "global" {
entries.push(ZoneadmEntry {
id: fields[0].to_string(),
name: fields[1].to_string(),
state: fields[2].to_string(),
path: fields[3].to_string(),
uuid: fields[4].to_string(),
brand: fields[5].to_string(),
ip_type: fields[6].to_string(),
});
}
}
Ok(entries)
}

119
src/import.rs Normal file
View file

@ -0,0 +1,119 @@
use std::path::Path;
use crate::error::Result;
use crate::exec;
use crate::pool::Pool;
use crate::template::Template;
use crate::zone::Zone;
/// Import existing zones from the system into the registry.
///
/// For each zone reported by `zoneadm list -cp` that is not already in the
/// registry, we parse its `zonecfg info` output to extract configuration and
/// create a registry entry.
pub fn import_zones(registry: &Path, filter_name: Option<&str>) -> Result<Vec<String>> {
let system_zones = exec::zoneadm_list()?;
let templates = Template::list(registry)?;
let pools = Pool::list(registry)?;
let mut imported = Vec::new();
for entry in &system_zones {
// Skip if already in registry
if Zone::exists(registry, &entry.name) {
continue;
}
// If user specified a filter, only import that zone
if let Some(filter) = filter_name {
if entry.name != filter {
continue;
}
}
// Skip bhyve zones — those belong to vm-manager
if entry.brand == "bhyve" {
continue;
}
// Get zonecfg info for details
let info = match exec::zonecfg_info(&entry.name) {
Ok(info) => info,
Err(e) => {
eprintln!("warning: cannot read config for zone '{}': {e}", entry.name);
continue;
}
};
// Parse zonecfg info output
let address = parse_zonecfg_field(&info, "allowed-address:")
.unwrap_or_default();
let vnic = parse_zonecfg_field(&info, "physical:")
.unwrap_or_else(|| format!("{}0", entry.name));
let gateway = parse_zonecfg_field(&info, "defrouter:")
.unwrap_or_default();
// Match to a template by brand
let template = match_template(&entry.brand, &templates, registry);
// Match to a pool by finding which pool's network contains the address
let stub = match_pool_stub(&address, &pools, registry);
let zone = Zone {
name: entry.name.clone(),
template: template.unwrap_or_else(|| entry.brand.clone()),
address,
gateway,
vnic,
stub: stub.unwrap_or_default(),
created: String::new(), // unknown for imported zones
};
zone.write(registry)?;
imported.push(entry.name.clone());
}
Ok(imported)
}
/// Parse a field from `zonecfg info` output.
/// Lines look like: `\tset allowed-address=10.1.0.5/24` or `\tallowed-address: 10.1.0.5/24`
fn parse_zonecfg_field(info: &str, field: &str) -> Option<String> {
for line in info.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(field) {
let value = rest.trim().trim_matches('"');
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
/// Match a brand to a template name.
fn match_template(brand: &str, template_names: &[String], registry: &Path) -> Option<String> {
for name in template_names {
if let Ok(t) = Template::load(registry, name) {
if t.brand == brand {
return Some(name.clone());
}
}
}
None
}
/// Find which pool contains the given address and return its stub.
fn match_pool_stub(address: &str, pool_names: &[String], registry: &Path) -> Option<String> {
let ip_str = address.split('/').next()?;
let ip: std::net::Ipv4Addr = ip_str.parse().ok()?;
for name in pool_names {
if let Ok(pool) = Pool::load(registry, name) {
if pool.network.contains(&ip) {
return Some(pool.stub.clone());
}
}
}
None
}

30
src/kdl_util.rs Normal file
View file

@ -0,0 +1,30 @@
use std::path::Path;
use kdl::KdlDocument;
use crate::error::{Result, ZmgrError};
/// Read and parse a KDL file.
pub fn read_document(path: &Path) -> Result<KdlDocument> {
let content = std::fs::read_to_string(path).map_err(ZmgrError::Io)?;
let doc: KdlDocument = content.parse().map_err(|e| ZmgrError::KdlParse {
path: path.display().to_string(),
source: e,
})?;
Ok(doc)
}
/// Get a string value from a top-level node: `name "value"`
pub fn get_string(doc: &KdlDocument, name: &str) -> Option<String> {
doc.get(name)
.and_then(|node| node.entries().first())
.and_then(|entry| entry.value().as_string())
.map(|s| s.to_string())
}
/// Get a bool value from a top-level node: `name true`
pub fn get_bool(doc: &KdlDocument, name: &str) -> Option<bool> {
doc.get(name)
.and_then(|node| node.entries().first())
.and_then(|entry| entry.value().as_bool())
}

522
src/main.rs Normal file
View file

@ -0,0 +1,522 @@
// thiserror derive generates false-positive unused_assignments warnings
// for fields used in #[error("...{field}...")] format strings
#![allow(unused_assignments)]
mod config;
mod error;
mod exec;
mod import;
mod kdl_util;
mod pool;
mod publisher;
mod template;
mod zone;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use std::path::Path;
use clap::{Parser, Subcommand};
use crate::config::Config;
use crate::error::{Result, ZmgrError};
use crate::pool::Pool;
use crate::publisher::Publisher;
use crate::template::Template;
use crate::zone::Zone;
#[derive(Parser)]
#[command(name = "zmgr", about = "illumos zone manager")]
struct Cli {
/// Registry path (default: /etc/zmgr)
#[arg(long, default_value = "/etc/zmgr")]
registry: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize the registry with default templates and pools
Init {
/// Overwrite existing registry
#[arg(long)]
force: bool,
},
/// Create a new zone
Create {
/// Zone name
name: String,
/// Template to use (default from config)
#[arg(short, long)]
template: Option<String>,
},
/// Destroy a managed zone
Destroy {
/// Zone name
name: String,
},
/// List managed zones
List,
/// Show zone status (registry + system state)
Status {
/// Zone name (omit for all)
name: Option<String>,
},
/// Import existing zones into the registry
Import {
/// Only import this specific zone
name: Option<String>,
},
/// Manage templates
Template {
#[command(subcommand)]
action: TemplateAction,
},
/// Manage IPAM pools
Pool {
#[command(subcommand)]
action: PoolAction,
},
/// Manage IPS publishers
Publisher {
#[command(subcommand)]
action: PublisherAction,
},
}
#[derive(Subcommand)]
enum TemplateAction {
/// List available templates
List,
/// Show template details
Show { name: String },
}
#[derive(Subcommand)]
enum PoolAction {
/// List available pools
List,
/// Show pool details and allocations
Show { name: String },
}
#[derive(Subcommand)]
enum PublisherAction {
/// List configured publishers
List,
/// Add a publisher
Add {
/// Publisher name (e.g., openindiana.org)
name: String,
/// Origin URL
origin: String,
},
/// Remove a publisher
Remove {
/// Publisher filename stem
name: String,
},
}
fn main() -> miette::Result<()> {
let cli = Cli::parse();
let registry = std::path::PathBuf::from(&cli.registry);
match cli.command {
Commands::Init { force } => cmd_init(&registry, force),
Commands::Create { name, template } => cmd_create(&registry, &name, template.as_deref()),
Commands::Destroy { name } => cmd_destroy(&registry, &name),
Commands::List => cmd_list(&registry),
Commands::Status { name } => cmd_status(&registry, name.as_deref()),
Commands::Import { name } => cmd_import(&registry, name.as_deref()),
Commands::Template { action } => match action {
TemplateAction::List => cmd_template_list(&registry),
TemplateAction::Show { name } => cmd_template_show(&registry, &name),
},
Commands::Pool { action } => match action {
PoolAction::List => cmd_pool_list(&registry),
PoolAction::Show { name } => cmd_pool_show(&registry, &name),
},
Commands::Publisher { action } => match action {
PublisherAction::List => cmd_publisher_list(&registry),
PublisherAction::Add { name, origin } => cmd_publisher_add(&registry, &name, &origin),
PublisherAction::Remove { name } => cmd_publisher_remove(&registry, &name),
},
}
}
fn cmd_init(registry: &Path, force: bool) -> Result<()> {
if registry.join("config.kdl").exists() && !force {
return Err(ZmgrError::AlreadyInitialized {
path: registry.display().to_string(),
}
.into());
}
std::fs::create_dir_all(registry).map_err(ZmgrError::Io)?;
std::fs::create_dir_all(registry.join("zones")).map_err(ZmgrError::Io)?;
Config::write_default(registry)?;
Template::write_defaults(registry)?;
Pool::write_defaults(registry)?;
Publisher::write_defaults(registry)?;
println!("Initialized zmgr registry at {}", registry.display());
println!(" config.kdl — global settings");
println!(" templates/ — zone templates (oi, ofl)");
println!(" pools/ — IPAM pools (internal, ofl)");
println!(" publishers/ — IPS publishers (openindiana)");
println!(" zones/ — zone registry (empty)");
Ok(())
}
fn cmd_create(registry: &Path, name: &str, template_name: Option<&str>) -> Result<()> {
config::ensure_initialized(registry)?;
if Zone::exists(registry, name) {
return Err(ZmgrError::ZoneExists {
name: name.to_string(),
}
.into());
}
let cfg = Config::load(registry)?;
let tmpl_name = template_name.unwrap_or(&cfg.default_template);
let tmpl = Template::load(registry, tmpl_name)?;
let pool = Pool::load(registry, &tmpl.pool)?;
// Allocate next free IP
let zones = Zone::list(registry)?;
let ip = pool.allocate(&zones)?;
let prefix_len = pool.network.prefix_len();
let address = format!("{ip}/{prefix_len}");
let vnic = format!("{name}0");
// Create VNIC
exec::dladm_create_vnic(&vnic, &pool.stub)?;
// Build zonecfg commands
let autoboot = if tmpl.autoboot { "true" } else { "false" };
let zonecfg_cmds = format!(
"create -b\n\
set zonepath={}/{name}\n\
set brand={}\n\
set autoboot={autoboot}\n\
set ip-type={}\n\
add net\n\
set physical={vnic}\n\
set allowed-address={address}\n\
set defrouter={}\n\
end\n\
verify\n\
commit\n",
cfg.zonepath_prefix, tmpl.brand, tmpl.ip_type, pool.gateway
);
exec::zonecfg_create(name, &zonecfg_cmds)?;
exec::zoneadm_install(name)?;
// Write registry entry
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let zone = Zone {
name: name.to_string(),
template: tmpl_name.to_string(),
address: address.clone(),
gateway: pool.gateway.to_string(),
vnic: vnic.clone(),
stub: pool.stub.clone(),
created: today,
};
zone.write(registry)?;
println!("Created zone '{name}'");
println!(" template: {tmpl_name}");
println!(" brand: {}", tmpl.brand);
println!(" address: {address}");
println!(" vnic: {vnic}");
println!(" gateway: {}", pool.gateway);
if tmpl.autoboot {
exec::zoneadm_boot(name)?;
println!(" status: booted");
} else {
println!(" status: installed (autoboot=false)");
println!(" hint: run `zoneadm -z {name} boot` to start");
}
Ok(())
}
fn cmd_destroy(registry: &Path, name: &str) -> Result<()> {
config::ensure_initialized(registry)?;
let zone = Zone::load(registry, name)?;
// Halt if running (ignore errors — zone may already be halted)
let _ = exec::zoneadm_halt(name);
// Uninstall
if let Err(e) = exec::zoneadm_uninstall(name) {
eprintln!("warning: zoneadm uninstall: {e}");
}
// Delete zonecfg
if let Err(e) = exec::zonecfg_delete(name) {
eprintln!("warning: zonecfg delete: {e}");
}
// Delete VNIC
if let Err(e) = exec::dladm_delete_vnic(&zone.vnic) {
eprintln!("warning: dladm delete-vnic: {e}");
}
// Remove from registry
Zone::delete(registry, name)?;
println!("Destroyed zone '{name}'");
Ok(())
}
fn cmd_list(registry: &Path) -> Result<()> {
config::ensure_initialized(registry)?;
let zones = Zone::list(registry)?;
if zones.is_empty() {
println!("No managed zones. Use `zmgr create <name>` or `zmgr import`.");
return Ok(());
}
println!(
"{:<20} {:<10} {:<20} {:<15}",
"NAME", "TEMPLATE", "ADDRESS", "VNIC"
);
for z in &zones {
println!(
"{:<20} {:<10} {:<20} {:<15}",
z.name, z.template, z.address, z.vnic
);
}
Ok(())
}
fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
config::ensure_initialized(registry)?;
let system_zones = exec::zoneadm_list()?;
let registry_zones = Zone::list(registry)?;
if let Some(name) = name {
let reg = registry_zones.iter().find(|z| z.name == name);
let sys = system_zones.iter().find(|z| z.name == name);
match (reg, sys) {
(Some(r), Some(s)) => {
println!("Zone: {name}");
println!(" template: {}", r.template);
println!(" brand: {}", s.brand);
println!(" state: {}", s.state);
println!(" address: {}", r.address);
println!(" vnic: {}", r.vnic);
println!(" path: {}", s.path);
println!(" uuid: {}", s.uuid);
println!(" created: {}", r.created);
}
(Some(r), None) => {
println!("Zone: {name} (in registry but not on system)");
println!(" template: {}", r.template);
println!(" address: {}", r.address);
}
(None, Some(s)) => {
println!("Zone: {name} (on system but not in registry)");
println!(" brand: {}", s.brand);
println!(" state: {}", s.state);
println!(" hint: run `zmgr import {name}` to add to registry");
}
(None, None) => {
return Err(ZmgrError::ZoneNotFound {
name: name.to_string(),
}
.into());
}
}
} else {
println!(
"{:<20} {:<10} {:<12} {:<20} {:<10}",
"NAME", "TEMPLATE", "STATE", "ADDRESS", "REGISTRY"
);
let mut seen = HashSet::new();
for r in &registry_zones {
let state = system_zones
.iter()
.find(|s| s.name == r.name)
.map(|s| s.state.as_str())
.unwrap_or("absent");
println!(
"{:<20} {:<10} {:<12} {:<20} {:<10}",
r.name, r.template, state, r.address, "yes"
);
seen.insert(r.name.clone());
}
for s in &system_zones {
if !seen.contains(&s.name) && s.brand != "bhyve" {
println!(
"{:<20} {:<10} {:<12} {:<20} {:<10}",
s.name, s.brand, s.state, "-", "no"
);
}
}
}
Ok(())
}
fn cmd_import(registry: &Path, name: Option<&str>) -> Result<()> {
config::ensure_initialized(registry)?;
let imported = import::import_zones(registry, name)?;
if imported.is_empty() {
if let Some(name) = name {
println!("Zone '{name}' not found on system or already in registry.");
} else {
println!("No new zones to import.");
}
} else {
for z in &imported {
println!("Imported zone '{z}'");
}
}
Ok(())
}
fn cmd_template_list(registry: &Path) -> Result<()> {
config::ensure_initialized(registry)?;
let names = Template::list(registry)?;
if names.is_empty() {
println!("No templates defined.");
return Ok(());
}
for name in &names {
let tmpl = Template::load(registry, name)?;
println!(
"{:<10} brand={:<10} pool={:<10} autoboot={}",
name, tmpl.brand, tmpl.pool, tmpl.autoboot
);
}
Ok(())
}
fn cmd_template_show(registry: &Path, name: &str) -> Result<()> {
config::ensure_initialized(registry)?;
let tmpl = Template::load(registry, name)?;
println!("Template: {name}");
println!(" brand: {}", tmpl.brand);
println!(" autoboot: {}", tmpl.autoboot);
println!(" ip-type: {}", tmpl.ip_type);
println!(" pool: {}", tmpl.pool);
Ok(())
}
fn cmd_pool_list(registry: &Path) -> Result<()> {
config::ensure_initialized(registry)?;
let names = Pool::list(registry)?;
if names.is_empty() {
println!("No pools defined.");
return Ok(());
}
let zones = Zone::list(registry)?;
for name in &names {
let pool = Pool::load(registry, name)?;
let allocated: usize = zones
.iter()
.filter(|z| {
z.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
.is_some_and(|ip| pool.network.contains(&ip))
})
.count();
let total = u32::from(pool.range_end) - u32::from(pool.range_start) + 1;
println!(
"{:<10} network={:<18} stub={:<12} used={}/{}",
name, pool.network, pool.stub, allocated, total
);
}
Ok(())
}
fn cmd_pool_show(registry: &Path, name: &str) -> Result<()> {
config::ensure_initialized(registry)?;
let pool = Pool::load(registry, name)?;
let zones = Zone::list(registry)?;
println!("Pool: {name}");
println!(" network: {}", pool.network);
println!(" gateway: {}", pool.gateway);
println!(" stub: {}", pool.stub);
println!(" range: {} - {}", pool.range_start, pool.range_end);
let allocated: Vec<&Zone> = zones
.iter()
.filter(|z| {
z.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
.is_some_and(|ip| pool.network.contains(&ip))
})
.collect();
if allocated.is_empty() {
println!(" allocations: none");
} else {
println!(" allocations:");
for z in &allocated {
println!(" {} -> {}", z.address, z.name);
}
}
Ok(())
}
fn cmd_publisher_list(registry: &Path) -> Result<()> {
config::ensure_initialized(registry)?;
let names = Publisher::list(registry)?;
if names.is_empty() {
println!("No publishers configured.");
return Ok(());
}
for name in &names {
let pub_ = Publisher::load(registry, name)?;
println!("{:<30} {}", pub_.name, pub_.origin);
}
Ok(())
}
fn cmd_publisher_add(registry: &Path, name: &str, origin: &str) -> Result<()> {
config::ensure_initialized(registry)?;
Publisher::add(registry, name, origin)?;
println!("Added publisher '{name}' -> {origin}");
Ok(())
}
fn cmd_publisher_remove(registry: &Path, name: &str) -> Result<()> {
config::ensure_initialized(registry)?;
Publisher::remove(registry, name)?;
println!("Removed publisher '{name}'");
Ok(())
}

189
src/pool.rs Normal file
View file

@ -0,0 +1,189 @@
use std::net::Ipv4Addr;
use std::path::Path;
use ipnet::Ipv4Net;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
use crate::zone::Zone;
/// An IPAM address pool.
pub struct Pool {
pub name: String,
pub network: Ipv4Net,
pub gateway: Ipv4Addr,
pub stub: String,
pub range_start: Ipv4Addr,
pub range_end: Ipv4Addr,
}
impl Pool {
/// Load a pool from `/etc/zmgr/pools/<name>.kdl`.
pub fn load(registry: &Path, name: &str) -> Result<Self> {
let path = registry.join("pools").join(format!("{name}.kdl"));
if !path.exists() {
return Err(ZmgrError::PoolNotFound {
name: name.to_string(),
}
.into());
}
let doc = kdl_util::read_document(&path)?;
let ctx = format!("pool '{name}'");
let pool_node = doc.get("pool").ok_or_else(|| ZmgrError::MissingField {
field: "pool".to_string(),
context: ctx.clone(),
})?;
let children = pool_node.children().ok_or_else(|| ZmgrError::MissingField {
field: "pool children".to_string(),
context: ctx.clone(),
})?;
let network_str = kdl_util::get_string(children, "network")
.ok_or_else(|| ZmgrError::MissingField {
field: "network".to_string(),
context: ctx.clone(),
})?;
let network: Ipv4Net = network_str.parse().map_err(|_| ZmgrError::MissingField {
field: format!("network (invalid CIDR: {network_str})"),
context: ctx.clone(),
})?;
let gateway_str = kdl_util::get_string(children, "gateway")
.ok_or_else(|| ZmgrError::MissingField {
field: "gateway".to_string(),
context: ctx.clone(),
})?;
let gateway: Ipv4Addr = gateway_str.parse().map_err(|_| ZmgrError::MissingField {
field: format!("gateway (invalid IP: {gateway_str})"),
context: ctx.clone(),
})?;
let stub = kdl_util::get_string(children, "stub")
.ok_or_else(|| ZmgrError::MissingField {
field: "stub".to_string(),
context: ctx.clone(),
})?;
let range_start_str = kdl_util::get_string(children, "range-start")
.ok_or_else(|| ZmgrError::MissingField {
field: "range-start".to_string(),
context: ctx.clone(),
})?;
let range_start: Ipv4Addr =
range_start_str
.parse()
.map_err(|_| ZmgrError::MissingField {
field: format!("range-start (invalid IP: {range_start_str})"),
context: ctx.clone(),
})?;
let range_end_str = kdl_util::get_string(children, "range-end")
.ok_or_else(|| ZmgrError::MissingField {
field: "range-end".to_string(),
context: ctx.clone(),
})?;
let range_end: Ipv4Addr =
range_end_str
.parse()
.map_err(|_| ZmgrError::MissingField {
field: format!("range-end (invalid IP: {range_end_str})"),
context: ctx.clone(),
})?;
Ok(Pool {
name: name.to_string(),
network,
gateway,
stub,
range_start,
range_end,
})
}
/// Allocate the next free IP from this pool, given existing zones.
pub fn allocate(&self, zones: &[Zone]) -> Result<Ipv4Addr> {
let allocated: Vec<Ipv4Addr> = zones
.iter()
.filter_map(|z| {
// address is stored as "x.x.x.x/prefix"
z.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
})
.filter(|ip| self.network.contains(ip))
.collect();
let start = u32::from(self.range_start);
let end = u32::from(self.range_end);
for candidate in start..=end {
let ip = Ipv4Addr::from(candidate);
if !allocated.contains(&ip) {
return Ok(ip);
}
}
Err(ZmgrError::PoolExhausted {
pool: self.name.clone(),
range_start: self.range_start.to_string(),
range_end: self.range_end.to_string(),
}
.into())
}
/// List all pool names.
pub fn list(registry: &Path) -> Result<Vec<String>> {
let dir = registry.join("pools");
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(ZmgrError::Io)? {
let entry = entry.map_err(ZmgrError::Io)?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "kdl") {
if let Some(stem) = path.file_stem() {
names.push(stem.to_string_lossy().to_string());
}
}
}
names.sort();
Ok(names)
}
/// Write default pools derived from the existing scripts.
pub fn write_defaults(registry: &Path) -> Result<()> {
let dir = registry.join("pools");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
std::fs::write(
dir.join("internal.kdl"),
r#"pool "internal" {
network "10.1.0.0/24"
gateway "10.1.0.1"
stub "oinetint0"
range-start "10.1.0.10"
range-end "10.1.0.250"
}
"#,
)
.map_err(ZmgrError::Io)?;
std::fs::write(
dir.join("ofl.kdl"),
r#"pool "ofl" {
network "192.168.10.0/24"
gateway "192.168.10.1"
stub "oflint0"
range-start "192.168.10.10"
range-end "192.168.10.250"
}
"#,
)
.map_err(ZmgrError::Io)?;
Ok(())
}
}

119
src/publisher.rs Normal file
View file

@ -0,0 +1,119 @@
use std::path::Path;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
/// An IPS publisher configuration.
pub struct Publisher {
pub name: String,
pub origin: String,
}
impl Publisher {
/// Load a publisher from `/etc/zmgr/publishers/<name>.kdl`.
pub fn load(registry: &Path, name: &str) -> Result<Self> {
let path = registry.join("publishers").join(format!("{name}.kdl"));
if !path.exists() {
return Err(ZmgrError::MissingField {
field: "publisher".to_string(),
context: format!("publishers/{name}.kdl"),
}
.into());
}
let doc = kdl_util::read_document(&path)?;
let ctx = format!("publisher '{name}'");
let pub_node = doc
.get("publisher")
.ok_or_else(|| ZmgrError::MissingField {
field: "publisher".to_string(),
context: ctx.clone(),
})?;
let pub_name = pub_node
.entries()
.first()
.and_then(|e| e.value().as_string())
.unwrap_or(name)
.to_string();
let children = pub_node.children().ok_or_else(|| ZmgrError::MissingField {
field: "publisher children".to_string(),
context: ctx.clone(),
})?;
let origin = kdl_util::get_string(children, "origin").ok_or_else(|| {
ZmgrError::MissingField {
field: "origin".to_string(),
context: ctx.clone(),
}
})?;
Ok(Publisher {
name: pub_name,
origin,
})
}
/// List all publisher names.
pub fn list(registry: &Path) -> Result<Vec<String>> {
let dir = registry.join("publishers");
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(ZmgrError::Io)? {
let entry = entry.map_err(ZmgrError::Io)?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "kdl") {
if let Some(stem) = path.file_stem() {
names.push(stem.to_string_lossy().to_string());
}
}
}
names.sort();
Ok(names)
}
/// Add a new publisher.
pub fn add(registry: &Path, name: &str, origin: &str) -> Result<()> {
let dir = registry.join("publishers");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
// Use the last segment of the publisher name as the filename
let filename = name.replace('.', "_");
let path = dir.join(format!("{filename}.kdl"));
let content = format!(
r#"publisher "{name}" {{
origin "{origin}"
}}
"#
);
std::fs::write(&path, content).map_err(ZmgrError::Io)?;
Ok(())
}
/// Remove a publisher by filename stem.
pub fn remove(registry: &Path, name: &str) -> Result<()> {
let dir = registry.join("publishers");
let path = dir.join(format!("{name}.kdl"));
if path.exists() {
std::fs::remove_file(&path).map_err(ZmgrError::Io)?;
}
Ok(())
}
/// Write default publishers.
pub fn write_defaults(registry: &Path) -> Result<()> {
let dir = registry.join("publishers");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
std::fs::write(
dir.join("openindiana.kdl"),
r#"publisher "openindiana.org" {
origin "https://pkg.openindiana.org/hipster"
}
"#,
)
.map_err(ZmgrError::Io)?;
Ok(())
}
}

116
src/template.rs Normal file
View file

@ -0,0 +1,116 @@
use std::path::Path;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
/// A zone creation template.
#[allow(dead_code)]
pub struct Template {
pub name: String,
pub brand: String,
pub autoboot: bool,
pub ip_type: String,
pub pool: String,
}
impl Template {
/// Load a template from `/etc/zmgr/templates/<name>.kdl`.
pub fn load(registry: &Path, name: &str) -> Result<Self> {
let path = registry.join("templates").join(format!("{name}.kdl"));
if !path.exists() {
return Err(ZmgrError::TemplateNotFound {
name: name.to_string(),
}
.into());
}
let doc = kdl_util::read_document(&path)?;
let ctx = format!("template '{name}'");
// The template node wraps the children
let template_node = doc.get("template").ok_or_else(|| ZmgrError::MissingField {
field: "template".to_string(),
context: ctx.clone(),
})?;
let children = template_node.children().ok_or_else(|| ZmgrError::MissingField {
field: "template children".to_string(),
context: ctx.clone(),
})?;
let brand = kdl_util::get_string(children, "brand")
.ok_or_else(|| ZmgrError::MissingField {
field: "brand".to_string(),
context: ctx.clone(),
})?;
let autoboot = kdl_util::get_bool(children, "autoboot").unwrap_or(false);
let ip_type = kdl_util::get_string(children, "ip-type")
.unwrap_or_else(|| "exclusive".to_string());
let pool = kdl_util::get_string(children, "pool")
.ok_or_else(|| ZmgrError::MissingField {
field: "pool".to_string(),
context: ctx.clone(),
})?;
Ok(Template {
name: name.to_string(),
brand,
autoboot,
ip_type,
pool,
})
}
/// List all template names.
pub fn list(registry: &Path) -> Result<Vec<String>> {
let dir = registry.join("templates");
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(ZmgrError::Io)? {
let entry = entry.map_err(ZmgrError::Io)?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "kdl") {
if let Some(stem) = path.file_stem() {
names.push(stem.to_string_lossy().to_string());
}
}
}
names.sort();
Ok(names)
}
/// Write default templates derived from the existing scripts.
pub fn write_defaults(registry: &Path) -> Result<()> {
let dir = registry.join("templates");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
std::fs::write(
dir.join("oi.kdl"),
r#"template "oi" {
brand "ipkg"
autoboot #false
ip-type "exclusive"
pool "internal"
}
"#,
)
.map_err(ZmgrError::Io)?;
std::fs::write(
dir.join("ofl.kdl"),
r#"template "ofl" {
brand "nlipkg"
autoboot #true
ip-type "exclusive"
pool "ofl"
}
"#,
)
.map_err(ZmgrError::Io)?;
Ok(())
}
}

131
src/zone.rs Normal file
View file

@ -0,0 +1,131 @@
use std::path::Path;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
/// A managed zone registry entry.
pub struct Zone {
pub name: String,
pub template: String,
pub address: String,
pub gateway: String,
pub vnic: String,
pub stub: String,
pub created: String,
}
impl Zone {
/// Load a zone from `/etc/zmgr/zones/<name>.kdl`.
pub fn load(registry: &Path, name: &str) -> Result<Self> {
let path = registry.join("zones").join(format!("{name}.kdl"));
if !path.exists() {
return Err(ZmgrError::ZoneNotFound {
name: name.to_string(),
}
.into());
}
Self::load_from_path(&path)
}
fn load_from_path(path: &Path) -> Result<Self> {
let doc = kdl_util::read_document(path)?;
let ctx = path.display().to_string();
let zone_node = doc.get("zone").ok_or_else(|| ZmgrError::MissingField {
field: "zone".to_string(),
context: ctx.clone(),
})?;
let name = zone_node
.entries()
.first()
.and_then(|e| e.value().as_string())
.ok_or_else(|| ZmgrError::MissingField {
field: "zone name argument".to_string(),
context: ctx.clone(),
})?
.to_string();
let children = zone_node.children().ok_or_else(|| ZmgrError::MissingField {
field: "zone children".to_string(),
context: ctx.clone(),
})?;
Ok(Zone {
name: name.clone(),
template: kdl_util::get_string(children, "template")
.unwrap_or_default(),
address: kdl_util::get_string(children, "address")
.unwrap_or_default(),
gateway: kdl_util::get_string(children, "gateway")
.unwrap_or_default(),
vnic: kdl_util::get_string(children, "vnic")
.unwrap_or_default(),
stub: kdl_util::get_string(children, "stub")
.unwrap_or_default(),
created: kdl_util::get_string(children, "created")
.unwrap_or_default(),
})
}
/// Write this zone to the registry.
pub fn write(&self, registry: &Path) -> Result<()> {
let dir = registry.join("zones");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
let path = dir.join(format!("{}.kdl", self.name));
let content = format!(
r#"zone "{}" {{
template "{}"
address "{}"
gateway "{}"
vnic "{}"
stub "{}"
created "{}"
}}
"#,
self.name, self.template, self.address, self.gateway, self.vnic, self.stub, self.created
);
std::fs::write(&path, content).map_err(ZmgrError::Io)?;
Ok(())
}
/// Delete this zone's registry entry.
pub fn delete(registry: &Path, name: &str) -> Result<()> {
let path = registry.join("zones").join(format!("{name}.kdl"));
if path.exists() {
std::fs::remove_file(&path).map_err(ZmgrError::Io)?;
}
Ok(())
}
/// List all zone registry entries.
pub fn list(registry: &Path) -> Result<Vec<Zone>> {
let dir = registry.join("zones");
if !dir.exists() {
return Ok(Vec::new());
}
let mut zones = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(ZmgrError::Io)? {
let entry = entry.map_err(ZmgrError::Io)?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "kdl") {
match Zone::load_from_path(&path) {
Ok(z) => zones.push(z),
Err(e) => {
eprintln!("warning: skipping {}: {e}", path.display());
}
}
}
}
zones.sort_by(|a, b| a.name.cmp(&b.name));
Ok(zones)
}
/// Check if a zone exists in the registry.
pub fn exists(registry: &Path, name: &str) -> bool {
registry
.join("zones")
.join(format!("{name}.kdl"))
.exists()
}
}