feat: first-draft preview-capable zosstorage
- CLI: add topology selection (-t/--topology), preview flags (--show/--report), and removable policy override (--allow-removable) (src/cli/args.rs) - Config: built-in sensible defaults; deterministic overlays for logging, fstab, removable, topology (src/config/loader.rs) - Device: discovery via /proc + /sys with include/exclude regex and removable policy (src/device/discovery.rs) - Idempotency: detection via blkid; safe emptiness checks (src/idempotency/mod.rs) - Partition: topology-driven planning (Single, DualIndependent, BtrfsRaid1, SsdHddBcachefs) (src/partition/plan.rs) - FS: planning + creation (mkfs.vfat, mkfs.btrfs, bcachefs format) and UUID capture via blkid (src/fs/plan.rs) - Orchestrator: pre-flight with preview JSON (disks, partition_plan, filesystems_planned, mount scheme). Skips emptiness in preview; supports stdout+file (src/orchestrator/run.rs) - Util/Logging/Types/Errors: process execution, tracing, shared types (src/util/mod.rs, src/logging/mod.rs, src/types.rs, src/errors.rs) - Docs: add README with exhaustive usage and preview JSON shape (README.md) Builds and unit tests pass: discovery, util, idempotency helpers, and fs parser tests.
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
917
Cargo.lock
generated
Normal file
917
Cargo.lock
generated
Normal file
@@ -0,0 +1,917 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_home"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||
|
||||
[[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.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.176"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.227"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.227"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.227"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
|
||||
dependencies = [
|
||||
"env_home",
|
||||
"rustix",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.0",
|
||||
"windows_aarch64_msvc 0.53.0",
|
||||
"windows_i686_gnu 0.53.0",
|
||||
"windows_i686_gnullvm 0.53.0",
|
||||
"windows_i686_msvc 0.53.0",
|
||||
"windows_x86_64_gnu 0.53.0",
|
||||
"windows_x86_64_gnullvm 0.53.0",
|
||||
"windows_x86_64_msvc 0.53.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "zosstorage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"clap",
|
||||
"humantime",
|
||||
"nix",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"which",
|
||||
]
|
||||
22
Cargo.toml
Normal file
22
Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "zosstorage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
base64 = "0.22.1"
|
||||
clap = { version = "4.5.48", features = ["derive"] }
|
||||
nix = "0.30.1"
|
||||
regex = "1.11.3"
|
||||
serde = { version = "1.0.227", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
serde_yaml = "0.9.33"
|
||||
tempfile = "3.23.0"
|
||||
thiserror = "2.0.16"
|
||||
time = "0.3.44"
|
||||
humantime = "2.1.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
uuid = "1.18.1"
|
||||
which = "8.0.0"
|
||||
101
PROMPT.md
Normal file
101
PROMPT.md
Normal file
@@ -0,0 +1,101 @@
|
||||
You are GPT-5 Codex paired with KILO coder. Produce only what is requested. Do not improvise.
|
||||
|
||||
Objective
|
||||
- Implement `zosstorage`, a Rust binary compiled for static `musl`, embedded in an Alpine Linux initramfs (x86_64 only).
|
||||
- Purpose: one-shot disk initializer invoked during the first boot of a fresh node. Idempotent; if rerun on a system already provisioned, it must perform no changes and exit success.
|
||||
- Absolutely never destroy, overwrite, or repartition devices containing existing data. Development/testing uses pristine virtual disks only. Abort immediately if a target device is not empty.
|
||||
|
||||
Execution Context
|
||||
- Runs inside initramfs on Alpine Linux (busybox environment). No reliance on system services or long-running daemons.
|
||||
- No Cargo.toml manual edits; dependencies managed via `cargo add`. All code must compile with stable Rust toolchains available in the build system.
|
||||
- Avoid stdout spam. Implement structured logging/tracing (details TBD) but no stray `println!`.
|
||||
|
||||
Development Methodology
|
||||
- Compartmentalize the codebase into clear modules from the outset.
|
||||
- Begin by proposing the repository layout (directories, modules, tests, docs).
|
||||
- Define public APIs first: traits, structs, enums, function signatures, and associated documentation comments.
|
||||
- Only after obtaining approval on the API surface may you proceed to fill in function bodies.
|
||||
- Use `todo!()` or explanatory comments as temporary placeholders until behavior is agreed upon.
|
||||
- Preserve this iterative approach for every major module: outline first, implementation after review.
|
||||
|
||||
Device Discovery
|
||||
- Enumerate candidate block devices under `/dev` and filter out all pseudodevices (`/dev/ram*`, `/dev/zram*`, `/dev/fd*`, `/dev/loop*`, etc.). The filtering rules must be configurable for future allowlists (e.g., removable media).
|
||||
- Default device classes include `/dev/sd*`, `/dev/nvme*`, `/dev/vd*`. If no eligible disks are found, return a well-defined error.
|
||||
|
||||
Partitioning Requirements
|
||||
- Use GPT exclusively. Honor 1 MiB alignment boundaries.
|
||||
- For BIOS compatibility, create a small `bios_boot` partition (exact size TBD—assume 1 MiB for now, placed first).
|
||||
- Create a 512 MiB FAT32 ESP on each disk, label `ZOSBOOT`. Each ESP is independent; synchronization will be handled by another tool (out of scope). Ensure unique partition UUIDs while keeping identical labels.
|
||||
- Remaining disk capacity is provisioned per configuration (see below).
|
||||
- Before making changes, verify the device has no existing partitions or filesystem signatures; abort otherwise.
|
||||
|
||||
Filesystem Provisioning
|
||||
- All data mounts are placed somewhere under `/var/cache`. Precise mountpoints and subvolume strategies are configurable.
|
||||
- Supported backends:
|
||||
* Single disk: default to `btrfs`, label `ZOSDATA`.
|
||||
* Two disks/NVMe: default to individual `btrfs` filesystems per disk, each labeled `ZOSDATA`, mounted under `/var/cache/<UUID>` (exact path pattern TBD). Optional support for `btrfs` RAID1 or `bcachefs` RAID1 if requested.
|
||||
* Mixed SSD/NVMe + HDD: default to `bcachefs` with SSD as cache/promote and HDD as backing store, label resulting filesystem `ZOSDATA`. Alternative mode: separate `btrfs` per device (label `ZOSDATA`).
|
||||
- Reserved filesystem labels: `ZOSBOOT` (ESP), `ZOSDATA` (all data filesystems). GPT partition names: `zosboot` (bios_boot and ESP), `zosdata` (data), `zoscache` (cache).
|
||||
- Filesystem tuning options (compression, RAID profile, etc.) must be configurable; define sensible defaults and provide extension points.
|
||||
|
||||
Configuration Input
|
||||
- Accept configuration via:
|
||||
* Kernel command line parameter (name TBD, e.g., `zosstorage.config=`) pointing to a YAML configuration descriptor.
|
||||
* Optional CLI flags when run in user space (must mirror kernel cmdline semantics).
|
||||
* On-disk YAML config file (default path TBD, e.g., `/etc/zosstorage/config.yaml`).
|
||||
- Establish clear precedence: kernel cmdline overrides CLI arguments, which override config file defaults. No interactive prompts inside initramfs.
|
||||
- YAML schema must at least describe disk selection rules, desired filesystem layout, boot partition preferences, filesystem options, mount targets, and logging verbosity. Document the schema and provide validation.
|
||||
|
||||
State Reporting
|
||||
- After successful provisioning, emit a JSON state report (path TBD, e.g., `/run/zosstorage/state.json`) capturing:
|
||||
* Enumerated disks and their roles,
|
||||
* Created partitions with identifiers,
|
||||
* Filesystems, labels (`ZOSBOOT`, `ZOSDATA`, `ZOSCACHE`), mountpoints,
|
||||
* Overall status and timestamp.
|
||||
- Ensure the report is machine-readable and versioned.
|
||||
|
||||
Logging
|
||||
- Integrate a structured logging/tracing backend (e.g., `tracing` crate). Provide log levels (error, warn, info, debug) and allow configuration through CLI/config/cmdline.
|
||||
- By default, logs go to stderr; design for optional redirection to a file (path TBD). Avoid using `println!`.
|
||||
|
||||
System Integration
|
||||
- Decide whether to generate `/etc/fstab` entries; if enabled, produce deterministic ordering and documentation. Otherwise, document alternative mount management.
|
||||
- After provisioning, ensure the initramfs can mount the new filesystems (e.g., call `udevadm settle` if necessary). No external services are invoked.
|
||||
- No responsibility for updating `vmlinuz.efi`; another subsystem handles kernel updates.
|
||||
|
||||
Failure Handling
|
||||
- If any target disk fails validation (non-empty, filtered out, or errors occur), abort the entire run with a descriptive error message. Provide a `--force` flag stub for future use, but keep it non-functional for now (must return “unimplemented”).
|
||||
|
||||
Testing & Validation (initial expectations)
|
||||
- Provide integration test scaffolding targeting QEMU/KVM scenarios (e.g., single virtio disk 40 GiB, dual NVMe 40 GiB each, SSD+HDD mix). Tests can be smoke-level initially but must compile.
|
||||
- Document manual testing steps for developers to reproduce in VMs.
|
||||
- VM test matrix using virtio disks (/dev/vd?) to validate topologies:
|
||||
* 1 disk (/dev/vda): single topology → create btrfs on the data partition labeled ZOSDATA.
|
||||
* 2 disks (/dev/vda, /dev/vdb):
|
||||
- dual_independent: btrfs per disk (two independent ZOSDATA filesystems).
|
||||
- bcachefs cache/backing: treat /dev/vda as cache (SSD-like) and /dev/vdb as backing (HDD-like); create one bcachefs labeled ZOSDATA.
|
||||
- btrfs_raid1: mirrored btrfs across the two data partitions labeled ZOSDATA.
|
||||
* 3 disks (/dev/vda, /dev/vdb, /dev/vdc):
|
||||
- bcachefs: cache on /dev/vda; backing on /dev/vdb and /dev/vdc with two replicas (two copies), labeled ZOSDATA.
|
||||
- Ensure device discovery includes /dev/vd* by default and filters pseudodevices.
|
||||
Documentation & Deliverables
|
||||
- Produce comprehensive README including: overview, prerequisites, configuration schema, example YAML, command-line usage, JSON report format, filesystem label semantics (`ZOSBOOT`, `ZOSDATA`, `ZOSCACHE`), limitations, and roadmap.
|
||||
- Ensure Rust code contains module-level and public API documentation (/// doc comments). Implement `--help` output mirroring README usage.
|
||||
- Include architectural notes describing module boundaries (device discovery, partitioning, filesystem provisioning, config parsing, logging, reporting).
|
||||
|
||||
Open Items (call out explicitly)
|
||||
- Exact sizes and ordering for `bios_boot` partition awaiting confirmation; note assumptions in code and documentation.
|
||||
- Mount point naming scheme under `/var/cache` (per-UUID vs. config-defined) still to be finalized.
|
||||
- Filesystem-specific tuning parameters (compression, RAID values, `bcachefs` options) require explicit defaults from stakeholders.
|
||||
- Path/location for YAML config, kernel cmdline key, JSON report path, and optional log file path need final confirmation.
|
||||
- Decision whether `/etc/fstab` is generated remains pending.
|
||||
|
||||
Implementation Constraints
|
||||
- Stick to clear module boundaries. Provide unit tests where possible (e.g., config parsing, device filtering).
|
||||
- Maintain strict idempotency: detect when provisioning already occurred (e.g., presence of `ZOSBOOT` partitions and expected filesystem labels `ZOSDATA`/`ZOSCACHE`) and exit gracefully.
|
||||
- Write clean, production-quality Rust adhering to idiomatic practices and Clippy.
|
||||
|
||||
Deliverables
|
||||
- Repository layout proposal (src modules, tests directory, docs). Highlight major components and their responsibilities.
|
||||
- API skeletons (traits, structs, function signatures) with doc comments, using `todo!()` placeholders for bodies until approved.
|
||||
- After API approval, progressively fill in implementations, preserving the compartmentalized structure and documenting assumptions.
|
||||
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# zosstorage
|
||||
|
||||
One-shot disk provisioning utility intended for initramfs. It discovers eligible disks, plans a GPT layout based on a chosen topology, creates filesystems, mounts them under a predictable scheme, and emits a machine-readable report. Safe-by-default with a non-destructive preview mode.
|
||||
|
||||
Status: first-draft preview capable. Partition apply, mkfs, and mounts are gated until the planning is validated in your environment.
|
||||
|
||||
Key modules
|
||||
- CLI and entrypoint:
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- [src/main.rs](src/main.rs)
|
||||
- Orchestration and preview JSON:
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- Configuration loader, overlays, defaults:
|
||||
- [src/config/loader.rs](src/config/loader.rs)
|
||||
- Device discovery (sysfs + regex filters, removable policy):
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- Idempotency detection and emptiness checks:
|
||||
- [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
- Partition planning (topology-aware):
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- Filesystem planning/creation and mkfs integration:
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- Mount planning and application (skeleton):
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
|
||||
Features at a glance
|
||||
- Topology-driven planning with built-in defaults: Single, DualIndependent, BtrfsRaid1, SsdHddBcachefs
|
||||
- Non-destructive preview: --show/--report outputs JSON summary (disks, partition plan, filesystems, planned mountpoints)
|
||||
- Safe discovery: excludes removable media by default (USB sticks) unless explicitly allowed
|
||||
- Config-optional: the tool runs without any YAML; sensible defaults are always present and may be overridden/merged by config
|
||||
|
||||
Requirements
|
||||
- Linux with /proc and /sys mounted (initramfs friendly)
|
||||
- External tools discovered at runtime:
|
||||
- blkid (for probing UUIDs and signatures)
|
||||
- sgdisk (for GPT application) — planned
|
||||
- mkfs.vfat, mkfs.btrfs, bcachefs (for formatting) — invoked by fs/plan when enabled in execution phase
|
||||
- Tracing/logging to stderr by default; optional file at /run/zosstorage/zosstorage.log
|
||||
|
||||
Install and build
|
||||
- System Rust toolchain:
|
||||
cargo build --release
|
||||
|
||||
Binary is target/release/zosstorage.
|
||||
|
||||
CLI usage
|
||||
- Topology selection (config optional):
|
||||
-t, --topology single|dual-independent|btrfs-raid1|ssd-hdd-bcachefs
|
||||
- Preview (non-destructive):
|
||||
--show Print JSON summary to stdout
|
||||
--report PATH Write JSON summary to a file
|
||||
- Discovery policy:
|
||||
--allow-removable Include removable devices (USB) during discovery
|
||||
- Tracing/logging:
|
||||
-l, --log-level LEVEL error|warn|info|debug (default: info)
|
||||
-L, --log-to-file Also write logs to /run/zosstorage/zosstorage.log
|
||||
- Other:
|
||||
-c, --config PATH Merge a YAML config file (overrides defaults)
|
||||
-s, --fstab Enable writing /etc/fstab entries (when mounts are applied)
|
||||
-f, --force Present but not implemented (returns an error)
|
||||
|
||||
Examples
|
||||
- Single disk plan with debug logs:
|
||||
sudo ./zosstorage --show -t single -l debug
|
||||
- RAID1 btrfs across two disks; print and write summary:
|
||||
sudo ./zosstorage --show --report /run/zosstorage/plan.json -t btrfs-raid1 -l debug -L
|
||||
- SSD+HDD bcachefs plan, include removable devices (for lab cases):
|
||||
sudo ./zosstorage --show -t ssd-hdd-bcachefs --allow-removable -l debug
|
||||
- Quiet plan to file:
|
||||
sudo ./zosstorage --report /run/zosstorage/plan.json -t dual-independent
|
||||
|
||||
Preview JSON shape (examples)
|
||||
1) Already provisioned (idempotency success):
|
||||
{
|
||||
"version": "v1",
|
||||
"timestamp": "2025-09-26T12:34:56Z",
|
||||
"status": "already_provisioned",
|
||||
"state": { ... full StateReport JSON ... }
|
||||
}
|
||||
|
||||
2) Planned (not yet provisioned):
|
||||
{
|
||||
"version": "v1",
|
||||
"timestamp": "2025-09-26T12:34:56Z",
|
||||
"status": "planned",
|
||||
"topology": "btrfs_raid1",
|
||||
"alignment_mib": 1,
|
||||
"require_empty_disks": true,
|
||||
"disks": [
|
||||
{ "path": "/dev/nvme0n1", "size_bytes": 1000204886016, "rotational": false, "model": "...", "serial": "..." },
|
||||
{ "path": "/dev/nvme1n1", "size_bytes": 1000204886016, "rotational": false, "model": "...", "serial": "..." }
|
||||
],
|
||||
"partition_plan": [
|
||||
{
|
||||
"disk": "/dev/nvme0n1",
|
||||
"parts": [
|
||||
{ "role": "bios_boot", "size_mib": 1, "gpt_name": "zosboot" },
|
||||
{ "role": "esp", "size_mib": 512, "gpt_name": "zosboot" },
|
||||
{ "role": "data", "size_mib": null, "gpt_name": "zosdata" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"disk": "/dev/nvme1n1",
|
||||
"parts": [
|
||||
{ "role": "data", "size_mib": null, "gpt_name": "zosdata" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"filesystems_planned": [
|
||||
{ "kind": "vfat", "from_roles": ["esp"], "label": "ZOSBOOT", "planned_mountpoint": null },
|
||||
{ "kind": "btrfs", "from_roles": ["data"], "devices_planned": 2, "label": "ZOSDATA", "planned_mountpoint_template": "/var/cache/{UUID}" }
|
||||
],
|
||||
"mount": {
|
||||
"scheme": "per_uuid",
|
||||
"base_dir": "/var/cache",
|
||||
"fstab_enabled": false,
|
||||
"target_template": "/var/cache/{UUID}"
|
||||
}
|
||||
}
|
||||
|
||||
Defaults and policies
|
||||
- Device selection defaults (see [src/config/loader.rs](src/config/loader.rs)):
|
||||
include_patterns: ^/dev/sd\w+$, ^/dev/nvme\w+n\d+$, ^/dev/vd\w+$
|
||||
exclude_patterns: ^/dev/ram\d+$, ^/dev/zram\d+$, ^/dev/loop\d+$, ^/dev/fd\d+$
|
||||
allow_removable: false (USB excluded unless --allow-removable)
|
||||
min_size_gib: 10
|
||||
- Partitioning defaults:
|
||||
alignment_mib: 1
|
||||
bios_boot: enabled (1 MiB), gpt_name: zosboot
|
||||
esp: 512 MiB, label: ZOSBOOT, gpt_name: zosboot
|
||||
data: gpt_name: zosdata
|
||||
cache: gpt_name: zoscache (only used in SSD+HDD topology)
|
||||
- Filesystem defaults:
|
||||
vfat (ESP) label: ZOSBOOT
|
||||
btrfs (data) label: ZOSDATA
|
||||
bcachefs (data/cache) label: ZOSDATA
|
||||
- Mount scheme:
|
||||
per-UUID under /var/cache/{UUID}
|
||||
/etc/fstab generation is disabled by default
|
||||
|
||||
Tracing and logs
|
||||
- stderr logging level controlled by -l/--log-level (info by default)
|
||||
- optional file logging with -L/--log-to-file at /run/zosstorage/zosstorage.log
|
||||
- Debug spans provide insight into discovery, idempotency, planning, and tool invocations
|
||||
|
||||
Extending and execution plan
|
||||
- Near-term:
|
||||
- Implement sgdisk partition apply and udev settle
|
||||
- Pass btrfs raid profile flags -m/-d from config (raid1, none) to mkfs
|
||||
- Create per-UUID mount targets; persist fstab when enabled
|
||||
- Atomic report write via tempfile+rename
|
||||
- Longer-term:
|
||||
- Dry-run mode with richer diffs
|
||||
- Richer schema for disks/partitions/filesystems in the report
|
||||
- Additional filesystem and tuning support
|
||||
|
||||
Safety model
|
||||
- Preview mode never mutates block devices
|
||||
- Emptiness checks guard destructive actions (skipped only in preview)
|
||||
- Reserved labels and GPT names validated to reduce risk
|
||||
|
||||
Troubleshooting
|
||||
- No eligible disks found: adjust include/exclude patterns or use --allow-removable for test media
|
||||
- Removable media excluded: either add --allow-removable or exclude it explicitly via config patterns
|
||||
- blkid missing: install util-linux; preview can still plan but cannot capture UUIDs for mkfs outputs
|
||||
- bcachefs command missing: install bcachefs-tools or skip the SSD+HDD topology
|
||||
|
||||
License
|
||||
- TBD (add your preferred license)
|
||||
185
config/zosstorage.example.yaml
Normal file
185
config/zosstorage.example.yaml
Normal file
@@ -0,0 +1,185 @@
|
||||
# zosstorage example configuration (full surface)
|
||||
# Copy to /etc/zosstorage/config.yaml on the target system, or pass with:
|
||||
# - CLI: --config /path/to/your.yaml
|
||||
# - Kernel cmdline: zosstorage.config=/path/to/your.yaml
|
||||
# Precedence (highest to lowest):
|
||||
# kernel cmdline > CLI flags > CLI --config file > /etc/zosstorage/config.yaml > built-in defaults
|
||||
|
||||
version: 1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logging:
|
||||
# one of: error, warn, info, debug
|
||||
level: info
|
||||
# when true, also logs to /run/zosstorage/zosstorage.log in initramfs
|
||||
to_file: false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Device selection rules
|
||||
# - include_patterns: device paths that are considered
|
||||
# - exclude_patterns: device paths to filter out
|
||||
# - allow_removable: future toggle for removable media (kept false by default)
|
||||
# - min_size_gib: ignore devices smaller than this size
|
||||
# -----------------------------------------------------------------------------
|
||||
device_selection:
|
||||
include_patterns:
|
||||
- "^/dev/sd\\w+$"
|
||||
- "^/dev/nvme\\w+n\\d+$"
|
||||
- "^/dev/vd\\w+$"
|
||||
exclude_patterns:
|
||||
- "^/dev/ram\\d+$"
|
||||
- "^/dev/zram\\d+$"
|
||||
- "^/dev/loop\\d+$"
|
||||
- "^/dev/fd\\d+$"
|
||||
allow_removable: false
|
||||
min_size_gib: 10
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Desired topology (choose ONE)
|
||||
# single : Single eligible disk; btrfs on data
|
||||
# dual_independent : Two disks; independent btrfs on each
|
||||
# ssd_hdd_bcachefs : SSD + HDD; bcachefs with SSD as cache/promote and HDD backing
|
||||
# btrfs_raid1 : Optional mirrored btrfs across two disks (only when explicitly requested)
|
||||
# -----------------------------------------------------------------------------
|
||||
topology:
|
||||
mode: single
|
||||
# mode: dual_independent
|
||||
# mode: ssd_hdd_bcachefs
|
||||
# mode: btrfs_raid1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Partitioning (GPT only)
|
||||
# Reserved GPT names:
|
||||
# - bios boot : "zosboot" (tiny BIOS boot partition, non-FS)
|
||||
# - ESP : "zosboot" (FAT32)
|
||||
# - Data : "zosdata"
|
||||
# - Cache : "zoscache" (only for ssd_hdd_bcachefs)
|
||||
# Reserved filesystem labels:
|
||||
# - ESP : ZOSBOOT
|
||||
# - Data (all filesystems including bcachefs): ZOSDATA
|
||||
# -----------------------------------------------------------------------------
|
||||
partitioning:
|
||||
# 1 MiB alignment
|
||||
alignment_mib: 1
|
||||
|
||||
# Abort if any target disk is not empty (required for safety)
|
||||
require_empty_disks: true
|
||||
|
||||
bios_boot:
|
||||
enabled: true
|
||||
size_mib: 1
|
||||
gpt_name: zosboot
|
||||
|
||||
esp:
|
||||
size_mib: 512
|
||||
label: ZOSBOOT
|
||||
gpt_name: zosboot
|
||||
|
||||
data:
|
||||
gpt_name: zosdata
|
||||
|
||||
# Only used in ssd_hdd_bcachefs
|
||||
cache:
|
||||
gpt_name: zoscache
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Filesystem options and tuning
|
||||
# All data filesystems (btrfs or bcachefs) use label ZOSDATA
|
||||
# ESP uses label ZOSBOOT
|
||||
# -----------------------------------------------------------------------------
|
||||
filesystem:
|
||||
btrfs:
|
||||
# Reserved; must be "ZOSDATA"
|
||||
label: ZOSDATA
|
||||
# e.g., "zstd:3", "zstd:5"
|
||||
compression: zstd:3
|
||||
# "none" | "raid1" (raid1 typically when topology.mode == btrfs_raid1)
|
||||
raid_profile: none
|
||||
|
||||
bcachefs:
|
||||
# Reserved; must be "ZOSDATA"
|
||||
label: ZOSDATA
|
||||
# "promote" (default) or "writeback" if supported by environment
|
||||
cache_mode: promote
|
||||
# Compression algorithm, e.g., "zstd"
|
||||
compression: zstd
|
||||
# Checksum algorithm, e.g., "crc32c"
|
||||
checksum: crc32c
|
||||
|
||||
vfat:
|
||||
# Reserved; must be "ZOSBOOT"
|
||||
label: ZOSBOOT
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mount scheme and optional fstab
|
||||
# Default behavior mounts data filesystems under /var/cache/<UUID>
|
||||
# -----------------------------------------------------------------------------
|
||||
mount:
|
||||
# Base directory for mounts
|
||||
base_dir: /var/cache
|
||||
# Scheme: per_uuid | custom (custom reserved for future)
|
||||
scheme: per_uuid
|
||||
# When true, zosstorage will generate /etc/fstab entries in deterministic order
|
||||
fstab_enabled: false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Report output
|
||||
# JSON report is written after successful provisioning
|
||||
# -----------------------------------------------------------------------------
|
||||
report:
|
||||
path: /run/zosstorage/state.json
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Examples for different topologies (uncomment and set topology.mode accordingly)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Example: single disk (uses btrfs on data)
|
||||
# topology:
|
||||
# mode: single
|
||||
# filesystem:
|
||||
# btrfs:
|
||||
# label: ZOSDATA
|
||||
# compression: zstd:3
|
||||
# raid_profile: none
|
||||
|
||||
# Example: dual independent btrfs (two disks)
|
||||
# topology:
|
||||
# mode: dual_independent
|
||||
# filesystem:
|
||||
# btrfs:
|
||||
# label: ZOSDATA
|
||||
# compression: zstd:5
|
||||
# raid_profile: none
|
||||
|
||||
# Example: SSD + HDD with bcachefs
|
||||
# topology:
|
||||
# mode: ssd_hdd_bcachefs
|
||||
# partitioning:
|
||||
# cache:
|
||||
# gpt_name: zoscache
|
||||
# filesystem:
|
||||
# bcachefs:
|
||||
# label: ZOSDATA
|
||||
# cache_mode: promote
|
||||
# compression: zstd
|
||||
# checksum: crc32c
|
||||
|
||||
# Example: btrfs RAID1 (two disks)
|
||||
# topology:
|
||||
# mode: btrfs_raid1
|
||||
# filesystem:
|
||||
# btrfs:
|
||||
# label: ZOSDATA
|
||||
# compression: zstd:3
|
||||
# raid_profile: raid1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notes:
|
||||
# - Never modify devices outside include_patterns or inside exclude_patterns.
|
||||
# - Idempotency: if expected GPT names and filesystem labels are already present,
|
||||
# zosstorage exits success without making changes.
|
||||
# - --force flag is reserved and not implemented; will return an "unimplemented" error.
|
||||
# - Kernel cmdline data: URLs for zosstorage.config= are currently unimplemented.
|
||||
# -----------------------------------------------------------------------------
|
||||
758
docs/API-SKELETONS.md
Normal file
758
docs/API-SKELETONS.md
Normal file
@@ -0,0 +1,758 @@
|
||||
# zosstorage API Skeletons (Proposed)
|
||||
|
||||
Purpose
|
||||
- This document proposes concrete Rust module skeletons with public API surface and doc comments.
|
||||
- Bodies intentionally use todo!() or are omitted for approval-first workflow.
|
||||
- After approval, these will be created in the src tree in Code mode.
|
||||
|
||||
Index
|
||||
- [src/lib.rs](src/lib.rs)
|
||||
- [src/errors.rs](src/errors.rs)
|
||||
- [src/main.rs](src/main.rs)
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- [src/logging/mod.rs](src/logging/mod.rs)
|
||||
- [src/types.rs](src/types.rs)
|
||||
- [src/config/loader.rs](src/config/loader.rs)
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
- [src/util/mod.rs](src/util/mod.rs)
|
||||
|
||||
Conventions
|
||||
- Shared [type Result<T>](src/errors.rs:1) and [enum Error](src/errors.rs:1).
|
||||
- No stdout prints; use tracing only.
|
||||
- External tools invoked via [util](src/util/mod.rs) wrappers.
|
||||
|
||||
---
|
||||
|
||||
## Crate root
|
||||
|
||||
References
|
||||
- [src/lib.rs](src/lib.rs)
|
||||
- [type Result<T> = std::result::Result<T, Error>](src/errors.rs:1)
|
||||
|
||||
Skeleton (for later implementation in code mode)
|
||||
```rust
|
||||
//! Crate root for zosstorage: one-shot disk provisioning utility for initramfs.
|
||||
|
||||
pub mod cli;
|
||||
pub mod logging;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod partition;
|
||||
pub mod fs;
|
||||
pub mod mount;
|
||||
pub mod report;
|
||||
pub mod orchestrator;
|
||||
pub mod idempotency;
|
||||
pub mod util;
|
||||
pub mod errors;
|
||||
|
||||
pub use errors::{Error, Result};
|
||||
|
||||
/// Crate version constants.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
References
|
||||
- [enum Error](src/errors.rs:1)
|
||||
- [type Result<T>](src/errors.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
/// Top-level error for zosstorage.
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("device discovery error: {0}")]
|
||||
Device(String),
|
||||
#[error("partitioning error: {0}")]
|
||||
Partition(String),
|
||||
#[error("filesystem error: {0}")]
|
||||
Filesystem(String),
|
||||
#[error("mount error: {0}")]
|
||||
Mount(String),
|
||||
#[error("report error: {0}")]
|
||||
Report(String),
|
||||
#[error("external tool '{tool}' failed with status {status}: {stderr}")]
|
||||
Tool {
|
||||
tool: String,
|
||||
status: i32,
|
||||
stderr: String,
|
||||
},
|
||||
#[error("unimplemented: {0}")]
|
||||
Unimplemented(&'static str),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entrypoint
|
||||
|
||||
References
|
||||
- [fn main()](src/main.rs:1)
|
||||
- [fn run(ctx: &Context) -> Result<()>](src/orchestrator/run.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use zosstorage::{cli, config, logging, orchestrator, Result};
|
||||
|
||||
fn main() {
|
||||
// Initialize minimal logging early (fallback).
|
||||
// Proper logging config will be applied after CLI parsing.
|
||||
// No stdout printing.
|
||||
if let Err(e) = real_main() {
|
||||
// In initramfs, emit minimal error to stderr and exit non-zero.
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn real_main() -> Result<()> {
|
||||
let cli = cli::from_args();
|
||||
let log_opts = logging::LogOptions::from_cli(&cli);
|
||||
logging::init_logging(&log_opts)?;
|
||||
|
||||
let cfg = config::load_and_merge(&cli)?;
|
||||
config::validate(&cfg)?;
|
||||
|
||||
let ctx = orchestrator::Context::new(cfg, log_opts);
|
||||
orchestrator::run(&ctx)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
References
|
||||
- [struct Cli](src/cli/args.rs:1)
|
||||
- [fn from_args() -> Cli](src/cli/args.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
/// zosstorage - one-shot disk initializer for initramfs.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "zosstorage", disable_help_subcommand = true)]
|
||||
pub struct Cli {
|
||||
/// Path to YAML configuration (mirrors kernel cmdline key 'zosstorage.config=')
|
||||
#[arg(long = "config")]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// Log level: error, warn, info, debug
|
||||
#[arg(long = "log-level", default_value = "info")]
|
||||
pub log_level: String,
|
||||
|
||||
/// Also log to /run/zosstorage/zosstorage.log
|
||||
#[arg(long = "log-to-file", default_value_t = false)]
|
||||
pub log_to_file: bool,
|
||||
|
||||
/// Enable writing /etc/fstab entries
|
||||
#[arg(long = "fstab", default_value_t = false)]
|
||||
pub fstab: bool,
|
||||
|
||||
/// Present but non-functional; returns unimplemented error
|
||||
#[arg(long = "force")]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// Parse CLI arguments (non-interactive; suitable for initramfs).
|
||||
pub fn from_args() -> Cli {
|
||||
Cli::parse()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
References
|
||||
- [struct LogOptions](src/logging/mod.rs:1)
|
||||
- [fn init_logging(opts: &LogOptions) -> Result<()>](src/logging/mod.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::Result;
|
||||
|
||||
/// Logging options resolved from CLI and/or config.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogOptions {
|
||||
pub level: String, // "error" | "warn" | "info" | "debug"
|
||||
pub to_file: bool, // when true, logs to /run/zosstorage/zosstorage.log
|
||||
}
|
||||
|
||||
impl LogOptions {
|
||||
pub fn from_cli(cli: &crate::cli::Cli) -> Self {
|
||||
Self { level: cli.log_level.clone(), to_file: cli.log_to_file }
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize tracing subscriber according to options.
|
||||
/// Must be idempotent when called once in process lifetime.
|
||||
pub fn init_logging(opts: &LogOptions) -> Result<()> {
|
||||
todo!("set up tracing for stderr and optional file layer")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration types
|
||||
|
||||
References
|
||||
- [struct Config](src/types.rs:1)
|
||||
- [enum Topology](src/types.rs:1)
|
||||
- [struct DeviceSelection](src/types.rs:1)
|
||||
- [struct Partitioning](src/types.rs:1)
|
||||
- [struct FsOptions](src/types.rs:1)
|
||||
- [struct MountScheme](src/types.rs:1)
|
||||
- [struct ReportOptions](src/types.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
pub level: String, // default "info"
|
||||
pub to_file: bool, // default false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceSelection {
|
||||
pub include_patterns: Vec<String>,
|
||||
pub exclude_patterns: Vec<String>,
|
||||
pub allow_removable: bool,
|
||||
pub min_size_gib: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Topology {
|
||||
Single,
|
||||
DualIndependent,
|
||||
SsdHddBcachefs,
|
||||
BtrfsRaid1,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BiosBootSpec {
|
||||
pub enabled: bool,
|
||||
pub size_mib: u64,
|
||||
pub gpt_name: String, // "zosboot"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EspSpec {
|
||||
pub size_mib: u64,
|
||||
pub label: String, // "ZOSBOOT"
|
||||
pub gpt_name: String, // "zosboot"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DataSpec {
|
||||
pub gpt_name: String, // "zosdata"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheSpec {
|
||||
pub gpt_name: String, // "zoscache"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Partitioning {
|
||||
pub alignment_mib: u64,
|
||||
pub require_empty_disks: bool,
|
||||
pub bios_boot: BiosBootSpec,
|
||||
pub esp: EspSpec,
|
||||
pub data: DataSpec,
|
||||
pub cache: CacheSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BtrfsOptions {
|
||||
pub label: String, // "ZOSDATA"
|
||||
pub compression: String, // "zstd:3"
|
||||
pub raid_profile: String // "none" | "raid1"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BcachefsOptions {
|
||||
pub label: String, // "ZOSDATA"
|
||||
pub cache_mode: String, // "promote" | "writeback?"
|
||||
pub compression: String, // "zstd"
|
||||
pub checksum: String, // "crc32c"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VfatOptions {
|
||||
pub label: String, // "ZOSBOOT"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FsOptions {
|
||||
pub btrfs: BtrfsOptions,
|
||||
pub bcachefs: BcachefsOptions,
|
||||
pub vfat: VfatOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MountSchemeKind {
|
||||
PerUuid,
|
||||
Custom, // reserved
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MountScheme {
|
||||
pub base_dir: String, // "/var/cache"
|
||||
pub scheme: MountSchemeKind,
|
||||
pub fstab_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReportOptions {
|
||||
pub path: String, // "/run/zosstorage/state.json"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub version: u32,
|
||||
pub logging: LoggingConfig,
|
||||
pub device_selection: DeviceSelection,
|
||||
pub topology: Topology,
|
||||
pub partitioning: Partitioning,
|
||||
pub filesystem: FsOptions,
|
||||
pub mount: MountScheme,
|
||||
pub report: ReportOptions,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration I/O
|
||||
|
||||
References
|
||||
- [fn load_and_merge(cli: &Cli) -> Result<Config>](src/config/loader.rs:1)
|
||||
- [fn validate(cfg: &Config) -> Result<()>](src/config/loader.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::{cli::Cli, Result};
|
||||
|
||||
/// Load defaults, merge file config, overlay CLI, and finally kernel cmdline.
|
||||
pub fn load_and_merge(cli: &Cli) -> Result<crate::config::types::Config> {
|
||||
todo!("implement precedence: file < CLI < kernel cmdline key 'zosstorage.config='")
|
||||
}
|
||||
|
||||
/// Validate semantic correctness of the configuration.
|
||||
pub fn validate(cfg: &crate::config::types::Config) -> Result<()> {
|
||||
todo!("ensure device filters, sizes, topology combinations are valid")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device discovery
|
||||
|
||||
References
|
||||
- [struct Disk](src/device/discovery.rs:1)
|
||||
- [struct DeviceFilter](src/device/discovery.rs:1)
|
||||
- [trait DeviceProvider](src/device/discovery.rs:1)
|
||||
- [fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>>](src/device/discovery.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::Result;
|
||||
|
||||
/// Eligible block device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Disk {
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub rotational: bool,
|
||||
pub model: Option<String>,
|
||||
pub serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiled device filters from config patterns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceFilter {
|
||||
pub include: Vec<regex::Regex>,
|
||||
pub exclude: Vec<regex::Regex>,
|
||||
pub min_size_gib: u64,
|
||||
}
|
||||
|
||||
/// Abstract provider for devices to enable testing with doubles.
|
||||
pub trait DeviceProvider {
|
||||
fn list_block_devices(&self) -> Result<Vec<Disk>>;
|
||||
fn probe_properties(&self, disk: &mut Disk) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Discover eligible disks according to the filter policy.
|
||||
pub fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>> {
|
||||
todo!("enumerate /dev, apply include/exclude, probe properties")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Partitioning
|
||||
|
||||
References
|
||||
- [enum PartRole](src/partition/plan.rs:1)
|
||||
- [struct PartitionSpec](src/partition/plan.rs:1)
|
||||
- [struct PartitionPlan](src/partition/plan.rs:1)
|
||||
- [struct PartitionResult](src/partition/plan.rs:1)
|
||||
- [fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan>](src/partition/plan.rs:1)
|
||||
- [fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>>](src/partition/plan.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::{config::types::Config, device::Disk, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PartRole {
|
||||
BiosBoot,
|
||||
Esp,
|
||||
Data,
|
||||
Cache,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionSpec {
|
||||
pub role: PartRole,
|
||||
pub size_mib: Option<u64>, // None means "remainder"
|
||||
pub gpt_name: String, // zosboot | zosdata | zoscache
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskPlan {
|
||||
pub disk: Disk,
|
||||
pub parts: Vec<PartitionSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionPlan {
|
||||
pub alignment_mib: u64,
|
||||
pub disks: Vec<DiskPlan>,
|
||||
pub require_empty_disks: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionResult {
|
||||
pub disk: String,
|
||||
pub part_number: u32,
|
||||
pub role: PartRole,
|
||||
pub gpt_name: String,
|
||||
pub uuid: String,
|
||||
pub start_mib: u64,
|
||||
pub size_mib: u64,
|
||||
pub device_path: String, // e.g., /dev/nvme0n1p2
|
||||
}
|
||||
|
||||
/// Compute GPT-only plan per topology and constraints.
|
||||
pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
todo!("layout bios boot, ESP, data (and cache for SSD/HDD); align to 1 MiB")
|
||||
}
|
||||
|
||||
/// Apply plan using sgdisk and verify via blkid; require empty disks if configured.
|
||||
pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
|
||||
todo!("shell out to sgdisk, trigger udev settle, collect partition GUIDs")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Filesystems
|
||||
|
||||
References
|
||||
- [enum FsKind](src/fs/plan.rs:1)
|
||||
- [struct FsSpec](src/fs/plan.rs:1)
|
||||
- [struct FsPlan](src/fs/plan.rs:1)
|
||||
- [struct FsResult](src/fs/plan.rs:1)
|
||||
- [fn plan_filesystems(...)](src/fs/plan.rs:1)
|
||||
- [fn make_filesystems(...)](src/fs/plan.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::{partition::PartitionResult, config::types::Config, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FsKind {
|
||||
Vfat,
|
||||
Btrfs,
|
||||
Bcachefs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsSpec {
|
||||
pub kind: FsKind,
|
||||
pub devices: Vec<String>, // 1 device for vfat/btrfs; 2 for bcachefs (cache + backing)
|
||||
pub label: String, // "ZOSBOOT" (vfat) or "ZOSDATA" (data)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPlan {
|
||||
pub specs: Vec<FsSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsResult {
|
||||
pub kind: FsKind,
|
||||
pub devices: Vec<String>,
|
||||
pub uuid: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Decide which partitions get which filesystem based on topology.
|
||||
pub fn plan_filesystems(
|
||||
parts: &[PartitionResult],
|
||||
cfg: &Config,
|
||||
) -> Result<FsPlan> {
|
||||
todo!("map ESP to vfat, data to btrfs or bcachefs according to topology")
|
||||
}
|
||||
|
||||
/// Create the filesystems and return identity info (UUIDs, labels).
|
||||
pub fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>> {
|
||||
todo!("invoke mkfs tools with configured options via util::run_cmd")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mounting
|
||||
|
||||
References
|
||||
- [struct MountPlan](src/mount/ops.rs:1)
|
||||
- [struct MountResult](src/mount/ops.rs:1)
|
||||
- [fn plan_mounts(...)](src/mount/ops.rs:1)
|
||||
- [fn apply_mounts(...)](src/mount/ops.rs:1)
|
||||
- [fn maybe_write_fstab(...)](src/mount/ops.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::{fs::FsResult, config::types::Config, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountPlan {
|
||||
pub entries: Vec<(String /* source */, String /* target */, String /* fstype */, String /* options */)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountResult {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub fstype: String,
|
||||
pub options: String,
|
||||
}
|
||||
|
||||
/// Build mount plan under /var/cache/<UUID> by default.
|
||||
pub fn plan_mounts(fs_results: &[FsResult], cfg: &Config) -> Result<MountPlan> {
|
||||
todo!("create per-UUID directories and mount mapping")
|
||||
}
|
||||
|
||||
/// Apply mounts using syscalls (nix), ensuring directories exist.
|
||||
pub fn apply_mounts(plan: &MountPlan) -> Result<Vec<MountResult>> {
|
||||
todo!("perform mount syscalls and return results")
|
||||
}
|
||||
|
||||
/// Optionally generate /etc/fstab entries in deterministic order.
|
||||
pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> {
|
||||
todo!("when enabled, write fstab entries")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporting
|
||||
|
||||
References
|
||||
- [const REPORT_VERSION: &str](src/report/state.rs:1)
|
||||
- [struct StateReport](src/report/state.rs:1)
|
||||
- [fn build_report(...)](src/report/state.rs:1)
|
||||
- [fn write_report(...)](src/report/state.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::{device::Disk, partition::PartitionResult, fs::FsResult, mount::MountResult, Result};
|
||||
|
||||
pub const REPORT_VERSION: &str = "v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateReport {
|
||||
pub version: String,
|
||||
pub timestamp: String, // RFC3339
|
||||
pub status: String, // "success" | "already_provisioned" | "error"
|
||||
pub disks: Vec<serde_json::Value>,
|
||||
pub partitions: Vec<serde_json::Value>,
|
||||
pub filesystems: Vec<serde_json::Value>,
|
||||
pub mounts: Vec<serde_json::Value>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the machine-readable state report.
|
||||
pub fn build_report(
|
||||
disks: &[Disk],
|
||||
parts: &[PartitionResult],
|
||||
fs: &[FsResult],
|
||||
mounts: &[MountResult],
|
||||
status: &str,
|
||||
) -> StateReport {
|
||||
todo!("assemble structured report in v1 format")
|
||||
}
|
||||
|
||||
/// Write the state report to the configured path (default /run/zosstorage/state.json).
|
||||
pub fn write_report(report: &StateReport, path: &str) -> Result<()> {
|
||||
todo!("serialize to JSON and persist atomically")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator
|
||||
|
||||
References
|
||||
- [struct Context](src/orchestrator/run.rs:1)
|
||||
- [fn run(ctx: &Context) -> Result<()>](src/orchestrator/run.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::{config::types::Config, logging::LogOptions, Result};
|
||||
|
||||
/// Execution context holding resolved configuration and environment flags.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Context {
|
||||
pub cfg: Config,
|
||||
pub log: LogOptions,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(cfg: Config, log: LogOptions) -> Self { Self { cfg, log } }
|
||||
}
|
||||
|
||||
/// High-level one-shot flow; aborts on any validation failure.
|
||||
/// Returns Ok(()) on success and also on no-op when already provisioned.
|
||||
pub fn run(ctx: &Context) -> Result<()> {
|
||||
todo!("idempotency check, discovery, planning, application, reporting")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Idempotency
|
||||
|
||||
References
|
||||
- [fn detect_existing_state() -> Result<Option<StateReport>>](src/idempotency/mod.rs:1)
|
||||
- [fn is_empty_disk(disk: &Disk) -> Result<bool>](src/idempotency/mod.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::{device::Disk, report::StateReport, Result};
|
||||
|
||||
/// Return existing state if system is already provisioned; otherwise None.
|
||||
pub fn detect_existing_state() -> Result<Option<StateReport>> {
|
||||
todo!("probe GPT names (zosboot, zosdata, zoscache) and FS labels (ZOSBOOT, ZOSDATA)")
|
||||
}
|
||||
|
||||
/// Determine if a disk is empty (no partitions and no known FS signatures).
|
||||
pub fn is_empty_disk(disk: &Disk) -> Result<bool> {
|
||||
todo!("use blkid and partition table inspection to declare emptiness")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
References
|
||||
- [struct CmdOutput](src/util/mod.rs:1)
|
||||
- [fn which_tool(name: &str) -> Result<Option<String>>](src/util/mod.rs:1)
|
||||
- [fn run_cmd(args: &[&str]) -> Result<()>](src/util/mod.rs:1)
|
||||
- [fn run_cmd_capture(args: &[&str]) -> Result<CmdOutput>](src/util/mod.rs:1)
|
||||
- [fn udev_settle(timeout_ms: u64) -> Result<()>](src/util/mod.rs:1)
|
||||
|
||||
Skeleton
|
||||
```rust
|
||||
use crate::Result;
|
||||
|
||||
/// Captured output from an external tool invocation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CmdOutput {
|
||||
pub status: i32,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// Locate the absolute path to required tool if available.
|
||||
pub fn which_tool(name: &str) -> Result<Option<String>> {
|
||||
todo!("use 'which' crate or manual PATH scanning")
|
||||
}
|
||||
|
||||
/// Run a command and return Ok if the exit status is zero.
|
||||
pub fn run_cmd(args: &[&str]) -> Result<()> {
|
||||
todo!("spawn process, log stderr on failure, map to Error::Tool")
|
||||
}
|
||||
|
||||
/// Run a command and capture stdout/stderr for parsing (e.g., blkid).
|
||||
pub fn run_cmd_capture(args: &[&str]) -> Result<CmdOutput> {
|
||||
todo!("spawn process and collect output")
|
||||
}
|
||||
|
||||
/// Call udevadm settle with a timeout; warn if unavailable, then no-op.
|
||||
pub fn udev_settle(timeout_ms: u64) -> Result<()> {
|
||||
todo!("invoke udevadm settle when present")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Approval gate
|
||||
- This API skeleton is ready for implementation as source files with todo!() bodies.
|
||||
- Upon approval, we will:
|
||||
- Create the src/ files as outlined.
|
||||
- Add dependencies via cargo add.
|
||||
- Ensure all modules compile with placeholders.
|
||||
- Add initial tests scaffolding and example configs.
|
||||
|
||||
References summary
|
||||
- [fn main()](src/main.rs:1)
|
||||
- [fn from_args()](src/cli/args.rs:1)
|
||||
- [fn init_logging(opts: &LogOptions)](src/logging/mod.rs:1)
|
||||
- [fn load_and_merge(cli: &Cli)](src/config/loader.rs:1)
|
||||
- [fn validate(cfg: &Config)](src/config/loader.rs:1)
|
||||
- [fn discover(filter: &DeviceFilter)](src/device/discovery.rs:1)
|
||||
- [fn plan_partitions(disks: &[Disk], cfg: &Config)](src/partition/plan.rs:1)
|
||||
- [fn apply_partitions(plan: &PartitionPlan)](src/partition/plan.rs:1)
|
||||
- [fn plan_filesystems(parts: &[PartitionResult], cfg: &Config)](src/fs/plan.rs:1)
|
||||
- [fn make_filesystems(plan: &FsPlan)](src/fs/plan.rs:1)
|
||||
- [fn plan_mounts(fs_results: &[FsResult], cfg: &Config)](src/mount/ops.rs:1)
|
||||
- [fn apply_mounts(plan: &MountPlan)](src/mount/ops.rs:1)
|
||||
- [fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config)](src/mount/ops.rs:1)
|
||||
- [fn build_report(...)](src/report/state.rs:1)
|
||||
- [fn write_report(report: &StateReport)](src/report/state.rs:1)
|
||||
- [fn detect_existing_state()](src/idempotency/mod.rs:1)
|
||||
- [fn is_empty_disk(disk: &Disk)](src/idempotency/mod.rs:1)
|
||||
- [fn which_tool(name: &str)](src/util/mod.rs:1)
|
||||
- [fn run_cmd(args: &[&str])](src/util/mod.rs:1)
|
||||
- [fn run_cmd_capture(args: &[&str])](src/util/mod.rs:1)
|
||||
- [fn udev_settle(timeout_ms: u64)](src/util/mod.rs:1)
|
||||
217
docs/API.md
Normal file
217
docs/API.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# zosstorage Public API Skeletons
|
||||
|
||||
This document defines the initial public API surface for all modules. It lists traits, structs, enums, constants, and functions with responsibilities and behavioral contracts. Implementations are intentionally deferred until approval. Function bodies will be added later and guarded by todo placeholders.
|
||||
|
||||
Conventions
|
||||
- All modules return a shared Result alias and Error enum.
|
||||
- No interactive prompts; APIs are deterministic and suitable for initramfs use.
|
||||
- External tooling calls are mediated via utility wrappers.
|
||||
|
||||
Module index
|
||||
- [src/main.rs](src/main.rs)
|
||||
- [src/lib.rs](src/lib.rs)
|
||||
- [src/errors.rs](src/errors.rs)
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- [src/logging/mod.rs](src/logging/mod.rs)
|
||||
- [src/types.rs](src/types.rs)
|
||||
- [src/config/loader.rs](src/config/loader.rs)
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
- [src/util/mod.rs](src/util/mod.rs)
|
||||
|
||||
Common errors and result
|
||||
- [enum Error](src/errors.rs:1)
|
||||
- Top-level error type covering parse/validation errors, device discovery errors, partitioning failures, filesystem mkfs errors, mount errors, report write errors, and external tool invocation failures with stderr capture.
|
||||
- [type Result<T> = std::result::Result<T, Error>](src/errors.rs:1)
|
||||
- Shared result alias across modules.
|
||||
|
||||
Crate root
|
||||
- [src/lib.rs](src/lib.rs)
|
||||
- Exposes crate version constants, the prelude, and re-exports common types for consumers of the library (tests/integration). No heavy logic.
|
||||
|
||||
Entrypoint
|
||||
- [fn main()](src/main.rs:1)
|
||||
- Initializes logging based on CLI defaults, parses CLI flags and kernel cmdline, loads and validates configuration, and invokes the orchestrator run sequence. Avoids stdout; logs via tracing only.
|
||||
|
||||
Orchestrator
|
||||
- [struct Context](src/orchestrator/run.rs:1)
|
||||
- Aggregates resolved configuration, logging options, and environment flags suited for initramfs execution.
|
||||
- [fn run(ctx: &Context) -> Result<()>](src/orchestrator/run.rs:1)
|
||||
- High-level one-shot flow:
|
||||
- Idempotency detection
|
||||
- Device discovery
|
||||
- Partition planning and application
|
||||
- Filesystem planning and creation
|
||||
- Mount planning and application
|
||||
- Report generation and write
|
||||
- Aborts the entire run on any validation or execution failure. Returns Ok on successful no-op if already provisioned.
|
||||
|
||||
CLI
|
||||
- [struct Cli](src/cli/args.rs:1)
|
||||
- Mirrors kernel cmdline semantics with flags:
|
||||
- --config PATH
|
||||
- --log-level LEVEL
|
||||
- --log-to-file
|
||||
- --fstab
|
||||
- --force (present, returns unimplemented error)
|
||||
- [fn from_args() -> Cli](src/cli/args.rs:1)
|
||||
- Parses argv without side effects; suitable for initramfs.
|
||||
|
||||
Logging
|
||||
- [struct LogOptions](src/logging/mod.rs:1)
|
||||
- Holds level and optional file target (/run/zosstorage/zosstorage.log).
|
||||
- [fn init_logging(opts: &LogOptions) -> Result<()>](src/logging/mod.rs:1)
|
||||
- Configures tracing-subscriber for stderr by default and optional file layer when enabled. Must be idempotent.
|
||||
|
||||
Configuration types
|
||||
- [struct Config](src/types.rs:1)
|
||||
- The validated configuration used by the orchestrator, containing logging, device selection rules, topology, partitioning, filesystem options, mount scheme, and report path.
|
||||
- [enum Topology](src/types.rs:1)
|
||||
- Values: single, dual_independent, ssd_hdd_bcachefs, btrfs_raid1 (opt-in).
|
||||
- [struct DeviceSelection](src/types.rs:1)
|
||||
- Include and exclude regex patterns, minimum size, removable policy.
|
||||
- [struct Partitioning](src/types.rs:1)
|
||||
- Alignment, emptiness requirement, bios_boot, esp, data, cache GPT names and sizes where applicable.
|
||||
- [struct BtrfsOptions](src/types.rs:1)
|
||||
- Compression string and raid profile (none or raid1).
|
||||
- [struct BcachefsOptions](src/types.rs:1)
|
||||
- Cache mode (promote or writeback), compression, checksum.
|
||||
- [struct VfatOptions](src/types.rs:1)
|
||||
- Reserved for ESP mkfs options; includes label ZOSBOOT.
|
||||
- [struct FsOptions](src/types.rs:1)
|
||||
- Aggregates BtrfsOptions, BcachefsOptions, VfatOptions and shared defaults such as ZOSDATA label.
|
||||
- [enum MountSchemeKind](src/types.rs:1)
|
||||
- Values: per_uuid, custom (future).
|
||||
- [struct MountScheme](src/types.rs:1)
|
||||
- Base directory (/var/cache), scheme kind, fstab enabled flag.
|
||||
- [struct ReportOptions](src/types.rs:1)
|
||||
- Output path (/run/zosstorage/state.json).
|
||||
|
||||
Configuration IO
|
||||
- [fn load_and_merge(cli: &Cli) -> Result<Config>](src/config/loader.rs:1)
|
||||
- Loads built-in defaults, optionally merges on-disk config, overlays CLI flags, and finally overlays kernel cmdline via zosstorage.config=. Validates the YAML against types and constraints.
|
||||
- [fn validate(cfg: &Config) -> Result<()>](src/config/loader.rs:1)
|
||||
- Ensures structural and semantic validity (e.g., disk selection rules not empty, sizes non-zero, supported topology combinations).
|
||||
|
||||
Device discovery
|
||||
- [struct Disk](src/device/discovery.rs:1)
|
||||
- Represents an eligible block device with path, size, rotational flag, and identifiers (serial, model if available).
|
||||
- [struct DeviceFilter](src/device/discovery.rs:1)
|
||||
- Derived from DeviceSelection; compiled regexes and size thresholds for efficient filtering.
|
||||
- [trait DeviceProvider](src/device/discovery.rs:1)
|
||||
- Abstraction for listing /dev and probing properties, enabling test doubles.
|
||||
- [fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>>](src/device/discovery.rs:1)
|
||||
- Returns eligible disks or a well-defined error if none are found.
|
||||
|
||||
Partitioning
|
||||
- [enum PartRole](src/partition/plan.rs:1)
|
||||
- Roles: BiosBoot, Esp, Data, Cache.
|
||||
- [struct PartitionSpec](src/partition/plan.rs:1)
|
||||
- Declarative spec for a single partition: role, optional size_mib, gpt_name (zosboot, zosdata, zoscache), and reserved filesystem label when role is Esp (ZOSBOOT).
|
||||
- [struct DiskPlan](src/partition/plan.rs:1)
|
||||
- The planned set of PartitionSpec instances for a single Disk in the chosen topology.
|
||||
- [struct PartitionPlan](src/partition/plan.rs:1)
|
||||
- Combined plan across all target disks, including alignment rules and safety checks.
|
||||
- [struct PartitionResult](src/partition/plan.rs:1)
|
||||
- Result of applying a DiskPlan: device path of each created partition, role, partition GUID, and gpt_name.
|
||||
- [fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan>](src/partition/plan.rs:1)
|
||||
- Produces a GPT-only plan with 1 MiB alignment, bios boot first (1 MiB), ESP 512 MiB, data remainder, and zoscache on SSD for ssd_hdd_bcachefs.
|
||||
- [fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>>](src/partition/plan.rs:1)
|
||||
- Executes the plan via sgdisk and related utilities. Aborts if target disks are not empty or if signatures are detected.
|
||||
|
||||
Filesystems
|
||||
- [enum FsKind](src/fs/plan.rs:1)
|
||||
- Values: Vfat, Btrfs, Bcachefs.
|
||||
- [struct FsSpec](src/fs/plan.rs:1)
|
||||
- Maps PartitionResult to desired filesystem kind and label (ZOSBOOT for ESP; ZOSDATA for all data filesystems including bcachefs).
|
||||
- [struct FsPlan](src/fs/plan.rs:1)
|
||||
- Plan of mkfs operations across all partitions and devices given the topology.
|
||||
- [struct FsResult](src/fs/plan.rs:1)
|
||||
- Output of mkfs: device path(s), fs uuid, label, and kind.
|
||||
- [fn plan_filesystems(disks: &[Disk], parts: &[PartitionResult], cfg: &Config) -> Result<FsPlan>](src/fs/plan.rs:1)
|
||||
- Determines which partitions receive vfat, btrfs, or bcachefs, and aggregates tuning options.
|
||||
- [fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>>](src/fs/plan.rs:1)
|
||||
- Invokes mkfs.vfat, mkfs.btrfs, mkfs.bcachefs accordingly via utility wrappers and returns filesystem identities.
|
||||
|
||||
Mounting
|
||||
- [struct MountPlan](src/mount/ops.rs:1)
|
||||
- Derived from FsResult entries: creates target directories under /var/cache/<UUID> and the mounts required for the current boot.
|
||||
- [struct MountResult](src/mount/ops.rs:1)
|
||||
- Actual mount operations performed (source, target, fstype, options).
|
||||
- [fn plan_mounts(fs_results: &[FsResult], cfg: &Config) -> Result<MountPlan>](src/mount/ops.rs:1)
|
||||
- Translates filesystem identities to mount targets and options.
|
||||
- [fn apply_mounts(plan: &MountPlan) -> Result<Vec<MountResult>>](src/mount/ops.rs:1)
|
||||
- Performs mounts using syscalls (nix crate) with minimal dependencies. Ensures directories exist.
|
||||
- [fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()>](src/mount/ops.rs:1)
|
||||
- When enabled, generates /etc/fstab entries in deterministic order. Disabled by default.
|
||||
|
||||
Reporting
|
||||
- [const REPORT_VERSION: &str](src/report/state.rs:1)
|
||||
- Version string for the JSON payload schema.
|
||||
- [struct StateReport](src/report/state.rs:1)
|
||||
- Machine-readable state describing discovered disks, created partitions, filesystems, labels, mountpoints, status, and timestamp.
|
||||
- [fn build_report(disks: &[Disk], parts: &[PartitionResult], fs: &[FsResult], mounts: &[MountResult], status: &str) -> StateReport](src/report/state.rs:1)
|
||||
- Constructs a StateReport matching REPORT_VERSION.
|
||||
- [fn write_report(report: &StateReport) -> Result<()>](src/report/state.rs:1)
|
||||
- Writes JSON to /run/zosstorage/state.json (configurable).
|
||||
|
||||
Idempotency
|
||||
- [fn detect_existing_state() -> Result<Option<StateReport>>](src/idempotency/mod.rs:1)
|
||||
- Probes for expected GPT names (zosboot, zosdata, zoscache where applicable) and filesystem labels (ZOSBOOT, ZOSDATA). If present and consistent, returns a StateReport; orchestrator exits success without changes.
|
||||
- [fn is_empty_disk(disk: &Disk) -> Result<bool>](src/idempotency/mod.rs:1)
|
||||
- Determines disk emptiness: absence of partitions and known filesystem signatures.
|
||||
|
||||
Utilities
|
||||
- [struct CmdOutput](src/util/mod.rs:1)
|
||||
- Captures status, stdout, stderr from external tool invocations.
|
||||
- [fn which_tool(name: &str) -> Result<Option<String>>](src/util/mod.rs:1)
|
||||
- Locates a required system utility in PATH, returning its absolute path if available.
|
||||
- [fn run_cmd(args: &[&str]) -> Result<()>](src/util/mod.rs:1)
|
||||
- Executes a command (args[0] is binary) and returns Ok when exit status is zero; logs stderr on failure.
|
||||
- [fn run_cmd_capture(args: &[&str]) -> Result<CmdOutput>](src/util/mod.rs:1)
|
||||
- Executes a command and returns captured output for parsing (e.g., blkid).
|
||||
- [fn udev_settle(timeout_ms: u64) -> Result<()>](src/util/mod.rs:1)
|
||||
- Calls udevadm settle with a timeout when available; otherwise no-ops with a warning.
|
||||
|
||||
Behavioral notes and contracts
|
||||
- Safety and idempotency:
|
||||
- Partitioning strictly aborts on any pre-existing partition or filesystem signature when require_empty_disks is true.
|
||||
- Filesystem creation never proceeds if partitioning validation fails.
|
||||
- The orchestrator treats any partial progress as failure; state report is only written on success.
|
||||
- Labels and GPT names:
|
||||
- ESP partitions are labeled ZOSBOOT (filesystem) and named zosboot (GPT).
|
||||
- Data filesystems use label ZOSDATA regardless of backend kind.
|
||||
- Cache partitions in bcachefs topology use GPT name zoscache.
|
||||
- Topology-specific behavior:
|
||||
- single: one data filesystem (btrfs) on the sole disk.
|
||||
- dual_independent: two separate btrfs filesystems, one per disk.
|
||||
- ssd_hdd_bcachefs: bcachefs spanning SSD (cache/promote) and HDD (backing), labeled ZOSDATA.
|
||||
- btrfs_raid1: only when explicitly requested; otherwise default to independent btrfs.
|
||||
|
||||
Module dependency overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cli --> config
|
||||
config --> orchestrator
|
||||
logging --> orchestrator
|
||||
orchestrator --> idempotency
|
||||
orchestrator --> device
|
||||
device --> partition
|
||||
partition --> fs
|
||||
fs --> mount
|
||||
mount --> report
|
||||
orchestrator --> report
|
||||
util --> partition
|
||||
util --> fs
|
||||
util --> mount
|
||||
util --> idempotency
|
||||
```
|
||||
|
||||
Status
|
||||
- This API surface is ready for code-mode skeleton implementation with todo placeholders in function bodies and comprehensive doc comments.
|
||||
237
docs/ARCHITECTURE.md
Normal file
237
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# zosstorage Architecture
|
||||
|
||||
This document defines the repository layout, module boundaries, public API surface (signatures only), defaults, and high-level execution flow for the initramfs-only provisioning utility.
|
||||
|
||||
Baseline decisions and labels
|
||||
- External tools inside initramfs are allowed and will be wrapped via helpers: sgdisk, blkid, mkfs.vfat, mkfs.btrfs, mkfs.bcachefs, udevadm.
|
||||
- Kernel cmdline key: zosstorage.config=
|
||||
- Default config path: /etc/zosstorage/config.yaml
|
||||
- JSON state report path: /run/zosstorage/state.json
|
||||
- Optional log file path: /run/zosstorage/zosstorage.log
|
||||
- fstab generation: disabled by default
|
||||
- GPT partition names: zosboot, zosdata, zoscache
|
||||
- Filesystem labels:
|
||||
- ESP: ZOSBOOT
|
||||
- Data filesystems including bcachefs: ZOSDATA
|
||||
|
||||
Repository layout
|
||||
Top level
|
||||
- [Cargo.toml](Cargo.toml)
|
||||
- [PROMPT.md](PROMPT.md)
|
||||
- [README.md](README.md)
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- [docs/SCHEMA.md](docs/SCHEMA.md)
|
||||
- [examples/config/minimal.yaml](examples/config/minimal.yaml)
|
||||
- [examples/config/dual-btrfs.yaml](examples/config/dual-btrfs.yaml)
|
||||
- [examples/config/ssd-hdd-bcachefs.yaml](examples/config/ssd-hdd-bcachefs.yaml)
|
||||
- [tests/](tests/)
|
||||
- [tests/integration_single_disk.rs](tests/integration_single_disk.rs)
|
||||
- [tests/integration_dual_disk.rs](tests/integration_dual_disk.rs)
|
||||
- [tests/integration_ssd_hdd.rs](tests/integration_ssd_hdd.rs)
|
||||
|
||||
Crate sources
|
||||
- [src/main.rs](src/main.rs)
|
||||
- [src/lib.rs](src/lib.rs)
|
||||
- [src/errors.rs](src/errors.rs)
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- [src/logging/mod.rs](src/logging/mod.rs)
|
||||
- [src/config/loader.rs](src/config/loader.rs)
|
||||
- [src/types.rs](src/types.rs)
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
- [src/util/mod.rs](src/util/mod.rs)
|
||||
|
||||
Module responsibilities
|
||||
- [src/main.rs](src/main.rs)
|
||||
- Entrypoint. Parse CLI, initialize logging, load and merge configuration per precedence, call orchestrator. No stdout spam.
|
||||
- [src/lib.rs](src/lib.rs)
|
||||
- Crate exports, prelude, version constants, Result alias.
|
||||
- [src/errors.rs](src/errors.rs)
|
||||
- Common error enum and Result alias via thiserror.
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- CLI definition mirroring kernel cmdline semantics; provide non-interactive interface. Stub --force returns unimplemented.
|
||||
- [src/logging/mod.rs](src/logging/mod.rs)
|
||||
- Initialize tracing; levels error, warn, info, debug; default to stderr; optional file target.
|
||||
- [src/config/loader.rs](src/config/loader.rs) and [src/types.rs](src/types.rs)
|
||||
- YAML schema types, validation, loading, and merging with CLI and kernel cmdline.
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- Device discovery under /dev with filters and allowlist; probe emptiness safely.
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- GPT-only planning and application; 1 MiB alignment; create bios boot, ESP, data and cache partitions with strict safety checks.
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- Filesystem provisioning: vfat for ESP, btrfs for ZOSDATA, bcachefs for SSD+HDD mode; all data filesystems labeled ZOSDATA.
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- Mount per-UUID under /var/cache/<UUID>. Optional fstab writing, disabled by default.
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
- Build and write JSON state report with version field.
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- One-shot flow orchestration with abort-on-any-validation-error policy.
|
||||
- [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
- Detect prior provisioning via GPT names and labels; return success-without-changes.
|
||||
- [src/util/mod.rs](src/util/mod.rs)
|
||||
- Shell-out, udev settle, and helpers.
|
||||
|
||||
Public API surface (signatures; implementation to follow after approval)
|
||||
Entrypoint and orchestrator
|
||||
- [fn main()](src/main.rs:1)
|
||||
- [struct Context](src/orchestrator/run.rs:1)
|
||||
- [fn run(ctx: &Context) -> Result<()>](src/orchestrator/run.rs:1)
|
||||
|
||||
CLI
|
||||
- [struct Cli](src/cli/args.rs:1)
|
||||
- [fn from_args() -> Cli](src/cli/args.rs:1)
|
||||
|
||||
Logging
|
||||
- [struct LogOptions](src/logging/mod.rs:1)
|
||||
- [fn init_logging(opts: &LogOptions) -> Result<()>](src/logging/mod.rs:1)
|
||||
|
||||
Config
|
||||
- [struct Config](src/types.rs:1)
|
||||
- [enum Topology](src/types.rs:1)
|
||||
- [struct DeviceSelection](src/types.rs:1)
|
||||
- [struct FsOptions](src/types.rs:1)
|
||||
- [struct MountScheme](src/types.rs:1)
|
||||
- [fn load_and_merge(cli: &Cli) -> Result<Config>](src/config/loader.rs:1)
|
||||
- [fn validate(cfg: &Config) -> Result<()>](src/config/loader.rs:1)
|
||||
|
||||
Device discovery
|
||||
- [struct Disk](src/device/discovery.rs:1)
|
||||
- [struct DeviceFilter](src/device/discovery.rs:1)
|
||||
- [trait DeviceProvider](src/device/discovery.rs:1)
|
||||
- [fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>>](src/device/discovery.rs:1)
|
||||
|
||||
Partitioning
|
||||
- [struct PartitionSpec](src/partition/plan.rs:1)
|
||||
- [struct PartitionPlan](src/partition/plan.rs:1)
|
||||
- [struct PartitionResult](src/partition/plan.rs:1)
|
||||
- [fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan>](src/partition/plan.rs:1)
|
||||
- [fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>>](src/partition/plan.rs:1)
|
||||
|
||||
Filesystems
|
||||
- [enum FsKind](src/fs/plan.rs:1)
|
||||
- [struct FsSpec](src/fs/plan.rs:1)
|
||||
- [struct FsPlan](src/fs/plan.rs:1)
|
||||
- [struct FsResult](src/fs/plan.rs:1)
|
||||
- [fn plan_filesystems(disks: &[Disk], parts: &[PartitionResult], cfg: &Config) -> Result<FsPlan>](src/fs/plan.rs:1)
|
||||
- [fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>>](src/fs/plan.rs:1)
|
||||
|
||||
Mounting
|
||||
- [struct MountPlan](src/mount/ops.rs:1)
|
||||
- [struct MountResult](src/mount/ops.rs:1)
|
||||
- [fn plan_mounts(fs_results: &[FsResult], cfg: &Config) -> Result<MountPlan>](src/mount/ops.rs:1)
|
||||
- [fn apply_mounts(plan: &MountPlan) -> Result<Vec<MountResult>>](src/mount/ops.rs:1)
|
||||
- [fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()>](src/mount/ops.rs:1)
|
||||
|
||||
Reporting
|
||||
- [const REPORT_VERSION: &str](src/report/state.rs:1)
|
||||
- [struct StateReport](src/report/state.rs:1)
|
||||
- [fn build_report(...) -> StateReport](src/report/state.rs:1)
|
||||
- [fn write_report(report: &StateReport) -> Result<()>](src/report/state.rs:1)
|
||||
|
||||
Idempotency
|
||||
- [fn detect_existing_state() -> Result<Option<StateReport>>](src/idempotency/mod.rs:1)
|
||||
- [fn is_empty_disk(disk: &Disk) -> Result<bool>](src/idempotency/mod.rs:1)
|
||||
|
||||
Errors and Result
|
||||
- [enum Error](src/errors.rs:1)
|
||||
- [type Result<T> = std::result::Result<T, Error>](src/errors.rs:1)
|
||||
|
||||
Execution flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start] --> B[Initialize logging]
|
||||
B --> C[Parse CLI and kernel cmdline]
|
||||
C --> D[Load and validate config]
|
||||
D --> E[Idempotency detection]
|
||||
E -->|already provisioned| Z[Exit success]
|
||||
E -->|not provisioned| F[Discover devices]
|
||||
F --> G[Plan partitions]
|
||||
G --> H[Apply partitions]
|
||||
H --> I[Plan filesystems]
|
||||
I --> J[Create filesystems]
|
||||
J --> K[Plan mounts]
|
||||
K --> L[Apply mounts]
|
||||
L --> M[Write state report]
|
||||
M --> N[Finalize]
|
||||
N --> Z[Exit success]
|
||||
```
|
||||
|
||||
Configuration precedence
|
||||
- Kernel cmdline key zosstorage.config= overrides CLI and file
|
||||
- CLI flags override config file
|
||||
- Config file provides defaults at /etc/zosstorage/config.yaml
|
||||
- No interactive prompts in initramfs
|
||||
|
||||
Device discovery and filtering
|
||||
- Include device classes by default: /dev/sd*, /dev/nvme*, /dev/vd*
|
||||
- Exclude pseudodevices: /dev/ram*, /dev/zram*, /dev/fd*, /dev/loop*, etc.
|
||||
- Allow future allowlists and removable media policies via configuration
|
||||
- If no eligible disks are found, return a well-defined error
|
||||
|
||||
Partitioning plan
|
||||
- GPT exclusively with 1 MiB alignment
|
||||
- bios boot partition first, 1 MiB
|
||||
- ESP 512 MiB FAT32, label ZOSBOOT, GPT name zosboot
|
||||
- Data partition consumes remainder, GPT name zosdata
|
||||
- When cache is requested, create GPT name zoscache partitions as needed
|
||||
- Abort if any pre-existing partitions or filesystem signatures are detected
|
||||
- Ensure unique partition UUIDs and identical labels where required
|
||||
|
||||
Filesystem provisioning defaults
|
||||
- Single disk: btrfs labeled ZOSDATA
|
||||
- Two disks: btrfs per disk labeled ZOSDATA, no RAID by default
|
||||
- SSD plus HDD: bcachefs with SSD as cache or promote and HDD as backing, filesystem label ZOSDATA
|
||||
- Filesystem tuning options configurable with sensible defaults and extension points
|
||||
|
||||
Mount scheme and fstab policy
|
||||
- Mount under /var/cache/<UUID> using filesystem UUID to create stable subdirectories
|
||||
- Optional /etc/fstab generation disabled by default; when enabled, produce deterministic order with documentation
|
||||
|
||||
Idempotency detection
|
||||
- Consider the system provisioned when expected GPT names and filesystem labels are present and consistent
|
||||
- On a provisioned system, exit success without making any changes
|
||||
|
||||
Reporting
|
||||
- Emit machine-readable JSON state report at /run/zosstorage/state.json
|
||||
- Report includes enumerated disks and roles, created partitions with identifiers, filesystems with labels and mountpoints, overall status and timestamp
|
||||
- Version the report payload via REPORT_VERSION
|
||||
|
||||
Logging
|
||||
- Use tracing with levels error, warn, info, debug
|
||||
- Default to stderr; optionally log to file at /run/zosstorage/zosstorage.log
|
||||
- Avoid println and stdout spam
|
||||
|
||||
External tooling policy
|
||||
- Invoke system utilities via wrappers that check for tool availability, capture stderr, and emit structured logs
|
||||
- Provide udev settle helper; avoid reliance on long-running services
|
||||
|
||||
Planned dependencies to add via cargo add
|
||||
- clap
|
||||
- serde, serde_yaml, serde_json
|
||||
- thiserror
|
||||
- anyhow or eyre
|
||||
- tracing, tracing-subscriber
|
||||
- nix
|
||||
- regex
|
||||
- uuid
|
||||
- which
|
||||
- time
|
||||
- tempfile
|
||||
|
||||
Open items and assumptions to track
|
||||
- Exact BIOS boot size and placement pending confirmation; currently 1 MiB first
|
||||
- Final mount naming scheme under /var/cache may evolve
|
||||
- Filesystem tuning defaults for btrfs and bcachefs require stakeholder input
|
||||
- Paths for config, report, and log file may be adjusted later
|
||||
- fstab generation remains disabled by default pending decision
|
||||
|
||||
Next steps after approval
|
||||
- Formalize configuration schema and validation rules in [docs/SCHEMA.md](docs/SCHEMA.md)
|
||||
- Define detailed doc comments for all listed types and functions
|
||||
- Prepare code-mode implementation skeletons with todo placeholders and add dependencies via cargo add
|
||||
123
docs/DEV_WORKFLOW.md
Normal file
123
docs/DEV_WORKFLOW.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Modular Development Workflow and Traceability
|
||||
|
||||
Goal
|
||||
- Enable incremental implementation without re-reading the entire codebase each time.
|
||||
- Make module changes discoverable via grep and predictable locations.
|
||||
- Keep a single source of truth for the API surface, invariants, and extension points.
|
||||
|
||||
Core Principles
|
||||
1) Contract-first per module
|
||||
- API signatures and responsibilities are documented in [docs/API-SKELETONS.md](docs/API-SKELETONS.md) and mirrored by crate modules:
|
||||
- [src/types.rs](src/types.rs)
|
||||
- [fn load_and_merge()](src/config/loader.rs:1), [fn validate()](src/config/loader.rs:1)
|
||||
- [fn from_args()](src/cli/args.rs:1)
|
||||
- [struct LogOptions](src/logging/mod.rs:1), [fn init_logging()](src/logging/mod.rs:1)
|
||||
- [fn discover()](src/device/discovery.rs:1)
|
||||
- [fn plan_partitions()](src/partition/plan.rs:1), [fn apply_partitions()](src/partition/plan.rs:1)
|
||||
- [fn plan_filesystems()](src/fs/plan.rs:1), [fn make_filesystems()](src/fs/plan.rs:1)
|
||||
- [fn plan_mounts()](src/mount/ops.rs:1), [fn apply_mounts()](src/mount/ops.rs:1), [fn maybe_write_fstab()](src/mount/ops.rs:1)
|
||||
- [const REPORT_VERSION](src/report/state.rs:1), [fn build_report()](src/report/state.rs:1), [fn write_report()](src/report/state.rs:1)
|
||||
- [struct Context](src/orchestrator/run.rs:1), [fn run()](src/orchestrator/run.rs:1)
|
||||
- [fn detect_existing_state()](src/idempotency/mod.rs:1), [fn is_empty_disk()](src/idempotency/mod.rs:1)
|
||||
- [struct CmdOutput](src/util/mod.rs:1), [fn which_tool()](src/util/mod.rs:1), [fn run_cmd()](src/util/mod.rs:1), [fn run_cmd_capture()](src/util/mod.rs:1), [fn udev_settle()](src/util/mod.rs:1)
|
||||
|
||||
2) Grep-able region markers in code
|
||||
- Every module contains the following optional annotated regions:
|
||||
- // REGION: API
|
||||
- // REGION: EXTENSION_POINTS
|
||||
- // REGION: SAFETY
|
||||
- // REGION: ERROR_MAPPING
|
||||
- // REGION: TODO
|
||||
- Example snippet to add near top of a module:
|
||||
// REGION: API
|
||||
// api: device::discover(filter: &DeviceFilter) -> Result<Vec<Disk>>
|
||||
// api: device::DeviceProvider
|
||||
// REGION: API-END
|
||||
- These must be kept concise, one-liners per function/trait/struct; they act as a quick index for search.
|
||||
|
||||
3) Stable identifiers for cross-references
|
||||
- Use short identifiers in comments to reference public items:
|
||||
- api: module::item
|
||||
- ext: module::hook_or_trait
|
||||
- safety: module::invariant_name
|
||||
- errmap: module::error_path
|
||||
- This allows quick discovery via regex: grep -R "api: device::" src/
|
||||
|
||||
4) Single source of truth for API surface
|
||||
- Keep high-level API in [docs/API-SKELETONS.md](docs/API-SKELETONS.md) as canonical index. After adding/removing a public function or type, update this file.
|
||||
- Add a short note in [docs/SPECS.md](docs/SPECS.md) if behavior or invariants change.
|
||||
|
||||
5) Architectural decisions recorded as ADRs
|
||||
- Use docs/adr/NNNN-title.md to document decisions (context, decision, consequences).
|
||||
- Start with [docs/adr/0001-modular-workflow.md](docs/adr/0001-modular-workflow.md) (added alongside this doc).
|
||||
- Link ADRs from [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) when they supersede or refine prior guidance.
|
||||
|
||||
6) Module ownership and boundaries
|
||||
- Add a “Module Responsibilities” section in each module’s header doc comment summarizing scope and non-goals.
|
||||
- Example references:
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
|
||||
7) Invariants and safety notes
|
||||
- For code that must uphold safety or idempotency invariants, annotate with:
|
||||
// SAFETY: explanation
|
||||
// IDEMPOTENCY: explanation
|
||||
- Example locations:
|
||||
- [fn apply_partitions()](src/partition/plan.rs:1) must enforce empty-disks rule when configured.
|
||||
- [fn make_filesystems()](src/fs/plan.rs:1) must not run if partitioning failed.
|
||||
|
||||
8) Error mapping consistency
|
||||
- Centralize conversions to [enum Error](src/errors.rs:1). When calling external tools, wrap failures into Error::Tool with stderr captured.
|
||||
- Annotate mapping areas with:
|
||||
// ERROR: mapping external failure to Error::Tool
|
||||
|
||||
9) Module-local CHANGELOG entries
|
||||
- Keep a single CHANGELOG in the repo root, plus module-local “Changes” sections appended on each module top comment (short bullets).
|
||||
- Cross-link to the ADR when relevant.
|
||||
|
||||
10) Task-level breadcrumbs
|
||||
- For multi-step features, add a short progress marker at top of relevant modules:
|
||||
// TODO(KILO): feature-X step 2/4 – parsing args done; next implement validation
|
||||
- Summary of active tasks can also live in docs/SPECS.md under “In-Progress Work”.
|
||||
|
||||
11) Example configs and fixtures
|
||||
- Keep comprehensive examples in:
|
||||
- [config/zosstorage.example.yaml](config/zosstorage.example.yaml)
|
||||
- Add minimal example variants if needed for tests (future):
|
||||
- examples/config/minimal.yaml
|
||||
- examples/config/dual-btrfs.yaml
|
||||
- examples/config/ssd-hdd-bcachefs.yaml
|
||||
|
||||
12) “Golden paths” for resuming work
|
||||
- If resuming work later:
|
||||
- Read module headers for responsibilities
|
||||
- Grep for REGION markers: API / TODO / SAFETY / ERROR_MAPPING
|
||||
- Check [docs/API-SKELETONS.md](docs/API-SKELETONS.md) for contract changes
|
||||
- Check latest ADRs in docs/adr/
|
||||
- Check [docs/SPECS.md](docs/SPECS.md) and the “In-Progress Work” section (if used)
|
||||
|
||||
Checklist for adding a new feature
|
||||
- Update contracts:
|
||||
- Add or modify function/type signatures in code and reflect in [docs/API-SKELETONS.md](docs/API-SKELETONS.md)
|
||||
- Add REGION: API one-liners for the new items
|
||||
- Update invariants:
|
||||
- Add REGION: SAFETY notes if needed
|
||||
- Update specs:
|
||||
- Document behavior in [docs/SPECS.md](docs/SPECS.md)
|
||||
- If it is a long-term decision, add an ADR under docs/adr/
|
||||
- Add examples if config or output formats change
|
||||
- Update [config/zosstorage.example.yaml](config/zosstorage.example.yaml) or add a new example file
|
||||
- Keep error mapping and logging consistent:
|
||||
- Ensure any external tool calls map errors to [enum Error](src/errors.rs:1)
|
||||
- Run cargo build and update any broken references
|
||||
|
||||
Optional automation (future)
|
||||
- A simple “index check” script (cargo xtask) could validate:
|
||||
- All public items referenced under REGION: API appear in [docs/API-SKELETONS.md](docs/API-SKELETONS.md)
|
||||
- No broken module references
|
||||
- REGION markers are well-formed
|
||||
|
||||
By following these conventions, changes stay localized and discoverable. A contributor (human or assistant) can quickly locate relevant areas by scanning module headers, REGION markers, and the centralized API/docs without re-reading the entire tree.
|
||||
200
docs/SCHEMA.md
Normal file
200
docs/SCHEMA.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# zosstorage Configuration Schema
|
||||
|
||||
This document defines the YAML configuration for the initramfs-only disk provisioning utility and the exact precedence rules between configuration sources. It complements [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
Canonical paths and keys
|
||||
- Kernel cmdline key: zosstorage.config=
|
||||
- Default config file path: /etc/zosstorage/config.yaml
|
||||
- JSON state report path: /run/zosstorage/state.json
|
||||
- Optional log file path: /run/zosstorage/zosstorage.log
|
||||
- fstab generation: disabled by default
|
||||
- Reserved filesystem labels: ZOSBOOT (ESP), ZOSDATA (all data filesystems)
|
||||
- GPT partition names: zosboot, zosdata, zoscache
|
||||
|
||||
Precedence and merge strategy
|
||||
1. Start from built-in defaults documented here.
|
||||
2. Merge in the on-disk config file if present at /etc/zosstorage/config.yaml.
|
||||
3. Merge CLI flags next; these override file values.
|
||||
4. Merge kernel cmdline last; zosstorage.config= overrides CLI and file.
|
||||
5. No interactive prompts are permitted.
|
||||
|
||||
The kernel cmdline key zosstorage.config= accepts:
|
||||
- A path to a YAML file inside the initramfs root (preferred).
|
||||
- A file: absolute path (e.g., file:/run/config/zos.yaml).
|
||||
- A data: URL containing base64 YAML (optional extension).
|
||||
|
||||
Top-level YAML structure
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
logging:
|
||||
level: info # one of: error, warn, info, debug
|
||||
to_file: false # default false; when true, logs to /run/zosstorage/zosstorage.log
|
||||
device_selection:
|
||||
include_patterns: # default: ["^/dev/sd\\w+$", "^/dev/nvme\\w+n\\d+$", "^/dev/vd\\w+$"]
|
||||
- "^/dev/sd\\w+$"
|
||||
- "^/dev/nvme\\w+n\\d+$"
|
||||
- "^/dev/vd\\w+$"
|
||||
exclude_patterns: # default excludes: ram, zram, loop, fd, dm-crypt mappings not matching include, etc.
|
||||
- "^/dev/ram\\d+$"
|
||||
- "^/dev/zram\\d+$"
|
||||
- "^/dev/loop\\d+$"
|
||||
- "^/dev/fd\\d+$"
|
||||
allow_removable: false # future option; default false
|
||||
min_size_gib: 10 # ignore devices smaller than this (default 10)
|
||||
topology: # desired overall layout; see values below
|
||||
mode: single # single | dual_independent | ssd_hdd_bcachefs | btrfs_raid1 (optional)
|
||||
partitioning:
|
||||
alignment_mib: 1 # GPT alignment in MiB
|
||||
require_empty_disks: true # abort if any partition or FS signatures exist
|
||||
bios_boot:
|
||||
enabled: true
|
||||
size_mib: 1
|
||||
gpt_name: zosboot # name for the tiny BIOS boot partition (non-FS)
|
||||
esp:
|
||||
size_mib: 512
|
||||
label: ZOSBOOT
|
||||
gpt_name: zosboot
|
||||
data:
|
||||
gpt_name: zosdata
|
||||
cache:
|
||||
gpt_name: zoscache
|
||||
filesystem:
|
||||
btrfs:
|
||||
label: ZOSDATA
|
||||
compression: zstd:3 # string passed to -O/compress option handling
|
||||
raid_profile: none # none | raid1
|
||||
bcachefs:
|
||||
label: ZOSDATA
|
||||
cache_mode: promote # promote | writeback (if supported during mkfs; default promote)
|
||||
compression: zstd
|
||||
checksum: crc32c
|
||||
vfat:
|
||||
label: ZOSBOOT
|
||||
mount:
|
||||
base_dir: /var/cache
|
||||
scheme: per_uuid # per_uuid | custom
|
||||
fstab:
|
||||
enabled: false
|
||||
report:
|
||||
path: /run/zosstorage/state.json
|
||||
```
|
||||
|
||||
Topology modes
|
||||
- single: One eligible disk. Create BIOS boot (if enabled), ESP 512 MiB, remainder as data. Make a btrfs filesystem labeled ZOSDATA on the data partition.
|
||||
- dual_independent: Two eligible disks. On each disk, create BIOS boot (if enabled) + ESP + data. Create a separate btrfs filesystem labeled ZOSDATA on each data partition. No RAID by default.
|
||||
- ssd_hdd_bcachefs: One SSD/NVMe and one HDD. Create BIOS boot (if enabled) + ESP on both as required. Create cache (on SSD) and data/backing (on HDD) partitions named zoscache and zosdata respectively. Make a bcachefs filesystem across both with label ZOSDATA, using SSD as cache/promote and HDD as backing.
|
||||
- btrfs_raid1: Optional mode if explicitly requested. Create mirrored btrfs across two disks for the data role with raid1 profile. Not enabled by default.
|
||||
|
||||
Validation rules
|
||||
- Abort if no eligible disks are found after filtering.
|
||||
- Abort if any target disk is not empty when require_empty_disks: true. Emptiness is determined by absence of partitions and known FS signatures.
|
||||
- Never modify devices outside include_patterns or inside exclude_patterns.
|
||||
- Ensure unique GPT partition UUIDs. ESP labels on different disks may be identical (ZOSBOOT), but partition UUIDs must differ.
|
||||
- Filesystem labels must follow reserved semantics: ESP uses ZOSBOOT, all data filesystems use ZOSDATA.
|
||||
|
||||
Logging section
|
||||
- logging.level: error | warn | info | debug. Default info.
|
||||
- logging.to_file: when true, logs also go to /run/zosstorage/zosstorage.log. Default false.
|
||||
|
||||
Device selection section
|
||||
- include_patterns: array of regex patterns (Rust-style) matched against absolute device paths.
|
||||
- exclude_patterns: array of regex patterns that are removed after inclusion.
|
||||
- allow_removable: future toggle for including removable media. Default false.
|
||||
- min_size_gib: minimum size to consider a disk eligible; default 10 GiB.
|
||||
|
||||
Partitioning section
|
||||
- alignment_mib: default 1 (MiB boundaries).
|
||||
- require_empty_disks: true by default to guarantee safety.
|
||||
- bios_boot: enabled true, size_mib 1, gpt_name zosboot. Used for BIOS-bootable GPT where needed.
|
||||
- esp: size_mib 512, label ZOSBOOT, gpt_name zosboot.
|
||||
- data: gpt_name zosdata.
|
||||
- cache: gpt_name zoscache, only created in ssd_hdd_bcachefs mode.
|
||||
|
||||
Filesystem section
|
||||
- btrfs: label ZOSDATA; compression string such as zstd:3; raid_profile none|raid1 (only applied when topology permits).
|
||||
- bcachefs: label ZOSDATA; cache_mode promote|writeback; compression; checksum. Exact tuning defaults remain open items.
|
||||
- vfat: label ZOSBOOT used for ESP.
|
||||
|
||||
Mount section
|
||||
- base_dir: default /var/cache.
|
||||
- scheme:
|
||||
- per_uuid: mount data filesystems at /var/cache/<FS-UUID>
|
||||
- custom: reserved for future mapping-by-config, not yet implemented.
|
||||
- fstab.enabled: default false. When true, zosstorage will generate fstab entries in deterministic order.
|
||||
|
||||
Report section
|
||||
- path: default /run/zosstorage/state.json.
|
||||
- The report content schema is defined separately in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and the reporting module.
|
||||
|
||||
Configuration examples
|
||||
|
||||
Minimal single-disk btrfs
|
||||
```yaml
|
||||
version: 1
|
||||
topology:
|
||||
mode: single
|
||||
```
|
||||
|
||||
Dual independent btrfs (two disks)
|
||||
```yaml
|
||||
version: 1
|
||||
topology:
|
||||
mode: dual_independent
|
||||
filesystem:
|
||||
btrfs:
|
||||
compression: zstd:5
|
||||
```
|
||||
|
||||
SSD + HDD with bcachefs
|
||||
```yaml
|
||||
version: 1
|
||||
topology:
|
||||
mode: ssd_hdd_bcachefs
|
||||
partitioning:
|
||||
cache:
|
||||
gpt_name: zoscache
|
||||
filesystem:
|
||||
bcachefs:
|
||||
cache_mode: promote
|
||||
compression: zstd
|
||||
checksum: crc32c
|
||||
```
|
||||
|
||||
CLI flags (to mirror kernel cmdline)
|
||||
- --config PATH: path to YAML config (mirrors zosstorage.config=)
|
||||
- --log-level LEVEL: error|warn|info|debug
|
||||
- --log-to-file: enables logging to /run/zosstorage/zosstorage.log
|
||||
- --fstab: enable writing fstab entries
|
||||
- --force: present but non-functional; returns unimplemented
|
||||
|
||||
Kernel cmdline examples
|
||||
- zosstorage.config=/etc/zosstorage/config.yaml
|
||||
- zosstorage.config=file:/run/zos.yaml
|
||||
- zosstorage.config=data:application/x-yaml;base64,PHZlcnNpb246IDEK...
|
||||
|
||||
Notes on idempotency
|
||||
- If expected GPT names (zosboot, zosdata, zoscache where applicable) and filesystem labels (ZOSBOOT, ZOSDATA) are found consistent with the chosen topology, zosstorage exits successfully without changes.
|
||||
|
||||
Future extensions
|
||||
- Removable media allowlist policies
|
||||
- btrfs RAID profiles beyond raid1
|
||||
- bcachefs extended tuning options
|
||||
- Custom mount mapping schemes
|
||||
- Multiple topology groups on multi-disk systems
|
||||
|
||||
Reference modules
|
||||
- [src/types.rs](src/types.rs)
|
||||
- [src/config/loader.rs](src/config/loader.rs)
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
- [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
|
||||
Change log
|
||||
- v1: Initial draft of schema and precedence rules
|
||||
|
||||
End of document
|
||||
336
docs/SPECS.md
Normal file
336
docs/SPECS.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# zosstorage Detailed Specifications
|
||||
|
||||
This document finalizes core specifications required before code skeleton implementation. It complements [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/SCHEMA.md](docs/SCHEMA.md), and references the API declarations listed in [docs/API.md](docs/API.md).
|
||||
|
||||
Linked modules and functions
|
||||
- Logging module: [src/logging/mod.rs](src/logging/mod.rs)
|
||||
- [fn init_logging(opts: &LogOptions) -> Result<()>](src/logging/mod.rs:1)
|
||||
- Report module: [src/report/state.rs](src/report/state.rs)
|
||||
- [const REPORT_VERSION: &str](src/report/state.rs:1)
|
||||
- [fn build_report(...) -> StateReport](src/report/state.rs:1)
|
||||
- [fn write_report(report: &StateReport) -> Result<()>](src/report/state.rs:1)
|
||||
- Device module: [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- [fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>>](src/device/discovery.rs:1)
|
||||
- Partitioning module: [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan>](src/partition/plan.rs:1)
|
||||
- [fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>>](src/partition/plan.rs:1)
|
||||
- Filesystems module: [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [fn plan_filesystems(disks: &[Disk], parts: &[PartitionResult], cfg: &Config) -> Result<FsPlan>](src/fs/plan.rs:1)
|
||||
- [fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>>](src/fs/plan.rs:1)
|
||||
- Mount module: [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [fn plan_mounts(fs_results: &[FsResult], cfg: &Config) -> Result<MountPlan>](src/mount/ops.rs:1)
|
||||
- [fn apply_mounts(plan: &MountPlan) -> Result<Vec<MountResult>>](src/mount/ops.rs:1)
|
||||
- [fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()>](src/mount/ops.rs:1)
|
||||
- Idempotency module: [src/idempotency/mod.rs](src/idempotency/mod.rs)
|
||||
- [fn detect_existing_state() -> Result<Option<StateReport>>](src/idempotency/mod.rs:1)
|
||||
- [fn is_empty_disk(disk: &Disk) -> Result<bool>](src/idempotency/mod.rs:1)
|
||||
- CLI module: [src/cli/args.rs](src/cli/args.rs)
|
||||
- [fn from_args() -> Cli](src/cli/args.rs:1)
|
||||
- Orchestrator: [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- [fn run(ctx: &Context) -> Result<()>](src/orchestrator/run.rs:1)
|
||||
|
||||
---
|
||||
|
||||
## 1. Logging and tracing
|
||||
|
||||
Goals
|
||||
- Structured, low-noise logging compatible with initramfs.
|
||||
- Defaults to stderr. Optional file at /run/zosstorage/zosstorage.log controlled by config or CLI.
|
||||
|
||||
Configuration
|
||||
- Levels: error, warn, info, debug (default info).
|
||||
- Propagation: single global initialization via [fn init_logging](src/logging/mod.rs:1). Subsequent calls must be no-ops.
|
||||
|
||||
Implementation notes
|
||||
- Use tracing and tracing-subscriber.
|
||||
- Format: compact, with fields level, target, message, and optional module path. Avoid timestamps if writing to stderr in initramfs; include timestamps when logging to file.
|
||||
|
||||
Example behavior
|
||||
- CLI --log-level debug sets level to debug.
|
||||
- CLI --log-to-file or config.logging.to_file true enables file layer at /run/zosstorage/zosstorage.log.
|
||||
|
||||
---
|
||||
|
||||
## 2. JSON state report schema v1
|
||||
|
||||
Location
|
||||
- Default output: /run/zosstorage/state.json
|
||||
|
||||
Versioning
|
||||
- Include a top-level string field version equal to [REPORT_VERSION](src/report/state.rs:1). Start with v1.
|
||||
|
||||
Schema example
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v1",
|
||||
"timestamp": "2025-09-25T12:00:00Z",
|
||||
"status": "success",
|
||||
"disks": [
|
||||
{
|
||||
"path": "/dev/nvme0n1",
|
||||
"size_bytes": 40007973632,
|
||||
"rotational": false,
|
||||
"model": "QEMU NVMe Ctrl",
|
||||
"serial": "nvme-1234",
|
||||
"selected": true,
|
||||
"roles": ["esp", "data"]
|
||||
}
|
||||
],
|
||||
"partitions": [
|
||||
{
|
||||
"disk": "/dev/nvme0n1",
|
||||
"number": 1,
|
||||
"role": "bios_boot",
|
||||
"gpt_name": "zosboot",
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"start_mib": 1,
|
||||
"size_mib": 1
|
||||
},
|
||||
{
|
||||
"disk": "/dev/nvme0n1",
|
||||
"number": 2,
|
||||
"role": "esp",
|
||||
"gpt_name": "zosboot",
|
||||
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||
"start_mib": 2,
|
||||
"size_mib": 512,
|
||||
"fs_label": "ZOSBOOT"
|
||||
},
|
||||
{
|
||||
"disk": "/dev/nvme0n1",
|
||||
"number": 3,
|
||||
"role": "data",
|
||||
"gpt_name": "zosdata",
|
||||
"uuid": "33333333-3333-3333-3333-333333333333",
|
||||
"start_mib": 514,
|
||||
"size_mib": 39000
|
||||
}
|
||||
],
|
||||
"filesystems": [
|
||||
{
|
||||
"kind": "vfat",
|
||||
"device": "/dev/nvme0n1p2",
|
||||
"uuid": "AAAA-BBBB",
|
||||
"label": "ZOSBOOT",
|
||||
"mountpoint": null
|
||||
},
|
||||
{
|
||||
"kind": "btrfs",
|
||||
"device": "/dev/nvme0n1p3",
|
||||
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeffffffff",
|
||||
"label": "ZOSDATA",
|
||||
"mountpoint": "/var/cache/aaaaaaaa-bbbb-cccc-dddd-eeeeffffffff"
|
||||
}
|
||||
],
|
||||
"mounts": [
|
||||
{
|
||||
"source": "/dev/nvme0n1p3",
|
||||
"target": "/var/cache/aaaaaaaa-bbbb-cccc-dddd-eeeeffffffff",
|
||||
"fstype": "btrfs",
|
||||
"options": "defaults,ssd,compress=zstd:3"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes
|
||||
- UUID formats follow tool output: VFAT UUID short form allowed.
|
||||
- Status values: success, already_provisioned, error. On error, add error field with reason.
|
||||
|
||||
---
|
||||
|
||||
## 3. Device discovery and filtering rules
|
||||
|
||||
Default include patterns
|
||||
- ^/dev/sd\\w+$
|
||||
- ^/dev/nvme\\w+n\\d+$
|
||||
- ^/dev/vd\\w+$
|
||||
|
||||
Default exclude patterns
|
||||
- ^/dev/ram\\d+$
|
||||
- ^/dev/zram\\d+$
|
||||
- ^/dev/loop\\d+$
|
||||
- ^/dev/fd\\d+$
|
||||
|
||||
Selection policy
|
||||
- Compile include and exclude regex into [DeviceFilter](src/device/discovery.rs).
|
||||
- Enumerate device candidates and apply:
|
||||
- Must match at least one include.
|
||||
- Must not match any exclude.
|
||||
- Must be larger than min_size_gib (default 10).
|
||||
- Probing
|
||||
- Gather size, rotational flag, model, serial when available.
|
||||
- Expose via [struct Disk](src/device/discovery.rs:1).
|
||||
|
||||
No eligible disks
|
||||
- Return a specific error variant in [enum Error](src/errors.rs:1).
|
||||
|
||||
---
|
||||
|
||||
## 4. Partitioning plan
|
||||
|
||||
Constraints
|
||||
- GPT only; enforce 1 MiB alignment.
|
||||
- Abort immediately if any target disk is non-empty when require_empty_disks is true.
|
||||
|
||||
Layout defaults
|
||||
- BIOS boot: 1 MiB first, role BiosBoot, GPT name zosboot, no filesystem.
|
||||
- ESP: 512 MiB FAT32, GPT name zosboot; filesystem label ZOSBOOT.
|
||||
- Data: remainder, GPT name zosdata.
|
||||
- Cache partitions (only in ssd_hdd_bcachefs): GPT name zoscache on SSD.
|
||||
|
||||
Per-topology specifics
|
||||
- single: All roles on the single disk.
|
||||
- dual_independent: Each disk gets BIOS boot + ESP + data.
|
||||
- ssd_hdd_bcachefs: SSD gets BIOS boot + ESP + zoscache, HDD gets BIOS boot + ESP + zosdata.
|
||||
|
||||
Safety checks
|
||||
- Ensure unique partition UUIDs.
|
||||
- Verify no pre-existing partitions or signatures. Use blkid or similar via [run_cmd_capture](src/util/mod.rs:1).
|
||||
- After partition creation, run udev settle via [udev_settle](src/util/mod.rs:1).
|
||||
|
||||
Application
|
||||
- Utilize sgdisk helpers in [apply_partitions](src/partition/plan.rs:1).
|
||||
|
||||
---
|
||||
|
||||
## 5. Filesystem provisioning strategies
|
||||
|
||||
Kinds
|
||||
- Vfat for ESP, label ZOSBOOT.
|
||||
- Btrfs for data on single and dual_independent.
|
||||
- Bcachefs for ssd_hdd_bcachefs (SSD cache, HDD backing).
|
||||
- All data filesystems use label ZOSDATA.
|
||||
|
||||
Defaults
|
||||
- btrfs: compression zstd:3, raid_profile none unless explicitly set to raid1 in btrfs_raid1 mode.
|
||||
- bcachefs: cache_mode promote, compression zstd, checksum crc32c.
|
||||
- vfat: ESP label ZOSBOOT.
|
||||
|
||||
Planning and execution
|
||||
- Decide mapping of [PartitionResult](src/partition/plan.rs:1) to [FsSpec](src/fs/plan.rs:1) in [plan_filesystems](src/fs/plan.rs:1).
|
||||
- Create filesystems in [make_filesystems](src/fs/plan.rs:1) through wrapped mkfs tools.
|
||||
- Capture resulting identifiers (fs uuid, label) in [FsResult](src/fs/plan.rs:1).
|
||||
|
||||
---
|
||||
|
||||
## 6. Mount scheme and fstab policy
|
||||
|
||||
Scheme
|
||||
- per_uuid under /var/cache: directories named as filesystem UUIDs.
|
||||
|
||||
Mount options
|
||||
- btrfs: ssd when non-rotational underlying device, compress from config, defaults otherwise.
|
||||
- vfat: defaults, utf8.
|
||||
|
||||
fstab
|
||||
- Disabled by default.
|
||||
- When enabled, [maybe_write_fstab](src/mount/ops.rs:1) writes deterministic entries sorted by target path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Idempotency detection
|
||||
|
||||
Signals for already-provisioned system
|
||||
- Expected GPT names found: zosboot, zosdata, and zoscache when applicable.
|
||||
- Filesystems with labels ZOSBOOT for ESP and ZOSDATA for all data filesystems.
|
||||
- When consistent with selected topology, [detect_existing_state](src/idempotency/mod.rs:1) returns a StateReport and orchestrator exits success without changes.
|
||||
|
||||
Disk emptiness
|
||||
- [is_empty_disk](src/idempotency/mod.rs:1) checks for absence of partitions and FS signatures before any modification.
|
||||
|
||||
---
|
||||
|
||||
## 8. CLI flags and help text outline
|
||||
|
||||
Flags mirrored by [struct Cli](src/cli/args.rs:1) parsed via [from_args](src/cli/args.rs:1)
|
||||
- --config PATH
|
||||
- --log-level LEVEL error | warn | info | debug
|
||||
- --log-to-file
|
||||
- --fstab enable fstab generation
|
||||
- --force present but returns unimplemented error
|
||||
|
||||
Kernel cmdline
|
||||
- zosstorage.config= accepts a path or file: URI or data: URL as described in [docs/SCHEMA.md](docs/SCHEMA.md).
|
||||
|
||||
Help text sections
|
||||
- NAME, SYNOPSIS, DESCRIPTION
|
||||
- CONFIG PRECEDENCE
|
||||
- TOPOLOGIES: single, dual_independent, ssd_hdd_bcachefs, btrfs_raid1
|
||||
- SAFETY AND IDEMPOTENCY
|
||||
- REPORTS
|
||||
- EXIT CODES: 0 success or already_provisioned, non-zero on error
|
||||
|
||||
---
|
||||
|
||||
## 9. Integration testing plan (QEMU KVM)
|
||||
|
||||
Scenarios to scaffold in [tests/](tests/)
|
||||
- Single disk 40 GiB virtio: validates single topology end-to-end smoke.
|
||||
- Dual NVMe 40 GiB each: validates dual_independent topology.
|
||||
- SSD NVMe + HDD virtio: validates ssd_hdd_bcachefs topology.
|
||||
- Negative: no eligible disks, or non-empty disk should abort.
|
||||
|
||||
Test strategy
|
||||
- Tests will be staged as integration test scaffolds that compile and document manual steps or automated harness placeholders.
|
||||
- Mocks
|
||||
- Provide a test DeviceProvider to simulate discovery when running without QEMU.
|
||||
- Wrap external tools via utility trait to enable command capture in dry-runs.
|
||||
|
||||
Artifacts to validate
|
||||
- Presence of expected partition GPT names.
|
||||
- Filesystems created with correct labels.
|
||||
- Mountpoints under /var/cache/<UUID> when running in a VM.
|
||||
- JSON report validates against v1 schema.
|
||||
|
||||
---
|
||||
|
||||
## 10. Documentation deliverables
|
||||
|
||||
- [README.md](README.md)
|
||||
- Overview, quickstart, config precedence, example YAML, topology walkthroughs, usage, report format, safety, limitations, roadmap.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- [docs/SCHEMA.md](docs/SCHEMA.md)
|
||||
- [docs/API.md](docs/API.md)
|
||||
- Release notes template and CHANGELOG policy.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build and packaging for static musl and Alpine initramfs
|
||||
|
||||
Rust build
|
||||
- Target: x86_64-unknown-linux-musl
|
||||
- Avoid glibc-only dependencies.
|
||||
|
||||
Binary constraints
|
||||
- No reliance on services; suitable for busybox initramfs.
|
||||
|
||||
Embedding in initramfs
|
||||
- Place the statically linked binary in initramfs.
|
||||
- Ensure required external tools (sgdisk, blkid, mkfs.vfat, mkfs.btrfs, mkfs.bcachefs, udevadm) are present in the same initramfs environment.
|
||||
|
||||
Runtime notes
|
||||
- Minimal stdout use; all status via tracing.
|
||||
- Exit codes:
|
||||
- 0 on success and on already provisioned.
|
||||
- Non-zero on any validation or execution error.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open items carried forward
|
||||
|
||||
- Confirm exact BIOS boot partition requirements across target platforms; currently set to 1 MiB first.
|
||||
- Finalize btrfs and bcachefs tuning defaults after stakeholder review.
|
||||
- Decide if/when to enable fstab generation by default in future.
|
||||
- Allow removable media policies and additional device classes in configuration.
|
||||
|
||||
---
|
||||
|
||||
## 13. Next steps
|
||||
|
||||
- Proceed to code-mode to scaffold modules and types as declared in [docs/API.md](docs/API.md).
|
||||
- Add dependencies via cargo add as listed in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
- Implement bodies with todo!() placeholders and exhaustive doc comments before enabling functional behavior.
|
||||
|
||||
End of specifications.
|
||||
130
docs/VIBE_HOWTO.md
Normal file
130
docs/VIBE_HOWTO.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# VIBE HOWTO — Work Fast Without Re-reading the Tree
|
||||
|
||||
Purpose
|
||||
- Ship features incrementally, with minimal context reload.
|
||||
- Use grep-friendly region markers, a single API index, and clear responsibilities per module.
|
||||
- Keep safety and error conventions explicit and consistent.
|
||||
|
||||
Key Ideas
|
||||
- Contracts live in [docs/API-SKELETONS.md](docs/API-SKELETONS.md).
|
||||
- Every module starts with REGION blocks that act like a 10-second summary.
|
||||
- Architectural decisions go in ADRs under [docs/adr/0001-modular-workflow.md](docs/adr/0001-modular-workflow.md).
|
||||
- Examples live at [config/zosstorage.example.yaml](../config/zosstorage.example.yaml).
|
||||
|
||||
Quickstart
|
||||
- Build:
|
||||
- cargo build
|
||||
- Inspect CLI:
|
||||
- cargo run -- --help
|
||||
- Run with config and debug logs:
|
||||
- cargo run -- --config ./config/zosstorage.example.yaml --log-level debug
|
||||
- Default config file location (initramfs/user space):
|
||||
- /etc/zosstorage/config.yaml (copy from [config/zosstorage.example.yaml](../config/zosstorage.example.yaml))
|
||||
|
||||
What the REGION markers mean
|
||||
- REGION: API — one-liners for public items; grep target
|
||||
- REGION: RESPONSIBILITIES — scope and non-goals
|
||||
- REGION: EXTENSION_POINTS — how to extend without rewriting modules
|
||||
- REGION: SAFETY — invariants; when to abort; idempotency rules
|
||||
- REGION: ERROR_MAPPING — exact mapping to Error variants
|
||||
- REGION: TODO — the next concrete steps to implement
|
||||
|
||||
Where to start (by responsibility)
|
||||
- Entrypoint/binary:
|
||||
- [src/main.rs](../src/main.rs)
|
||||
- CLI parsing:
|
||||
- [src/cli/args.rs](../src/cli/args.rs)
|
||||
- Logging initialization:
|
||||
- [src/logging/mod.rs](../src/logging/mod.rs)
|
||||
- Config load/merge/validate:
|
||||
- [src/config/loader.rs](../src/config/loader.rs)
|
||||
- [src/types.rs](../src/types.rs)
|
||||
- Device discovery:
|
||||
- [src/device/discovery.rs](../src/device/discovery.rs)
|
||||
- Partitioning planning/apply:
|
||||
- [src/partition/plan.rs](../src/partition/plan.rs)
|
||||
- Filesystem planning/create:
|
||||
- [src/fs/plan.rs](../src/fs/plan.rs)
|
||||
- Mount planning/apply + fstab:
|
||||
- [src/mount/ops.rs](../src/mount/ops.rs)
|
||||
- Reporting JSON:
|
||||
- [src/report/state.rs](../src/report/state.rs)
|
||||
- Orchestration:
|
||||
- [src/orchestrator/run.rs](../src/orchestrator/run.rs)
|
||||
- Idempotency/emptiness probes:
|
||||
- [src/idempotency/mod.rs](../src/idempotency/mod.rs)
|
||||
- External tools and shell-out:
|
||||
- [src/util/mod.rs](../src/util/mod.rs)
|
||||
|
||||
Daily workflow (feature implementation)
|
||||
1) Find the module
|
||||
- Open the file and read the REGION header block for a 10-second overview.
|
||||
- If you don’t know where to look, grep by region:
|
||||
- grep -R "REGION: TODO" src/
|
||||
- grep -R "REGION: API" src/
|
||||
|
||||
2) Implement only the declared APIs first
|
||||
- Keep signatures stable (as in [docs/API-SKELETONS.md](docs/API-SKELETONS.md)).
|
||||
- Replace todo!() bodies one by one.
|
||||
- Add safety and error notes under REGION: SAFETY and REGION: ERROR_MAPPING.
|
||||
|
||||
3) Keep docs in sync
|
||||
- If you change a signature, update [docs/API-SKELETONS.md](docs/API-SKELETONS.md).
|
||||
- If behavior or invariants change, update [docs/SPECS.md](docs/SPECS.md).
|
||||
- If it’s an architectural choice, add or edit an ADR under [docs/adr/0001-modular-workflow.md](docs/adr/0001-modular-workflow.md).
|
||||
|
||||
4) Validate and build
|
||||
- cargo build
|
||||
- Use [config/zosstorage.example.yaml](../config/zosstorage.example.yaml) to drive configs.
|
||||
- For VM testing guidance see [PROMPT.md](../PROMPT.md) Testing & Validation.
|
||||
|
||||
Error policy (quick rules)
|
||||
- Map external tool failures to Error::Tool with status and stderr (see [src/errors.rs](../src/errors.rs)).
|
||||
- Validation failures should be Error::Validation with clear messages.
|
||||
- Config IO/parse issues are Error::Config; reporting IO errors are Error::Report.
|
||||
- Use Error::Other(anyhow) sparingly (last-resort wrapping).
|
||||
|
||||
Safety and idempotency rules
|
||||
- Never change a disk that isn’t empty when require_empty_disks=true.
|
||||
- Partitioning must guarantee GPT, 1 MiB alignment, reserved GPT names, and no preexisting signatures.
|
||||
- Filesystems must use reserved labels: ZOSBOOT (ESP) and ZOSDATA (all data filesystems).
|
||||
- Orchestrator must do nothing on already-provisioned systems and exit success.
|
||||
|
||||
Suggested first implementation tasks
|
||||
- Utilities (unblocks everything else)
|
||||
- Implement which_tool, run_cmd, run_cmd_capture, udev_settle in [src/util/mod.rs](../src/util/mod.rs)
|
||||
- Kernel cmdline data: URL support
|
||||
- Add base64 YAML support to config loading in [src/config/loader.rs](../src/config/loader.rs)
|
||||
- Logging init
|
||||
- Implement tracing setup in [src/logging/mod.rs](../src/logging/mod.rs), idempotent setup, optional file target
|
||||
|
||||
VM test matrix (virtio /dev/vd?)
|
||||
- 1 disk (/dev/vda):
|
||||
- single → btrfs on data, label ZOSDATA
|
||||
- 2 disks (/dev/vda, /dev/vdb):
|
||||
- dual_independent → btrfs per disk (two ZOSDATA)
|
||||
- bcachefs cache/backing → /dev/vda cache (SSD-like), /dev/vdb backing (HDD-like), label ZOSDATA
|
||||
- btrfs_raid1 → mirrored btrfs across both, label ZOSDATA
|
||||
- 3 disks (/dev/vda, /dev/vdb, /dev/vdc):
|
||||
- bcachefs → cache on /dev/vda; backing on /dev/vdb and /dev/vdc with two replicas; label ZOSDATA
|
||||
|
||||
Continuity checklist (resume after a break)
|
||||
- Open the target module and read the REGION block.
|
||||
- grep -R "REGION: TODO" src/ to find pending items.
|
||||
- Verify the function signatures in [docs/API-SKELETONS.md](docs/API-SKELETONS.md).
|
||||
- Check recent ADRs under [docs/adr/0001-modular-workflow.md](docs/adr/0001-modular-workflow.md).
|
||||
- Skim [docs/SPECS.md](docs/SPECS.md) for behavior notes and in-progress sections.
|
||||
|
||||
Commit discipline
|
||||
- Small commits per module/functionality.
|
||||
- Keep REGION markers accurate (especially API and TODO).
|
||||
- When changing behavior, update docs and/or ADRs in the same PR.
|
||||
|
||||
Reference index
|
||||
- Architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- Schema: [docs/SCHEMA.md](docs/SCHEMA.md)
|
||||
- API Index: [docs/API-SKELETONS.md](docs/API-SKELETONS.md)
|
||||
- Specs: [docs/SPECS.md](docs/SPECS.md)
|
||||
- Dev Workflow (detailed): [docs/DEV_WORKFLOW.md](docs/DEV_WORKFLOW.md)
|
||||
- ADR: [docs/adr/0001-modular-workflow.md](docs/adr/0001-modular-workflow.md)
|
||||
- Example config: [config/zosstorage.example.yaml](../config/zosstorage.example.yaml)
|
||||
74
docs/adr/0001-modular-workflow.md
Normal file
74
docs/adr/0001-modular-workflow.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# ADR 0001: Modular Development Workflow with Grep-able Contracts and Regions
|
||||
|
||||
Status
|
||||
- Accepted
|
||||
- Date: 2025-09-25
|
||||
|
||||
Context
|
||||
- The project will be developed iteratively with frequent returns and feature additions. We need a workflow that avoids re-reading the entire tree to find context.
|
||||
- APIs should remain stable and discoverable, with a single source of truth for contracts and invariants.
|
||||
- Contributors should be able to quickly grep the repository and see where to implement behavior, extend via hooks, and understand safety/error mapping.
|
||||
|
||||
Decision
|
||||
- Adopt a modular development workflow comprising:
|
||||
1) Contract-first documentation
|
||||
- Keep canonical API declarations in [docs/API-SKELETONS.md](docs/API-SKELETONS.md).
|
||||
- Mirror these contracts in module headers and doc comments.
|
||||
|
||||
2) Grep-friendly REGION markers in source files
|
||||
- Each module (where applicable) starts with compact region markers:
|
||||
- REGION: API — one-liners listing public items and signatures
|
||||
- REGION: RESPONSIBILITIES — scope/non-goals
|
||||
- REGION: EXTENSION_POINTS — how to extend without rewriting
|
||||
- REGION: SAFETY — invariants and safety/idempotency constraints
|
||||
- REGION: ERROR_MAPPING — standard mapping to error types
|
||||
- REGION: TODO — active work or future steps
|
||||
- These markers enable quick discovery via grep without deep reading.
|
||||
|
||||
3) ADRs for architectural decisions
|
||||
- Document significant structural or process choices in docs/adr/.
|
||||
- Cross-link in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) when decisions are refined/superseded.
|
||||
|
||||
4) Examples and fixtures
|
||||
- Maintain comprehensive example configs in config/, beginning with [config/zosstorage.example.yaml](config/zosstorage.example.yaml).
|
||||
|
||||
5) Golden path to resume work
|
||||
- Read module headers, grep REGION markers, consult [docs/API-SKELETONS.md](docs/API-SKELETONS.md) and recent ADRs, and check [docs/SPECS.md](docs/SPECS.md) for in-progress notes.
|
||||
|
||||
Consequences
|
||||
- Pros
|
||||
- Fast onboarding and re-entry; contributors quickly find affected code by grepping markers or function names.
|
||||
- Clear boundaries and invariants reduce coupling across modules.
|
||||
- Documentation remains synchronized with code via consistent, enforceable patterns.
|
||||
|
||||
- Cons
|
||||
- Slight overhead to keep REGION markers and API docs updated.
|
||||
- Requires discipline to maintain concise one-liners and avoid duplication.
|
||||
|
||||
Implementation Notes
|
||||
- Region markers have been added to key modules:
|
||||
- [src/config/loader.rs](src/config/loader.rs)
|
||||
- [src/orchestrator/run.rs](src/orchestrator/run.rs)
|
||||
- [src/cli/args.rs](src/cli/args.rs)
|
||||
- [src/device/discovery.rs](src/device/discovery.rs)
|
||||
- [src/partition/plan.rs](src/partition/plan.rs)
|
||||
- [src/fs/plan.rs](src/fs/plan.rs)
|
||||
- [src/mount/ops.rs](src/mount/ops.rs)
|
||||
- [src/report/state.rs](src/report/state.rs)
|
||||
- Remaining modules will follow the same pattern as needed (e.g., util, idempotency, main/lib if helpful).
|
||||
|
||||
Related Documents
|
||||
- Architecture: [docs/ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
- API Index: [docs/API-SKELETONS.md](../API-SKELETONS.md)
|
||||
- Specs: [docs/SPECS.md](../SPECS.md)
|
||||
- Dev Workflow: [docs/DEV_WORKFLOW.md](../DEV_WORKFLOW.md)
|
||||
- Example Config: [config/zosstorage.example.yaml](../../config/zosstorage.example.yaml)
|
||||
|
||||
Alternatives Considered
|
||||
- Heavy code generation for interface docs — rejected due to complexity and limited incremental value for a small codebase.
|
||||
- Relying solely on doc comments — insufficient discoverability without grep-able structured markers.
|
||||
|
||||
Adoption Plan
|
||||
- Keep REGION markers up to date with each change to public APIs and module scope.
|
||||
- Update [docs/API-SKELETONS.md](../API-SKELETONS.md) when signatures change.
|
||||
- Add new ADRs when we introduce significant architectural adjustments.
|
||||
53
docs/adr/README.md
Normal file
53
docs/adr/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Architectural Decision Records (ADR) Index
|
||||
|
||||
Purpose
|
||||
- Central place to list and navigate all ADRs.
|
||||
- Naming scheme: `NNNN-title.md` (zero-padded sequence).
|
||||
|
||||
Index
|
||||
- [0001: Modular Development Workflow with Grep-able Contracts and Regions](0001-modular-workflow.md)
|
||||
|
||||
Conventions
|
||||
- Location: [docs/adr/](.)
|
||||
- Create new ADRs incrementally with next number (e.g., `0002-<short-title>.md`).
|
||||
- Each ADR should include:
|
||||
- Status (Proposed/Accepted/Deprecated)
|
||||
- Context
|
||||
- Decision
|
||||
- Consequences
|
||||
- Links to related docs (e.g., [docs/ARCHITECTURE.md](../ARCHITECTURE.md), [docs/API-SKELETONS.md](../API-SKELETONS.md))
|
||||
|
||||
Workflow
|
||||
1) Propose an ADR when a design decision impacts architecture or module boundaries.
|
||||
2) Add the ADR file under [docs/adr/](.) with the next sequence number.
|
||||
3) Append the ADR to this index under “Index”.
|
||||
4) Optionally add a short “Architectural Decisions” section to [docs/ARCHITECTURE.md](../ARCHITECTURE.md) summarizing the latest ADRs.
|
||||
|
||||
Template
|
||||
|
||||
```md
|
||||
# ADR NNNN: Title
|
||||
|
||||
Status
|
||||
- Proposed | Accepted | Deprecated
|
||||
- Date: YYYY-MM-DD
|
||||
|
||||
Context
|
||||
- Brief background and forces at play.
|
||||
|
||||
Decision
|
||||
- The choice made; bullet any alternatives considered.
|
||||
|
||||
Consequences
|
||||
- Pros/cons, impacts on modules, tests, build, etc.
|
||||
|
||||
Links
|
||||
- [docs/ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
- Related ADRs
|
||||
```
|
||||
|
||||
Validation ideas (optional future work)
|
||||
- Add a CI step or `cargo xtask adr-check` to verify:
|
||||
- Files match `NNNN-title.md`
|
||||
- All ADRs are listed in this README
|
||||
- Sequence numbers are contiguous
|
||||
121
src/cli/args.rs
Normal file
121
src/cli/args.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// REGION: API
|
||||
// api: cli::LogLevelArg { Error, Warn, Info, Debug }
|
||||
// api: cli::Cli { config: Option<String>, log_level: LogLevelArg, log_to_file: bool, fstab: bool, force: bool }
|
||||
// api: cli::from_args() -> crate::cli::Cli
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Define non-interactive CLI flags mirroring kernel cmdline semantics.
|
||||
// - Provide a stable parsing entry (from_args) suitable for initramfs.
|
||||
// Non-goals: config validation, IO, or side effects beyond parsing.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: add --dry-run to support planning without changes (future).
|
||||
// ext: add --report-path to override JSON report location (future).
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: no interactive prompts; default values are explicit; parsing errors should be clear.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: clap parsing errors are emitted by clap; higher layers should handle exit strategy.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: consider hidden/unstable flags gated by build features for developers.
|
||||
// REGION: TODO-END
|
||||
//! CLI definition mirroring kernel cmdline semantics; non-interactive.
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
#[value(rename_all = "kebab_case")]
|
||||
pub enum LogLevelArg {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LogLevelArg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
LogLevelArg::Error => "error",
|
||||
LogLevelArg::Warn => "warn",
|
||||
LogLevelArg::Info => "info",
|
||||
LogLevelArg::Debug => "debug",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Topology argument (maps to config Topology with snake_case semantics).
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
#[value(rename_all = "kebab_case")]
|
||||
pub enum TopologyArg {
|
||||
Single,
|
||||
DualIndependent,
|
||||
SsdHddBcachefs,
|
||||
BtrfsRaid1,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TopologyArg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
TopologyArg::Single => "single",
|
||||
TopologyArg::DualIndependent => "dual_independent",
|
||||
TopologyArg::SsdHddBcachefs => "ssd_hdd_bcachefs",
|
||||
TopologyArg::BtrfsRaid1 => "btrfs_raid1",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// zosstorage - one-shot disk initializer for initramfs.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "zosstorage", disable_help_subcommand = true)]
|
||||
pub struct Cli {
|
||||
/// Path to YAML configuration (mirrors kernel cmdline key 'zosstorage.config=')
|
||||
#[arg(short = 'c', long = "config")]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// Log level: error, warn, info, debug
|
||||
#[arg(short = 'l', long = "log-level", value_enum, default_value_t = LogLevelArg::Info)]
|
||||
pub log_level: LogLevelArg,
|
||||
|
||||
/// Also log to /run/zosstorage/zosstorage.log
|
||||
#[arg(short = 'L', long = "log-to-file", default_value_t = false)]
|
||||
pub log_to_file: bool,
|
||||
|
||||
/// Enable writing /etc/fstab entries
|
||||
#[arg(short = 's', long = "fstab", default_value_t = false)]
|
||||
pub fstab: bool,
|
||||
|
||||
/// Select topology (overrides config topology)
|
||||
#[arg(short = 't', long = "topology", value_enum)]
|
||||
pub topology: Option<TopologyArg>,
|
||||
|
||||
/// Present but non-functional; returns unimplemented error
|
||||
#[arg(short = 'f', long = "force")]
|
||||
pub force: bool,
|
||||
|
||||
/// Allow removable devices (e.g., USB sticks) to be considered during discovery
|
||||
/// Overrides config.device_selection.allow_removable when provided
|
||||
#[arg(long = "allow-removable", default_value_t = false)]
|
||||
pub allow_removable: bool,
|
||||
|
||||
/// Print detection and planning summary as JSON to stdout (non-default)
|
||||
#[arg(long = "show", default_value_t = false)]
|
||||
pub show: bool,
|
||||
|
||||
/// Write detection/planning JSON report to the given path (overrides config.report.path)
|
||||
#[arg(long = "report")]
|
||||
pub report: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse CLI arguments (non-interactive; suitable for initramfs).
|
||||
pub fn from_args() -> Cli {
|
||||
Cli::parse()
|
||||
}
|
||||
13
src/cli/mod.rs
Normal file
13
src/cli/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! CLI barrel module: re-export concrete args implementation from args.rs
|
||||
//!
|
||||
//! Keeps the API stable while avoiding a large mod.rs. See [src/cli/args.rs](args.rs) for details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: cli::args::*
|
||||
// api: cli::Cli
|
||||
// api: cli::from_args()
|
||||
// REGION: API-END
|
||||
|
||||
pub mod args;
|
||||
|
||||
pub use args::*;
|
||||
401
src/config/loader.rs
Normal file
401
src/config/loader.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
//! Configuration loading, merging, and validation (loader).
|
||||
//!
|
||||
//! Precedence (highest to lowest):
|
||||
//! - Kernel cmdline key `zosstorage.config=`
|
||||
//! - CLI flags
|
||||
//! - On-disk config file at /etc/zosstorage/config.yaml (if present)
|
||||
//! - Built-in defaults
|
||||
//!
|
||||
//! See [docs/SCHEMA.md](../../docs/SCHEMA.md) for the schema details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: config::load_and_merge(cli: &crate::cli::Cli) -> crate::Result<crate::config::types::Config>
|
||||
// api: config::validate(cfg: &crate::config::types::Config) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Load defaults, merge /etc config, optional CLI-referenced YAML, CLI flag overlays,
|
||||
// and kernel cmdline (zosstorage.config=) into a final Config.
|
||||
// - Validate structural and semantic correctness early.
|
||||
// Non-goals: device probing, partitioning, filesystem operations.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: kernel cmdline URI schemes (e.g., http:, data:) can be added here.
|
||||
// ext: alternate default config location via build-time feature or CLI.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: precedence enforced (kernel > CLI flags > CLI --config > /etc file > defaults).
|
||||
// safety: reserved GPT names and labels validated to avoid destructive operations later.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: serde_yaml::Error -> Error::Config
|
||||
// errmap: std::io::Error (file read) -> Error::Config
|
||||
// errmap: serde_json::Error (merge/convert) -> Error::Other(anyhow)
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: consider environment variable overlays if required.
|
||||
// REGION: TODO-END
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{cli::Cli, Error, Result};
|
||||
use crate::types::*;
|
||||
use serde_json::{Map, Value};
|
||||
use base64::Engine as _;
|
||||
|
||||
/// Load defaults, merge on-disk config, overlay CLI, and finally kernel cmdline key.
|
||||
/// Returns a validated Config on success.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Starts from built-in defaults (documented in docs/SCHEMA.md)
|
||||
/// - If /etc/zosstorage/config.yaml exists, merge it
|
||||
/// - If CLI --config is provided, merge that (overrides file defaults)
|
||||
/// - If kernel cmdline provides `zosstorage.config=...`, merge that last (highest precedence)
|
||||
/// - Returns Error::Unimplemented when --force is used
|
||||
pub fn load_and_merge(cli: &Cli) -> Result<Config> {
|
||||
if cli.force {
|
||||
return Err(Error::Unimplemented("--force flag is not implemented"));
|
||||
}
|
||||
|
||||
// 1) Start with defaults
|
||||
let mut merged = to_value(default_config())?;
|
||||
|
||||
// 2) Merge default on-disk config if present
|
||||
let default_cfg_path = "/etc/zosstorage/config.yaml";
|
||||
if Path::new(default_cfg_path).exists() {
|
||||
let v = load_yaml_value(default_cfg_path)?;
|
||||
merge_value(&mut merged, v);
|
||||
}
|
||||
|
||||
// 3) Merge CLI referenced config (if any)
|
||||
if let Some(cfg_path) = &cli.config {
|
||||
let v = load_yaml_value(cfg_path)?;
|
||||
merge_value(&mut merged, v);
|
||||
}
|
||||
|
||||
// 4) Overlay CLI flags (non-path flags)
|
||||
let cli_overlay = cli_overlay_value(cli);
|
||||
merge_value(&mut merged, cli_overlay);
|
||||
|
||||
// 5) Merge kernel cmdline referenced config (if any)
|
||||
if let Some(src) = kernel_cmdline_config_source()? {
|
||||
match src {
|
||||
KernelConfigSource::Path(kpath) => {
|
||||
let v = load_yaml_value(&kpath)?;
|
||||
merge_value(&mut merged, v);
|
||||
}
|
||||
KernelConfigSource::Data(yaml) => {
|
||||
let v: serde_json::Value = serde_yaml::from_str(&yaml)
|
||||
.map_err(|e| Error::Config(format!("failed to parse YAML from data: URL: {}", e)))?;
|
||||
merge_value(&mut merged, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize
|
||||
let cfg: Config = serde_json::from_value(merged).map_err(|e| Error::Other(e.into()))?;
|
||||
validate(&cfg)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Validate semantic correctness of the configuration.
|
||||
pub fn validate(cfg: &Config) -> Result<()> {
|
||||
// Logging
|
||||
match cfg.logging.level.as_str() {
|
||||
"error" | "warn" | "info" | "debug" => {}
|
||||
other => return Err(Error::Validation(format!("invalid logging.level: {other}"))),
|
||||
}
|
||||
|
||||
// Device selection
|
||||
if cfg.device_selection.include_patterns.is_empty() {
|
||||
return Err(Error::Validation(
|
||||
"device_selection.include_patterns must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if cfg.device_selection.min_size_gib == 0 {
|
||||
return Err(Error::Validation(
|
||||
"device_selection.min_size_gib must be >= 1".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Partitioning
|
||||
if cfg.partitioning.alignment_mib < 1 {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.alignment_mib must be >= 1".into(),
|
||||
));
|
||||
}
|
||||
if cfg.partitioning.bios_boot.enabled && cfg.partitioning.bios_boot.size_mib < 1 {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.bios_boot.size_mib must be >= 1 when enabled".into(),
|
||||
));
|
||||
}
|
||||
if cfg.partitioning.esp.size_mib < 1 {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.esp.size_mib must be >= 1".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reserved GPT names
|
||||
if cfg.partitioning.esp.gpt_name != "zosboot" {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.esp.gpt_name must be 'zosboot'".into(),
|
||||
));
|
||||
}
|
||||
if cfg.partitioning.data.gpt_name != "zosdata" {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.data.gpt_name must be 'zosdata'".into(),
|
||||
));
|
||||
}
|
||||
if cfg.partitioning.cache.gpt_name != "zoscache" {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.cache.gpt_name must be 'zoscache'".into(),
|
||||
));
|
||||
}
|
||||
// BIOS boot name is also 'zosboot' per current assumption
|
||||
if cfg.partitioning.bios_boot.gpt_name != "zosboot" {
|
||||
return Err(Error::Validation(
|
||||
"partitioning.bios_boot.gpt_name must be 'zosboot'".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reserved filesystem labels
|
||||
if cfg.filesystem.vfat.label != "ZOSBOOT" {
|
||||
return Err(Error::Validation(
|
||||
"filesystem.vfat.label must be 'ZOSBOOT'".into(),
|
||||
));
|
||||
}
|
||||
if cfg.filesystem.btrfs.label != "ZOSDATA" {
|
||||
return Err(Error::Validation(
|
||||
"filesystem.btrfs.label must be 'ZOSDATA'".into(),
|
||||
));
|
||||
}
|
||||
if cfg.filesystem.bcachefs.label != "ZOSDATA" {
|
||||
return Err(Error::Validation(
|
||||
"filesystem.bcachefs.label must be 'ZOSDATA'".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Mount scheme
|
||||
if cfg.mount.base_dir.trim().is_empty() {
|
||||
return Err(Error::Validation("mount.base_dir must not be empty".into()));
|
||||
}
|
||||
|
||||
// Topology-specific quick checks (basic for now)
|
||||
match cfg.topology {
|
||||
Topology::Single => {} // nothing special
|
||||
Topology::DualIndependent => {}
|
||||
Topology::SsdHddBcachefs => {}
|
||||
Topology::BtrfsRaid1 => {
|
||||
// No enforced requirement here beyond presence of two disks at runtime.
|
||||
if cfg.filesystem.btrfs.raid_profile != "raid1" && cfg.filesystem.btrfs.raid_profile != "none" {
|
||||
return Err(Error::Validation(
|
||||
"filesystem.btrfs.raid_profile must be 'none' or 'raid1'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report path
|
||||
if cfg.report.path.trim().is_empty() {
|
||||
return Err(Error::Validation("report.path must not be empty".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------- helpers -----------------------
|
||||
|
||||
fn to_value<T: serde::Serialize>(t: T) -> Result<Value> {
|
||||
serde_json::to_value(t).map_err(|e| Error::Other(e.into()))
|
||||
}
|
||||
|
||||
fn load_yaml_value(path: &str) -> Result<Value> {
|
||||
let s = fs::read_to_string(path)
|
||||
.map_err(|e| Error::Config(format!("failed to read config file {}: {}", path, e)))?;
|
||||
// Load as generic serde_json::Value for merging flexibility
|
||||
let v: serde_json::Value = serde_yaml::from_str(&s)
|
||||
.map_err(|e| Error::Config(format!("failed to parse YAML {}: {}", path, e)))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Merge b into a in-place:
|
||||
/// - Objects are merged key-by-key (recursively)
|
||||
/// - Arrays and scalars replace
|
||||
fn merge_value(a: &mut Value, b: Value) {
|
||||
match (a, b) {
|
||||
(Value::Object(a_map), Value::Object(b_map)) => {
|
||||
for (k, v) in b_map {
|
||||
match a_map.get_mut(&k) {
|
||||
Some(a_sub) => merge_value(a_sub, v),
|
||||
None => {
|
||||
a_map.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(a_slot, b_other) => {
|
||||
*a_slot = b_other;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a JSON overlay from CLI flags.
|
||||
/// Only sets fields that should override defaults when present.
|
||||
fn cli_overlay_value(cli: &Cli) -> Value {
|
||||
let mut root = Map::new();
|
||||
|
||||
// logging overrides (always overlay CLI values for determinism)
|
||||
let mut logging = Map::new();
|
||||
logging.insert("level".into(), Value::String(cli.log_level.to_string()));
|
||||
logging.insert("to_file".into(), Value::Bool(cli.log_to_file));
|
||||
root.insert("logging".into(), Value::Object(logging));
|
||||
|
||||
// mount.fstab_enabled via --fstab
|
||||
if cli.fstab {
|
||||
let mut mount = Map::new();
|
||||
mount.insert("fstab_enabled".into(), Value::Bool(true));
|
||||
root.insert("mount".into(), Value::Object(mount));
|
||||
}
|
||||
|
||||
// device_selection.allow_removable via --allow-removable
|
||||
if cli.allow_removable {
|
||||
let mut device_selection = Map::new();
|
||||
device_selection.insert("allow_removable".into(), Value::Bool(true));
|
||||
root.insert("device_selection".into(), Value::Object(device_selection));
|
||||
}
|
||||
|
||||
// topology override via --topology
|
||||
if let Some(t) = cli.topology {
|
||||
root.insert("topology".into(), Value::String(t.to_string()));
|
||||
}
|
||||
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
enum KernelConfigSource {
|
||||
Path(String),
|
||||
/// Raw YAML from a data: URL payload after decoding (if base64-encoded).
|
||||
Data(String),
|
||||
}
|
||||
|
||||
/// Resolve a config from kernel cmdline key `zosstorage.config=`.
|
||||
/// Supports:
|
||||
/// - absolute paths (e.g., /run/zos.yaml)
|
||||
/// - file:/absolute/path
|
||||
/// - data:application/x-yaml;base64,BASE64CONTENT
|
||||
/// Returns Ok(None) when key absent.
|
||||
fn kernel_cmdline_config_source() -> Result<Option<KernelConfigSource>> {
|
||||
let cmdline = fs::read_to_string("/proc/cmdline").unwrap_or_default();
|
||||
for token in cmdline.split_whitespace() {
|
||||
if let Some(rest) = token.strip_prefix("zosstorage.config=") {
|
||||
let mut val = rest.to_string();
|
||||
// Trim surrounding quotes if any
|
||||
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
|
||||
val = val[1..val.len() - 1].to_string();
|
||||
}
|
||||
if let Some(path) = val.strip_prefix("file:") {
|
||||
return Ok(Some(KernelConfigSource::Path(path.to_string())));
|
||||
}
|
||||
if let Some(data_url) = val.strip_prefix("data:") {
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
// Find comma separating the header and payload
|
||||
if let Some(idx) = data_url.find(',') {
|
||||
let (header, payload) = data_url.split_at(idx);
|
||||
let payload = &payload[1..]; // skip the comma
|
||||
let is_base64 = header.split(';').any(|seg| seg.eq_ignore_ascii_case("base64"));
|
||||
let yaml = if is_base64 {
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(payload.as_bytes())
|
||||
.map_err(|e| Error::Config(format!("invalid base64 in data: URL: {}", e)))?;
|
||||
String::from_utf8(decoded)
|
||||
.map_err(|e| Error::Config(format!("data: URL payload not UTF-8: {}", e)))?
|
||||
} else {
|
||||
payload.to_string()
|
||||
};
|
||||
return Ok(Some(KernelConfigSource::Data(yaml)));
|
||||
} else {
|
||||
return Err(Error::Config("malformed data: URL (missing comma)".into()));
|
||||
}
|
||||
}
|
||||
// Treat as direct path
|
||||
return Ok(Some(KernelConfigSource::Path(val)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Built-in defaults for the entire configuration (schema version 1).
|
||||
fn default_config() -> Config {
|
||||
Config {
|
||||
version: 1,
|
||||
logging: LoggingConfig {
|
||||
level: "info".into(),
|
||||
to_file: false,
|
||||
},
|
||||
device_selection: DeviceSelection {
|
||||
include_patterns: vec![
|
||||
String::from(r"^/dev/sd\w+$"),
|
||||
String::from(r"^/dev/nvme\w+n\d+$"),
|
||||
String::from(r"^/dev/vd\w+$"),
|
||||
],
|
||||
exclude_patterns: vec![
|
||||
String::from(r"^/dev/ram\d+$"),
|
||||
String::from(r"^/dev/zram\d+$"),
|
||||
String::from(r"^/dev/loop\d+$"),
|
||||
String::from(r"^/dev/fd\d+$"),
|
||||
],
|
||||
allow_removable: false,
|
||||
min_size_gib: 10,
|
||||
},
|
||||
topology: Topology::Single,
|
||||
partitioning: Partitioning {
|
||||
alignment_mib: 1,
|
||||
require_empty_disks: true,
|
||||
bios_boot: BiosBootSpec {
|
||||
enabled: true,
|
||||
size_mib: 1,
|
||||
gpt_name: "zosboot".into(),
|
||||
},
|
||||
esp: EspSpec {
|
||||
size_mib: 512,
|
||||
label: "ZOSBOOT".into(),
|
||||
gpt_name: "zosboot".into(),
|
||||
},
|
||||
data: DataSpec {
|
||||
gpt_name: "zosdata".into(),
|
||||
},
|
||||
cache: CacheSpec {
|
||||
gpt_name: "zoscache".into(),
|
||||
},
|
||||
},
|
||||
filesystem: FsOptions {
|
||||
btrfs: BtrfsOptions {
|
||||
label: "ZOSDATA".into(),
|
||||
compression: "zstd:3".into(),
|
||||
raid_profile: "none".into(),
|
||||
},
|
||||
bcachefs: BcachefsOptions {
|
||||
label: "ZOSDATA".into(),
|
||||
cache_mode: "promote".into(),
|
||||
compression: "zstd".into(),
|
||||
checksum: "crc32c".into(),
|
||||
},
|
||||
vfat: VfatOptions {
|
||||
label: "ZOSBOOT".into(),
|
||||
},
|
||||
},
|
||||
mount: MountScheme {
|
||||
base_dir: "/var/cache".into(),
|
||||
scheme: MountSchemeKind::PerUuid,
|
||||
fstab_enabled: false,
|
||||
},
|
||||
report: ReportOptions {
|
||||
path: "/run/zosstorage/state.json".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
15
src/config/mod.rs
Normal file
15
src/config/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Configuration module barrel.
|
||||
//!
|
||||
//! This module re-exports the config types and the loader/validator so callers
|
||||
//! can `use zosstorage::config::*;` without caring about file layout.
|
||||
//
|
||||
// REGION: API
|
||||
// api: config::types::*
|
||||
// api: config::load_and_merge(cli: &crate::cli::Cli) -> crate::Result<crate::config::types::Config>
|
||||
// api: config::validate(cfg: &crate::config::types::Config) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
|
||||
pub mod loader;
|
||||
|
||||
pub use loader::{load_and_merge, validate};
|
||||
pub use crate::types::*;
|
||||
366
src/device/discovery.rs
Normal file
366
src/device/discovery.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
// REGION: API
|
||||
// api: device::Disk { path: String, size_bytes: u64, rotational: bool, model: Option<String>, serial: Option<String> }
|
||||
// api: device::DeviceFilter { include: Vec<regex::Regex>, exclude: Vec<regex::Regex>, min_size_gib: u64 }
|
||||
// api: device::DeviceProvider::list_block_devices(&self) -> crate::Result<Vec<Disk>>
|
||||
// api: device::DeviceProvider::probe_properties(&self, disk: &mut Disk) -> crate::Result<()>
|
||||
// api: device::discover(filter: &DeviceFilter) -> crate::Result<Vec<Disk>>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Enumerate candidate block devices under /dev.
|
||||
// - Filter using include/exclude regex and minimum size threshold.
|
||||
// - Probe device properties (size, rotational, model, serial).
|
||||
// Non-goals: partitioning, mkfs, or mounting.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: pluggable DeviceProvider to allow mocking in tests and alternative discovery backends.
|
||||
// ext: future allowlist policies for removable media, device classes, or path patterns.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must not modify devices; read-only probing only.
|
||||
// safety: ensure pseudodevices (/dev/ram*, /dev/zram*, /dev/loop*, /dev/fd*, /dev/dm-*, /dev/md*) are excluded by default.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: IO and parsing errors -> crate::Error::Device with context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//! Device discovery and filtering for zosstorage.
|
||||
//!
|
||||
//! Exposes abstractions to enumerate and filter block devices under /dev,
|
||||
//! with compiled include/exclude regexes and size thresholds.
|
||||
//!
|
||||
//! See device::Disk and device::discover.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::{Error, Result};
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Eligible block device discovered on the system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Disk {
|
||||
/// Absolute device path (e.g., "/dev/nvme0n1").
|
||||
pub path: String,
|
||||
/// Device size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// True for spinning disks; false for SSD/NVMe when detectable.
|
||||
pub rotational: bool,
|
||||
/// Optional model string (if available).
|
||||
pub model: Option<String>,
|
||||
/// Optional serial string (if available).
|
||||
pub serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiled device filters derived from configuration patterns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceFilter {
|
||||
/// Inclusion regexes (any match qualifies). If empty, default include any.
|
||||
pub include: Vec<Regex>,
|
||||
/// Exclusion regexes (any match disqualifies).
|
||||
pub exclude: Vec<Regex>,
|
||||
/// Minimum size in GiB to consider eligible.
|
||||
pub min_size_gib: u64,
|
||||
/// Allow removable devices (e.g., USB sticks). Default false.
|
||||
pub allow_removable: bool,
|
||||
}
|
||||
|
||||
impl DeviceFilter {
|
||||
fn matches(&self, dev_path: &str, size_bytes: u64) -> bool {
|
||||
// size filter
|
||||
let size_gib = size_bytes as f64 / 1073741824.0;
|
||||
if size_gib < self.min_size_gib as f64 {
|
||||
return false;
|
||||
}
|
||||
// include filter
|
||||
if !self.include.is_empty() {
|
||||
if !self.include.iter().any(|re| re.is_match(dev_path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// exclude filter
|
||||
if self.exclude.iter().any(|re| re.is_match(dev_path)) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract provider to enable testing without real /dev access.
|
||||
pub trait DeviceProvider {
|
||||
/// List candidate block devices (whole disks only; not partitions).
|
||||
fn list_block_devices(&self) -> Result<Vec<Disk>>;
|
||||
/// Probe and update additional properties for a disk.
|
||||
fn probe_properties(&self, _disk: &mut Disk) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// System-backed provider using /proc and /sys for discovery.
|
||||
struct SysProvider;
|
||||
|
||||
impl SysProvider {
|
||||
fn new() -> Self {
|
||||
SysProvider
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceProvider for SysProvider {
|
||||
fn list_block_devices(&self) -> Result<Vec<Disk>> {
|
||||
let mut disks = Vec::new();
|
||||
|
||||
let content = fs::read_to_string("/proc/partitions")
|
||||
.map_err(|e| Error::Device(format!("/proc/partitions read error: {}", e)))?;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("major") {
|
||||
continue;
|
||||
}
|
||||
// Format: major minor #blocks name
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let name = parts[3];
|
||||
|
||||
// Exclude common pseudo and virtual device names
|
||||
if is_ignored_name(name) {
|
||||
trace!("skipping pseudo/ignored device: {}", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip partitions; we want whole-disk devices only
|
||||
if is_partition_sysfs(name) {
|
||||
trace!("skipping partition device: {}", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure /dev node exists
|
||||
let dev_path = format!("/dev/{}", name);
|
||||
if !Path::new(&dev_path).exists() {
|
||||
trace!("skipping: missing device node {}", dev_path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read size in 512-byte sectors from sysfs, then convert to bytes
|
||||
let size_bytes = match read_disk_size_bytes(name) {
|
||||
Ok(sz) => sz,
|
||||
Err(e) => {
|
||||
warn!("failed to read size for {}: {}", name, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let rotational = read_rotational(name).unwrap_or(false);
|
||||
let (model, serial) = read_model_serial(name);
|
||||
|
||||
let disk = Disk {
|
||||
path: dev_path,
|
||||
size_bytes,
|
||||
rotational,
|
||||
model,
|
||||
serial,
|
||||
};
|
||||
disks.push(disk);
|
||||
}
|
||||
|
||||
Ok(disks)
|
||||
}
|
||||
|
||||
fn probe_properties(&self, _disk: &mut Disk) -> Result<()> {
|
||||
// Properties are filled during enumeration above.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover eligible disks according to the filter policy.
|
||||
///
|
||||
/// Returns Error::Device when no eligible disks are found.
|
||||
pub fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>> {
|
||||
let provider = SysProvider::new();
|
||||
discover_with_provider(&provider, filter)
|
||||
}
|
||||
|
||||
fn discover_with_provider<P: DeviceProvider>(provider: &P, filter: &DeviceFilter) -> Result<Vec<Disk>> {
|
||||
let mut candidates = provider.list_block_devices()?;
|
||||
// Probe properties if provider needs to enrich
|
||||
for d in &mut candidates {
|
||||
provider.probe_properties(d)?;
|
||||
}
|
||||
|
||||
// Apply filters (including removable policy)
|
||||
let filtered: Vec<Disk> = candidates
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
if !filter.allow_removable {
|
||||
if let Some(name) = base_name(&d.path) {
|
||||
if is_removable_sysfs(&name).unwrap_or(false) {
|
||||
trace!("excluding removable device by policy: {}", d.path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
filter.matches(&d.path, d.size_bytes)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return Err(Error::Device("no eligible disks found after applying filters".to_string()));
|
||||
}
|
||||
|
||||
debug!("eligible disks: {:?}", filtered.iter().map(|d| &d.path).collect::<Vec<_>>());
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Sysfs helper functions
|
||||
// =========================
|
||||
|
||||
fn is_ignored_name(name: &str) -> bool {
|
||||
// Pseudo and virtual device common patterns
|
||||
name.starts_with("loop")
|
||||
|| name.starts_with("ram")
|
||||
|| name.starts_with("zram")
|
||||
|| name.starts_with("fd")
|
||||
|| name.starts_with("dm-")
|
||||
|| name.starts_with("md")
|
||||
|| name.starts_with("sr")
|
||||
}
|
||||
|
||||
fn sys_block_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("/sys/class/block/{}", name))
|
||||
}
|
||||
|
||||
fn base_name(dev_path: &str) -> Option<String> {
|
||||
Path::new(dev_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Returns Ok(true) if /sys/class/block/<name>/removable == "1"
|
||||
fn is_removable_sysfs(name: &str) -> Result<bool> {
|
||||
let p = sys_block_path(name).join("removable");
|
||||
let s = fs::read_to_string(&p)
|
||||
.map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?;
|
||||
Ok(s.trim() == "1")
|
||||
}
|
||||
|
||||
fn is_partition_sysfs(name: &str) -> bool {
|
||||
let p = sys_block_path(name).join("partition");
|
||||
p.exists()
|
||||
}
|
||||
|
||||
fn read_disk_size_bytes(name: &str) -> Result<u64> {
|
||||
let p = sys_block_path(name).join("size");
|
||||
let sectors = fs::read_to_string(&p)
|
||||
.map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?;
|
||||
let sectors: u64 = sectors.trim().parse().map_err(|e| {
|
||||
Error::Device(format!("parse sectors for {} failed: {}", name, e))
|
||||
})?;
|
||||
Ok(sectors.saturating_mul(512))
|
||||
}
|
||||
|
||||
fn read_rotational(name: &str) -> Result<bool> {
|
||||
let p = sys_block_path(name).join("queue/rotational");
|
||||
let s = fs::read_to_string(&p)
|
||||
.map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?;
|
||||
Ok(s.trim() == "1")
|
||||
}
|
||||
|
||||
fn read_model_serial(name: &str) -> (Option<String>, Option<String>) {
|
||||
let base = sys_block_path(name).join("device");
|
||||
let model = read_optional_string(base.join("model"));
|
||||
// Some devices expose "vendor" + "model"; if model missing, try "device/model" anyway
|
||||
let serial = read_optional_string(base.join("serial"));
|
||||
(model, serial)
|
||||
}
|
||||
|
||||
fn read_optional_string(p: PathBuf) -> Option<String> {
|
||||
match fs::read_to_string(&p) {
|
||||
Ok(mut s) => {
|
||||
// Trim trailing newline/spaces
|
||||
while s.ends_with('\n') || s.ends_with('\r') {
|
||||
s.pop();
|
||||
}
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Tests (mock provider)
|
||||
// =========================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use regex::Regex;
|
||||
|
||||
struct MockProvider {
|
||||
disks: Vec<Disk>,
|
||||
}
|
||||
|
||||
impl DeviceProvider for MockProvider {
|
||||
fn list_block_devices(&self) -> Result<Vec<Disk>> {
|
||||
Ok(self.disks.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn re(s: &str) -> Regex {
|
||||
Regex::new(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_size_and_include_exclude() {
|
||||
let provider = MockProvider {
|
||||
disks: vec![
|
||||
Disk { path: "/dev/sda".into(), size_bytes: 500 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None }, // 500 GiB
|
||||
Disk { path: "/dev/nvme0n1".into(), size_bytes: 128 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None }, // 128 GiB
|
||||
Disk { path: "/dev/loop0".into(), size_bytes: 8 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None }, // 8 GiB pseudo (but mock provider supplies it)
|
||||
],
|
||||
};
|
||||
|
||||
let filter = DeviceFilter {
|
||||
include: vec![re(r"^/dev/(sd|nvme)")],
|
||||
exclude: vec![re(r"/dev/loop")],
|
||||
min_size_gib: 200, // >= 200 GiB
|
||||
allow_removable: true,
|
||||
};
|
||||
|
||||
let out = discover_with_provider(&provider, &filter).expect("discover ok");
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].path, "/dev/sda");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_returns_error() {
|
||||
let provider = MockProvider {
|
||||
disks: vec![
|
||||
Disk { path: "/dev/sdb".into(), size_bytes: 50 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None }, // 50 GiB
|
||||
],
|
||||
};
|
||||
|
||||
let filter = DeviceFilter {
|
||||
include: vec![re(r"^/dev/nvme")],
|
||||
exclude: vec![],
|
||||
min_size_gib: 200,
|
||||
allow_removable: true,
|
||||
};
|
||||
|
||||
let err = discover_with_provider(&provider, &filter).unwrap_err();
|
||||
match err {
|
||||
Error::Device(msg) => assert!(msg.contains("no eligible disks")),
|
||||
other => panic!("unexpected error: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/device/mod.rs
Normal file
12
src/device/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Device module barrel.
|
||||
//!
|
||||
//! Re-exports the concrete discovery implementation from discovery.rs to avoid a large mod.rs.
|
||||
//! See [src/device/discovery.rs](discovery.rs) for details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: device::discovery::*
|
||||
// REGION: API-END
|
||||
|
||||
pub mod discovery;
|
||||
|
||||
pub use discovery::*;
|
||||
56
src/errors.rs
Normal file
56
src/errors.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Common error types and result alias for zosstorage.
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
/// Top-level error for zosstorage covering configuration, validation,
|
||||
/// device discovery, partitioning, filesystem, mounting, reporting,
|
||||
/// and external tool invocation failures.
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
/// Invalid or malformed configuration input.
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Semantic validation failure.
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
/// Errors related to device discovery and probing.
|
||||
#[error("device discovery error: {0}")]
|
||||
Device(String),
|
||||
|
||||
/// Partitioning or GPT manipulation failures.
|
||||
#[error("partitioning error: {0}")]
|
||||
Partition(String),
|
||||
|
||||
/// Filesystem creation or probing failures.
|
||||
#[error("filesystem error: {0}")]
|
||||
Filesystem(String),
|
||||
|
||||
/// Mount operation failures.
|
||||
#[error("mount error: {0}")]
|
||||
Mount(String),
|
||||
|
||||
/// State report construction or write failures.
|
||||
#[error("report error: {0}")]
|
||||
Report(String),
|
||||
|
||||
/// External system tool invocation failure with captured stderr.
|
||||
#[error("external tool '{tool}' failed with status {status}: {stderr}")]
|
||||
Tool {
|
||||
tool: String,
|
||||
status: i32,
|
||||
stderr: String,
|
||||
},
|
||||
|
||||
/// Placeholder for not-yet-implemented functionality (e.g., --force).
|
||||
#[error("unimplemented: {0}")]
|
||||
Unimplemented(&'static str),
|
||||
|
||||
/// Any other error wrapped with context.
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
12
src/fs/mod.rs
Normal file
12
src/fs/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Filesystem module barrel.
|
||||
//!
|
||||
//! Re-exports the concrete planning/creation implementation from plan.rs to avoid a large mod.rs.
|
||||
//! See [src/fs/plan.rs](plan.rs) for details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: fs::plan::*
|
||||
// REGION: API-END
|
||||
|
||||
pub mod plan;
|
||||
|
||||
pub use plan::*;
|
||||
308
src/fs/plan.rs
Normal file
308
src/fs/plan.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
// REGION: API
|
||||
// api: fs::FsKind { Vfat, Btrfs, Bcachefs }
|
||||
// api: fs::FsSpec { kind: FsKind, devices: Vec<String>, label: String }
|
||||
// api: fs::FsPlan { specs: Vec<FsSpec> }
|
||||
// api: fs::FsResult { kind: FsKind, devices: Vec<String>, uuid: String, label: String }
|
||||
// api: fs::plan_filesystems(parts: &[crate::partition::PartitionResult], cfg: &crate::config::types::Config) -> crate::Result<FsPlan>
|
||||
// api: fs::make_filesystems(plan: &FsPlan) -> crate::Result<Vec<FsResult>>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Map partition roles to concrete filesystems (vfat for ESP, btrfs for data, bcachefs for SSD+HDD).
|
||||
// - Execute mkfs operations via external tooling wrappers and capture resulting UUIDs/labels.
|
||||
// Non-goals: partition layout decisions, mount orchestration, device discovery.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: support additional filesystems or tuning flags through Config (e.g., more btrfs/bcachefs options).
|
||||
// ext: dry-run mode to emit mkfs commands without executing (future).
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced.
|
||||
// safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS).
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: external mkfs/blkid failures -> crate::Error::Tool with captured stderr.
|
||||
// errmap: planning mismatches -> crate::Error::Filesystem with context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement mapping of topology to FsSpec including bcachefs cache/backing composition.
|
||||
// todo: implement mkfs invocation and UUID capture via util::run_cmd / util::run_cmd_capture.
|
||||
// REGION: TODO-END
|
||||
//! Filesystem planning and creation for zosstorage.
|
||||
//!
|
||||
//! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs)
|
||||
//! and executes mkfs operations via external tooling wrappers.
|
||||
//!
|
||||
//! See [fn plan_filesystems](plan.rs:1) and
|
||||
//! [fn make_filesystems](plan.rs:1).
|
||||
|
||||
use crate::{
|
||||
Result,
|
||||
types::{Config, Topology},
|
||||
partition::{PartitionResult, PartRole},
|
||||
util::{run_cmd, run_cmd_capture, which_tool},
|
||||
Error,
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Filesystem kinds supported by zosstorage.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FsKind {
|
||||
/// FAT32 for the EFI System Partition.
|
||||
Vfat,
|
||||
/// Btrfs for data filesystems in single/dual topologies.
|
||||
Btrfs,
|
||||
/// Bcachefs for SSD+HDD topology (SSD as cache/promote, HDD backing).
|
||||
Bcachefs,
|
||||
}
|
||||
|
||||
/// Declarative specification for creating a filesystem.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsSpec {
|
||||
/// Filesystem kind.
|
||||
pub kind: FsKind,
|
||||
/// Source device(s):
|
||||
/// - single path for vfat and btrfs
|
||||
/// - two paths for bcachefs (cache + backing)
|
||||
pub devices: Vec<String>,
|
||||
/// Filesystem label:
|
||||
/// - "ZOSBOOT" for ESP
|
||||
/// - "ZOSDATA" for all data filesystems
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Plan of filesystem creations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPlan {
|
||||
/// All filesystem creation specs.
|
||||
pub specs: Vec<FsSpec>,
|
||||
}
|
||||
|
||||
/// Result of creating a filesystem.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsResult {
|
||||
/// Filesystem kind.
|
||||
pub kind: FsKind,
|
||||
/// Devices the filesystem was created on.
|
||||
pub devices: Vec<String>,
|
||||
/// Filesystem UUID (string as reported by blkid or related).
|
||||
pub uuid: String,
|
||||
/// Filesystem label ("ZOSBOOT" or "ZOSDATA").
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/**
|
||||
Determine which partitions get which filesystem based on topology.
|
||||
|
||||
Rules:
|
||||
- ESP partitions => Vfat with label from cfg.filesystem.vfat.label (reserved "ZOSBOOT")
|
||||
- Data partitions => Btrfs with label cfg.filesystem.btrfs.label ("ZOSDATA"), unless topology SsdHddBcachefs
|
||||
- SsdHddBcachefs => pair one Cache partition (SSD) with one Data partition (HDD) into one Bcachefs FsSpec with devices [cache, data] and label cfg.filesystem.bcachefs.label ("ZOSDATA")
|
||||
- DualIndependent/BtrfsRaid1 => map each Data partition to its own Btrfs FsSpec (raid profile concerns are handled later during mkfs)
|
||||
*/
|
||||
pub fn plan_filesystems(
|
||||
parts: &[PartitionResult],
|
||||
cfg: &Config,
|
||||
) -> Result<FsPlan> {
|
||||
let mut specs: Vec<FsSpec> = Vec::new();
|
||||
|
||||
// Always map ESP partitions
|
||||
for p in parts.iter().filter(|p| matches!(p.role, PartRole::Esp)) {
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Vfat,
|
||||
devices: vec![p.device_path.clone()],
|
||||
label: cfg.filesystem.vfat.label.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
match cfg.topology {
|
||||
Topology::SsdHddBcachefs => {
|
||||
// Expect exactly one cache (SSD) and at least one data (HDD). Use the first data for pairing.
|
||||
let cache = parts.iter().find(|p| matches!(p.role, PartRole::Cache))
|
||||
.ok_or_else(|| Error::Filesystem("expected a Cache partition for SsdHddBcachefs topology".to_string()))?;
|
||||
let data = parts.iter().find(|p| matches!(p.role, PartRole::Data))
|
||||
.ok_or_else(|| Error::Filesystem("expected a Data partition for SsdHddBcachefs topology".to_string()))?;
|
||||
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Bcachefs,
|
||||
devices: vec![cache.device_path.clone(), data.device_path.clone()],
|
||||
label: cfg.filesystem.bcachefs.label.clone(),
|
||||
});
|
||||
}
|
||||
Topology::BtrfsRaid1 => {
|
||||
// Group all Data partitions into a single Btrfs filesystem across multiple devices.
|
||||
let data_devs: Vec<String> = parts
|
||||
.iter()
|
||||
.filter(|p| matches!(p.role, PartRole::Data))
|
||||
.map(|p| p.device_path.clone())
|
||||
.collect();
|
||||
|
||||
if data_devs.len() < 2 {
|
||||
return Err(Error::Filesystem(
|
||||
"BtrfsRaid1 topology requires at least 2 data partitions".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Btrfs,
|
||||
devices: data_devs,
|
||||
label: cfg.filesystem.btrfs.label.clone(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Map each Data partition to individual Btrfs filesystems.
|
||||
for p in parts.iter().filter(|p| matches!(p.role, PartRole::Data)) {
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Btrfs,
|
||||
devices: vec![p.device_path.clone()],
|
||||
label: cfg.filesystem.btrfs.label.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if specs.is_empty() {
|
||||
return Err(Error::Filesystem("no filesystems to create from provided partitions".to_string()));
|
||||
}
|
||||
|
||||
Ok(FsPlan { specs })
|
||||
}
|
||||
|
||||
/// Create the filesystems and return identity info (UUIDs, labels).
|
||||
///
|
||||
//// Uses external tooling via util wrappers (mkfs.vfat, mkfs.btrfs, bcachefs format).
|
||||
/// Notes:
|
||||
/// - This initial implementation applies labels and creates filesystems with minimal flags.
|
||||
/// - Btrfs RAID profile (e.g., raid1) will be applied in a follow-up by mapping config to mkfs flags.
|
||||
/// - UUID is captured via blkid -o export on the first device of each spec.
|
||||
pub fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>> {
|
||||
// Discover required tools up-front
|
||||
let vfat_tool = which_tool("mkfs.vfat")?;
|
||||
let btrfs_tool = which_tool("mkfs.btrfs")?;
|
||||
let bcachefs_tool = which_tool("bcachefs")?;
|
||||
let blkid_tool = which_tool("blkid")?;
|
||||
|
||||
if blkid_tool.is_none() {
|
||||
return Err(Error::Filesystem("blkid not found in PATH; cannot capture filesystem UUIDs".into()));
|
||||
}
|
||||
let blkid = blkid_tool.unwrap();
|
||||
|
||||
let mut results: Vec<FsResult> = Vec::new();
|
||||
|
||||
for spec in &plan.specs {
|
||||
match spec.kind {
|
||||
FsKind::Vfat => {
|
||||
let Some(ref mkfs) = vfat_tool else {
|
||||
return Err(Error::Filesystem("mkfs.vfat not found in PATH".into()));
|
||||
};
|
||||
if spec.devices.len() != 1 {
|
||||
return Err(Error::Filesystem("vfat requires exactly one device".into()));
|
||||
}
|
||||
let dev = &spec.devices[0];
|
||||
// mkfs.vfat -n LABEL /dev/...
|
||||
run_cmd(&[mkfs.as_str(), "-n", spec.label.as_str(), dev.as_str()])?;
|
||||
|
||||
// Capture UUID
|
||||
let uuid = capture_uuid(&blkid, dev)?;
|
||||
results.push(FsResult {
|
||||
kind: FsKind::Vfat,
|
||||
devices: vec![dev.clone()],
|
||||
uuid,
|
||||
label: spec.label.clone(),
|
||||
});
|
||||
}
|
||||
FsKind::Btrfs => {
|
||||
let Some(ref mkfs) = btrfs_tool else {
|
||||
return Err(Error::Filesystem("mkfs.btrfs not found in PATH".into()));
|
||||
};
|
||||
if spec.devices.is_empty() {
|
||||
return Err(Error::Filesystem("btrfs requires at least one device".into()));
|
||||
}
|
||||
// mkfs.btrfs -L LABEL dev1 [dev2 ...]
|
||||
let mut args: Vec<String> = vec![mkfs.clone(), "-L".into(), spec.label.clone()];
|
||||
args.extend(spec.devices.iter().cloned());
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_cmd(&args_ref)?;
|
||||
|
||||
// Capture UUID from the first device
|
||||
let dev0 = &spec.devices[0];
|
||||
let uuid = capture_uuid(&blkid, dev0)?;
|
||||
results.push(FsResult {
|
||||
kind: FsKind::Btrfs,
|
||||
devices: spec.devices.clone(),
|
||||
uuid,
|
||||
label: spec.label.clone(),
|
||||
});
|
||||
}
|
||||
FsKind::Bcachefs => {
|
||||
let Some(ref mkfs) = bcachefs_tool else {
|
||||
return Err(Error::Filesystem("bcachefs not found in PATH".into()));
|
||||
};
|
||||
if spec.devices.len() < 2 {
|
||||
return Err(Error::Filesystem("bcachefs requires at least two devices (cache + backing)".into()));
|
||||
}
|
||||
// bcachefs format --label LABEL dev_cache dev_backing ...
|
||||
let mut args: Vec<String> = vec![mkfs.clone(), "format".into(), "--label".into(), spec.label.clone()];
|
||||
args.extend(spec.devices.iter().cloned());
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_cmd(&args_ref)?;
|
||||
|
||||
// Capture UUID from the first device
|
||||
let dev0 = &spec.devices[0];
|
||||
let uuid = capture_uuid(&blkid, dev0)?;
|
||||
results.push(FsResult {
|
||||
kind: FsKind::Bcachefs,
|
||||
devices: spec.devices.clone(),
|
||||
uuid,
|
||||
label: spec.label.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("make_filesystems: created {} filesystems", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn capture_uuid(blkid: &str, dev: &str) -> Result<String> {
|
||||
// blkid -o export /dev/...
|
||||
let out = run_cmd_capture(&[blkid, "-o", "export", dev])?;
|
||||
let map = parse_blkid_export(&out.stdout);
|
||||
// Prefer ID_FS_UUID if present, fall back to UUID
|
||||
if let Some(u) = map.get("ID_FS_UUID") {
|
||||
return Ok(u.clone());
|
||||
}
|
||||
if let Some(u) = map.get("UUID") {
|
||||
return Ok(u.clone());
|
||||
}
|
||||
warn!("blkid did not report UUID for {}", dev);
|
||||
Err(Error::Filesystem(format!("missing UUID in blkid output for {}", dev)))
|
||||
}
|
||||
|
||||
/// Minimal parser for blkid -o export KEY=VAL lines.
|
||||
fn parse_blkid_export(s: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for line in s.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_parse {
|
||||
use super::parse_blkid_export;
|
||||
|
||||
#[test]
|
||||
fn parse_export_ok() {
|
||||
let s = "ID_FS_UUID=abcd-1234\nUUID=abcd-1234\nTYPE=btrfs\n";
|
||||
let m = parse_blkid_export(s);
|
||||
assert_eq!(m.get("ID_FS_UUID").unwrap(), "abcd-1234");
|
||||
assert_eq!(m.get("TYPE").unwrap(), "btrfs");
|
||||
}
|
||||
}
|
||||
284
src/idempotency/mod.rs
Normal file
284
src/idempotency/mod.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
// REGION: API
|
||||
// api: idempotency::detect_existing_state() -> crate::Result<Option<crate::report::StateReport>>
|
||||
// api: idempotency::is_empty_disk(disk: &crate::device::Disk) -> crate::Result<bool>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Detect whether the system is already provisioned by probing GPT names and filesystem labels.
|
||||
// - Provide safe emptiness checks for target disks before any destructive operations.
|
||||
// Non-goals: performing changes; this module only inspects state.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: add heuristics for partial provisioning detection with guided remediation (future).
|
||||
// ext: support caching previous successful run state to speed up detection.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: reads only; must not write to devices. Use blkid and partition table reads where possible.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: probing errors -> crate::Error::Device or crate::Error::Other(anyhow) with context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//! Idempotency detection and disk emptiness probes.
|
||||
//!
|
||||
//! Provides helpers to detect whether the system is already provisioned
|
||||
//! (based on GPT names and filesystem labels), and to verify that target
|
||||
//! disks are empty before making any destructive changes.
|
||||
|
||||
use crate::{
|
||||
device::Disk,
|
||||
report::{StateReport, REPORT_VERSION},
|
||||
util::{run_cmd_capture, which_tool},
|
||||
Error, Result,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use humantime::format_rfc3339;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Return existing state if system is already provisioned; otherwise None.
|
||||
///
|
||||
/// Signals for provisioned state:
|
||||
/// - Expected GPT partition names present: "zosboot", "zosdata", and optional "zoscache"
|
||||
/// - Filesystem labels present: "ZOSBOOT" for ESP, "ZOSDATA" for data filesystems
|
||||
///
|
||||
/// Implementation notes:
|
||||
/// - Uses blkid -o export on discovered device nodes from /proc/partitions.
|
||||
/// - Missing blkid results in Ok(None) (cannot detect safely).
|
||||
pub fn detect_existing_state() -> Result<Option<StateReport>> {
|
||||
let Some(blkid) = which_tool("blkid")? else {
|
||||
warn!("blkid not found; skipping idempotency detection (assuming not provisioned)");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let names = read_proc_partitions_names()?;
|
||||
if names.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut partlabel_hits: Vec<serde_json::Value> = Vec::new();
|
||||
let mut fslabel_hits: Vec<serde_json::Value> = Vec::new();
|
||||
let mut saw_partlabel_zosboot = false;
|
||||
let mut saw_partlabel_zosdata = false;
|
||||
let mut saw_partlabel_zoscache = false;
|
||||
let mut saw_label_zosboot = false;
|
||||
let mut saw_label_zosdata = false;
|
||||
|
||||
for name in names {
|
||||
let dev_path = format!("/dev/{}", name);
|
||||
let args = [blkid.as_str(), "-o", "export", dev_path.as_str()];
|
||||
let map_opt = match run_cmd_capture(&args) {
|
||||
Ok(out) => Some(parse_blkid_export(&out.stdout)),
|
||||
Err(Error::Tool { status, .. }) if status != 0 => {
|
||||
// Typical when device has no recognizable signature; ignore.
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
// Unexpected failure; log and continue.
|
||||
warn!("blkid failed on {}: {:?}", dev_path, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(map) = map_opt {
|
||||
if let Some(pl) = map.get("PARTLABEL") {
|
||||
let pl_lc = pl.to_ascii_lowercase();
|
||||
if pl_lc == "zosboot" {
|
||||
saw_partlabel_zosboot = true;
|
||||
partlabel_hits.push(json!({ "device": dev_path, "partlabel": pl }));
|
||||
} else if pl_lc == "zosdata" {
|
||||
saw_partlabel_zosdata = true;
|
||||
partlabel_hits.push(json!({ "device": dev_path, "partlabel": pl }));
|
||||
} else if pl_lc == "zoscache" {
|
||||
saw_partlabel_zoscache = true;
|
||||
partlabel_hits.push(json!({ "device": dev_path, "partlabel": pl }));
|
||||
}
|
||||
}
|
||||
if let Some(lbl) = map.get("LABEL") {
|
||||
if lbl == "ZOSBOOT" {
|
||||
saw_label_zosboot = true;
|
||||
fslabel_hits.push(json!({ "device": dev_path, "label": lbl }));
|
||||
} else if lbl == "ZOSDATA" {
|
||||
saw_label_zosdata = true;
|
||||
fslabel_hits.push(json!({ "device": dev_path, "label": lbl }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consider provisioned when we see both boot and data signals.
|
||||
let boot_ok = saw_partlabel_zosboot || saw_label_zosboot;
|
||||
let data_ok = saw_partlabel_zosdata || saw_label_zosdata;
|
||||
|
||||
if boot_ok && data_ok {
|
||||
let ts = format_rfc3339(std::time::SystemTime::now()).to_string();
|
||||
let report = StateReport {
|
||||
version: REPORT_VERSION.to_string(),
|
||||
timestamp: ts,
|
||||
status: "already_provisioned".to_string(),
|
||||
disks: vec![], // can be enriched later
|
||||
partitions: partlabel_hits,
|
||||
filesystems: fslabel_hits,
|
||||
mounts: vec![],
|
||||
error: None,
|
||||
};
|
||||
debug!(
|
||||
"idempotency: already provisioned (boot_ok={}, data_ok={}, cache={})",
|
||||
boot_ok, data_ok, saw_partlabel_zoscache
|
||||
);
|
||||
return Ok(Some(report));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Determine if a disk is empty (no partitions and no known filesystem signatures).
|
||||
///
|
||||
/// Algorithm:
|
||||
/// - Parse /proc/partitions for any child partitions of the base device name.
|
||||
/// - Probe with blkid -p -o export on the whole-disk node:
|
||||
/// - Exit status 0 => recognized signature (PTTYPE or FS) -> not empty
|
||||
/// - Exit status 2 (typically "nothing found") -> treat as empty
|
||||
/// - Missing blkid -> conservative: not empty (return Ok(false))
|
||||
pub fn is_empty_disk(disk: &Disk) -> Result<bool> {
|
||||
let base = base_name(&disk.path)
|
||||
.ok_or_else(|| Error::Device(format!("invalid disk path: {}", disk.path)))?;
|
||||
|
||||
// Check for any child partitions listed in /proc/partitions.
|
||||
let names = read_proc_partitions_names()?;
|
||||
if names.iter().any(|n| is_partition_of(&base, n)) {
|
||||
debug!("disk {} has child partitions -> not empty", disk.path);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Probe with blkid -p
|
||||
let Some(blkid) = which_tool("blkid")? else {
|
||||
warn!("blkid not found; conservatively treating {} as not empty", disk.path);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let args = [blkid.as_str(), "-p", "-o", "export", disk.path.as_str()];
|
||||
match run_cmd_capture(&args) {
|
||||
Ok(_out) => {
|
||||
// Some signature recognized (filesystem or partition table)
|
||||
debug!("blkid found signatures on {} -> not empty", disk.path);
|
||||
Ok(false)
|
||||
}
|
||||
Err(Error::Tool { status, .. }) => {
|
||||
if status == 2 {
|
||||
// Nothing recognized by blkid
|
||||
debug!("blkid reports no signatures on {} -> empty", disk.path);
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(Error::Device(format!(
|
||||
"blkid unexpected status {} probing {}",
|
||||
status, disk.path
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(Error::Device(format!(
|
||||
"blkid probing error on {}: {}",
|
||||
disk.path, e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Helpers (module-private)
|
||||
// =========================
|
||||
|
||||
fn parse_blkid_export(s: &str) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
for line in s.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn read_proc_partitions_names() -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
let content = fs::read_to_string("/proc/partitions")
|
||||
.map_err(|e| Error::Device(format!("/proc/partitions read error: {}", e)))?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("major") {
|
||||
continue;
|
||||
}
|
||||
// Format: major minor #blocks name
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let name = parts[3].to_string();
|
||||
// Skip pseudo devices commonly not relevant (loop, ram, zram, fd)
|
||||
if name.starts_with("loop")
|
||||
|| name.starts_with("ram")
|
||||
|| name.starts_with("zram")
|
||||
|| name.starts_with("fd")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn base_name(path: &str) -> Option<String> {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn is_partition_of(base: &str, name: &str) -> bool {
|
||||
if name == base {
|
||||
return false;
|
||||
}
|
||||
let ends_with_digit = base.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false);
|
||||
if ends_with_digit {
|
||||
// nvme0n1 -> nvme0n1p1
|
||||
if name.starts_with(base) {
|
||||
let rest = &name[base.len()..];
|
||||
return rest.starts_with('p') && rest[1..].chars().all(|c| c.is_ascii_digit());
|
||||
}
|
||||
false
|
||||
} else {
|
||||
// sda -> sda1
|
||||
name.starts_with(base) && name[base.len()..].chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_blkid_export_basic() {
|
||||
let s = "ID_FS_LABEL=ZOSDATA\nPARTLABEL=zosdata\nUUID=1234-ABCD\n";
|
||||
let m = parse_blkid_export(s);
|
||||
assert_eq!(m.get("ID_FS_LABEL").unwrap(), "ZOSDATA");
|
||||
assert_eq!(m.get("PARTLABEL").unwrap(), "zosdata");
|
||||
assert_eq!(m.get("UUID").unwrap(), "1234-ABCD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_partition_of_cases_sda_style() {
|
||||
// sda base: partitions sda1, sda2 are children; sdb is not
|
||||
assert!(is_partition_of("sda", "sda1"));
|
||||
assert!(is_partition_of("sda", "sda10"));
|
||||
assert!(!is_partition_of("sda", "sda"));
|
||||
assert!(!is_partition_of("sda", "sdb1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_partition_of_cases_nvme_style() {
|
||||
// nvme0n1 base: partitions nvme0n1p1, nvme0n1p10 are children; nvme0n2p1 is not
|
||||
assert!(is_partition_of("nvme0n1", "nvme0n1p1"));
|
||||
assert!(is_partition_of("nvme0n1", "nvme0n1p10"));
|
||||
assert!(!is_partition_of("nvme0n1", "nvme0n1"));
|
||||
assert!(!is_partition_of("nvme0n1", "nvme0n2p1"));
|
||||
}
|
||||
}
|
||||
20
src/lib.rs
Normal file
20
src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! Crate root for zosstorage: one-shot disk provisioning utility for initramfs.
|
||||
|
||||
pub mod cli;
|
||||
pub mod logging;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod partition;
|
||||
pub mod fs;
|
||||
pub mod mount;
|
||||
pub mod report;
|
||||
pub mod orchestrator;
|
||||
pub mod idempotency;
|
||||
pub mod util;
|
||||
pub mod errors;
|
||||
pub mod types; // top-level types (moved from config/types.rs for visibility)
|
||||
|
||||
pub use errors::{Error, Result};
|
||||
|
||||
/// Crate version string from Cargo.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
136
src/logging/mod.rs
Normal file
136
src/logging/mod.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
// REGION: API
|
||||
// api: logging::LogOptions { level: String, to_file: bool }
|
||||
// api: logging::LogOptions::from_cli(cli: &crate::cli::Cli) -> Self
|
||||
// api: logging::init_logging(opts: &LogOptions) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Provide structured logging initialization via tracing, defaulting to stderr.
|
||||
// - Optionally enable file logging at /run/zosstorage/zosstorage.log.
|
||||
// Non-goals: runtime log level reconfiguration or external log forwarders.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: add env-filter support for selective module verbosity (feature-gated).
|
||||
// ext: add JSON log formatting for machine-readability (feature-gated).
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: initialization must be idempotent; calling twice should not double-install layers.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: IO errors when opening log file -> crate::Error::Other(anyhow)
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement file layer initialization and idempotent guard.
|
||||
// REGION: TODO-END
|
||||
//! Logging initialization and options for zosstorage.
|
||||
//!
|
||||
//! Provides structured logging via the `tracing` ecosystem. Defaults to stderr,
|
||||
//! with an optional file target at /run/zosstorage/zosstorage.log.
|
||||
|
||||
use crate::Result;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self};
|
||||
use std::sync::OnceLock;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::fmt;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::registry::Registry;
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
/// Logging options resolved from CLI and/or config.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogOptions {
|
||||
/// Level: "error" | "warn" | "info" | "debug"
|
||||
pub level: String,
|
||||
/// When true, also log to /run/zosstorage/zosstorage.log
|
||||
pub to_file: bool,
|
||||
}
|
||||
|
||||
impl LogOptions {
|
||||
/// Construct options from [struct Cli](src/cli/mod.rs:1).
|
||||
pub fn from_cli(cli: &crate::cli::Cli) -> Self {
|
||||
Self {
|
||||
level: cli.log_level.to_string(),
|
||||
to_file: cli.log_to_file,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn level_from_str(s: &str) -> Level {
|
||||
match s {
|
||||
"error" => Level::ERROR,
|
||||
"warn" => Level::WARN,
|
||||
"info" => Level::INFO,
|
||||
"debug" => Level::DEBUG,
|
||||
_ => Level::INFO,
|
||||
}
|
||||
}
|
||||
|
||||
static INIT_GUARD: OnceLock<()> = OnceLock::new();
|
||||
|
||||
/// Initialize tracing subscriber according to options.
|
||||
/// Must be idempotent when called once in process lifetime.
|
||||
pub fn init_logging(opts: &LogOptions) -> Result<()> {
|
||||
if INIT_GUARD.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let lvl = level_from_str(&opts.level);
|
||||
let stderr_layer = fmt::layer()
|
||||
.with_writer(io::stderr) // no timestamps by default for initramfs
|
||||
.with_ansi(false)
|
||||
.with_level(true)
|
||||
.with_target(false)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(false)
|
||||
.with_line_number(false)
|
||||
.with_filter(LevelFilter::from_level(lvl));
|
||||
|
||||
if opts.to_file {
|
||||
let log_path = "/run/zosstorage/zosstorage.log";
|
||||
if let Ok(file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(log_path)
|
||||
{
|
||||
// Make a writer that clones the file handle per write to satisfy MakeWriter.
|
||||
let make_file = move || file.try_clone().expect("failed to clone log file handle");
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(make_file)
|
||||
.with_ansi(false)
|
||||
.with_level(true)
|
||||
.with_target(false)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(false)
|
||||
.with_line_number(false)
|
||||
.with_filter(LevelFilter::from_level(lvl));
|
||||
Registry::default()
|
||||
.with(stderr_layer)
|
||||
.with(file_layer)
|
||||
.try_init()
|
||||
.map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?;
|
||||
} else {
|
||||
// Fall back to stderr-only if file cannot be opened
|
||||
Registry::default()
|
||||
.with(stderr_layer)
|
||||
.try_init()
|
||||
.map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?;
|
||||
}
|
||||
} else {
|
||||
Registry::default()
|
||||
.with(stderr_layer)
|
||||
.try_init()
|
||||
.map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?;
|
||||
}
|
||||
|
||||
let _ = INIT_GUARD.set(());
|
||||
Ok(())
|
||||
}
|
||||
56
src/main.rs
Normal file
56
src/main.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
// REGION: API
|
||||
// api: binary::main() -> (process exit)
|
||||
// api: binary::real_main() -> zosstorage::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Minimal binary wrapper: parse CLI, init logging, load+validate config, run orchestrator.
|
||||
// - Emit minimal fatal errors to stderr only; no stdout spam.
|
||||
// Non-goals: business logic, module orchestration details (delegated to orchestrator).
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: add --version/--help output via clap (already provided by clap derive).
|
||||
// ext: add build-info banner to logs when debug level (feature-gated).
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: never print secrets; errors are concise. Avoids panics; returns proper exit codes.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: any failure bubbles as crate::Error via real_main() and is printed as a single-line stderr.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: add tracing spans around boot phases once logging init is implemented.
|
||||
// REGION: TODO-END
|
||||
//! Binary entrypoint for zosstorage.
|
||||
//!
|
||||
//! Initializes logging, parses CLI, loads/validates configuration,
|
||||
//! and invokes the orchestrator run sequence. Avoids stdout spam.
|
||||
|
||||
use zosstorage::{Result, cli, config, logging, orchestrator};
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = real_main() {
|
||||
// Minimal stderr emission permitted for fatal errors in initramfs.
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates initialization steps and runs the provisioning flow.
|
||||
fn real_main() -> Result<()> {
|
||||
let cli = cli::from_args();
|
||||
let log_opts = logging::LogOptions::from_cli(&cli);
|
||||
logging::init_logging(&log_opts)?;
|
||||
|
||||
let cfg = config::load_and_merge(&cli)?;
|
||||
config::validate(&cfg)?;
|
||||
|
||||
let ctx = orchestrator::Context::new(cfg, log_opts)
|
||||
.with_show(cli.show)
|
||||
.with_report_path(cli.report.clone());
|
||||
orchestrator::run(&ctx)
|
||||
}
|
||||
12
src/mount/mod.rs
Normal file
12
src/mount/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Mount module barrel.
|
||||
//!
|
||||
//! Re-exports the concrete ops implementation from ops.rs to avoid a large mod.rs.
|
||||
//! See [src/mount/ops.rs](ops.rs) for details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: mount::ops::*
|
||||
// REGION: API-END
|
||||
|
||||
pub mod ops;
|
||||
|
||||
pub use ops::*;
|
||||
84
src/mount/ops.rs
Normal file
84
src/mount/ops.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
// REGION: API
|
||||
// api: mount::MountPlan { entries: Vec<(String /* source */, String /* target */, String /* fstype */, String /* options */)> }
|
||||
// api: mount::MountResult { source: String, target: String, fstype: String, options: String }
|
||||
// api: mount::plan_mounts(fs_results: &[crate::fs::FsResult], cfg: &crate::config::types::Config) -> crate::Result<MountPlan>
|
||||
// api: mount::apply_mounts(plan: &MountPlan) -> crate::Result<Vec<MountResult>>
|
||||
// api: mount::maybe_write_fstab(mounts: &[MountResult], cfg: &crate::config::types::Config) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Translate filesystem identities to mount targets, defaulting to /var/cache/<UUID>.
|
||||
// - Perform mounts using syscalls (nix) and create target directories as needed.
|
||||
// - Optionally generate /etc/fstab entries in deterministic order.
|
||||
// Non-goals: filesystem creation, device discovery, partitioning.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: support custom mount scheme mapping beyond per-UUID.
|
||||
// ext: add configurable mount options per filesystem kind via Config.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must ensure target directories exist and avoid overwriting unintended paths.
|
||||
// safety: ensure options include sensible defaults (e.g., btrfs compress, ssd) when applicable.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: syscall failures -> crate::Error::Mount with context.
|
||||
// errmap: fstab write IO errors -> crate::Error::Mount with path details.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement option synthesis (e.g., compress=zstd:3 for btrfs) based on Config and device rotational hints.
|
||||
// todo: implement deterministic fstab ordering and idempotent writes.
|
||||
// REGION: TODO-END
|
||||
//! Mount planning and application.
|
||||
//!
|
||||
//! Translates filesystem results into mount targets (default under /var/cache/<UUID>)
|
||||
//! and applies mounts using syscalls (via nix) in later implementation.
|
||||
//!
|
||||
//! See [fn plan_mounts](ops.rs:1), [fn apply_mounts](ops.rs:1),
|
||||
//! and [fn maybe_write_fstab](ops.rs:1).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::{Result, types::Config, fs::FsResult};
|
||||
|
||||
/// Mount plan entries: (source, target, fstype, options)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountPlan {
|
||||
/// Source device path, target directory, filesystem type, and mount options.
|
||||
pub entries: Vec<(String, String, String, String)>,
|
||||
}
|
||||
|
||||
/// Result of applying a single mount entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountResult {
|
||||
/// Source device path (e.g., /dev/nvme0n1p3).
|
||||
pub source: String,
|
||||
/// Target directory (e.g., /var/cache/<UUID>).
|
||||
pub target: String,
|
||||
/// Filesystem type (e.g., "btrfs", "vfat").
|
||||
pub fstype: String,
|
||||
/// Options string (comma-separated).
|
||||
pub options: String,
|
||||
}
|
||||
|
||||
/// Build mount plan under /var/cache/<UUID> by default.
|
||||
pub fn plan_mounts(fs_results: &[FsResult], _cfg: &Config) -> Result<MountPlan> {
|
||||
let _ = fs_results;
|
||||
// Placeholder: map filesystem UUIDs to per-UUID directories and assemble options.
|
||||
todo!("create per-UUID directories and mount mapping based on config")
|
||||
}
|
||||
|
||||
/// Apply mounts using syscalls (nix), ensuring directories exist.
|
||||
pub fn apply_mounts(_plan: &MountPlan) -> Result<Vec<MountResult>> {
|
||||
// Placeholder: perform mount syscalls and return results.
|
||||
todo!("perform mount syscalls and return results")
|
||||
}
|
||||
|
||||
/// Optionally generate /etc/fstab entries in deterministic order.
|
||||
pub fn maybe_write_fstab(_mounts: &[MountResult], _cfg: &Config) -> Result<()> {
|
||||
// Placeholder: write fstab when enabled in configuration.
|
||||
todo!("when enabled, write fstab entries deterministically")
|
||||
}
|
||||
6
src/orchestrator/mod.rs
Normal file
6
src/orchestrator/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Orchestrator module barrel.
|
||||
//!
|
||||
//! Re-exports the concrete implementation from run.rs to avoid duplicating types/functions.
|
||||
|
||||
pub mod run;
|
||||
pub use run::*;
|
||||
372
src/orchestrator/run.rs
Normal file
372
src/orchestrator/run.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
// REGION: API
|
||||
// api: orchestrator::Context { cfg: crate::config::types::Config, log: crate::logging::LogOptions }
|
||||
// api: orchestrator::Context::new(cfg: crate::config::types::Config, log: crate::logging::LogOptions) -> Self
|
||||
// api: orchestrator::run(ctx: &Context) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - High-level one-shot flow controller: idempotency check, device discovery,
|
||||
// partition planning and application, filesystem creation, mounting, reporting.
|
||||
// - Enforces abort-on-first-error semantics across subsystems.
|
||||
// Non-goals: direct device IO or shelling out; delegates to subsystem modules.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: pluggable DeviceProvider for discovery (mocking/testing).
|
||||
// ext: dry-run mode (future) to emit planned actions without applying.
|
||||
// ext: hooks before/after each phase for metrics or additional validation.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must never proceed to filesystem creation if partition planning/apply failed.
|
||||
// safety: must exit success without changes when idempotency detection indicates provisioned.
|
||||
// safety: must ensure reporting only on overall success (no partial-success report).
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: subsystem errors bubble up as crate::Error::* without stringly-typed loss.
|
||||
// errmap: external tool failures are expected as Error::Tool from util layer.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement orchestration steps in phases with structured logs and timing.
|
||||
// todo: add per-phase tracing spans and outcome summaries.
|
||||
// REGION: TODO-END
|
||||
//! High-level orchestration for zosstorage.
|
||||
//!
|
||||
//! Drives the one-shot provisioning flow:
|
||||
//! - Idempotency detection
|
||||
//! - Device discovery
|
||||
//! - Partition planning and application
|
||||
//! - Filesystem planning and creation
|
||||
//! - Mount planning and application
|
||||
//! - Report generation and write
|
||||
|
||||
use crate::{
|
||||
types::Config,
|
||||
logging::LogOptions,
|
||||
device::{discover, DeviceFilter, Disk},
|
||||
idempotency,
|
||||
partition,
|
||||
Error, Result,
|
||||
};
|
||||
use humantime::format_rfc3339;
|
||||
use regex::Regex;
|
||||
use serde_json::{json, to_value};
|
||||
use std::fs;
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Execution context holding resolved configuration and environment flags.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Context {
|
||||
/// Validated configuration.
|
||||
pub cfg: Config,
|
||||
/// Logging options in effect.
|
||||
pub log: LogOptions,
|
||||
/// When true, print detection and planning summary to stdout (JSON).
|
||||
pub show: bool,
|
||||
/// Optional report path override (when provided by CLI --report).
|
||||
pub report_path_override: Option<String>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Construct a new context from config and logging options.
|
||||
pub fn new(cfg: Config, log: LogOptions) -> Self {
|
||||
Self {
|
||||
cfg,
|
||||
log,
|
||||
show: false,
|
||||
report_path_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: enable showing summary to stdout.
|
||||
pub fn with_show(mut self, show: bool) -> Self {
|
||||
self.show = show;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: override report path.
|
||||
pub fn with_report_path(mut self, path: Option<String>) -> Self {
|
||||
self.report_path_override = path;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the one-shot provisioning flow.
|
||||
///
|
||||
/// Returns Ok(()) on success and also on success-noop when already provisioned.
|
||||
/// Any validation or execution failure aborts with an error.
|
||||
pub fn run(ctx: &Context) -> Result<()> {
|
||||
info!("orchestrator: starting run() with topology {:?}", ctx.cfg.topology);
|
||||
|
||||
// 1) Idempotency pre-flight: if already provisioned, optionally emit summary then exit success.
|
||||
match idempotency::detect_existing_state()? {
|
||||
Some(state) => {
|
||||
info!("orchestrator: already provisioned");
|
||||
if ctx.show || ctx.report_path_override.is_some() {
|
||||
let now = format_rfc3339(SystemTime::now()).to_string();
|
||||
let state_json = to_value(&state).map_err(|e| {
|
||||
Error::Report(format!("failed to serialize StateReport: {}", e))
|
||||
})?;
|
||||
let summary = json!({
|
||||
"version": "v1",
|
||||
"timestamp": now,
|
||||
"status": "already_provisioned",
|
||||
"state": state_json
|
||||
});
|
||||
if ctx.show {
|
||||
println!("{}", summary);
|
||||
}
|
||||
if let Some(path) = &ctx.report_path_override {
|
||||
fs::write(path, summary.to_string())
|
||||
.map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?;
|
||||
info!("orchestrator: wrote idempotency report to {}", path);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
debug!("orchestrator: not provisioned; continuing");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Device discovery using compiled filter from config.
|
||||
let filter = build_device_filter(&ctx.cfg)?;
|
||||
let disks = discover(&filter)?;
|
||||
info!("orchestrator: discovered {} eligible disk(s)", disks.len());
|
||||
|
||||
// 3) Emptiness enforcement: skip in preview mode (--show/--report) to allow planning output.
|
||||
let preview = ctx.show || ctx.report_path_override.is_some();
|
||||
if ctx.cfg.partitioning.require_empty_disks && !preview {
|
||||
enforce_empty_disks(&disks)?;
|
||||
info!("orchestrator: all target disks verified empty");
|
||||
} else if ctx.cfg.partitioning.require_empty_disks && preview {
|
||||
warn!("orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement");
|
||||
} else {
|
||||
warn!("orchestrator: require_empty_disks=false; proceeding without emptiness enforcement");
|
||||
}
|
||||
|
||||
// 4) Partition planning (declarative only; application not yet implemented in this step).
|
||||
let plan = partition::plan_partitions(&disks, &ctx.cfg)?;
|
||||
debug!(
|
||||
"orchestrator: partition plan ready (alignment={} MiB, disks={})",
|
||||
plan.alignment_mib,
|
||||
plan.disks.len()
|
||||
);
|
||||
for dp in &plan.disks {
|
||||
debug!("plan for {}: {} part(s)", dp.disk.path, dp.parts.len());
|
||||
}
|
||||
|
||||
// Note:
|
||||
// - Applying partitions, creating filesystems, mounting, and reporting
|
||||
// will be wired in subsequent steps. For now this performs pre-flight
|
||||
// checks and planning to exercise real code paths safely.
|
||||
|
||||
info!("orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)");
|
||||
|
||||
// Optional: emit JSON summary via --show or write via --report
|
||||
if ctx.show || ctx.report_path_override.is_some() {
|
||||
let summary = build_summary_json(&disks, &plan, &ctx.cfg)?;
|
||||
if ctx.show {
|
||||
// Print compact JSON to stdout
|
||||
println!("{}", summary);
|
||||
}
|
||||
if let Some(path) = &ctx.report_path_override {
|
||||
// Best-effort write (non-atomic for now, pending report::write_report implementation)
|
||||
fs::write(path, summary.to_string()).map_err(|e| {
|
||||
Error::Report(format!("failed to write report to {}: {}", path, e))
|
||||
})?;
|
||||
info!("orchestrator: wrote summary report to {}", path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_device_filter(cfg: &Config) -> Result<DeviceFilter> {
|
||||
let mut include = Vec::new();
|
||||
let mut exclude = Vec::new();
|
||||
|
||||
for pat in &cfg.device_selection.include_patterns {
|
||||
let re = Regex::new(pat).map_err(|e| {
|
||||
Error::Validation(format!("invalid include regex '{}': {}", pat, e))
|
||||
})?;
|
||||
include.push(re);
|
||||
}
|
||||
for pat in &cfg.device_selection.exclude_patterns {
|
||||
let re = Regex::new(pat).map_err(|e| {
|
||||
Error::Validation(format!("invalid exclude regex '{}': {}", pat, e))
|
||||
})?;
|
||||
exclude.push(re);
|
||||
}
|
||||
|
||||
Ok(DeviceFilter {
|
||||
include,
|
||||
exclude,
|
||||
min_size_gib: cfg.device_selection.min_size_gib,
|
||||
allow_removable: cfg.device_selection.allow_removable,
|
||||
})
|
||||
}
|
||||
|
||||
fn enforce_empty_disks(disks: &[Disk]) -> Result<()> {
|
||||
for d in disks {
|
||||
let empty = idempotency::is_empty_disk(d)?;
|
||||
if !empty {
|
||||
return Err(Error::Validation(format!(
|
||||
"target disk {} is not empty (partitions or signatures present)",
|
||||
d.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn role_str(role: partition::PartRole) -> &'static str {
|
||||
match role {
|
||||
partition::PartRole::BiosBoot => "bios_boot",
|
||||
partition::PartRole::Esp => "esp",
|
||||
partition::PartRole::Data => "data",
|
||||
partition::PartRole::Cache => "cache",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Config) -> Result<serde_json::Value> {
|
||||
// Disks summary
|
||||
let disks_json: Vec<serde_json::Value> = disks
|
||||
.iter()
|
||||
.map(|d| {
|
||||
json!({
|
||||
"path": d.path,
|
||||
"size_bytes": d.size_bytes,
|
||||
"rotational": d.rotational,
|
||||
"model": d.model,
|
||||
"serial": d.serial,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Partition plan summary (spec-level)
|
||||
let mut plan_json: Vec<serde_json::Value> = Vec::new();
|
||||
for dp in &plan.disks {
|
||||
let parts: Vec<serde_json::Value> = dp
|
||||
.parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"role": role_str(p.role),
|
||||
"size_mib": p.size_mib, // null means "remainder"
|
||||
"gpt_name": p.gpt_name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
plan_json.push(json!({
|
||||
"disk": dp.disk.path,
|
||||
"parts": parts
|
||||
}));
|
||||
}
|
||||
|
||||
// Decide filesystem kinds and planned mountpoints (template) from plan + cfg.topology
|
||||
let topo_str = match cfg.topology {
|
||||
crate::types::Topology::Single => "single",
|
||||
crate::types::Topology::DualIndependent => "dual_independent",
|
||||
crate::types::Topology::SsdHddBcachefs => "ssd_hdd_bcachefs",
|
||||
crate::types::Topology::BtrfsRaid1 => "btrfs_raid1",
|
||||
};
|
||||
|
||||
// Count roles across plan to infer filesystems
|
||||
let mut esp_count = 0usize;
|
||||
let mut data_count = 0usize;
|
||||
let mut cache_count = 0usize;
|
||||
for dp in &plan.disks {
|
||||
for p in &dp.parts {
|
||||
match p.role {
|
||||
partition::PartRole::Esp => esp_count += 1,
|
||||
partition::PartRole::Data => data_count += 1,
|
||||
partition::PartRole::Cache => cache_count += 1,
|
||||
partition::PartRole::BiosBoot => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut filesystems_planned: Vec<serde_json::Value> = Vec::new();
|
||||
// ESP -> vfat (typically mounted by bootloader; no runtime target here)
|
||||
if esp_count > 0 {
|
||||
filesystems_planned.push(json!({
|
||||
"kind": "vfat",
|
||||
"from_roles": ["esp"],
|
||||
"label": cfg.filesystem.vfat.label,
|
||||
"planned_mountpoint": null
|
||||
}));
|
||||
}
|
||||
|
||||
// Data/cache-driven FS + mount targets. Mount scheme is per-UUID under base_dir.
|
||||
let target_template = format!("{}/{{UUID}}", cfg.mount.base_dir);
|
||||
match cfg.topology {
|
||||
crate::types::Topology::SsdHddBcachefs => {
|
||||
if cache_count > 0 && data_count > 0 {
|
||||
filesystems_planned.push(json!({
|
||||
"kind": "bcachefs",
|
||||
"from_roles": ["cache", "data"],
|
||||
"label": cfg.filesystem.bcachefs.label,
|
||||
"planned_mountpoint_template": target_template,
|
||||
}));
|
||||
}
|
||||
}
|
||||
crate::types::Topology::BtrfsRaid1 => {
|
||||
// One multi-device btrfs across all data partitions
|
||||
if data_count >= 2 {
|
||||
filesystems_planned.push(json!({
|
||||
"kind": "btrfs",
|
||||
"from_roles": ["data"],
|
||||
"devices_planned": data_count,
|
||||
"label": cfg.filesystem.btrfs.label,
|
||||
"planned_mountpoint_template": target_template,
|
||||
}));
|
||||
} else if data_count == 1 {
|
||||
filesystems_planned.push(json!({
|
||||
"kind": "btrfs",
|
||||
"from_roles": ["data"],
|
||||
"label": cfg.filesystem.btrfs.label,
|
||||
"planned_mountpoint_template": target_template,
|
||||
"note": "only one data partition present; raid1 requires >= 2",
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// One btrfs per data partition
|
||||
for _ in 0..data_count {
|
||||
filesystems_planned.push(json!({
|
||||
"kind": "btrfs",
|
||||
"from_roles": ["data"],
|
||||
"label": cfg.filesystem.btrfs.label,
|
||||
"planned_mountpoint_template": target_template,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mount_scheme = json!({
|
||||
"scheme": "per_uuid",
|
||||
"base_dir": cfg.mount.base_dir,
|
||||
"fstab_enabled": cfg.mount.fstab_enabled,
|
||||
"target_template": target_template,
|
||||
});
|
||||
|
||||
let now = format_rfc3339(SystemTime::now()).to_string();
|
||||
let summary = json!({
|
||||
"version": "v1",
|
||||
"timestamp": now,
|
||||
"status": "planned",
|
||||
"topology": topo_str,
|
||||
"alignment_mib": plan.alignment_mib,
|
||||
"require_empty_disks": plan.require_empty_disks,
|
||||
"disks": disks_json,
|
||||
"partition_plan": plan_json,
|
||||
"filesystems_planned": filesystems_planned,
|
||||
"mount": mount_scheme
|
||||
});
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
12
src/partition/mod.rs
Normal file
12
src/partition/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Partition module barrel.
|
||||
//!
|
||||
//! Re-exports the concrete planning/apply implementation from plan.rs to avoid a large mod.rs.
|
||||
//! See [src/partition/plan.rs](plan.rs) for details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: partition::plan::*
|
||||
// REGION: API-END
|
||||
|
||||
pub mod plan;
|
||||
|
||||
pub use plan::*;
|
||||
290
src/partition/plan.rs
Normal file
290
src/partition/plan.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
// REGION: API
|
||||
// api: partition::PartRole { BiosBoot, Esp, Data, Cache }
|
||||
// api: partition::PartitionSpec { role: PartRole, size_mib: Option<u64>, gpt_name: String }
|
||||
// api: partition::DiskPlan { disk: crate::device::Disk, parts: Vec<PartitionSpec> }
|
||||
// api: partition::PartitionPlan { alignment_mib: u64, disks: Vec<DiskPlan>, require_empty_disks: bool }
|
||||
// api: partition::PartitionResult { disk: String, part_number: u32, role: PartRole, gpt_name: String, uuid: String, start_mib: u64, size_mib: u64, device_path: String }
|
||||
// api: partition::plan_partitions(disks: &[crate::device::Disk], cfg: &crate::config::types::Config) -> crate::Result<PartitionPlan>
|
||||
// api: partition::apply_partitions(plan: &PartitionPlan) -> crate::Result<Vec<PartitionResult>>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Compute a declarative GPT partitioning plan per topology with 1 MiB alignment.
|
||||
// - Apply the plan safely via system tools (sgdisk) using util wrappers.
|
||||
// Non-goals: filesystem creation, mounting, reporting.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: support additional partition roles (e.g., metadata) via PartRole extension.
|
||||
// ext: device-specific alignment or reserved areas configurable via cfg in the future.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must verify require_empty_disks before any modification.
|
||||
// safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT).
|
||||
// safety: must call udev settle after partition table writes.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: external tool failure -> crate::Error::Tool { tool, status, stderr }.
|
||||
// errmap: validation and planning errors -> crate::Error::Partition with clear context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement topology-aware layout including SSD/HDD cache/backing with gpt_name zoscache.
|
||||
// todo: integrate blkid probing to confirm absence of FS signatures prior to changes.
|
||||
// REGION: TODO-END
|
||||
//! GPT partition planning and application.
|
||||
//!
|
||||
//! Provides declarative planning APIs and an apply step that will later
|
||||
//! shell out to system tools (sgdisk) wrapped via util helpers.
|
||||
//!
|
||||
//! See [fn plan_partitions](plan.rs:1) and
|
||||
//! [fn apply_partitions](plan.rs:1).
|
||||
|
||||
use crate::{types::{Config, Topology}, device::Disk, Error, Result};
|
||||
|
||||
/// Partition roles supported by zosstorage.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PartRole {
|
||||
/// Tiny BIOS boot partition (no filesystem).
|
||||
BiosBoot,
|
||||
/// EFI System Partition (vfat, label ZOSBOOT).
|
||||
Esp,
|
||||
/// Primary data partition.
|
||||
Data,
|
||||
/// Cache partition (for bcachefs SSD roles).
|
||||
Cache,
|
||||
}
|
||||
|
||||
/// Declarative spec for a partition on a disk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionSpec {
|
||||
/// Role of this partition.
|
||||
pub role: PartRole,
|
||||
/// Explicit size in MiB; None means "use remainder".
|
||||
pub size_mib: Option<u64>,
|
||||
/// GPT partition name (zosboot, zosdata, zoscache).
|
||||
pub gpt_name: String,
|
||||
}
|
||||
|
||||
/// Plan for a single disk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskPlan {
|
||||
/// Target disk.
|
||||
pub disk: Disk,
|
||||
/// Ordered partition specs for the disk.
|
||||
pub parts: Vec<PartitionSpec>,
|
||||
}
|
||||
|
||||
/// Full partitioning plan across all target disks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionPlan {
|
||||
/// Alignment in MiB (1 by default).
|
||||
pub alignment_mib: u64,
|
||||
/// Plans per disk.
|
||||
pub disks: Vec<DiskPlan>,
|
||||
/// When true, abort if any target disk is not empty.
|
||||
pub require_empty_disks: bool,
|
||||
}
|
||||
|
||||
/// Result of applying partitioning on a particular disk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PartitionResult {
|
||||
/// Parent disk path (e.g., /dev/nvme0n1).
|
||||
pub disk: String,
|
||||
/// Partition index number (1-based).
|
||||
pub part_number: u32,
|
||||
/// Role assigned to this partition.
|
||||
pub role: PartRole,
|
||||
/// GPT partition name used.
|
||||
pub gpt_name: String,
|
||||
/// Partition GUID.
|
||||
pub uuid: String,
|
||||
/// Start offset in MiB.
|
||||
pub start_mib: u64,
|
||||
/// Size in MiB.
|
||||
pub size_mib: u64,
|
||||
/// Partition device path (e.g., /dev/nvme0n1p2).
|
||||
pub device_path: String,
|
||||
}
|
||||
|
||||
/**
|
||||
Compute GPT-only plan per topology and constraints.
|
||||
|
||||
Layout defaults:
|
||||
- BIOS boot: cfg.partitioning.bios_boot if enabled (size_mib)
|
||||
- ESP: cfg.partitioning.esp.size_mib, GPT name cfg.partitioning.esp.gpt_name (typically "zosboot")
|
||||
- Data: remainder, GPT name cfg.partitioning.data.gpt_name ("zosdata")
|
||||
- Cache (only for SSD/HDD topology): remainder on SSD after boot/ESP, GPT name cfg.partitioning.cache.gpt_name ("zoscache")
|
||||
|
||||
Topology mapping:
|
||||
- Single: use first eligible disk; create BIOS (opt) + ESP + Data
|
||||
- DualIndependent: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data
|
||||
- BtrfsRaid1: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data
|
||||
- SsdHddBcachefs: need >=1 SSD (rotational=false) and >=1 HDD (rotational=true);
|
||||
SSD: BIOS (opt) + ESP + Cache; HDD: Data
|
||||
*/
|
||||
pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
let align = cfg.partitioning.alignment_mib;
|
||||
let require_empty = cfg.partitioning.require_empty_disks;
|
||||
|
||||
if disks.is_empty() {
|
||||
return Err(Error::Partition("no disks provided to partition planner".into()));
|
||||
}
|
||||
|
||||
let mut plans: Vec<DiskPlan> = Vec::new();
|
||||
|
||||
match cfg.topology {
|
||||
Topology::Single => {
|
||||
let d0 = &disks[0];
|
||||
let mut parts = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
parts.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
gpt_name: cfg.partitioning.bios_boot.gpt_name.clone(),
|
||||
});
|
||||
}
|
||||
parts.push(PartitionSpec {
|
||||
role: PartRole::Esp,
|
||||
size_mib: Some(cfg.partitioning.esp.size_mib),
|
||||
gpt_name: cfg.partitioning.esp.gpt_name.clone(),
|
||||
});
|
||||
parts.push(PartitionSpec {
|
||||
role: PartRole::Data,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.data.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: d0.clone(), parts });
|
||||
}
|
||||
Topology::DualIndependent => {
|
||||
if disks.len() < 2 {
|
||||
return Err(Error::Partition("DualIndependent topology requires at least 2 disks".into()));
|
||||
}
|
||||
let d0 = &disks[0];
|
||||
let d1 = &disks[1];
|
||||
|
||||
// Disk 0: BIOS (opt) + ESP + Data
|
||||
let mut parts0 = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
gpt_name: cfg.partitioning.bios_boot.gpt_name.clone(),
|
||||
});
|
||||
}
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::Esp,
|
||||
size_mib: Some(cfg.partitioning.esp.size_mib),
|
||||
gpt_name: cfg.partitioning.esp.gpt_name.clone(),
|
||||
});
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::Data,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.data.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: d0.clone(), parts: parts0 });
|
||||
|
||||
// Disk 1: Data only
|
||||
let mut parts1 = Vec::new();
|
||||
parts1.push(PartitionSpec {
|
||||
role: PartRole::Data,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.data.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: d1.clone(), parts: parts1 });
|
||||
}
|
||||
Topology::BtrfsRaid1 => {
|
||||
if disks.len() < 2 {
|
||||
return Err(Error::Partition("BtrfsRaid1 topology requires at least 2 disks".into()));
|
||||
}
|
||||
let d0 = &disks[0];
|
||||
let d1 = &disks[1];
|
||||
|
||||
// Disk 0: BIOS (opt) + ESP + Data
|
||||
let mut parts0 = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
gpt_name: cfg.partitioning.bios_boot.gpt_name.clone(),
|
||||
});
|
||||
}
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::Esp,
|
||||
size_mib: Some(cfg.partitioning.esp.size_mib),
|
||||
gpt_name: cfg.partitioning.esp.gpt_name.clone(),
|
||||
});
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::Data,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.data.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: d0.clone(), parts: parts0 });
|
||||
|
||||
// Disk 1: Data only (for RAID1)
|
||||
let mut parts1 = Vec::new();
|
||||
parts1.push(PartitionSpec {
|
||||
role: PartRole::Data,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.data.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: d1.clone(), parts: parts1 });
|
||||
}
|
||||
Topology::SsdHddBcachefs => {
|
||||
// Choose SSD (rotational=false) and HDD (rotational=true)
|
||||
let ssd = disks.iter().find(|d| !d.rotational)
|
||||
.ok_or_else(|| Error::Partition("SsdHddBcachefs requires an SSD (non-rotational) disk".into()))?;
|
||||
let hdd = disks.iter().find(|d| d.rotational)
|
||||
.ok_or_else(|| Error::Partition("SsdHddBcachefs requires an HDD (rotational) disk".into()))?;
|
||||
|
||||
// SSD: BIOS (opt) + ESP + Cache remainder
|
||||
let mut parts_ssd = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
parts_ssd.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
gpt_name: cfg.partitioning.bios_boot.gpt_name.clone(),
|
||||
});
|
||||
}
|
||||
parts_ssd.push(PartitionSpec {
|
||||
role: PartRole::Esp,
|
||||
size_mib: Some(cfg.partitioning.esp.size_mib),
|
||||
gpt_name: cfg.partitioning.esp.gpt_name.clone(),
|
||||
});
|
||||
parts_ssd.push(PartitionSpec {
|
||||
role: PartRole::Cache,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.cache.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: ssd.clone(), parts: parts_ssd });
|
||||
|
||||
// HDD: Data remainder
|
||||
let mut parts_hdd = Vec::new();
|
||||
parts_hdd.push(PartitionSpec {
|
||||
role: PartRole::Data,
|
||||
size_mib: None,
|
||||
gpt_name: cfg.partitioning.data.gpt_name.clone(),
|
||||
});
|
||||
plans.push(DiskPlan { disk: hdd.clone(), parts: parts_hdd });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PartitionPlan {
|
||||
alignment_mib: align,
|
||||
disks: plans,
|
||||
require_empty_disks: require_empty,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the partition plan using system utilities (sgdisk) via util wrappers.
|
||||
///
|
||||
/// Safety:
|
||||
/// - Must verify target disks are empty when required.
|
||||
/// - Must ensure unique partition GUIDs.
|
||||
/// - Should call udev settle after changes.
|
||||
pub fn apply_partitions(_plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
|
||||
// To be implemented: sgdisk orchestration + udev settle + GUID collection
|
||||
todo!("shell out to sgdisk, trigger udev settle, collect partition GUIDs")
|
||||
}
|
||||
12
src/report/mod.rs
Normal file
12
src/report/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Report module barrel.
|
||||
//!
|
||||
//! Re-exports the concrete implementation from state.rs to avoid a large mod.rs.
|
||||
//! See [src/report/state.rs](state.rs) for details.
|
||||
//
|
||||
// REGION: API
|
||||
// api: report::state::*
|
||||
// REGION: API-END
|
||||
|
||||
pub mod state;
|
||||
|
||||
pub use state::*;
|
||||
80
src/report/state.rs
Normal file
80
src/report/state.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
// REGION: API
|
||||
// api: report::REPORT_VERSION: &str
|
||||
// api: report::StateReport { version: String, timestamp: String, status: String, disks: Vec<serde_json::Value>, partitions: Vec<serde_json::Value>, filesystems: Vec<serde_json::Value>, mounts: Vec<serde_json::Value>, error: Option<String> }
|
||||
// api: report::build_report(disks: &[serde_json::Value], parts: &[serde_json::Value], fs: &[serde_json::Value], mounts: &[serde_json::Value], status: &str) -> StateReport
|
||||
// api: report::write_report(report: &StateReport, path: &str) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Construct and persist a machine-readable JSON describing the provisioning outcome.
|
||||
// - Maintain a versioned schema via REPORT_VERSION and ensure forward compatibility guidance.
|
||||
// Non-goals: orchestrating actions or mutating system state beyond writing the report file.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: add typed sub-structures for disks/partitions/filesystems/mounts when schema stabilizes.
|
||||
// ext: emit an additional compact/summary report for boot logs.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: write atomically (tempfile + rename) to avoid partial report reads.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: IO/serialization errors -> crate::Error::Report with clear path/context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement atomic write using tempfile and fs::rename.
|
||||
// todo: include monotonic timestamps or sequence numbers if required.
|
||||
// REGION: TODO-END
|
||||
//! Machine-readable state reporting for zosstorage.
|
||||
//!
|
||||
//! Emits a JSON report describing discovered disks, partitions, filesystems,
|
||||
//! mounts, overall status, and timestamp. The schema is versioned.
|
||||
|
||||
use crate::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Report payload version string.
|
||||
pub const REPORT_VERSION: &str = "v1";
|
||||
|
||||
/// State report structure (versioned).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateReport {
|
||||
/// Payload version (e.g., "v1").
|
||||
pub version: String,
|
||||
/// RFC3339 timestamp.
|
||||
pub timestamp: String,
|
||||
/// "success" | "already_provisioned" | "error"
|
||||
pub status: String,
|
||||
/// Disks (shape defined by implementation; kept flexible for now).
|
||||
pub disks: Vec<serde_json::Value>,
|
||||
/// Partitions (shape defined by implementation; kept flexible for now).
|
||||
pub partitions: Vec<serde_json::Value>,
|
||||
/// Filesystems (shape defined by implementation; kept flexible for now).
|
||||
pub filesystems: Vec<serde_json::Value>,
|
||||
/// Mounts (shape defined by implementation; kept flexible for now).
|
||||
pub mounts: Vec<serde_json::Value>,
|
||||
/// Optional error message when status == "error".
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the machine-readable state report from inputs.
|
||||
///
|
||||
/// The concrete shapes for disks/partitions/filesystems/mounts are intentionally
|
||||
/// flexible (serde_json::Value) in this skeleton and will be formalized later.
|
||||
pub fn build_report(
|
||||
_disks: &[serde_json::Value],
|
||||
_parts: &[serde_json::Value],
|
||||
_fs: &[serde_json::Value],
|
||||
_mounts: &[serde_json::Value],
|
||||
_status: &str,
|
||||
) -> StateReport {
|
||||
todo!("assemble structured report in v1 format with timestamp and inputs")
|
||||
}
|
||||
|
||||
/// Write the state report JSON to disk (default path in config: /run/zosstorage/state.json).
|
||||
pub fn write_report(_report: &StateReport, _path: &str) -> Result<()> {
|
||||
todo!("serialize to JSON and persist atomically via tempfile and rename")
|
||||
}
|
||||
170
src/types.rs
Normal file
170
src/types.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! Typed configuration schema for zosstorage.
|
||||
//!
|
||||
//! Mirrors docs in [docs/SCHEMA.md](docs/SCHEMA.md) and is loaded/validated by
|
||||
//! [fn load_and_merge()](src/config/loader.rs:1) and [fn validate()](src/config/loader.rs:1).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
/// Log level: "error" | "warn" | "info" | "debug"
|
||||
pub level: String, // default "info"
|
||||
/// When true, also log to /run/zosstorage/zosstorage.log
|
||||
pub to_file: bool, // default false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceSelection {
|
||||
/// Regex patterns to include device paths (Rust regex).
|
||||
pub include_patterns: Vec<String>,
|
||||
/// Regex patterns to exclude device paths.
|
||||
pub exclude_patterns: Vec<String>,
|
||||
/// Whether to include removable devices (future).
|
||||
pub allow_removable: bool,
|
||||
/// Minimum device size (GiB) to consider eligible.
|
||||
pub min_size_gib: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Topology {
|
||||
/// Single eligible disk; btrfs on remainder.
|
||||
Single,
|
||||
/// Two eligible disks; independent btrfs on each data partition.
|
||||
DualIndependent,
|
||||
/// SSD + HDD; bcachefs with SSD cache/promote and HDD backing.
|
||||
SsdHddBcachefs,
|
||||
/// Optional mirrored btrfs across two disks when explicitly requested.
|
||||
BtrfsRaid1,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BiosBootSpec {
|
||||
/// Whether to create a tiny BIOS boot partition.
|
||||
pub enabled: bool,
|
||||
/// Size in MiB (default 1).
|
||||
pub size_mib: u64,
|
||||
/// GPT partition name (e.g., "zosboot").
|
||||
pub gpt_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EspSpec {
|
||||
/// ESP size in MiB (default 512).
|
||||
pub size_mib: u64,
|
||||
/// Filesystem label for ESP (ZOSBOOT).
|
||||
pub label: String,
|
||||
/// GPT partition name (e.g., "zosboot").
|
||||
pub gpt_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DataSpec {
|
||||
/// GPT partition name for data (e.g., "zosdata").
|
||||
pub gpt_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheSpec {
|
||||
/// GPT partition name for cache partitions (e.g., "zoscache").
|
||||
pub gpt_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Partitioning {
|
||||
/// Alignment in MiB (default 1 MiB).
|
||||
pub alignment_mib: u64,
|
||||
/// Abort if any target disk is not empty (default true).
|
||||
pub require_empty_disks: bool,
|
||||
/// BIOS boot partition spec.
|
||||
pub bios_boot: BiosBootSpec,
|
||||
/// ESP partition spec.
|
||||
pub esp: EspSpec,
|
||||
/// Data partition spec.
|
||||
pub data: DataSpec,
|
||||
/// Cache partition spec (only in ssd_hdd_bcachefs).
|
||||
pub cache: CacheSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BtrfsOptions {
|
||||
/// Filesystem label (ZOSDATA).
|
||||
pub label: String,
|
||||
/// Compression string (e.g., "zstd:3").
|
||||
pub compression: String,
|
||||
/// "none" | "raid1"
|
||||
pub raid_profile: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BcachefsOptions {
|
||||
/// Filesystem label (ZOSDATA).
|
||||
pub label: String,
|
||||
/// "promote" | "writeback" (if supported).
|
||||
pub cache_mode: String,
|
||||
/// Compression algorithm (e.g., "zstd").
|
||||
pub compression: String,
|
||||
/// Checksum algorithm (e.g., "crc32c").
|
||||
pub checksum: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VfatOptions {
|
||||
/// Filesystem label (ZOSBOOT).
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FsOptions {
|
||||
/// btrfs tuning.
|
||||
pub btrfs: BtrfsOptions,
|
||||
/// bcachefs tuning.
|
||||
pub bcachefs: BcachefsOptions,
|
||||
/// vfat tuning for ESP.
|
||||
pub vfat: VfatOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MountSchemeKind {
|
||||
/// Mount under /var/cache/<UUID>
|
||||
PerUuid,
|
||||
/// Reserved for future custom mappings.
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MountScheme {
|
||||
/// Base directory (default: /var/cache).
|
||||
pub base_dir: String,
|
||||
/// Scheme kind (PerUuid | Custom (reserved)).
|
||||
pub scheme: MountSchemeKind,
|
||||
/// When true, write /etc/fstab entries (disabled by default).
|
||||
pub fstab_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReportOptions {
|
||||
/// Path for JSON state report (default: /run/zosstorage/state.json).
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Schema version (start at 1).
|
||||
pub version: u32,
|
||||
/// Logging configuration.
|
||||
pub logging: LoggingConfig,
|
||||
/// Device selection and filtering rules.
|
||||
pub device_selection: DeviceSelection,
|
||||
/// Desired topology mode.
|
||||
pub topology: Topology,
|
||||
/// Partitioning parameters.
|
||||
pub partitioning: Partitioning,
|
||||
/// Filesystem options and tuning.
|
||||
pub filesystem: FsOptions,
|
||||
/// Mount scheme and fstab policy.
|
||||
pub mount: MountScheme,
|
||||
/// Report output configuration.
|
||||
pub report: ReportOptions,
|
||||
}
|
||||
197
src/util/mod.rs
Normal file
197
src/util/mod.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
// REGION: API
|
||||
// api: util::CmdOutput { status: i32, stdout: String, stderr: String }
|
||||
// api: util::which_tool(name: &str) -> crate::Result<Option<String>>
|
||||
// api: util::run_cmd(args: &[&str]) -> crate::Result<()>
|
||||
// api: util::run_cmd_capture(args: &[&str]) -> crate::Result<CmdOutput>
|
||||
// api: util::udev_settle(timeout_ms: u64) -> crate::Result<()>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Centralize external tool discovery and invocation (sgdisk, blkid, mkfs.*, udevadm).
|
||||
// - Provide capture and error mapping to crate::Error consistently.
|
||||
// Non-goals: business logic (planning/validation), direct parsing of complex outputs beyond what callers need.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: pluggable command runner for tests/dry-run; inject via cfg(test) or trait in future.
|
||||
// ext: backoff/retry policies for transient tool failures (feature-gated).
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: never mutate state here except invoking intended external tools; callers enforce preconditions.
|
||||
// safety: capture stderr/stdout to aid diagnostics without leaking sensitive data.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: process::ExitStatus non-zero -> crate::Error::Tool { tool, status, stderr }.
|
||||
// errmap: IO/spawn errors -> crate::Error::Other(anyhow) with context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement which_tool via 'which' crate; consider PATH overrides in initramfs.
|
||||
// todo: implement run_cmd and run_cmd_capture with tracing spans.
|
||||
// todo: implement udev_settle to no-op if udevadm missing, with warn-level log.
|
||||
// REGION: TODO-END
|
||||
//! Utility helpers for external tool invocation and system integration.
|
||||
//!
|
||||
//! All shell-outs are centralized here to enable testing, structured logging,
|
||||
//! and consistent error handling.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use std::process::Command;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Captured output from an external tool invocation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CmdOutput {
|
||||
/// Process exit status code.
|
||||
pub status: i32,
|
||||
/// Captured stdout as UTF-8 (lossy if needed).
|
||||
pub stdout: String,
|
||||
/// Captured stderr as UTF-8 (lossy if needed).
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// Locate the absolute path to a required tool if available in PATH.
|
||||
///
|
||||
/// Returns Ok(Some(path)) when found, Ok(None) when missing.
|
||||
pub fn which_tool(name: &str) -> Result<Option<String>> {
|
||||
match which::which(name) {
|
||||
Ok(path) => Ok(Some(path.to_string_lossy().into_owned())),
|
||||
Err(which::Error::CannotFindBinaryPath) => Ok(None),
|
||||
Err(e) => Err(Error::Other(anyhow::anyhow!("which({name}) failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command and return Ok if the exit status is zero.
|
||||
///
|
||||
/// args[0] must be the program binary path; the rest are arguments.
|
||||
/// On non-zero exit, returns Error::Tool with captured stderr.
|
||||
pub fn run_cmd(args: &[&str]) -> Result<()> {
|
||||
if args.is_empty() {
|
||||
return Err(Error::Other(anyhow::anyhow!(
|
||||
"run_cmd requires at least one arg (the program)"
|
||||
)));
|
||||
}
|
||||
debug!(target: "util.run_cmd", "exec: {:?}", args);
|
||||
let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| {
|
||||
Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e))
|
||||
})?;
|
||||
|
||||
let status_code = output.status.code().unwrap_or(-1);
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
return Err(Error::Tool {
|
||||
tool: args[0].to_string(),
|
||||
status: status_code,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command and capture stdout/stderr for parsing (e.g., blkid).
|
||||
///
|
||||
/// On non-zero exit, returns Error::Tool with captured stderr and status.
|
||||
pub fn run_cmd_capture(args: &[&str]) -> Result<CmdOutput> {
|
||||
if args.is_empty() {
|
||||
return Err(Error::Other(anyhow::anyhow!(
|
||||
"run_cmd_capture requires at least one arg (the program)"
|
||||
)));
|
||||
}
|
||||
debug!(target: "util.run_cmd_capture", "exec: {:?}", args);
|
||||
let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| {
|
||||
Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e))
|
||||
})?;
|
||||
let status_code = output.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(Error::Tool {
|
||||
tool: args[0].to_string(),
|
||||
status: status_code,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CmdOutput {
|
||||
status: status_code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
/// Call udevadm settle with a timeout; warn if unavailable, then no-op.
|
||||
///
|
||||
/// Ensures the system has settled after partition table changes.
|
||||
pub fn udev_settle(timeout_ms: u64) -> Result<()> {
|
||||
// Locate udevadm
|
||||
let Some(udevadm) = which_tool("udevadm")? else {
|
||||
warn!("udevadm not found; skipping udev settle");
|
||||
return Ok(());
|
||||
};
|
||||
let timeout_arg = format!("--timeout={}", timeout_ms / 1000); // udevadm takes seconds; floor
|
||||
// Some implementations accept milliseconds if provided without units; prefer seconds for portability.
|
||||
let args = [udevadm.as_str(), "settle", timeout_arg.as_str()];
|
||||
// We intentionally ignore non-zero exit as some initramfs may not have udev running.
|
||||
match Command::new(args[0]).args(&args[1..]).status() {
|
||||
Ok(_status) => {
|
||||
debug!("udevadm settle invoked");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("failed to invoke udevadm settle: {}", e);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn which_tool_finds_sh() {
|
||||
// 'sh' should exist on virtually all Linux systems
|
||||
let p = which_tool("sh").expect("which_tool failed");
|
||||
assert!(p.is_some(), "expected to find 'sh' in PATH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_tool_missing() {
|
||||
let p = which_tool("definitely-not-a-cmd-xyz").expect("which_tool failed");
|
||||
assert!(p.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cmd_true_ok() {
|
||||
// Use sh -c true to ensure availability
|
||||
run_cmd(&["sh", "-c", "true"]).expect("true should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cmd_false_err() {
|
||||
let err = run_cmd(&["sh", "-c", "false"]).unwrap_err();
|
||||
match err {
|
||||
Error::Tool { tool, status, .. } => {
|
||||
assert_eq!(tool, "sh");
|
||||
assert_ne!(status, 0);
|
||||
}
|
||||
other => panic!("expected Error::Tool, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cmd_capture_echo_stdout() {
|
||||
let out = run_cmd_capture(&["sh", "-c", "printf hello"]).expect("capture ok");
|
||||
assert_eq!(out.stdout, "hello");
|
||||
assert_eq!(out.status, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udev_settle_ok_even_if_missing() {
|
||||
// Should never fail even if udevadm is missing.
|
||||
udev_settle(1000).expect("udev_settle should be non-fatal");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user