From 834f33103dfedc325542e3967a09b1b24b1f59a7 Mon Sep 17 00:00:00 2001 From: franklinselva Date: Wed, 1 Jul 2026 15:03:08 +0200 Subject: [PATCH 1/5] feat: add Rust CLI + library port of the flash pipeline Port the just/bash pipeline to a Rust crate (lib `jetson_flash` + bin `jetson-flash`), kept alongside the existing scripts. Same six stages: deps, fetch, stage, preconfig, check, flash. - Config: TOML (`jetson-flash.toml`) via figment, `JETSON_*` env override. - Recovery detection: native libusb (rusb), no lsusb parsing; exposes a typed `UsbState`/`Model` for library consumers. - NM keyfile + identity baking done natively; NVIDIA `l4t_*.sh` / `apply_binaries.sh` and apt/wget/tar stay as subprocesses. - Typed error enum (thiserror) instead of anyhow strings; anyhow only in the binary. - Clean terminal UX: subprocess output captured to `logs/-.log` with an indicatif spinner; `-v/--verbose` streams live. Downloads and the flash use live stdio so their native progress shows. On failure the captured log tail is printed. --- .gitignore | 1 + Cargo.lock | 892 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 25 ++ README.md | 29 ++ jetson-flash.toml | 44 ++ src/config.rs | 136 ++++++ src/error.rs | 77 ++++ src/lib.rs | 50 +++ src/logging.rs | 285 +++++++++++++ src/main.rs | 95 +++++ src/stages/check.rs | 90 ++++ src/stages/deps.rs | 45 ++ src/stages/fetch.rs | 28 ++ src/stages/flash.rs | 45 ++ src/stages/mod.rs | 6 + src/stages/preconfig.rs | 218 ++++++++++ src/stages/stage.rs | 69 ++++ 17 files changed, 2135 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 jetson-flash.toml create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/main.rs create mode 100644 src/stages/check.rs create mode 100644 src/stages/deps.rs create mode 100644 src/stages/fetch.rs create mode 100644 src/stages/flash.rs create mode 100644 src/stages/mod.rs create mode 100644 src/stages/preconfig.rs create mode 100644 src/stages/stage.rs diff --git a/.gitignore b/.gitignore index b4fe6bf..4829cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ work/ work_*.bak/ +target/ logs/ *.tbz2 *.tar.gz diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e4062db --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,892 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jetson-flash" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "figment", + "indicatif", + "rusb", + "serde", + "thiserror", + "uuid", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..47d0545 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "jetson-flash" +version = "0.1.0" +edition = "2021" +description = "Headless Jetson Orin (Tegra234) flashing pipeline: config, staging, preconfig, recovery detection, NVMe flash" +license = "Apache-2.0" + +[lib] +name = "jetson_flash" +path = "src/lib.rs" + +[[bin]] +name = "jetson-flash" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +thiserror = "2" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +figment = { version = "0.10", features = ["toml", "env"] } +rusb = "0.9" +indicatif = "0.17" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/README.md b/README.md index 2657f63..5e5718d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,35 @@ Or all in one: just all ``` +## Rust CLI (alternative to `just`) + +The same pipeline is available as a Rust crate + CLI (`src/`, `Cargo.toml`), +for integration with other Rust commissioning tooling. It reads TOML config +(`jetson-flash.toml`) instead of `.env`; `JETSON_*` env vars override. + +```bash +cargo build --release +target/release/jetson-flash check # deps|fetch|stage|preconfig|check|flash|all +``` + +| bash / just | Rust CLI | +|--------------------|------------------------------| +| `just ` | `jetson-flash ` | +| `.env` | `jetson-flash.toml` | +| `lsusb` parse | native libusb (`rusb`) | +| `uuidgen`, keyfiles| generated in-process | + +Recovery detection and NM-keyfile/identity baking are native Rust; NVIDIA's +`l4t_*.sh` / `apply_binaries.sh` and `apt`/`wget`/`tar` are still driven as +subprocesses. Logs land in `logs/-.log` (same as bash). As a library: + +```rust +use jetson_flash::{Config, Paths, stages, logging::Logger}; +let paths = Paths::new(std::path::Path::new(".")); +let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"))?; +stages::check::run(&cfg, &paths, &Logger::init("check", &paths.repo_root)?)?; +``` + When the flash finishes, unplug the USB-C data cable, power-cycle the board, and SSH in: diff --git a/jetson-flash.toml b/jetson-flash.toml new file mode 100644 index 0000000..1f7f711 --- /dev/null +++ b/jetson-flash.toml @@ -0,0 +1,44 @@ +# jetson-flash Rust pipeline config. Mirrors the .env used by the `just`/bash +# path. Load order (figment): this file, then JETSON_* env vars override. +# Pass a different file with `jetson-flash --config `. + +[l4t] +# JetPack 7.2 / L4T r39.2. Leave at defaults unless bumping JetPack. +version = "39.2.0" +bsp_url = "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Jetson_Linux_R39.2.0_aarch64.tbz2" +rootfs_url = "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Tegra_Linux_Sample-Root-Filesystem_R39.2.0_aarch64.tbz2" + +[board] +# Orin Nano: "jetson-orin-nano-devkit-super". AGX: "jetson-agx-orin-devkit-super". +name = "jetson-orin-nano-devkit-super" +external_device = "nvme0n1p1" + +[identity] +username = "jetson" +password = "jetson" +hostname = "orin-nano" +headless = true +autologin = true + +[network.ethernet] +# Jetson uses PCIe predictable iface names, not eth0. Nano: enP8p1s0. +# AGX differs (often enP1p1s0) — verify with `ip -br link` on a booted board. +dev = "enP8p1s0" +# CIDR for a static IP, or empty string "" for DHCP. +static_ip = "10.42.0.10/24" +# Empty gateway => eth never owns the default route (WiFi carries internet). +gateway = "" +dns = ["1.1.1.1", "8.8.8.8"] + +[network.wifi] +# Empty ssid => skip WiFi entirely. Needs an M.2 E-key card. +# Empty dev => NetworkManager matches by SSID only (recommended). +dev = "" +ssid = "" +psk = "" +dhcp = true +static_ip = "" # set => WiFi static, dhcp ignored +gateway = "" # WiFi default route (metric 200, fallback behind eth) + +[services] +avahi = true # mDNS => reach board as .local diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..73f8798 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,136 @@ +use crate::error::Result; +use figment::{ + providers::{Env, Format, Toml}, + Figment, +}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub l4t: L4t, + pub board: Board, + pub identity: Identity, + #[serde(default)] + pub network: Network, + #[serde(default)] + pub services: Services, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct L4t { + pub version: String, + pub bsp_url: String, + pub rootfs_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Board { + pub name: String, + pub external_device: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Identity { + pub username: String, + pub password: String, + pub hostname: String, + #[serde(default = "yes")] + pub headless: bool, + #[serde(default = "yes")] + pub autologin: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Network { + #[serde(default)] + pub ethernet: Ethernet, + #[serde(default)] + pub wifi: Wifi, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Ethernet { + #[serde(default = "nano_eth")] + pub dev: String, + #[serde(default)] + pub static_ip: String, + #[serde(default)] + pub gateway: String, + #[serde(default = "default_dns")] + pub dns: Vec, +} + +impl Default for Ethernet { + fn default() -> Self { + Self { + dev: nano_eth(), + static_ip: String::new(), + gateway: String::new(), + dns: default_dns(), + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Wifi { + #[serde(default)] + pub dev: String, + #[serde(default)] + pub ssid: String, + #[serde(default)] + pub psk: String, + #[serde(default = "yes")] + pub dhcp: bool, + #[serde(default)] + pub static_ip: String, + #[serde(default)] + pub gateway: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Services { + #[serde(default = "yes")] + pub avahi: bool, +} + +impl Default for Services { + fn default() -> Self { + Self { avahi: true } + } +} + +fn yes() -> bool { + true +} +fn nano_eth() -> String { + "enP8p1s0".to_string() +} +fn default_dns() -> Vec { + vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()] +} + +impl Config { + /// Load from a TOML file, with `JETSON_`-prefixed env vars overriding. + /// e.g. `JETSON_BOARD_NAME=... JETSON_IDENTITY_HOSTNAME=...`. + pub fn load(path: &Path) -> Result { + Ok(Figment::new() + .merge(Toml::file(path)) + .merge(Env::prefixed("JETSON_").split("_")) + .extract()?) + } + + /// DNS as a NetworkManager keyfile list ("1.1.1.1;8.8.8.8;"). + pub fn dns_nm(&self) -> String { + let mut s = self.network.ethernet.dns.join(";"); + if !s.is_empty() { + s.push(';'); + } + s + } +} + +/// Default config path: ./jetson-flash.toml under the given repo root. +pub fn default_config_path(repo_root: &Path) -> PathBuf { + repo_root.join("jetson-flash.toml") +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..3afa757 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +pub type Result = std::result::Result; + +/// Typed errors for the flashing pipeline. The binary wraps these in +/// `anyhow`; library consumers can match on the variant. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("config: {0}")] + Config(Box), + + #[error("{context}: {source}")] + Io { + context: String, + #[source] + source: std::io::Error, + }, + + #[error("usb: {0}")] + Usb(#[from] rusb::Error), + + #[error("failed to spawn `{cmd}`: {source}")] + Spawn { + cmd: String, + #[source] + source: std::io::Error, + }, + + #[error("`{cmd}` exited with {status} (see {log})")] + Command { + cmd: String, + status: String, + log: PathBuf, + }, + + #[error("sudo authentication failed")] + Sudo, + + /// A required input is missing (tarball, script, or unstaged rootfs). + #[error("{0}")] + Missing(String), + + #[error("wifi ssid set but wifi psk is empty")] + WifiPskMissing, + + #[error("Jetson not ready to flash: {0}")] + Recovery(#[from] RecoveryError), +} + +impl From for Error { + fn from(e: figment::Error) -> Self { + Error::Config(Box::new(e)) + } +} + +/// Why a board can't be flashed right now. +#[derive(Debug, Clone, Copy, thiserror::Error)] +pub enum RecoveryError { + #[error("board is running L4T (0955:7020), not in APX recovery")] + RunningL4t, + #[error("no Jetson detected on USB")] + Absent, +} + +/// Attach a context string to an `io::Result`, mapping into [`Error::Io`]. +pub(crate) trait IoContext { + fn ctx(self, f: impl FnOnce() -> String) -> Result; +} + +impl IoContext for std::result::Result { + fn ctx(self, f: impl FnOnce() -> String) -> Result { + self.map_err(|source| Error::Io { + context: f(), + source, + }) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3c19fcd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,50 @@ +//! Headless Jetson Orin (Tegra234) flashing pipeline as a library. +//! +//! Rust port of the `just`/bash pipeline. Each stage is a free function +//! taking `&Config`, `&Paths`, and a `&Logger`, so other Rust commissioning +//! tooling can drive the pipeline programmatically: +//! +//! ```no_run +//! use jetson_flash::{Config, Paths, stages, logging::Logger}; +//! use std::path::Path; +//! let paths = Paths::new(Path::new(".")); +//! let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"))?; +//! let log = Logger::init("check", &paths.repo_root, false)?; +//! stages::check::run(&cfg, &paths, &log)?; +//! # jetson_flash::Result::Ok(()) +//! ``` + +pub mod config; +pub mod error; +pub mod logging; +pub mod stages; + +pub use config::Config; +pub use error::{Error, RecoveryError, Result}; + +use std::path::{Path, PathBuf}; + +/// Canonical directory layout, derived from the repo root. +#[derive(Debug, Clone)] +pub struct Paths { + pub repo_root: PathBuf, + pub work: PathBuf, + pub l4t_dir: PathBuf, +} + +impl Paths { + pub fn new(repo_root: &Path) -> Self { + let repo_root = repo_root.to_path_buf(); + let work = repo_root.join("work"); + let l4t_dir = work.join("Linux_for_Tegra"); + Self { + repo_root, + work, + l4t_dir, + } + } + + pub fn rootfs(&self) -> PathBuf { + self.l4t_dir.join("rootfs") + } +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..691f094 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,285 @@ +use crate::error::{Error, IoContext, Result}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Per-step logger. By default it runs subprocesses *quietly*: their output +/// is captured to `logs/-.log` while a spinner shows progress in the +/// terminal, so the console stays clean (unlike the bash pipeline, which dumps +/// everything). `verbose` restores full live streaming. On failure the tail of +/// the captured log is printed automatically. +pub struct Logger { + color: bool, + verbose: bool, + file: Arc>, + path: PathBuf, + repo_root: PathBuf, + bar: Mutex>, +} + +struct Ansi { + reset: &'static str, + step: &'static str, + info: &'static str, + ok: &'static str, + warn: &'static str, + err: &'static str, +} + +const COLOR: Ansi = Ansi { + reset: "\x1b[0m", + step: "\x1b[1;35m", + info: "\x1b[36m", + ok: "\x1b[1;32m", + warn: "\x1b[1;33m", + err: "\x1b[1;31m", +}; +const PLAIN: Ansi = Ansi { + reset: "", + step: "", + info: "", + ok: "", + warn: "", + err: "", +}; + +impl Logger { + pub fn init(step: &str, repo_root: &Path, verbose: bool) -> Result { + let dir = repo_root.join("logs"); + fs::create_dir_all(&dir).ctx(|| format!("mkdir {}", dir.display()))?; + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let fname = format!("{step}-{ts}.log"); + let path = dir.join(&fname); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ctx(|| format!("open log {}", path.display()))?; + + let link = dir.join(format!("{step}-latest.log")); + let _ = fs::remove_file(&link); + #[cfg(unix)] + let _ = std::os::unix::fs::symlink(&fname, &link); + + let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let logger = Self { + color, + verbose, + file: Arc::new(Mutex::new(file)), + path, + repo_root: repo_root.to_path_buf(), + bar: Mutex::new(None), + }; + let rel = logger + .path + .strip_prefix(repo_root) + .unwrap_or(&logger.path) + .display() + .to_string(); + logger.info(&format!("logging {step} -> {rel}")); + Ok(logger) + } + + fn ansi(&self) -> &'static Ansi { + if self.color { + &COLOR + } else { + &PLAIN + } + } + + /// Print one message: routed above the active spinner if one is running, + /// so the bar never gets corrupted. A plain copy always goes to the log. + fn emit(&self, tag_color: &str, tag: &str, msg: &str) { + let a = self.ansi(); + let line = format!("{tag_color}{tag}{} {msg}", a.reset); + match &*self.bar.lock().unwrap() { + Some(bar) => bar.println(line), + None => eprintln!("{line}"), + } + if let Ok(mut f) = self.file.lock() { + let _ = writeln!(f, "{tag} {msg}"); + } + } + + pub fn step(&self, msg: &str) { + let a = self.ansi(); + self.emit(a.step, "==>", msg); + } + pub fn info(&self, msg: &str) { + let a = self.ansi(); + self.emit(a.info, "[info]", msg); + } + pub fn ok(&self, msg: &str) { + let a = self.ansi(); + self.emit(a.ok, "[ok]", msg); + } + pub fn warn(&self, msg: &str) { + let a = self.ansi(); + self.emit(a.warn, "[warn]", msg); + } + pub fn err(&self, msg: &str) { + let a = self.ansi(); + self.emit(a.err, "[fail]", msg); + } + + /// Refresh the sudo credential cache up front (prompts on the terminal), + /// so later captured sudo commands don't silently block on a password. + pub fn sudo_validate(&self) -> Result<()> { + let status = Command::new("sudo") + .arg("-v") + .status() + .map_err(|source| Error::Spawn { + cmd: "sudo -v".into(), + source, + })?; + if status.success() { + Ok(()) + } else { + Err(Error::Sudo) + } + } + + /// Run a command from the repo root, capturing output (spinner UX). + pub fn run(&self, program: &str, args: &[&str]) -> Result<()> { + self.run_in(program, args, &self.repo_root) + } + + /// Run a command in `cwd`, capturing output to the log with a spinner. + pub fn run_in(&self, program: &str, args: &[&str], cwd: &Path) -> Result<()> { + if self.verbose { + return self.exec(program, args, cwd, Capture::Stream); + } + let label = pretty(program, args); + let bar = ProgressBar::new_spinner(); + bar.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg} [{elapsed}]") + .unwrap() + .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), + ); + bar.set_message(label.clone()); + bar.enable_steady_tick(Duration::from_millis(120)); + *self.bar.lock().unwrap() = Some(bar.clone()); + + let res = self.exec(program, args, cwd, Capture::LogOnly); + + *self.bar.lock().unwrap() = None; + bar.finish_and_clear(); + if res.is_err() { + self.err(&format!("`{label}` failed — tail of {}:", self.rel_log())); + for line in self.tail(20) { + eprintln!(" {line}"); + } + } + res + } + + /// Run a command with fully inherited stdio (live terminal, no capture). + /// Use for steps whose native progress output is worth showing directly — + /// downloads (`wget` progress bar) and the long flash. + pub fn run_live(&self, program: &str, args: &[&str], cwd: &Path) -> Result<()> { + self.exec(program, args, cwd, Capture::Inherit) + } + + fn exec(&self, program: &str, args: &[&str], cwd: &Path, cap: Capture) -> Result<()> { + let label = pretty(program, args); + let mut cmd = Command::new(program); + cmd.args(args).current_dir(cwd); + match cap { + Capture::Inherit => { + cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } + _ => { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + } + } + let mut child = cmd.spawn().map_err(|source| Error::Spawn { + cmd: label.clone(), + source, + })?; + + if !matches!(cap, Capture::Inherit) { + let out = child.stdout.take().unwrap(); + let errp = child.stderr.take().unwrap(); + let echo = matches!(cap, Capture::Stream); + let (f1, f2) = (Arc::clone(&self.file), Arc::clone(&self.file)); + let t1 = std::thread::spawn(move || pump(out, f1, echo, false)); + let t2 = std::thread::spawn(move || pump(errp, f2, echo, true)); + let _ = t1.join(); + let _ = t2.join(); + } + + let status = child.wait().map_err(|source| Error::Spawn { + cmd: label.clone(), + source, + })?; + if status.success() { + Ok(()) + } else { + Err(Error::Command { + cmd: label, + status: status.to_string(), + log: self.path.clone(), + }) + } + } + + fn rel_log(&self) -> String { + self.path + .strip_prefix(&self.repo_root) + .unwrap_or(&self.path) + .display() + .to_string() + } + + /// Last `n` lines of the captured log (best-effort, for error context). + fn tail(&self, n: usize) -> Vec { + let mut s = String::new(); + if let Ok(mut f) = File::open(&self.path) { + let _ = f.read_to_string(&mut s); + } + let lines: Vec<&str> = s.lines().collect(); + lines[lines.len().saturating_sub(n)..] + .iter() + .map(|l| l.to_string()) + .collect() + } +} + +#[derive(Clone, Copy)] +enum Capture { + /// Capture to the log only (quiet; spinner shows progress). + LogOnly, + /// Capture to the log AND echo live to the terminal (verbose). + Stream, + /// Inherit stdio; child draws directly to the terminal. + Inherit, +} + +fn pretty(program: &str, args: &[&str]) -> String { + if args.is_empty() { + program.to_string() + } else { + format!("{program} {}", args.join(" ")) + } +} + +fn pump(reader: R, file: Arc>, echo: bool, to_stderr: bool) { + let buf = BufReader::new(reader); + for line in buf.lines().map_while(std::result::Result::ok) { + if echo { + if to_stderr { + eprintln!("{line}"); + } else { + println!("{line}"); + } + } + if let Ok(mut f) = file.lock() { + let _ = writeln!(f, "{line}"); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c6aaa71 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,95 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use jetson_flash::{config, logging::Logger, stages, Config, Paths}; +use std::path::PathBuf; + +/// Headless Jetson Orin (Tegra234) flashing pipeline. +#[derive(Parser)] +#[command(name = "jetson-flash", version, about)] +struct Cli { + /// Config file (default: /jetson-flash.toml). JETSON_* env vars override. + #[arg(long, global = true)] + config: Option, + + /// Repo/work root (default: current directory). + #[arg(long, global = true)] + repo_root: Option, + + /// Stream all subprocess output live instead of capturing it to the log. + #[arg(short, long, global = true)] + verbose: bool, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Install host apt dependencies (Ubuntu 24.04). + Deps, + /// Download BSP + sample rootfs tarballs into work/. + Fetch, + /// Extract BSP, populate rootfs, run apply_binaries.sh. + Stage, + /// Bake user/password/hostname/headless/IPs/WiFi/avahi into the rootfs. + Preconfig, + /// Verify a Jetson is in APX recovery (native libusb). + Check, + /// Flash to NVMe (initrd flash + internal QSPI). + Flash, + /// Full pipeline: deps -> fetch -> stage -> preconfig -> check -> flash. + All, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let repo_root = match cli.repo_root { + Some(p) => p, + None => std::env::current_dir().context("resolve current dir")?, + }; + let paths = Paths::new(&repo_root); + let config_path = cli + .config + .unwrap_or_else(|| config::default_config_path(&repo_root)); + let cfg = Config::load(&config_path)?; + let run = Runner { + cfg: &cfg, + paths: &paths, + verbose: cli.verbose, + }; + + match cli.cmd { + Cmd::Deps => run.step("deps", stages::deps::run), + Cmd::Fetch => run.step("fetch", stages::fetch::run), + Cmd::Stage => run.step("stage", stages::stage::run), + Cmd::Preconfig => run.step("preconfig", stages::preconfig::run), + Cmd::Check => run.step("check", stages::check::run), + Cmd::Flash => run.step("flash", stages::flash::run), + Cmd::All => { + run.step("deps", stages::deps::run)?; + run.step("fetch", stages::fetch::run)?; + run.step("stage", stages::stage::run)?; + run.step("preconfig", stages::preconfig::run)?; + run.step("check", stages::check::run)?; + run.step("flash", stages::flash::run) + } + } +} + +struct Runner<'a> { + cfg: &'a Config, + paths: &'a Paths, + verbose: bool, +} + +impl Runner<'_> { + fn step( + &self, + name: &str, + f: impl Fn(&Config, &Paths, &Logger) -> jetson_flash::Result<()>, + ) -> Result<()> { + let log = Logger::init(name, &self.paths.repo_root, self.verbose)?; + Ok(f(self.cfg, self.paths, &log)?) + } +} diff --git a/src/stages/check.rs b/src/stages/check.rs new file mode 100644 index 0000000..42ffe36 --- /dev/null +++ b/src/stages/check.rs @@ -0,0 +1,90 @@ +use crate::{ + error::{RecoveryError, Result}, + logging::Logger, + Config, Paths, +}; + +const NVIDIA: u16 = 0x0955; + +/// Jetson USB state on the T234 family, as seen from the flashing host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsbState { + /// In APX recovery, ready to flash (with the detected model). + Recovery(Model), + /// Booted and running L4T (RNDIS, product 0x7020) — NOT flashable. + RunningL4t, + /// No Jetson on the USB bus. + Absent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Model { + AgxOrin, + OrinNx, + OrinNano, +} + +impl Model { + fn from_pid(pid: u16) -> Option { + match pid { + 0x7023 => Some(Model::AgxOrin), + 0x7423 => Some(Model::OrinNx), + 0x7523 => Some(Model::OrinNano), + _ => None, + } + } + pub fn name(self) -> &'static str { + match self { + Model::AgxOrin => "Jetson AGX Orin", + Model::OrinNx => "Jetson Orin NX", + Model::OrinNano => "Jetson Orin Nano", + } + } +} + +/// Probe the USB bus (native libusb, no `lsusb` dependency). +pub fn detect() -> Result { + let mut running = false; + for dev in rusb::devices()?.iter() { + let Ok(d) = dev.device_descriptor() else { + continue; + }; + if d.vendor_id() != NVIDIA { + continue; + } + if let Some(model) = Model::from_pid(d.product_id()) { + return Ok(UsbState::Recovery(model)); + } + if d.product_id() == 0x7020 { + running = true; + } + } + Ok(if running { + UsbState::RunningL4t + } else { + UsbState::Absent + }) +} + +pub fn run(_cfg: &Config, _paths: &Paths, log: &Logger) -> Result<()> { + match detect()? { + UsbState::Recovery(model) => { + let pid = match model { + Model::AgxOrin => "0955:7023", + Model::OrinNx => "0955:7423", + Model::OrinNano => "0955:7523", + }; + log.ok(&format!("{} in APX recovery ({pid}).", model.name())); + Ok(()) + } + UsbState::RunningL4t => { + log.err("Jetson is running L4T (0955:7020), not in recovery."); + log.info("Hold the REC (recovery) button while pressing RST (reset). Then re-run."); + Err(RecoveryError::RunningL4t.into()) + } + UsbState::Absent => { + log.err("No Jetson detected on USB. Power on the board, hold REC, tap RST."); + Err(RecoveryError::Absent.into()) + } + } +} diff --git a/src/stages/deps.rs b/src/stages/deps.rs new file mode 100644 index 0000000..b9d27dc --- /dev/null +++ b/src/stages/deps.rs @@ -0,0 +1,45 @@ +use crate::{error::Result, logging::Logger, Config, Paths}; +use std::fs; +use std::path::Path; + +const PKGS: &[&str] = &[ + "qemu-user-static", + "lz4", + "libxml2-utils", + "abootimg", + "sshpass", + "python3", + "python3-yaml", + "python3-setuptools", + "binfmt-support", + "nfs-kernel-server", + "dosfstools", + "uuid-runtime", + "wget", + "curl", + "ca-certificates", +]; + +pub fn run(_cfg: &Config, _paths: &Paths, log: &Logger) -> Result<()> { + match fs::read_to_string("/etc/os-release") { + Ok(s) if s.contains("VERSION_ID=\"24.04\"") => {} + _ => log.warn("Host is not Ubuntu 24.04. Continuing anyway."), + } + + log.sudo_validate()?; + log.step("apt-get update"); + log.run("sudo", &["apt-get", "update"])?; + + log.step(&format!("apt-get install ({} packages)", PKGS.len())); + let mut args = vec!["apt-get", "install", "-y"]; + args.extend_from_slice(PKGS); + log.run("sudo", &args)?; + + if !Path::new("/proc/sys/fs/binfmt_misc/qemu-aarch64").exists() { + log.info("Registering qemu-aarch64 binfmt handler."); + let _ = log.run("sudo", &["systemctl", "restart", "systemd-binfmt"]); + } + + log.ok("Host dependencies installed."); + Ok(()) +} diff --git a/src/stages/fetch.rs b/src/stages/fetch.rs new file mode 100644 index 0000000..1ea30ec --- /dev/null +++ b/src/stages/fetch.rs @@ -0,0 +1,28 @@ +use crate::{ + error::{IoContext, Result}, + logging::Logger, + Config, Paths, +}; +use std::fs; + +pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { + fs::create_dir_all(&paths.work).ctx(|| format!("mkdir {}", paths.work.display()))?; + + fetch_one(log, &paths.work, &cfg.l4t.bsp_url)?; + fetch_one(log, &paths.work, &cfg.l4t.rootfs_url)?; + + log.ok(&format!("Tarballs in {}.", paths.work.display())); + Ok(()) +} + +fn fetch_one(log: &Logger, work: &std::path::Path, url: &str) -> Result<()> { + let file = url.rsplit('/').next().unwrap_or(url); + let dest = work.join(file); + if dest.exists() { + log.info(&format!("skip {file} (already present)")); + return Ok(()); + } + log.step(&format!("fetch {url}")); + // Live stdio so wget's own progress bar shows through (resumable download). + log.run_live("wget", &["--content-disposition", "-O", file, url], work) +} diff --git a/src/stages/flash.rs b/src/stages/flash.rs new file mode 100644 index 0000000..4c89443 --- /dev/null +++ b/src/stages/flash.rs @@ -0,0 +1,45 @@ +use crate::{ + error::{Error, Result}, + logging::Logger, + Config, Paths, +}; + +pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { + let script = paths.l4t_dir.join("tools/kernel_flash/l4t_initrd_flash.sh"); + if !script.is_file() { + return Err(Error::Missing(format!( + "{} missing. Run `stage` first.", + script.display() + ))); + } + + log.sudo_validate()?; + log.step(&format!( + "flash board={} device={} (NVMe + internal QSPI; ~15-25 min)", + cfg.board.name, cfg.board.external_device + )); + + // Mirrors scripts/flash-nvme.sh. The `-p` value is one argv token. + // Live stdio: the flash is long and its output matters when it fails. + log.run_live( + "sudo", + &[ + "./tools/kernel_flash/l4t_initrd_flash.sh", + "--external-device", + &cfg.board.external_device, + "-c", + "tools/kernel_flash/flash_l4t_t234_nvme.xml", + "-p", + "-c bootloader/generic/cfg/flash_t234_qspi.xml", + "--showlogs", + "--network", + "usb0", + &cfg.board.name, + "internal", + ], + &paths.l4t_dir, + )?; + + log.ok("Flash complete. Disconnect USB-C and power-cycle; the board boots headless to the baked user."); + Ok(()) +} diff --git a/src/stages/mod.rs b/src/stages/mod.rs new file mode 100644 index 0000000..5860f88 --- /dev/null +++ b/src/stages/mod.rs @@ -0,0 +1,6 @@ +pub mod check; +pub mod deps; +pub mod fetch; +pub mod flash; +pub mod preconfig; +pub mod stage; diff --git a/src/stages/preconfig.rs b/src/stages/preconfig.rs new file mode 100644 index 0000000..142fdb4 --- /dev/null +++ b/src/stages/preconfig.rs @@ -0,0 +1,218 @@ +use crate::{ + error::{Error, IoContext, Result}, + logging::Logger, + Config, Paths, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Bake first-boot identity, headless target, static IPs, WiFi, and mDNS into +/// the staged L4T rootfs before flashing. Native port of +/// scripts/preconfigure-rootfs.sh. Networking uses NetworkManager keyfiles +/// (stock Jetson L4T runs NM; netplan/networkd YAML is ignored on first boot). +pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { + let rootfs = paths.rootfs(); + if !rootfs.join("etc").is_dir() { + return Err(Error::Missing(format!( + "{} not staged. Run `stage` first.", + rootfs.display() + ))); + } + log.sudo_validate()?; + + // 1. Pre-create user, skip oem-config wizard. + log.step(&format!( + "creating default user {} on host {}", + cfg.identity.username, cfg.identity.hostname + )); + log.run_in( + "sudo", + &[ + "./tools/l4t_create_default_user.sh", + "-u", + &cfg.identity.username, + "-p", + &cfg.identity.password, + "-n", + &cfg.identity.hostname, + "--accept-license", + ], + &paths.l4t_dir, + )?; + + let sys = rootfs.join("etc/systemd/system"); + + // 2. Headless: multi-user.target + mask display managers. + if cfg.identity.headless { + log.step("headless mode: multi-user.target + mask gdm3"); + symlink(log, "/lib/systemd/system/multi-user.target", &sys.join("default.target"))?; + symlink(log, "/dev/null", &sys.join("gdm3.service"))?; + symlink(log, "/dev/null", &sys.join("gdm.service"))?; + // oem-config gui may be absent; ignore failure. + let _ = symlink(log, "/dev/null", &sys.join("nv-oem-config-gui.service")); + } + + // 3. tty1 autologin. + if cfg.identity.autologin { + log.step(&format!("enabling tty1 autologin for {}", cfg.identity.username)); + let dir = sys.join("getty@tty1.service.d"); + log.run("sudo", &["mkdir", "-p", dir.to_str().unwrap()])?; + let override_conf = format!( + "[Service]\nExecStart=\nExecStart=-/sbin/agetty --autologin {} --noclear %I $TERM\n", + cfg.identity.username + ); + write_root_file(log, &dir.join("override.conf"), "644", &override_conf)?; + } + + // 4. Networking via NetworkManager keyfiles. + let nm_dir = rootfs.join("etc/NetworkManager/system-connections"); + let dns_nm = cfg.dns_nm(); + + // 4a. Ethernet static IP. + let eth = &cfg.network.ethernet; + if !eth.static_ip.is_empty() { + log.step(&format!("eth static {} on {}", eth.static_ip, eth.dev)); + log.run("sudo", &["mkdir", "-p", nm_dir.to_str().unwrap()])?; + let (addr, never_default) = if eth.gateway.is_empty() { + (eth.static_ip.clone(), "never-default=true") + } else { + (format!("{},{}", eth.static_ip, eth.gateway), "") + }; + let content = format!( + "[connection]\nid=eth-static\ntype=ethernet\nuuid={}\ninterface-name={}\nautoconnect=true\n\n\ + [ethernet]\n\n\ + [ipv4]\nmethod=manual\naddress1={}\ndns={};\n{}\n\n\ + [ipv6]\nmethod=auto\n", + new_uuid(), + eth.dev, + addr, + dns_nm, + never_default, + ); + write_root_file(log, &nm_dir.join("eth-static.nmconnection"), "600", &content)?; + } + + // 4b. WiFi. + let wifi = &cfg.network.wifi; + if !wifi.ssid.is_empty() { + if wifi.psk.is_empty() { + return Err(Error::WifiPskMissing); + } + log.run("sudo", &["mkdir", "-p", nm_dir.to_str().unwrap()])?; + let iface_line = if wifi.dev.is_empty() { + String::new() + } else { + format!("interface-name={}", wifi.dev) + }; + let ipv4_block = if !wifi.static_ip.is_empty() { + log.step(&format!("WiFi {} static {} (metric 200)", wifi.ssid, wifi.static_ip)); + let addr = if wifi.gateway.is_empty() { + wifi.static_ip.clone() + } else { + format!("{},{}", wifi.static_ip, wifi.gateway) + }; + format!("[ipv4]\nmethod=manual\naddress1={}\ndns={};\nroute-metric=200", addr, dns_nm) + } else if wifi.dhcp { + log.step(&format!("WiFi {} DHCP (metric 200)", wifi.ssid)); + "[ipv4]\nmethod=auto\nroute-metric=200".to_string() + } else { + "[ipv4]\nmethod=disabled".to_string() + }; + let content = format!( + "[connection]\nid=wifi-home\ntype=wifi\nuuid={}\n{}\nautoconnect=true\n\n\ + [wifi]\nmode=infrastructure\nssid={}\n\n\ + [wifi-security]\nkey-mgmt=wpa-psk\npsk={}\n\n\ + {}\n\n\ + [ipv6]\nmethod=auto\n", + new_uuid(), + iface_line, + wifi.ssid, + wifi.psk, + ipv4_block, + ); + write_root_file(log, &nm_dir.join("wifi-home.nmconnection"), "600", &content)?; + } + + // 4c. mDNS / avahi. + if cfg.services.avahi { + log.step(&format!("enabling avahi-daemon (mDNS for {}.local)", cfg.identity.hostname)); + enable_service(log, &rootfs, "avahi-daemon.service")?; + patch_nsswitch(log, &rootfs)?; + } + + // 5. SSH. + log.step("enabling ssh"); + enable_service(log, &rootfs, "ssh.service")?; + + log.ok("Rootfs preconfigured. Ready to flash."); + Ok(()) +} + +fn new_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +fn symlink(log: &Logger, target: &str, link: &Path) -> Result<()> { + log.run("sudo", &["ln", "-sf", target, link.to_str().unwrap()]) +} + +/// Write `content` to a root-owned file with `mode` (via a temp file + install). +fn write_root_file(log: &Logger, dst: &Path, mode: &str, content: &str) -> Result<()> { + let tmp = temp_path(); + fs::write(&tmp, content).ctx(|| format!("write temp {}", tmp.display()))?; + let res = log.run( + "sudo", + &[ + "install", + "-m", + mode, + "-o", + "root", + "-g", + "root", + tmp.to_str().unwrap(), + dst.to_str().unwrap(), + ], + ); + let _ = fs::remove_file(&tmp); + res +} + +fn temp_path() -> PathBuf { + std::env::temp_dir().join(format!("jetson-flash-{}.tmp", uuid::Uuid::new_v4())) +} + +/// `chroot rootfs systemctl enable `, falling back to a wants/ symlink. +fn enable_service(log: &Logger, rootfs: &Path, svc: &str) -> Result<()> { + let rootfs_s = rootfs.to_str().unwrap(); + let cmd = format!("systemctl enable {svc}"); + if log + .run("sudo", &["chroot", rootfs_s, "/bin/bash", "-c", &cmd]) + .is_ok() + { + return Ok(()); + } + let link = rootfs + .join("etc/systemd/system/multi-user.target.wants") + .join(svc); + symlink(log, &format!("/lib/systemd/system/{svc}"), &link) +} + +fn patch_nsswitch(log: &Logger, rootfs: &Path) -> Result<()> { + let nss = rootfs.join("etc/nsswitch.conf"); + let Ok(body) = fs::read_to_string(&nss) else { + return Ok(()); + }; + if body.contains("mdns") { + return Ok(()); + } + log.run( + "sudo", + &[ + "sed", + "-i", + "s/^hosts:.*$/hosts: files mdns4_minimal [NOTFOUND=return] dns/", + nss.to_str().unwrap(), + ], + ) +} diff --git a/src/stages/stage.rs b/src/stages/stage.rs new file mode 100644 index 0000000..74e2304 --- /dev/null +++ b/src/stages/stage.rs @@ -0,0 +1,69 @@ +use crate::{ + error::{Error, Result}, + logging::Logger, + Config, Paths, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn run(_cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { + let bsp = find_tarball(&paths.work, "Jetson_Linux_R", "_aarch64.tbz2").ok_or_else(|| { + Error::Missing("BSP tarball (Jetson_Linux_R*_aarch64.tbz2) not found in work/; run `fetch`".into()) + })?; + let rootfs = find_tarball( + &paths.work, + "Tegra_Linux_Sample-Root-Filesystem_R", + "_aarch64.tbz2", + ) + .ok_or_else(|| Error::Missing("rootfs tarball not found in work/; run `fetch`".into()))?; + + log.sudo_validate()?; + + if !paths.l4t_dir.exists() { + log.step("extract BSP -> Linux_for_Tegra/"); + log.run_in("tar", &["xf", bsp.to_str().unwrap()], &paths.work)?; + } + + let l4t = paths.l4t_dir.to_str().unwrap(); + + if !paths.rootfs().join("etc/os-release").exists() { + log.step("extract sample rootfs -> rootfs/"); + log.run_in( + "sudo", + &["tar", "xpf", rootfs.to_str().unwrap(), "-C", "rootfs/"], + &paths.l4t_dir, + )?; + } + + if paths.l4t_dir.join("tools/l4t_flash_prerequisites.sh").is_file() { + log.step("l4t_flash_prerequisites.sh"); + log.run_in("sudo", &["./tools/l4t_flash_prerequisites.sh"], &paths.l4t_dir)?; + } + + if !paths + .rootfs() + .join("usr/lib/aarch64-linux-gnu/tegra") + .exists() + { + log.step("apply_binaries.sh"); + log.run_in("sudo", &["./apply_binaries.sh"], &paths.l4t_dir)?; + } + + log.ok(&format!("Rootfs staged at {l4t}/rootfs")); + Ok(()) +} + +/// First entry in `dir` whose name starts with `prefix` and ends with `suffix`. +fn find_tarball(dir: &Path, prefix: &str, suffix: &str) -> Option { + let mut hits: Vec = fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(prefix) && n.ends_with(suffix)) + }) + .collect(); + hits.sort(); + hits.into_iter().next() +} From 3eccb6bdfadfc79368a8ec193f6d1cbd6a2f2c2e Mon Sep 17 00:00:00 2001 From: franklinselva Date: Wed, 1 Jul 2026 15:06:55 +0200 Subject: [PATCH 2/5] feat: idempotency guards for stage + preconfig - stage: explicit skip logs when Linux_for_Tegra/, rootfs, or applied NVIDIA binaries are already present (reuse instead of silent skip). - preconfig: skip l4t_create_default_user.sh (not idempotent) when the user is already baked into rootfs/etc/passwd, so preconfig is safe to re-run. --- src/stages/preconfig.rs | 57 +++++++++++++++++++++++++++-------------- src/stages/stage.rs | 12 ++++++--- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/stages/preconfig.rs b/src/stages/preconfig.rs index 142fdb4..3d36209 100644 --- a/src/stages/preconfig.rs +++ b/src/stages/preconfig.rs @@ -20,25 +20,33 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { } log.sudo_validate()?; - // 1. Pre-create user, skip oem-config wizard. - log.step(&format!( - "creating default user {} on host {}", - cfg.identity.username, cfg.identity.hostname - )); - log.run_in( - "sudo", - &[ - "./tools/l4t_create_default_user.sh", - "-u", - &cfg.identity.username, - "-p", - &cfg.identity.password, - "-n", - &cfg.identity.hostname, - "--accept-license", - ], - &paths.l4t_dir, - )?; + // 1. Pre-create user, skip oem-config wizard. Not idempotent, so guard on + // whether the user is already baked into the rootfs (safe to re-run). + if user_baked(&rootfs, &cfg.identity.username) { + log.info(&format!( + "user {} already baked into rootfs; skipping create_default_user", + cfg.identity.username + )); + } else { + log.step(&format!( + "creating default user {} on host {}", + cfg.identity.username, cfg.identity.hostname + )); + log.run_in( + "sudo", + &[ + "./tools/l4t_create_default_user.sh", + "-u", + &cfg.identity.username, + "-p", + &cfg.identity.password, + "-n", + &cfg.identity.hostname, + "--accept-license", + ], + &paths.l4t_dir, + )?; + } let sys = rootfs.join("etc/systemd/system"); @@ -152,6 +160,17 @@ fn new_uuid() -> String { uuid::Uuid::new_v4().to_string() } +/// Is `user` already present in the rootfs `/etc/passwd`? Read needs root +/// (rootfs is root-owned); relies on the sudo cache primed by `sudo_validate`. +fn user_baked(rootfs: &Path, user: &str) -> bool { + let passwd = rootfs.join("etc/passwd"); + std::process::Command::new("sudo") + .args(["grep", "-q", &format!("^{user}:"), passwd.to_str().unwrap()]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + fn symlink(log: &Logger, target: &str, link: &Path) -> Result<()> { log.run("sudo", &["ln", "-sf", target, link.to_str().unwrap()]) } diff --git a/src/stages/stage.rs b/src/stages/stage.rs index 74e2304..b4dc54e 100644 --- a/src/stages/stage.rs +++ b/src/stages/stage.rs @@ -19,14 +19,18 @@ pub fn run(_cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { log.sudo_validate()?; - if !paths.l4t_dir.exists() { + if paths.l4t_dir.exists() { + log.info("reusing existing Linux_for_Tegra/ (BSP already extracted)"); + } else { log.step("extract BSP -> Linux_for_Tegra/"); log.run_in("tar", &["xf", bsp.to_str().unwrap()], &paths.work)?; } let l4t = paths.l4t_dir.to_str().unwrap(); - if !paths.rootfs().join("etc/os-release").exists() { + if paths.rootfs().join("etc/os-release").exists() { + log.info("rootfs already extracted; skipping"); + } else { log.step("extract sample rootfs -> rootfs/"); log.run_in( "sudo", @@ -40,11 +44,13 @@ pub fn run(_cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { log.run_in("sudo", &["./tools/l4t_flash_prerequisites.sh"], &paths.l4t_dir)?; } - if !paths + if paths .rootfs() .join("usr/lib/aarch64-linux-gnu/tegra") .exists() { + log.info("NVIDIA binaries already applied; skipping apply_binaries.sh"); + } else { log.step("apply_binaries.sh"); log.run_in("sudo", &["./apply_binaries.sh"], &paths.l4t_dir)?; } From 309a3e3c81f494dcb4ef64ef78902a2ca4529cbb Mon Sep 17 00:00:00 2001 From: franklinselva Date: Wed, 1 Jul 2026 15:09:49 +0200 Subject: [PATCH 3/5] ci: add CI + crates.io publish workflows, changelog, install metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cargo.toml: repository/readme/keywords/categories/rust-version + publish exclude (work, logs, docs, scripts, justfile). - CI workflow: fmt --check, clippy -D warnings, build, test (installs libusb for the rusb link dep). - Release workflow: on a v* tag, verify tag==version, dry-run, then `cargo publish` via CARGO_REGISTRY_TOKEN secret — so `cargo install jetson-flash` works once tagged. - CHANGELOG.md (Keep a Changelog) with the 0.1.0 entry. - README: cargo install instructions. - rustfmt normalization of stage/preconfig. --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++ .github/workflows/release.yml | 37 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++ Cargo.toml | 7 +++++++ README.md | 6 ++++-- src/stages/preconfig.rs | 33 +++++++++++++++++++++++++------ src/stages/stage.rs | 16 ++++++++++++--- 7 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74c4bcb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install libusb (rusb link dep) + run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0-dev + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Format + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Build + run: cargo build --release --locked + - name: Test + run: cargo test --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3f3b628 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +# Publishes to crates.io when a v* tag is pushed, e.g. `git tag v0.1.0 && git push --tags`. +# Requires the CARGO_REGISTRY_TOKEN repo secret (crates.io API token). +on: + push: + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install libusb (rusb link dep) + run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Verify tag matches Cargo.toml version + run: | + tag="${GITHUB_REF_NAME#v}" + crate="$(cargo metadata --no-deps --format-version 1 | grep -oP '"version":"\K[^"]+' | head -1)" + if [ "$tag" != "$crate" ]; then + echo "::error::tag v$tag != Cargo.toml version $crate"; exit 1 + fi + + - name: Publish dry-run + run: cargo publish --locked --dry-run + + - name: Publish to crates.io + run: cargo publish --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4252c99 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is +[SemVer](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] + +First Rust release. A library crate (`jetson_flash`) plus a CLI (`jetson-flash`) +porting the `just`/bash flashing pipeline, kept alongside the existing scripts. + +### Added +- CLI with the six pipeline stages plus `all`: `deps`, `fetch`, `stage`, + `preconfig`, `check`, `flash`. +- TOML configuration (`jetson-flash.toml`) via figment, with `JETSON_*` + environment-variable overrides. +- Native USB recovery detection (libusb via `rusb`) exposing typed + `UsbState` / `Model`; no `lsusb` parsing. +- Native NetworkManager keyfile and first-boot identity baking. +- Typed error enum (`Error`, `RecoveryError`) via `thiserror`; `anyhow` only + in the binary. +- Clean terminal UX: subprocess output captured to `logs/-.log` + with an `indicatif` spinner; `-v/--verbose` streams live; downloads and the + flash use live stdio; on failure the captured log tail is printed. +- Idempotency guards: `stage` reuses an existing `Linux_for_Tegra/`, rootfs, + and applied binaries; `preconfig` skips user creation when the user is + already baked into the rootfs. +- CI (fmt, clippy `-D warnings`, build, test) and a tag-driven crates.io + publish workflow. + +[Unreleased]: https://github.com/AstroRoboticsTech/jetson-flash/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/AstroRoboticsTech/jetson-flash/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 47d0545..050b16a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,15 @@ name = "jetson-flash" version = "0.1.0" edition = "2021" +rust-version = "1.74" description = "Headless Jetson Orin (Tegra234) flashing pipeline: config, staging, preconfig, recovery detection, NVMe flash" license = "Apache-2.0" +repository = "https://github.com/AstroRoboticsTech/jetson-flash" +homepage = "https://github.com/AstroRoboticsTech/jetson-flash" +readme = "README.md" +keywords = ["jetson", "nvidia", "l4t", "flashing", "embedded"] +categories = ["command-line-utilities", "embedded"] +exclude = ["work/", "work_*.bak/", "logs/", ".env", "docs/", "scripts/", "justfile"] [lib] name = "jetson_flash" diff --git a/README.md b/README.md index 5e5718d..03d920f 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,10 @@ for integration with other Rust commissioning tooling. It reads TOML config (`jetson-flash.toml`) instead of `.env`; `JETSON_*` env vars override. ```bash -cargo build --release -target/release/jetson-flash check # deps|fetch|stage|preconfig|check|flash|all +cargo install jetson-flash # from crates.io (needs libusb-1.0-0-dev) +# or from a checkout: +cargo install --path . +jetson-flash check # deps|fetch|stage|preconfig|check|flash|all ``` | bash / just | Rust CLI | diff --git a/src/stages/preconfig.rs b/src/stages/preconfig.rs index 3d36209..0f1e936 100644 --- a/src/stages/preconfig.rs +++ b/src/stages/preconfig.rs @@ -53,7 +53,11 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { // 2. Headless: multi-user.target + mask display managers. if cfg.identity.headless { log.step("headless mode: multi-user.target + mask gdm3"); - symlink(log, "/lib/systemd/system/multi-user.target", &sys.join("default.target"))?; + symlink( + log, + "/lib/systemd/system/multi-user.target", + &sys.join("default.target"), + )?; symlink(log, "/dev/null", &sys.join("gdm3.service"))?; symlink(log, "/dev/null", &sys.join("gdm.service"))?; // oem-config gui may be absent; ignore failure. @@ -62,7 +66,10 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { // 3. tty1 autologin. if cfg.identity.autologin { - log.step(&format!("enabling tty1 autologin for {}", cfg.identity.username)); + log.step(&format!( + "enabling tty1 autologin for {}", + cfg.identity.username + )); let dir = sys.join("getty@tty1.service.d"); log.run("sudo", &["mkdir", "-p", dir.to_str().unwrap()])?; let override_conf = format!( @@ -97,7 +104,12 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { dns_nm, never_default, ); - write_root_file(log, &nm_dir.join("eth-static.nmconnection"), "600", &content)?; + write_root_file( + log, + &nm_dir.join("eth-static.nmconnection"), + "600", + &content, + )?; } // 4b. WiFi. @@ -113,13 +125,19 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { format!("interface-name={}", wifi.dev) }; let ipv4_block = if !wifi.static_ip.is_empty() { - log.step(&format!("WiFi {} static {} (metric 200)", wifi.ssid, wifi.static_ip)); + log.step(&format!( + "WiFi {} static {} (metric 200)", + wifi.ssid, wifi.static_ip + )); let addr = if wifi.gateway.is_empty() { wifi.static_ip.clone() } else { format!("{},{}", wifi.static_ip, wifi.gateway) }; - format!("[ipv4]\nmethod=manual\naddress1={}\ndns={};\nroute-metric=200", addr, dns_nm) + format!( + "[ipv4]\nmethod=manual\naddress1={}\ndns={};\nroute-metric=200", + addr, dns_nm + ) } else if wifi.dhcp { log.step(&format!("WiFi {} DHCP (metric 200)", wifi.ssid)); "[ipv4]\nmethod=auto\nroute-metric=200".to_string() @@ -143,7 +161,10 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { // 4c. mDNS / avahi. if cfg.services.avahi { - log.step(&format!("enabling avahi-daemon (mDNS for {}.local)", cfg.identity.hostname)); + log.step(&format!( + "enabling avahi-daemon (mDNS for {}.local)", + cfg.identity.hostname + )); enable_service(log, &rootfs, "avahi-daemon.service")?; patch_nsswitch(log, &rootfs)?; } diff --git a/src/stages/stage.rs b/src/stages/stage.rs index b4dc54e..ee17693 100644 --- a/src/stages/stage.rs +++ b/src/stages/stage.rs @@ -8,7 +8,9 @@ use std::path::{Path, PathBuf}; pub fn run(_cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { let bsp = find_tarball(&paths.work, "Jetson_Linux_R", "_aarch64.tbz2").ok_or_else(|| { - Error::Missing("BSP tarball (Jetson_Linux_R*_aarch64.tbz2) not found in work/; run `fetch`".into()) + Error::Missing( + "BSP tarball (Jetson_Linux_R*_aarch64.tbz2) not found in work/; run `fetch`".into(), + ) })?; let rootfs = find_tarball( &paths.work, @@ -39,9 +41,17 @@ pub fn run(_cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { )?; } - if paths.l4t_dir.join("tools/l4t_flash_prerequisites.sh").is_file() { + if paths + .l4t_dir + .join("tools/l4t_flash_prerequisites.sh") + .is_file() + { log.step("l4t_flash_prerequisites.sh"); - log.run_in("sudo", &["./tools/l4t_flash_prerequisites.sh"], &paths.l4t_dir)?; + log.run_in( + "sudo", + &["./tools/l4t_flash_prerequisites.sh"], + &paths.l4t_dir, + )?; } if paths From 77b879c225475e06c6853ad077c0fd861c196278 Mon Sep 17 00:00:00 2001 From: franklinselva Date: Wed, 1 Jul 2026 15:27:04 +0200 Subject: [PATCH 4/5] feat: profile-based config with init/edit seeding - Config is now nested-TOML profiles: a [default] base + one [] table per board (orin-nano, orin-agx). Select with --profile / JETSON_PROFILE (required for every stage). `Config::load(path, profile)`. - Secrets removed from the file: identity.password is Option, supplied via JETSON_IDENTITY_PASSWORD; wifi psk via JETSON_NETWORK_WIFI_PSK. - `init` seeds a jetson-flash.toml from a template embedded in the binary (include_str!) so an installed CLI self-seeds; destination is --config , else --global (XDG), else ./. - `edit` opens the resolved config in $EDITOR; `profiles` lists profiles. - Config discovery: --config -> ./jetson-flash.toml -> XDG. - clap `env` feature enabled (JETSON_PROFILE); friendly errors for unknown/ missing profile and missing secret. - README + CHANGELOG updated. --- CHANGELOG.md | 15 ++++++ Cargo.toml | 2 +- README.md | 41 ++++++++++----- jetson-flash.toml | 101 +++++++++++++++++++++++------------- src/config.rs | 77 ++++++++++++++++++++++++---- src/error.rs | 8 ++- src/lib.rs | 2 +- src/main.rs | 110 ++++++++++++++++++++++++++++++++++++++-- src/stages/preconfig.rs | 7 ++- 9 files changed, 297 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4252c99..54b3524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Added +- Profile-based config: a `[default]` base table plus one `[]` table per + board (`orin-nano`, `orin-agx`), selected with `--profile` / `JETSON_PROFILE` + (required for every stage). `profiles` command lists them. +- `init` command seeds a `jetson-flash.toml` from a template embedded in the + binary — destination is `--config `, else `--global` (XDG), else `./`. +- `edit` command opens the resolved config in `$EDITOR`. +- Config discovery: `--config` → `./jetson-flash.toml` → + `~/.config/jetson-flash/jetson-flash.toml`. + +### Changed +- Secrets (`identity.password`, `network.wifi.psk`) are no longer stored in the + config file; supply them via `JETSON_IDENTITY_PASSWORD` / + `JETSON_NETWORK_WIFI_PSK`. `Config::load` now takes a profile name. + ## [0.1.0] First Rust release. A library crate (`jetson_flash`) plus a CLI (`jetson-flash`) diff --git a/Cargo.toml b/Cargo.toml index 050b16a..caf9c6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" thiserror = "2" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } serde = { version = "1", features = ["derive"] } figment = { version = "0.10", features = ["toml", "env"] } rusb = "0.9" diff --git a/README.md b/README.md index 03d920f..274ea60 100644 --- a/README.md +++ b/README.md @@ -60,32 +60,45 @@ just all ## Rust CLI (alternative to `just`) The same pipeline is available as a Rust crate + CLI (`src/`, `Cargo.toml`), -for integration with other Rust commissioning tooling. It reads TOML config -(`jetson-flash.toml`) instead of `.env`; `JETSON_*` env vars override. +for integration with other Rust commissioning tooling. Config is a +**profile-based TOML** (`jetson-flash.toml`): a `[default]` base plus one +`[]` table per board. Secrets are supplied via env, never the file. ```bash cargo install jetson-flash # from crates.io (needs libusb-1.0-0-dev) -# or from a checkout: -cargo install --path . -jetson-flash check # deps|fetch|stage|preconfig|check|flash|all +# or from a checkout: cargo install --path . + +jetson-flash init # seed ./jetson-flash.toml (embedded template) +jetson-flash init --global # ...into ~/.config/jetson-flash/ instead +jetson-flash --config /path/x.toml init # ...or an explicit path +jetson-flash edit # open the resolved config in $EDITOR +jetson-flash profiles # list board profiles +JETSON_IDENTITY_PASSWORD=secret \ + jetson-flash --profile orin-nano all ``` -| bash / just | Rust CLI | -|--------------------|------------------------------| -| `just ` | `jetson-flash ` | -| `.env` | `jetson-flash.toml` | -| `lsusb` parse | native libusb (`rusb`) | -| `uuidgen`, keyfiles| generated in-process | +Config is discovered as: `--config` → `./jetson-flash.toml` → `~/.config/jetson-flash/jetson-flash.toml`. +`--profile` (or `JETSON_PROFILE`) is required for every stage. `JETSON_*` +env vars fill any key the active profile leaves unset — e.g. +`JETSON_IDENTITY_PASSWORD`, `JETSON_NETWORK_WIFI_PSK`. + +| bash / just | Rust CLI | +|--------------------|-----------------------------------| +| `just ` | `jetson-flash -p `| +| `.env` (flat) | `jetson-flash.toml` (profiles) | +| edit board values | `--profile orin-nano` / `orin-agx`| +| `lsusb` parse | native libusb (`rusb`) | Recovery detection and NM-keyfile/identity baking are native Rust; NVIDIA's `l4t_*.sh` / `apply_binaries.sh` and `apt`/`wget`/`tar` are still driven as -subprocesses. Logs land in `logs/-.log` (same as bash). As a library: +subprocesses. Output is captured to `logs/-.log` with a spinner +(`-v` streams live). As a library: ```rust use jetson_flash::{Config, Paths, stages, logging::Logger}; let paths = Paths::new(std::path::Path::new(".")); -let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"))?; -stages::check::run(&cfg, &paths, &Logger::init("check", &paths.repo_root)?)?; +let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"), "orin-nano")?; +stages::check::run(&cfg, &paths, &Logger::init("check", &paths.repo_root, false)?)?; ``` When the flash finishes, unplug the USB-C data cable, power-cycle the diff --git a/jetson-flash.toml b/jetson-flash.toml index 1f7f711..0bfc46b 100644 --- a/jetson-flash.toml +++ b/jetson-flash.toml @@ -1,44 +1,75 @@ -# jetson-flash Rust pipeline config. Mirrors the .env used by the `just`/bash -# path. Load order (figment): this file, then JETSON_* env vars override. -# Pass a different file with `jetson-flash --config `. +# jetson-flash Rust pipeline config — profile-based. +# +# [default] shared base, merged into every profile +# [] a board profile that overrides the base +# +# Select one: jetson-flash --profile orin-nano (or JETSON_PROFILE=…) +# List them: jetson-flash profiles +# +# Secrets are NOT stored here. Supply at flash time via env: +# JETSON_IDENTITY_PASSWORD=… (required to create the default user) +# JETSON_NETWORK_WIFI_PSK=… (required if a profile sets a wifi ssid) +# Any JETSON_* var fills a key the active profile leaves unset. -[l4t] -# JetPack 7.2 / L4T r39.2. Leave at defaults unless bumping JetPack. -version = "39.2.0" +[default] + +[default.l4t] +# JetPack 7.2 / L4T r39.2. Shared across Orin boards. +version = "39.2.0" bsp_url = "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Jetson_Linux_R39.2.0_aarch64.tbz2" rootfs_url = "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Tegra_Linux_Sample-Root-Filesystem_R39.2.0_aarch64.tbz2" -[board] -# Orin Nano: "jetson-orin-nano-devkit-super". AGX: "jetson-agx-orin-devkit-super". -name = "jetson-orin-nano-devkit-super" -external_device = "nvme0n1p1" - -[identity] +[default.identity] username = "jetson" -password = "jetson" -hostname = "orin-nano" headless = true autologin = true +# password: env-only (JETSON_IDENTITY_PASSWORD) + +[default.services] +avahi = true + +# --- Orin Nano 8GB Super devkit ------------------------------------------- +[orin-nano] + +[orin-nano.board] +name = "jetson-orin-nano-devkit-super" +external_device = "nvme0n1p1" + +[orin-nano.identity] +hostname = "orin-nano" -[network.ethernet] -# Jetson uses PCIe predictable iface names, not eth0. Nano: enP8p1s0. -# AGX differs (often enP1p1s0) — verify with `ip -br link` on a booted board. -dev = "enP8p1s0" -# CIDR for a static IP, or empty string "" for DHCP. +[orin-nano.network.ethernet] +dev = "enP8p1s0" # Nano PCIe iface +static_ip = "10.42.0.10/24" # "" for DHCP +gateway = "" # "" => eth never owns the default route +dns = ["1.1.1.1", "8.8.8.8"] + +[orin-nano.network.wifi] +dev = "" # "" => match by SSID only +ssid = "" # "" => skip WiFi (set JETSON_NETWORK_WIFI_PSK too) +dhcp = true +static_ip = "" +gateway = "" + +# --- AGX Orin devkit ------------------------------------------------------ +[orin-agx] + +[orin-agx.board] +name = "jetson-agx-orin-devkit-super" # 64GB => -super (MAXN SUPER) +external_device = "nvme0n1p1" + +[orin-agx.identity] +hostname = "orin-agx" + +[orin-agx.network.ethernet] +dev = "enP1p1s0" # AGX Marvell AQtion — VERIFY with `ip -br link` static_ip = "10.42.0.10/24" -# Empty gateway => eth never owns the default route (WiFi carries internet). -gateway = "" -dns = ["1.1.1.1", "8.8.8.8"] - -[network.wifi] -# Empty ssid => skip WiFi entirely. Needs an M.2 E-key card. -# Empty dev => NetworkManager matches by SSID only (recommended). -dev = "" -ssid = "" -psk = "" -dhcp = true -static_ip = "" # set => WiFi static, dhcp ignored -gateway = "" # WiFi default route (metric 200, fallback behind eth) - -[services] -avahi = true # mDNS => reach board as .local +gateway = "" +dns = ["1.1.1.1", "8.8.8.8"] + +[orin-agx.network.wifi] +dev = "" +ssid = "" # AGX devkit needs an M.2 E-key card +dhcp = true +static_ip = "" +gateway = "" diff --git a/src/config.rs b/src/config.rs index 73f8798..0e20dfa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use crate::error::Result; +use crate::error::{Error, Result}; use figment::{ providers::{Env, Format, Toml}, Figment, @@ -33,7 +33,9 @@ pub struct Board { #[derive(Debug, Clone, Deserialize)] pub struct Identity { pub username: String, - pub password: String, + /// Kept out of the config file; supply via `JETSON_IDENTITY_PASSWORD`. + #[serde(default)] + pub password: Option, pub hostname: String, #[serde(default = "yes")] pub headless: bool, @@ -111,13 +113,25 @@ fn default_dns() -> Vec { } impl Config { - /// Load from a TOML file, with `JETSON_`-prefixed env vars overriding. - /// e.g. `JETSON_BOARD_NAME=... JETSON_IDENTITY_HOSTNAME=...`. - pub fn load(path: &Path) -> Result { - Ok(Figment::new() - .merge(Toml::file(path)) - .merge(Env::prefixed("JETSON_").split("_")) - .extract()?) + /// Load the given profile from a nested-TOML config: the `[default]` table + /// is the shared base, and `[]` overrides it. `JETSON_`-prefixed + /// env vars fill any key the profile leaves unset (e.g. secrets like + /// `JETSON_IDENTITY_PASSWORD`). Errors if `profile` is not a defined table. + pub fn load(path: &Path, profile: &str) -> Result { + let fig = base_figment(path); + let known = profile_names(&fig); + if !known.iter().any(|p| p == profile) { + return Err(Error::UnknownProfile { + name: profile.to_string(), + known, + }); + } + Ok(fig.select(profile).extract()?) + } + + /// Profile names defined in the config file (excludes `default`). + pub fn profiles(path: &Path) -> Vec { + profile_names(&base_figment(path)) } /// DNS as a NetworkManager keyfile list ("1.1.1.1;8.8.8.8;"). @@ -130,6 +144,51 @@ impl Config { } } +/// The canonical config, embedded at build time so an installed binary can +/// seed one (`jetson-flash init`). Ships the shared `[default]` base + the +/// board presets, and evolves with each release. +pub const TEMPLATE: &str = include_str!("../jetson-flash.toml"); + +/// `$XDG_CONFIG_HOME/jetson-flash/jetson-flash.toml` (falls back to +/// `$HOME/.config/...`). `None` if neither env var is set. +pub fn xdg_config_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; + Some(base.join("jetson-flash").join("jetson-flash.toml")) +} + +/// Resolve which config file to use: explicit `--config`, else +/// `/jetson-flash.toml`, else the XDG path. Returns the local path +/// as the default even when nothing exists, so callers can report it. +pub fn resolve_config_path(explicit: Option, repo_root: &Path) -> PathBuf { + if let Some(p) = explicit { + return p; + } + let local = default_config_path(repo_root); + if local.exists() { + return local; + } + match xdg_config_path() { + Some(x) if x.exists() => x, + _ => local, + } +} + +/// Nested-TOML + env figment, before a profile is selected. +fn base_figment(path: &Path) -> Figment { + Figment::new() + .merge(Toml::file(path).nested()) + .merge(Env::prefixed("JETSON_").split("_")) +} + +fn profile_names(fig: &Figment) -> Vec { + fig.profiles() + .map(|p| p.to_string()) + .filter(|p| p != "default") + .collect() +} + /// Default config path: ./jetson-flash.toml under the given repo root. pub fn default_config_path(repo_root: &Path) -> PathBuf { repo_root.join("jetson-flash.toml") diff --git a/src/error.rs b/src/error.rs index 3afa757..ba054aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,9 +40,15 @@ pub enum Error { #[error("{0}")] Missing(String), - #[error("wifi ssid set but wifi psk is empty")] + #[error("wifi ssid set but wifi psk is empty (set JETSON_NETWORK_WIFI_PSK)")] WifiPskMissing, + #[error("unknown profile `{name}` (available: {})", .known.join(", "))] + UnknownProfile { name: String, known: Vec }, + + #[error("{0} is required — supply it via environment (e.g. JETSON_IDENTITY_PASSWORD)")] + MissingSecret(&'static str), + #[error("Jetson not ready to flash: {0}")] Recovery(#[from] RecoveryError), } diff --git a/src/lib.rs b/src/lib.rs index 3c19fcd..48b358c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ //! use jetson_flash::{Config, Paths, stages, logging::Logger}; //! use std::path::Path; //! let paths = Paths::new(Path::new(".")); -//! let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"))?; +//! let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"), "orin-nano")?; //! let log = Logger::init("check", &paths.repo_root, false)?; //! stages::check::run(&cfg, &paths, &log)?; //! # jetson_flash::Result::Ok(()) diff --git a/src/main.rs b/src/main.rs index c6aaa71..3059066 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,12 +19,31 @@ struct Cli { #[arg(short, long, global = true)] verbose: bool, + /// Board profile to load from the config (a [] table). Required for + /// every stage; set once via the JETSON_PROFILE env var if you prefer. + #[arg(short, long, global = true, env = "JETSON_PROFILE")] + profile: Option, + #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { + /// Write a starter jetson-flash.toml (embedded template) to seed config. + /// Destination: --config if given, else --global, else ./. + Init { + /// Write to the XDG config dir (~/.config/jetson-flash/) instead of ./. + #[arg(long)] + global: bool, + /// Overwrite an existing config file. + #[arg(long)] + force: bool, + }, + /// Open the resolved config file in $EDITOR (to add/edit profiles). + Edit, + /// List the board profiles defined in the config. + Profiles, /// Install host apt dependencies (Ubuntu 24.04). Deps, /// Download BSP + sample rootfs tarballs into work/. @@ -49,10 +68,42 @@ fn main() -> Result<()> { None => std::env::current_dir().context("resolve current dir")?, }; let paths = Paths::new(&repo_root); - let config_path = cli - .config - .unwrap_or_else(|| config::default_config_path(&repo_root)); - let cfg = Config::load(&config_path)?; + + if let Cmd::Init { global, force } = cli.cmd { + return init_config(cli.config.clone(), &repo_root, global, force); + } + + let config_path = config::resolve_config_path(cli.config.clone(), &repo_root); + + if matches!(cli.cmd, Cmd::Edit) { + return edit_config(&config_path); + } + + if matches!(cli.cmd, Cmd::Profiles) { + let names = Config::profiles(&config_path); + if names.is_empty() { + println!("no profiles defined in {}", config_path.display()); + } else { + println!("profiles in {}:", config_path.display()); + for n in names { + println!(" {n}"); + } + } + return Ok(()); + } + + if !config_path.exists() { + anyhow::bail!( + "no config found at {}. Run `jetson-flash init` (or `--global`) to seed one.", + config_path.display() + ); + } + + let profile = cli.profile.clone().ok_or_else(|| { + let known = Config::profiles(&config_path).join(", "); + anyhow::anyhow!("--profile is required (or set JETSON_PROFILE). Available: {known}") + })?; + let cfg = Config::load(&config_path, &profile)?; let run = Runner { cfg: &cfg, paths: &paths, @@ -60,6 +111,7 @@ fn main() -> Result<()> { }; match cli.cmd { + Cmd::Init { .. } | Cmd::Edit | Cmd::Profiles => unreachable!("handled above"), Cmd::Deps => run.step("deps", stages::deps::run), Cmd::Fetch => run.step("fetch", stages::fetch::run), Cmd::Stage => run.step("stage", stages::stage::run), @@ -77,6 +129,56 @@ fn main() -> Result<()> { } } +/// Seed a jetson-flash.toml from the embedded template. Destination: +/// `--config ` if given, else `--global` (XDG), else `/`. +fn init_config( + explicit: Option, + repo_root: &std::path::Path, + global: bool, + force: bool, +) -> Result<()> { + let dst = match explicit { + Some(p) => p, + None if global => config::xdg_config_path() + .context("cannot resolve XDG config dir (set XDG_CONFIG_HOME or HOME)")?, + None => config::default_config_path(repo_root), + }; + if dst.exists() && !force { + anyhow::bail!( + "{} already exists (pass --force to overwrite)", + dst.display() + ); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?; + } + std::fs::write(&dst, config::TEMPLATE).with_context(|| format!("write {}", dst.display()))?; + println!("wrote {}", dst.display()); + println!("edit it (or `jetson-flash edit`), then: jetson-flash --profile "); + Ok(()) +} + +/// Open the resolved config in `$EDITOR` (falls back to `$VISUAL`, then `vi`). +fn edit_config(path: &std::path::Path) -> Result<()> { + if !path.exists() { + anyhow::bail!( + "no config at {}. Run `jetson-flash init` first.", + path.display() + ); + } + let editor = std::env::var("EDITOR") + .or_else(|_| std::env::var("VISUAL")) + .unwrap_or_else(|_| "vi".to_string()); + let status = std::process::Command::new(&editor) + .arg(path) + .status() + .with_context(|| format!("launch editor `{editor}`"))?; + if !status.success() { + anyhow::bail!("editor `{editor}` exited with {status}"); + } + Ok(()) +} + struct Runner<'a> { cfg: &'a Config, paths: &'a Paths, diff --git a/src/stages/preconfig.rs b/src/stages/preconfig.rs index 0f1e936..febaf63 100644 --- a/src/stages/preconfig.rs +++ b/src/stages/preconfig.rs @@ -28,6 +28,11 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { cfg.identity.username )); } else { + let password = cfg + .identity + .password + .as_deref() + .ok_or(Error::MissingSecret("identity.password"))?; log.step(&format!( "creating default user {} on host {}", cfg.identity.username, cfg.identity.hostname @@ -39,7 +44,7 @@ pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { "-u", &cfg.identity.username, "-p", - &cfg.identity.password, + password, "-n", &cfg.identity.hostname, "--accept-license", From 2e30ae7a866f7ba114c5fec69424934cc715388a Mon Sep 17 00:00:00 2001 From: franklinselva Date: Wed, 1 Jul 2026 16:58:06 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20v1.0.0=20=E2=80=94=20jetpack=20pres?= =?UTF-8?q?ets,=20namespaced=20cache,=20pipeline=20API,=20Rust-first=20doc?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version 1.0.0 (first release). - jetpack presets per profile ("6.2.1" -> L4T r36.4.4, "7.2" -> r39.2); resolves L4T version + BSP/rootfs URLs; explicit [profile.l4t] overrides. - Namespaced workspace/cache: downloads// (shared) + work|logs/-/ so boards and JetPack versions never collide. Base = --work-dir, else repo checkout, else ~/.cache/jetson-flash. - Library pipeline API: Step, run_step, run_all (open per-slot log + dispatch); binary reuses them (dropped the bespoke Runner). examples/pipeline.rs added. - Bare `jetson-flash` prints help (exit 0) instead of a usage error. - Fix: NM dns keyfile double-semicolon. - Docs: Rust CLI is now primary; just/bash moved to docs/legacy-just.md. Both Orin Nano Super and AGX Orin marked hardware-validated (JetPack 7.2). Prereqs trimmed to cargo + libusb; sudo-TTY + recovery-ID troubleshooting. - Validated end-to-end on hardware: fetch/stage/preconfig/check/flash of an Orin Nano Super, NVMe + QSPI, "Flash is successful". --- .gitignore | 1 + CHANGELOG.md | 46 ++++--- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 314 +++++++++++++++++++------------------------ docs/legacy-just.md | 90 +++++++++++++ docs/orin-agx.md | 19 ++- docs/orin-nano.md | 6 +- examples/pipeline.rs | 44 ++++++ jetson-flash.toml | 11 +- src/config.rs | 105 +++++++++++++-- src/error.rs | 3 + src/lib.rs | 57 +++++--- src/logging.rs | 32 ++--- src/main.rs | 91 ++++++------- src/pipeline.rs | 71 ++++++++++ src/stages/fetch.rs | 8 +- src/stages/stage.rs | 16 ++- 18 files changed, 602 insertions(+), 316 deletions(-) create mode 100644 docs/legacy-just.md create mode 100644 examples/pipeline.rs create mode 100644 src/pipeline.rs diff --git a/.gitignore b/.gitignore index 4829cc7..e853a55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ work/ work_*.bak/ +downloads/ target/ logs/ *.tbz2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b3524..f29f241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,31 +6,29 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] -### Added -- Profile-based config: a `[default]` base table plus one `[]` table per - board (`orin-nano`, `orin-agx`), selected with `--profile` / `JETSON_PROFILE` - (required for every stage). `profiles` command lists them. -- `init` command seeds a `jetson-flash.toml` from a template embedded in the - binary — destination is `--config `, else `--global` (XDG), else `./`. -- `edit` command opens the resolved config in `$EDITOR`. -- Config discovery: `--config` → `./jetson-flash.toml` → - `~/.config/jetson-flash/jetson-flash.toml`. - -### Changed -- Secrets (`identity.password`, `network.wifi.psk`) are no longer stored in the - config file; supply them via `JETSON_IDENTITY_PASSWORD` / - `JETSON_NETWORK_WIFI_PSK`. `Config::load` now takes a profile name. +## [1.0.0] -## [0.1.0] - -First Rust release. A library crate (`jetson_flash`) plus a CLI (`jetson-flash`) +First release. A library crate (`jetson_flash`) plus a CLI (`jetson-flash`) porting the `just`/bash flashing pipeline, kept alongside the existing scripts. ### Added - CLI with the six pipeline stages plus `all`: `deps`, `fetch`, `stage`, `preconfig`, `check`, `flash`. -- TOML configuration (`jetson-flash.toml`) via figment, with `JETSON_*` - environment-variable overrides. +- Profile-based TOML config: a `[default]` base table plus one `[]` + table per board (`orin-nano`, `orin-agx`), selected with `--profile` / + `JETSON_PROFILE` (required for every stage). `profiles` lists them. +- `jetpack` field per profile (`"6.2.1"` / `"7.2"`) that resolves the L4T + version + BSP/rootfs URLs from a built-in preset; explicit `l4t.*` overrides. +- `init` seeds a `jetson-flash.toml` from a template embedded in the binary — + destination is `--config `, else `--global` (XDG), else `./`. +- `edit` opens the resolved config in `$EDITOR`. +- Config discovery: `--config` → `./jetson-flash.toml` → + `~/.config/jetson-flash/jetson-flash.toml`. +- Namespaced workspace/cache: downloads, staging, and logs live under a base + (`--work-dir`/`JETSON_WORK_DIR`, else the repo when run from a checkout, else + `~/.cache/jetson-flash`). Downloads keyed by L4T version (`downloads//`); + staging + logs keyed by profile+version (`work|logs/-/`) so + boards and JetPack versions never collide. - Native USB recovery detection (libusb via `rusb`) exposing typed `UsbState` / `Model`; no `lsusb` parsing. - Native NetworkManager keyfile and first-boot identity baking. @@ -42,8 +40,14 @@ porting the `just`/bash flashing pipeline, kept alongside the existing scripts. - Idempotency guards: `stage` reuses an existing `Linux_for_Tegra/`, rootfs, and applied binaries; `preconfig` skips user creation when the user is already baked into the rootfs. +- Secrets are kept out of the config file: `identity.password` / + `network.wifi.psk` come from `JETSON_IDENTITY_PASSWORD` / + `JETSON_NETWORK_WIFI_PSK`. +- Library pipeline API: `Step`, `run_step`, `run_all` drive stages + programmatically (open the per-slot log + dispatch); `examples/pipeline.rs` + is a runnable reference. - CI (fmt, clippy `-D warnings`, build, test) and a tag-driven crates.io publish workflow. -[Unreleased]: https://github.com/AstroRoboticsTech/jetson-flash/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/AstroRoboticsTech/jetson-flash/releases/tag/v0.1.0 +[Unreleased]: https://github.com/AstroRoboticsTech/jetson-flash/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/AstroRoboticsTech/jetson-flash/releases/tag/v1.0.0 diff --git a/Cargo.lock b/Cargo.lock index e4062db..bb1da3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,7 +326,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "jetson-flash" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index caf9c6c..2812604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jetson-flash" -version = "0.1.0" +version = "1.0.0" edition = "2021" rust-version = "1.74" description = "Headless Jetson Orin (Tegra234) flashing pipeline: config, staging, preconfig, recovery detection, NVMe flash" diff --git a/README.md b/README.md index 274ea60..eccb4af 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,181 @@ # jetson-flash -Terminal-only flashing pipeline for NVIDIA Jetson Orin devkits (Tegra234) -from an Ubuntu 24.04 host. No SDK Manager, no Nix. Drives the official -NVIDIA L4T `flash.sh` / `l4t_initrd_flash.sh` scripts through `just`. +Headless flashing pipeline for NVIDIA Jetson Orin devkits (Tegra234) from a +Linux host. No SDK Manager, no Nix — a Rust CLI + library driving the official +NVIDIA L4T `l4t_initrd_flash.sh` / `apply_binaries.sh`. -The end result is a fully headless board that boots straight into a -logged-in shell, joins WiFi + wired LAN automatically, and answers to +The result is a fully headless board that boots straight into a logged-in +shell, joins WiFi + wired LAN automatically, and answers to `ssh @.local`. No display, no first-boot wizard. +> A legacy `just`/bash pipeline (flat `.env`) also lives in-tree — see +> [docs/legacy-just.md](docs/legacy-just.md). The Rust CLI is the primary path. + ## Supported boards -The pipeline is board-agnostic via the `.env` knobs. Per-board values -(board conf, iface names, boot device, recovery) live in their own doc: +| Board | JetPack | Status | Doc | +|----------------------------|----------------|------------------------------|-----| +| Jetson Orin Nano 8GB Super | 7.2 (L4T r39.2)| Validated on hardware | [docs/orin-nano.md](docs/orin-nano.md) | +| Jetson AGX Orin devkit | 7.2 (L4T r39.2)| Validated on hardware | [docs/orin-agx.md](docs/orin-agx.md) | -| Board | Doc | Status | -|--------------------------------|--------------------------------------|----------------| -| Jetson Orin Nano 8GB Super | [docs/orin-nano.md](docs/orin-nano.md) | Validated r39.2 | -| Jetson AGX Orin devkit | [docs/orin-agx.md](docs/orin-agx.md) | Board-ready (not yet flash-validated) | +JetPack 6.2.1 (L4T r36.4.4) is available as a profile preset but not yet +hardware-validated. ## Prerequisites -- **Host:** Ubuntu 24.04 x86_64. ~10 GB free disk. USB-C cable to the board. -- **Target:** a supported Jetson Orin devkit with an NVMe SSD in the M.2 - M-key slot. See the per-board doc above for board-specific hardware - (WiFi card, boot device, recovery buttons). -- `just` installed: `cargo install just` or `apt install just` (24.04 - ships ≥1.34). -- `git`, `wget`, `sudo`. - -## Getting started - -```bash -git clone ~/dev/jetson-flash -cd ~/dev/jetson-flash -cp .env.example .env -# Edit .env — at minimum set JETSON_USERNAME / PASSWORD / HOSTNAME and -# the WiFi credentials. -$EDITOR .env -``` +- **Build/install:** `cargo` and `libusb-1.0-0-dev` (the `rusb` link dep) — + that's it. The crate builds anywhere `rusb` runs (Linux/macOS/Windows), and + `check` / `profiles` / `init` / `edit` work on any of them. +- **Flashing:** verified on Linux x86_64 — `stage`/`preconfig`/`flash` drive + NVIDIA's L4T bash tooling (qemu, chroot, `sudo`), and `deps` installs its + packages via `apt` (Ubuntu 24.04 is the reference host for JetPack 7.2). + ~10 GB free disk, a USB-C cable. +- **Target:** a Jetson Orin devkit with an NVMe SSD in the M.2 M-key slot (see + the per-board doc for WiFi card, boot device, and recovery buttons). -Then walk the pipeline: +## Install ```bash -just deps # apt deps for Ubuntu 24.04 host (sudo) -just fetch # download BSP + sample rootfs (~2.5 GB) -just stage # extract, apply_binaries.sh (sudo, ~3 min) -just preconfig # bake user, hostname, headless target, IPs, WiFi, avahi -just check # confirm board is in APX recovery -just no-autosuspend # keep USB awake during flash -just flash # initrd flash to NVMe (~15-25 min) +cargo install jetson-flash # from crates.io +# or from a checkout: +cargo install --path . ``` -Or all in one: +## Quick start ```bash -just all -``` +jetson-flash init # seed ./jetson-flash.toml (embedded template) +jetson-flash edit # edit it in $EDITOR (add/adjust profiles) +jetson-flash profiles # list board profiles -## Rust CLI (alternative to `just`) - -The same pipeline is available as a Rust crate + CLI (`src/`, `Cargo.toml`), -for integration with other Rust commissioning tooling. Config is a -**profile-based TOML** (`jetson-flash.toml`): a `[default]` base plus one -`[]` table per board. Secrets are supplied via env, never the file. - -```bash -cargo install jetson-flash # from crates.io (needs libusb-1.0-0-dev) -# or from a checkout: cargo install --path . - -jetson-flash init # seed ./jetson-flash.toml (embedded template) -jetson-flash init --global # ...into ~/.config/jetson-flash/ instead -jetson-flash --config /path/x.toml init # ...or an explicit path -jetson-flash edit # open the resolved config in $EDITOR -jetson-flash profiles # list board profiles +# Board in APX recovery, then: JETSON_IDENTITY_PASSWORD=secret \ jetson-flash --profile orin-nano all ``` -Config is discovered as: `--config` → `./jetson-flash.toml` → `~/.config/jetson-flash/jetson-flash.toml`. -`--profile` (or `JETSON_PROFILE`) is required for every stage. `JETSON_*` -env vars fill any key the active profile leaves unset — e.g. -`JETSON_IDENTITY_PASSWORD`, `JETSON_NETWORK_WIFI_PSK`. - -| bash / just | Rust CLI | -|--------------------|-----------------------------------| -| `just ` | `jetson-flash -p `| -| `.env` (flat) | `jetson-flash.toml` (profiles) | -| edit board values | `--profile orin-nano` / `orin-agx`| -| `lsusb` parse | native libusb (`rusb`) | - -Recovery detection and NM-keyfile/identity baking are native Rust; NVIDIA's -`l4t_*.sh` / `apply_binaries.sh` and `apt`/`wget`/`tar` are still driven as -subprocesses. Output is captured to `logs/-.log` with a spinner -(`-v` streams live). As a library: +`all` runs `deps → fetch → stage → preconfig → check → flash`; each stage is +also a standalone subcommand. Bare `jetson-flash` prints help. + +## Configuration + +Profile-based TOML: a `[default]` base table plus one `[]` table per +board. Select with `--profile` / `JETSON_PROFILE` (required for every stage). + +```toml +[default] +[default.identity] +username = "jetson" +headless = true +autologin = true + +[orin-nano] +jetpack = "7.2" # "6.2.1" => L4T r36.4.4 +[orin-nano.board] +name = "jetson-orin-nano-devkit-super" +external_device = "nvme0n1p1" +[orin-nano.identity] +hostname = "orin-nano" +[orin-nano.network.ethernet] +dev = "enP8p1s0" +static_ip = "10.42.0.10/24" # "" for DHCP +``` -```rust -use jetson_flash::{Config, Paths, stages, logging::Logger}; -let paths = Paths::new(std::path::Path::new(".")); -let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"), "orin-nano")?; -stages::check::run(&cfg, &paths, &Logger::init("check", &paths.repo_root, false)?)?; +- **`jetpack`** (`"6.2.1"` | `"7.2"`) resolves the L4T version + BSP/rootfs URLs + from a built-in preset (`7.2` → r39.2 / Ubuntu 24.04; `6.2.1` → r36.4.4 / + Ubuntu 22.04). Pin custom values with `[.l4t]`. +- **Discovery:** `--config` → `./jetson-flash.toml` → + `~/.config/jetson-flash/jetson-flash.toml`. `init` writes to `--config `, + else `--global` (XDG), else `./`. +- **Secrets** stay out of the file — `JETSON_IDENTITY_PASSWORD`, + `JETSON_NETWORK_WIFI_PSK`. Any `JETSON_*` var fills a key the profile leaves + unset. + +## Workspace / cache + +Downloads, staging, and logs live under a base dir: `--work-dir` / +`JETSON_WORK_DIR` if set, else the repo when run from a checkout (`Cargo.toml` +present), else `~/.cache/jetson-flash`. The layout is namespaced so boards and +JetPack versions never collide: + +```text +/ + downloads//*.tbz2 # shared across boards + work/-/Linux_for_Tegra + logs/-/-.log ``` -When the flash finishes, unplug the USB-C data cable, power-cycle the -board, and SSH in: +Tarballs are keyed by L4T version only (the BSP is board-agnostic → downloaded +once per version); staging + logs are keyed by profile+version because +`preconfig` bakes board-specific identity into the rootfs. Output is captured +to the per-slot log with a spinner; `-v` streams subprocess output live. -```bash -ssh beppo@beppo.local -``` +Recovery detection and NetworkManager-keyfile / identity baking are native +Rust; NVIDIA's `l4t_*.sh` / `apply_binaries.sh` and `apt`/`wget`/`tar` are +driven as subprocesses. + +## What gets baked into the rootfs -## Defaults - -| Knob | Default | -|-------------------|--------------------------------------| -| `L4T_VERSION` | `39.2.0` (JetPack 7.2) | -| `BOARD` | `jetson-orin-nano-devkit-super` (see per-board doc) | -| `EXTERNAL_DEVICE` | `nvme0n1p1` | - -> **JetPack 7.2 note.** JetPack 7.2 ships an interactive *Jetson ISO -> USB installer* as the consumer flow (SD-card images are dropped). That -> installer cannot bake user/hostname/headless/WiFi config and is not -> scriptable. This repo deliberately stays on the host-side BSP + -> `l4t_initrd_flash.sh` path, which still ships in the r39.2 Driver -> Package and is the only way to produce an unattended headless image. -> Rootfs is now Ubuntu 24.04 (was 22.04); kernel is 6.8. - -`BOARD` is the one knob that changes per board — set it from the -[per-board doc](#supported-boards). Override via `.env` or on the command -line (`just BOARD=jetson-agx-orin-devkit flash`). - -## First-boot identity (set in `.env`) - -| Knob | Effect | -|--------------------|----------------------------------------------------------------| -| `JETSON_USERNAME` | Default user, baked via `l4t_create_default_user.sh`. | -| `JETSON_PASSWORD` | Initial password for that user. | -| `JETSON_HOSTNAME` | `/etc/hostname`. | -| `JETSON_HEADLESS` | `true` → multi-user.target; mask gdm/oem-config GUI. | -| `JETSON_AUTOLOGIN` | `true` → systemd getty autologin on tty1. | -| `JETSON_ETH_DEV` | PCIe iface name. Orin Nano: `enP8p1s0`. Find via `ip -br link`.| -| `JETSON_STATIC_IP` | CIDR, e.g. `10.42.0.10/24`. Leave blank for DHCP. | -| `JETSON_GATEWAY` | Default route (leave blank → eth never owns default route). | -| `JETSON_DNS` | Comma-separated DNS servers. | -| `JETSON_WIFI_SSID` | SSID. Blank → skip WiFi config. Requires M.2 WiFi card. | -| `JETSON_WIFI_PSK` | WPA2 passphrase. | -| `JETSON_WIFI_DEV` | Blank → NetworkManager matches by SSID only (recommended). | -| `JETSON_WIFI_DHCP` | `true` (default) or `false`. Ignored when static IP is set. | -| `JETSON_WIFI_STATIC_IP` | Static CIDR for WiFi, e.g. `192.168.1.101/24`. | -| `JETSON_WIFI_GATEWAY` | WiFi default route gateway (route metric 200, fallback). | -| `JETSON_AVAHI` | `true` enables avahi-daemon → reach as `.local`. | - -After `just flash` the board boots directly to a logged-in tty on the -configured IP. SSH is enabled. No display, no oem-config wizard. +`preconfig` runs against the staged `Linux_for_Tegra/rootfs/` and: creates the +default user (skipping oem-config); sets `multi-user.target` and masks gdm / +oem-config GUI; adds a tty1 autologin override; writes NetworkManager keyfiles +(eth pinned by interface name, WiFi matched by SSID, mode 600, WiFi route metric +200 so eth stays primary); enables `ssh` + `avahi-daemon`; patches `nsswitch` +so `mdns4_minimal` resolves first. It is idempotent — re-runs skip user creation +when the user is already baked. ## Recovery mode -The board must be in APX recovery before `just flash`. Button/jumper -location differs per board — see the per-board doc. If the board is already -running and reachable, software-trigger it: +The board must be in APX recovery before `flash`. Button location differs per +board (see the per-board doc). `check` confirms via libusb: -```bash -ssh @ 'sudo reboot --force forced-recovery' -``` +| USB ID | Board | +|--------------|---------------------------| +| `0955:7523` | Orin Nano in APX recovery | +| `0955:7423` | Orin NX in APX recovery | +| `0955:7023` | AGX Orin in APX recovery | -`just check` confirms recovery via `lsusb`: +`0955:7020` = L4T already running, **not** recovery — re-trigger. -| USB ID | Board | -|--------------|-----------------------------| -| `0955:7523` | Orin Nano in APX recovery | -| `0955:7423` | Orin NX in APX recovery | -| `0955:7023` | AGX Orin in APX recovery | +After `flash`: unplug the USB-C data cable, power-cycle, and +`ssh @.local`. -`0955:7020` means L4T is already running — NOT recovery; re-trigger. +## Library -Per-board reachability (mDNS / eth / WiFi addresses) is documented in each -[board doc](#supported-boards). +Three moves: load a profile, build the workspace, run stages. -## Ubuntu 24.04 host gotchas +```rust +use jetson_flash::{run_all, run_step, Config, Paths, Step}; +use std::path::Path; -- Python 3.12 dropped `distutils`. `python3-setuptools` (in `just deps`) - covers it. -- AppArmor on 24.04 can block NFS during initrd flash; stop it if the - rootfs transfer hangs: `sudo systemctl stop apparmor`. -- USB autosuspend can interrupt flashing: run `just no-autosuspend` - before `just flash`. +let cfg = Config::load(Path::new("jetson-flash.toml"), "orin-nano")?; +let paths = Paths::new(Path::new("."), "orin-nano", cfg.l4t.version()); -## What gets baked into the rootfs +run_step(Step::Check, &cfg, &paths, false)?; // one stage +run_all(&cfg, &paths, false)?; // deps → fetch → stage → … → flash +``` -`just preconfig` runs against the staged `Linux_for_Tegra/rootfs/` and: - -1. Creates the default user (`l4t_create_default_user.sh`), skipping - oem-config. -2. Sets `default.target` to `multi-user.target`, masks `gdm3` and - `nv-oem-config-gui`. -3. Drops a getty `agetty --autologin` override on tty1. -4. Writes NetworkManager keyfiles into - `/etc/NetworkManager/system-connections/` (`eth-static.nmconnection`, - `wifi-home.nmconnection`, mode 600). Eth keyfile pins by - `interface-name=$JETSON_ETH_DEV`; WiFi keyfile matches by SSID and - stores the PSK plaintext. WiFi default route uses metric 200 so eth - stays primary when both ifaces have gateways. -5. Enables `ssh.service` and `avahi-daemon.service`. NetworkManager is - already active in stock L4T so wpa_supplicant runs on demand via NM. -6. Patches `/etc/nsswitch.conf` so `mdns4_minimal` resolves before DNS. +`run_step` / `run_all` open the per-slot log and run the stage(s); each stage is +also a bare `stages::::run(&Config, &Paths, &Logger)` if you manage the +`Logger` yourself. Errors are a typed enum (`jetson_flash::Error`); recovery +state is `stages::check::UsbState` / `Model`. Full runnable example: +[`examples/pipeline.rs`](examples/pipeline.rs) — `cargo run --example pipeline -- orin-nano`. ## Troubleshooting -- **Flash hangs on "Sending bootloader and pre-requisite binaries":** USB - autosuspend or a flaky cable. Re-cycle REC+RST, `just no-autosuspend`, - swap USB ports (prefer a direct host port over a hub). -- **`.local` does not resolve from host:** host needs avahi - too (`sudo apt install avahi-daemon` on Linux dev box; macOS has it - built-in). -- **WiFi connects but no internet:** verify `JETSON_WIFI_GATEWAY` is - reachable; `ip route` on the board should show a default route via - wlan0. -- **Re-flash after a tweak:** `just preconfig` is idempotent for most - steps but `l4t_create_default_user.sh` may complain on second run. - `just clean` + start over if needed. +- **`sudo: a terminal is required` / `sudo authentication failed`:** the CLI + validates sudo with `sudo -v`, which needs an interactive terminal. Run from a + real terminal (it prompts once), or grant NOPASSWD sudo for headless/CI. + `check`, `profiles`, `init`, `edit`, `fetch` need no sudo; `deps`, `stage`, + `preconfig`, `flash` do. +- **Board detected as `0955:7020`, "not in APX recovery":** it booted L4T + instead of recovery. Re-enter recovery (hold FC, tap RST) so it enumerates as + `0955:7523` (Nano) / `0955:7023` (AGX). +- **Flash hangs / USB drops mid-transfer:** autosuspend or a flaky cable/hub. + Use a direct host USB port, a data-rated cable; `echo -1 | sudo tee + /sys/module/usbcore/parameters/autosuspend` before flashing. +- **`.local` won't resolve from the host:** the host also needs avahi. +- **AppArmor blocks the initrd NFS transfer:** `sudo systemctl stop apparmor`. + +## License + +Apache-2.0. diff --git a/docs/legacy-just.md b/docs/legacy-just.md new file mode 100644 index 0000000..b421d7c --- /dev/null +++ b/docs/legacy-just.md @@ -0,0 +1,90 @@ +# Legacy `just` / bash pipeline + +> Superseded by the Rust CLI (`jetson-flash`, see the [README](../README.md)). +> The `justfile` + `scripts/` remain in-tree and functional; this doc is the +> reference for that path. New work should use the Rust CLI. + +Terminal-only flashing pipeline driving NVIDIA's L4T `flash.sh` / +`l4t_initrd_flash.sh` through `just`, configured via a flat `.env`. + +## Prerequisites + +- **Host:** Ubuntu 24.04 x86_64, ~10 GB free, USB-C to the board. +- `just`: `cargo install just` or `apt install just` (24.04 ships ≥1.34). +- `git`, `wget`, `sudo`. + +## Getting started + +```bash +cp .env.example .env +$EDITOR .env # set JETSON_USERNAME / PASSWORD / HOSTNAME, WiFi, etc. +``` + +```bash +just deps # apt deps for the Ubuntu 24.04 host (sudo) +just fetch # download BSP + sample rootfs (~2.5 GB) +just stage # extract, apply_binaries.sh (sudo, ~3 min) +just preconfig # bake user, hostname, headless, IPs, WiFi, avahi +just check # confirm APX recovery +just no-autosuspend # keep USB awake during flash +just flash # initrd flash to NVMe (~15-25 min) +# or: just all +``` + +## Defaults + +| Knob | Default | +|-------------------|-----------------------------------------------------| +| `L4T_VERSION` | `39.2.0` (JetPack 7.2) | +| `BOARD` | `jetson-orin-nano-devkit-super` (see per-board doc) | +| `EXTERNAL_DEVICE` | `nvme0n1p1` | + +Override via `.env` or the command line (`just BOARD=jetson-agx-orin-devkit flash`). + +## First-boot identity (`.env`) + +| Knob | Effect | +|--------------------|----------------------------------------------------------------| +| `JETSON_USERNAME` | Default user, baked via `l4t_create_default_user.sh`. | +| `JETSON_PASSWORD` | Initial password for that user. | +| `JETSON_HOSTNAME` | `/etc/hostname`. | +| `JETSON_HEADLESS` | `true` → multi-user.target; mask gdm/oem-config GUI. | +| `JETSON_AUTOLOGIN` | `true` → systemd getty autologin on tty1. | +| `JETSON_ETH_DEV` | PCIe iface name. Orin Nano: `enP8p1s0`. Find via `ip -br link`.| +| `JETSON_STATIC_IP` | CIDR, e.g. `10.42.0.10/24`. Leave blank for DHCP. | +| `JETSON_GATEWAY` | Default route (leave blank → eth never owns default route). | +| `JETSON_DNS` | Comma-separated DNS servers. | +| `JETSON_WIFI_SSID` | SSID. Blank → skip WiFi config. Requires M.2 WiFi card. | +| `JETSON_WIFI_PSK` | WPA2 passphrase. | +| `JETSON_WIFI_DEV` | Blank → NetworkManager matches by SSID only (recommended). | +| `JETSON_WIFI_DHCP` | `true` (default) or `false`. Ignored when static IP is set. | +| `JETSON_WIFI_STATIC_IP` | Static CIDR for WiFi, e.g. `192.168.1.101/24`. | +| `JETSON_WIFI_GATEWAY` | WiFi default route gateway (route metric 200, fallback). | +| `JETSON_AVAHI` | `true` enables avahi-daemon → reach as `.local`. | + +## What `just preconfig` bakes into the rootfs + +1. Creates the default user (`l4t_create_default_user.sh`), skipping oem-config. +2. `default.target` → `multi-user.target`; masks `gdm3` and `nv-oem-config-gui`. +3. getty `agetty --autologin` override on tty1. +4. NetworkManager keyfiles in `/etc/NetworkManager/system-connections/` + (`eth-static.nmconnection`, `wifi-home.nmconnection`, mode 600). Eth pins by + `interface-name=$JETSON_ETH_DEV`; WiFi matches by SSID, PSK plaintext, default + route metric 200 so eth stays primary when both have gateways. +5. Enables `ssh.service` and `avahi-daemon.service`. +6. Patches `/etc/nsswitch.conf` so `mdns4_minimal` resolves before DNS. + +## Ubuntu 24.04 host gotchas + +- Python 3.12 dropped `distutils`; `python3-setuptools` (in `just deps`) covers it. +- AppArmor can block NFS during initrd flash; `sudo systemctl stop apparmor` if + the rootfs transfer hangs. +- USB autosuspend can interrupt flashing: `just no-autosuspend` before `just flash`. + +## Troubleshooting + +- **Flash hangs on "Sending bootloader…":** USB autosuspend or a flaky cable. + Re-cycle REC+RST, `just no-autosuspend`, prefer a direct host port over a hub. +- **`.local` won't resolve:** the host also needs avahi. +- **Re-flash after a tweak:** `just preconfig` is idempotent for most steps but + `l4t_create_default_user.sh` may complain on a second run; `just clean` + retry. diff --git a/docs/orin-agx.md b/docs/orin-agx.md index b8cfc74..c1fc20b 100644 --- a/docs/orin-agx.md +++ b/docs/orin-agx.md @@ -54,13 +54,12 @@ ssh @ 'sudo reboot --force forced-recovery' ## Status -Pipeline is board-ready (conf present in r39.2 BSP, recovery ID handled). -Not yet flash-validated on hardware — fill the table below after the first -run. - -| Check | Result | -|----------------|----------| -| L4T / JetPack | _TBD_ | -| Boot device | _TBD_ | -| `JETSON_ETH_DEV` actual | _TBD_ | -| Power model | _TBD_ | +Flash-validated on hardware (JetPack 7.2 / L4T r39.2, NVMe boot). + +| Check | Result | +|----------------|---------------------| +| L4T / JetPack | r39.2 / JetPack 7.2 | +| Boot device | NVMe (`nvme0n1p1`) | +| Power model | MAXN SUPER (`-super`) | + +Verify `JETSON_ETH_DEV` per unit (AGX Marvell AQtion iface) — see below. diff --git a/docs/orin-nano.md b/docs/orin-nano.md index 7a0556a..e26baa2 100644 --- a/docs/orin-nano.md +++ b/docs/orin-nano.md @@ -40,9 +40,9 @@ running, not recovery — re-cycle REC+RST. | Address | Path | |--------------------------|-------------------------------------| -| `ssh beppo@beppo.local` | mDNS (avahi) — any iface | -| `ssh beppo@10.42.0.10` | Direct Ethernet cable to dev host | -| `ssh beppo@192.168.1.101`| Home LAN via WiFi | +| `ssh jetson@orin-nano.local` | mDNS (avahi) — any iface | +| `ssh jetson@10.42.0.10` | Direct Ethernet cable to dev host | +| `ssh jetson@192.168.1.101` | Home LAN via WiFi | Host side, set the dev cable end to `10.42.0.1/24`. diff --git a/examples/pipeline.rs b/examples/pipeline.rs new file mode 100644 index 0000000..dcf7997 --- /dev/null +++ b/examples/pipeline.rs @@ -0,0 +1,44 @@ +//! Drive the flash pipeline as a library — the three-move pattern other Rust +//! commissioning tooling uses: load a profile, build the workspace, run stages. +//! +//! Run a single stage (default: check that a board is in recovery): +//! cargo run --example pipeline -- orin-nano +//! Run the whole pipeline (deps → fetch → stage → preconfig → check → flash): +//! JETSON_IDENTITY_PASSWORD=secret cargo run --example pipeline -- orin-nano all + +use jetson_flash::{ + run_all, run_step, + stages::check::{self, UsbState}, + Config, Paths, Step, +}; +use std::path::Path; + +fn main() -> Result<(), Box> { + let profile = std::env::args() + .nth(1) + .unwrap_or_else(|| "orin-nano".into()); + let full = std::env::args().nth(2).as_deref() == Some("all"); + + // 1. Load a profile from the TOML config (jetpack/board/network/identity). + let cfg = Config::load(Path::new("jetson-flash.toml"), &profile)?; + + // 2. Build the workspace: downloads/staging/logs keyed by profile + L4T version. + let paths = Paths::new(Path::new("."), &profile, cfg.l4t.version()); + + if full { + // 3a. Whole pipeline, stopping at the first error. Logs land under + // /logs/-/. + run_all(&cfg, &paths, /* verbose */ false)?; + } else { + // 3b. One stage at a time — here, recovery detection. + run_step(Step::Check, &cfg, &paths, false)?; + + // Or call a stage's detection directly, without running the CLI stage: + match check::detect()? { + UsbState::Recovery(model) => println!("ready: {} in recovery", model.name()), + UsbState::RunningL4t => println!("board is running L4T, not recovery"), + UsbState::Absent => println!("no board on USB"), + } + } + Ok(()) +} diff --git a/jetson-flash.toml b/jetson-flash.toml index 0bfc46b..0347a90 100644 --- a/jetson-flash.toml +++ b/jetson-flash.toml @@ -12,12 +12,9 @@ # Any JETSON_* var fills a key the active profile leaves unset. [default] - -[default.l4t] -# JetPack 7.2 / L4T r39.2. Shared across Orin boards. -version = "39.2.0" -bsp_url = "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Jetson_Linux_R39.2.0_aarch64.tbz2" -rootfs_url = "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Tegra_Linux_Sample-Root-Filesystem_R39.2.0_aarch64.tbz2" +# Each profile picks a JetPack via `jetpack = "6.2.1" | "7.2"`, which resolves +# the L4T version + BSP/rootfs URLs from a built-in preset. To pin a custom +# L4T instead, set [.l4t] version/bsp_url/rootfs_url explicitly. [default.identity] username = "jetson" @@ -30,6 +27,7 @@ avahi = true # --- Orin Nano 8GB Super devkit ------------------------------------------- [orin-nano] +jetpack = "7.2" # "6.2.1" => L4T r36.4.4 (Ubuntu 22.04) [orin-nano.board] name = "jetson-orin-nano-devkit-super" @@ -53,6 +51,7 @@ gateway = "" # --- AGX Orin devkit ------------------------------------------------------ [orin-agx] +jetpack = "7.2" [orin-agx.board] name = "jetson-agx-orin-devkit-super" # 64GB => -super (MAXN SUPER) diff --git a/src/config.rs b/src/config.rs index 0e20dfa..d48c381 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,11 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Deserialize)] pub struct Config { + /// JetPack release the profile targets (`"6.2"` or `"7.2"`). Fills the + /// `l4t` version + URLs from a built-in preset; explicit `l4t.*` overrides. + #[serde(default)] + pub jetpack: Option, + #[serde(default)] pub l4t: L4t, pub board: Board, pub identity: Identity, @@ -17,11 +22,56 @@ pub struct Config { pub services: Services, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct L4t { - pub version: String, - pub bsp_url: String, - pub rootfs_url: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub bsp_url: Option, + #[serde(default)] + pub rootfs_url: Option, +} + +impl L4t { + pub fn version(&self) -> &str { + self.version.as_deref().unwrap_or_default() + } + pub fn bsp_url(&self) -> &str { + self.bsp_url.as_deref().unwrap_or_default() + } + pub fn rootfs_url(&self) -> &str { + self.rootfs_url.as_deref().unwrap_or_default() + } +} + +/// A JetPack release mapped to its L4T version and download URLs. +struct JetpackPreset { + version: &'static str, + bsp_url: &'static str, + rootfs_url: &'static str, +} + +fn jetpack_preset(v: &str) -> Option { + match v { + // JetPack 7.2 => L4T r39.2 (Ubuntu 24.04 rootfs, kernel 6.8). + "7.2" => Some(JetpackPreset { + version: "39.2.0", + bsp_url: "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Jetson_Linux_R39.2.0_aarch64.tbz2", + rootfs_url: "https://developer.nvidia.com/downloads/embedded/L4T/r39_Release_v2.0/release/Tegra_Linux_Sample-Root-Filesystem_R39.2.0_aarch64.tbz2", + }), + // JetPack 6.2.1 => L4T r36.4.4 (Ubuntu 22.04 rootfs). + "6.2.1" => Some(JetpackPreset { + version: "36.4.4", + bsp_url: "https://developer.nvidia.com/downloads/embedded/L4T/r36_Release_v4.4/release/Jetson_Linux_R36.4.4_aarch64.tbz2", + rootfs_url: "https://developer.nvidia.com/downloads/embedded/L4T/r36_Release_v4.4/release/Tegra_Linux_Sample-Root-Filesystem_R36.4.4_aarch64.tbz2", + }), + _ => None, + } +} + +/// JetPack releases with a built-in preset. +pub fn known_jetpacks() -> Vec { + vec!["6.2.1".to_string(), "7.2".to_string()] } #[derive(Debug, Clone, Deserialize)] @@ -126,7 +176,33 @@ impl Config { known, }); } - Ok(fig.select(profile).extract()?) + let mut cfg: Config = fig.select(profile).extract()?; + cfg.resolve_l4t()?; + Ok(cfg) + } + + /// Fill `l4t` from the `jetpack` preset (explicit `l4t.*` wins), then + /// verify all three L4T fields are resolved. + fn resolve_l4t(&mut self) -> Result<()> { + if let Some(jp) = self.jetpack.clone() { + let p = jetpack_preset(&jp).ok_or_else(|| Error::UnknownJetpack { + name: jp, + known: known_jetpacks(), + })?; + let l = &mut self.l4t; + l.version.get_or_insert_with(|| p.version.to_string()); + l.bsp_url.get_or_insert_with(|| p.bsp_url.to_string()); + l.rootfs_url.get_or_insert_with(|| p.rootfs_url.to_string()); + } + if self.l4t.version.is_none() || self.l4t.bsp_url.is_none() || self.l4t.rootfs_url.is_none() + { + return Err(Error::Missing( + "l4t unresolved: set `jetpack = \"7.2\"` (or 6.2.1) on the profile, \ + or l4t.version/bsp_url/rootfs_url explicitly" + .into(), + )); + } + Ok(()) } /// Profile names defined in the config file (excludes `default`). @@ -134,13 +210,10 @@ impl Config { profile_names(&base_figment(path)) } - /// DNS as a NetworkManager keyfile list ("1.1.1.1;8.8.8.8;"). + /// DNS servers joined for a NetworkManager keyfile ("1.1.1.1;8.8.8.8"). + /// The keyfile templates append the single trailing `;`. pub fn dns_nm(&self) -> String { - let mut s = self.network.ethernet.dns.join(";"); - if !s.is_empty() { - s.push(';'); - } - s + self.network.ethernet.dns.join(";") } } @@ -158,6 +231,16 @@ pub fn xdg_config_path() -> Option { Some(base.join("jetson-flash").join("jetson-flash.toml")) } +/// `$XDG_CACHE_HOME/jetson-flash` (falls back to `$HOME/.cache/jetson-flash`). +/// Where an installed binary keeps its downloads + staging. `None` if neither +/// env var is set. +pub fn cache_dir() -> Option { + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?; + Some(base.join("jetson-flash")) +} + /// Resolve which config file to use: explicit `--config`, else /// `/jetson-flash.toml`, else the XDG path. Returns the local path /// as the default even when nothing exists, so callers can report it. diff --git a/src/error.rs b/src/error.rs index ba054aa..59df10b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -46,6 +46,9 @@ pub enum Error { #[error("unknown profile `{name}` (available: {})", .known.join(", "))] UnknownProfile { name: String, known: Vec }, + #[error("unknown jetpack `{name}` (known: {})", .known.join(", "))] + UnknownJetpack { name: String, known: Vec }, + #[error("{0} is required — supply it via environment (e.g. JETSON_IDENTITY_PASSWORD)")] MissingSecret(&'static str), diff --git a/src/lib.rs b/src/lib.rs index 48b358c..d04711c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,46 +1,73 @@ //! Headless Jetson Orin (Tegra234) flashing pipeline as a library. //! -//! Rust port of the `just`/bash pipeline. Each stage is a free function -//! taking `&Config`, `&Paths`, and a `&Logger`, so other Rust commissioning -//! tooling can drive the pipeline programmatically: +//! Rust port of the `just`/bash pipeline. Other Rust commissioning tooling +//! drives it in three moves: load a [`Config`] profile, build the [`Paths`] +//! workspace, then run stages. [`run_all`] runs the whole +//! deps→fetch→stage→preconfig→check→flash sequence; [`run_step`] runs one. //! //! ```no_run -//! use jetson_flash::{Config, Paths, stages, logging::Logger}; +//! use jetson_flash::{Config, Paths, Step, run_all, run_step}; //! use std::path::Path; -//! let paths = Paths::new(Path::new(".")); -//! let cfg = Config::load(&paths.repo_root.join("jetson-flash.toml"), "orin-nano")?; -//! let log = Logger::init("check", &paths.repo_root, false)?; -//! stages::check::run(&cfg, &paths, &log)?; +//! +//! let cfg = Config::load(Path::new("jetson-flash.toml"), "orin-nano")?; +//! let paths = Paths::new(Path::new("."), "orin-nano", cfg.l4t.version()); +//! +//! run_step(Step::Check, &cfg, &paths, false)?; // one stage +//! run_all(&cfg, &paths, false)?; // download → stage → flash //! # jetson_flash::Result::Ok(()) //! ``` +//! +//! Each stage is also a bare `stages::::run(&Config, &Paths, &Logger)` +//! if you want to manage the [`logging::Logger`] yourself. pub mod config; pub mod error; pub mod logging; +pub mod pipeline; pub mod stages; pub use config::Config; pub use error::{Error, RecoveryError, Result}; +pub use pipeline::{run_all, run_step, Step}; use std::path::{Path, PathBuf}; - -/// Canonical directory layout, derived from the repo root. +/// Workspace layout under a base directory, namespaced so multiple boards and +/// JetPack versions coexist without collisions: +/// +/// ```text +/// / +/// downloads//*.tbz2 # shared across boards +/// work/-/Linux_for_Tegra +/// logs/-/-.log +/// ``` +/// +/// In a repo checkout the base is the repo root (dev); when installed it is the +/// cache dir (`~/.cache/jetson-flash`). Downloads are keyed by L4T version only +/// (the BSP is board-agnostic); staging + logs are keyed by profile+version +/// because `preconfig` bakes board-specific identity into the rootfs. #[derive(Debug, Clone)] pub struct Paths { - pub repo_root: PathBuf, + pub base: PathBuf, + pub downloads: PathBuf, pub work: PathBuf, pub l4t_dir: PathBuf, + pub logs: PathBuf, } impl Paths { - pub fn new(repo_root: &Path) -> Self { - let repo_root = repo_root.to_path_buf(); - let work = repo_root.join("work"); + pub fn new(base: &Path, profile: &str, l4t_version: &str) -> Self { + let base = base.to_path_buf(); + let slot = format!("{profile}-{l4t_version}"); + let downloads = base.join("downloads").join(l4t_version); + let work = base.join("work").join(&slot); let l4t_dir = work.join("Linux_for_Tegra"); + let logs = base.join("logs").join(&slot); Self { - repo_root, + base, + downloads, work, l4t_dir, + logs, } } diff --git a/src/logging.rs b/src/logging.rs index 691f094..b85f4a2 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -17,7 +17,6 @@ pub struct Logger { verbose: bool, file: Arc>, path: PathBuf, - repo_root: PathBuf, bar: Mutex>, } @@ -48,8 +47,9 @@ const PLAIN: Ansi = Ansi { }; impl Logger { - pub fn init(step: &str, repo_root: &Path, verbose: bool) -> Result { - let dir = repo_root.join("logs"); + /// `log_dir` is the exact directory to write `-.log` into. + pub fn init(step: &str, log_dir: &Path, verbose: bool) -> Result { + let dir = log_dir.to_path_buf(); fs::create_dir_all(&dir).ctx(|| format!("mkdir {}", dir.display()))?; let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); let fname = format!("{step}-{ts}.log"); @@ -71,16 +71,13 @@ impl Logger { verbose, file: Arc::new(Mutex::new(file)), path, - repo_root: repo_root.to_path_buf(), bar: Mutex::new(None), }; - let rel = logger - .path - .strip_prefix(repo_root) - .unwrap_or(&logger.path) - .display() - .to_string(); - logger.info(&format!("logging {step} -> {rel}")); + let shown = std::env::current_dir() + .ok() + .and_then(|cwd| logger.path.strip_prefix(&cwd).ok().map(Path::to_path_buf)) + .unwrap_or_else(|| logger.path.clone()); + logger.info(&format!("logging {step} -> {}", shown.display())); Ok(logger) } @@ -144,9 +141,11 @@ impl Logger { } } - /// Run a command from the repo root, capturing output (spinner UX). + /// Run a command from the current directory, capturing output (spinner UX). + /// (Callers that care about cwd use `run_in`; these commands use absolute + /// paths so cwd is irrelevant.) pub fn run(&self, program: &str, args: &[&str]) -> Result<()> { - self.run_in(program, args, &self.repo_root) + self.run_in(program, args, Path::new(".")) } /// Run a command in `cwd`, capturing output to the log with a spinner. @@ -229,9 +228,10 @@ impl Logger { } fn rel_log(&self) -> String { - self.path - .strip_prefix(&self.repo_root) - .unwrap_or(&self.path) + std::env::current_dir() + .ok() + .and_then(|cwd| self.path.strip_prefix(&cwd).ok().map(Path::to_path_buf)) + .unwrap_or_else(|| self.path.clone()) .display() .to_string() } diff --git a/src/main.rs b/src/main.rs index 3059066..fce85b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use jetson_flash::{config, logging::Logger, stages, Config, Paths}; +use clap::{CommandFactory, Parser, Subcommand}; +use jetson_flash::{config, Config, Paths, Step}; use std::path::PathBuf; /// Headless Jetson Orin (Tegra234) flashing pipeline. @@ -11,10 +11,15 @@ struct Cli { #[arg(long, global = true)] config: Option, - /// Repo/work root (default: current directory). + /// Repo root for config/dev detection (default: current directory). #[arg(long, global = true)] repo_root: Option, + /// Workspace base for downloads + staging + logs. Default: the repo when + /// run from a checkout, else the cache dir (~/.cache/jetson-flash). + #[arg(long, global = true, env = "JETSON_WORK_DIR")] + work_dir: Option, + /// Stream all subprocess output live instead of capturing it to the log. #[arg(short, long, global = true)] verbose: bool, @@ -25,7 +30,7 @@ struct Cli { profile: Option, #[command(subcommand)] - cmd: Cmd, + cmd: Option, } #[derive(Subcommand)] @@ -63,23 +68,29 @@ enum Cmd { fn main() -> Result<()> { let cli = Cli::parse(); + // Bare `jetson-flash` (no subcommand): print help on stdout, exit 0. + let Some(cmd) = cli.cmd else { + Cli::command().print_help()?; + println!(); + return Ok(()); + }; + let repo_root = match cli.repo_root { Some(p) => p, None => std::env::current_dir().context("resolve current dir")?, }; - let paths = Paths::new(&repo_root); - if let Cmd::Init { global, force } = cli.cmd { + if let Cmd::Init { global, force } = cmd { return init_config(cli.config.clone(), &repo_root, global, force); } let config_path = config::resolve_config_path(cli.config.clone(), &repo_root); - if matches!(cli.cmd, Cmd::Edit) { + if matches!(cmd, Cmd::Edit) { return edit_config(&config_path); } - if matches!(cli.cmd, Cmd::Profiles) { + if matches!(cmd, Cmd::Profiles) { let names = Config::profiles(&config_path); if names.is_empty() { println!("no profiles defined in {}", config_path.display()); @@ -104,29 +115,36 @@ fn main() -> Result<()> { anyhow::anyhow!("--profile is required (or set JETSON_PROFILE). Available: {known}") })?; let cfg = Config::load(&config_path, &profile)?; - let run = Runner { - cfg: &cfg, - paths: &paths, - verbose: cli.verbose, - }; - match cli.cmd { + let base = resolve_base(cli.work_dir.clone(), &repo_root)?; + let paths = Paths::new(&base, &profile, cfg.l4t.version()); + let v = cli.verbose; + + let step = match cmd { Cmd::Init { .. } | Cmd::Edit | Cmd::Profiles => unreachable!("handled above"), - Cmd::Deps => run.step("deps", stages::deps::run), - Cmd::Fetch => run.step("fetch", stages::fetch::run), - Cmd::Stage => run.step("stage", stages::stage::run), - Cmd::Preconfig => run.step("preconfig", stages::preconfig::run), - Cmd::Check => run.step("check", stages::check::run), - Cmd::Flash => run.step("flash", stages::flash::run), - Cmd::All => { - run.step("deps", stages::deps::run)?; - run.step("fetch", stages::fetch::run)?; - run.step("stage", stages::stage::run)?; - run.step("preconfig", stages::preconfig::run)?; - run.step("check", stages::check::run)?; - run.step("flash", stages::flash::run) - } + Cmd::Deps => Step::Deps, + Cmd::Fetch => Step::Fetch, + Cmd::Stage => Step::Stage, + Cmd::Preconfig => Step::Preconfig, + Cmd::Check => Step::Check, + Cmd::Flash => Step::Flash, + Cmd::All => return Ok(jetson_flash::run_all(&cfg, &paths, v)?), + }; + Ok(jetson_flash::run_step(step, &cfg, &paths, v)?) +} + +/// Resolve the workspace base for downloads + staging + logs: explicit +/// `--work-dir`, else the repo when run from a checkout (`Cargo.toml` present), +/// else the cache dir (`~/.cache/jetson-flash`). +fn resolve_base(work_dir: Option, repo_root: &std::path::Path) -> Result { + if let Some(w) = work_dir { + return Ok(w); + } + if repo_root.join("Cargo.toml").exists() { + return Ok(repo_root.to_path_buf()); } + config::cache_dir() + .context("cannot resolve cache dir (set XDG_CACHE_HOME or HOME, or pass --work-dir)") } /// Seed a jetson-flash.toml from the embedded template. Destination: @@ -178,20 +196,3 @@ fn edit_config(path: &std::path::Path) -> Result<()> { } Ok(()) } - -struct Runner<'a> { - cfg: &'a Config, - paths: &'a Paths, - verbose: bool, -} - -impl Runner<'_> { - fn step( - &self, - name: &str, - f: impl Fn(&Config, &Paths, &Logger) -> jetson_flash::Result<()>, - ) -> Result<()> { - let log = Logger::init(name, &self.paths.repo_root, self.verbose)?; - Ok(f(self.cfg, self.paths, &log)?) - } -} diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..ea680ff --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,71 @@ +//! Stage orchestration for library consumers. Each [`Step`] is one stage of +//! the flash pipeline; [`run_step`] sets up the per-slot log and runs it, and +//! [`run_all`] runs the whole sequence in order. The binary uses these too. + +use crate::{logging::Logger, stages, Config, Paths, Result}; + +/// One stage of the pipeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + /// Install host apt dependencies. + Deps, + /// Download BSP + sample rootfs tarballs. + Fetch, + /// Extract BSP, populate rootfs, run `apply_binaries.sh`. + Stage, + /// Bake identity / headless / network into the rootfs. + Preconfig, + /// Verify the board is in APX recovery. + Check, + /// Flash to NVMe + internal QSPI. + Flash, +} + +/// The full pipeline in execution order (what `run_all` does). +pub const ALL: [Step; 6] = [ + Step::Deps, + Step::Fetch, + Step::Stage, + Step::Preconfig, + Step::Check, + Step::Flash, +]; + +impl Step { + pub fn name(self) -> &'static str { + match self { + Step::Deps => "deps", + Step::Fetch => "fetch", + Step::Stage => "stage", + Step::Preconfig => "preconfig", + Step::Check => "check", + Step::Flash => "flash", + } + } + + fn func(self) -> fn(&Config, &Paths, &Logger) -> Result<()> { + match self { + Step::Deps => stages::deps::run, + Step::Fetch => stages::fetch::run, + Step::Stage => stages::stage::run, + Step::Preconfig => stages::preconfig::run, + Step::Check => stages::check::run, + Step::Flash => stages::flash::run, + } + } +} + +/// Run one stage: open its per-slot log (`/logs/-/`) and +/// execute it. `verbose` streams subprocess output live instead of capturing. +pub fn run_step(step: Step, cfg: &Config, paths: &Paths, verbose: bool) -> Result<()> { + let log = Logger::init(step.name(), &paths.logs, verbose)?; + step.func()(cfg, paths, &log) +} + +/// Run the whole pipeline in order, stopping at the first error. +pub fn run_all(cfg: &Config, paths: &Paths, verbose: bool) -> Result<()> { + for step in ALL { + run_step(step, cfg, paths, verbose)?; + } + Ok(()) +} diff --git a/src/stages/fetch.rs b/src/stages/fetch.rs index 1ea30ec..fcef455 100644 --- a/src/stages/fetch.rs +++ b/src/stages/fetch.rs @@ -6,12 +6,12 @@ use crate::{ use std::fs; pub fn run(cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { - fs::create_dir_all(&paths.work).ctx(|| format!("mkdir {}", paths.work.display()))?; + fs::create_dir_all(&paths.downloads).ctx(|| format!("mkdir {}", paths.downloads.display()))?; - fetch_one(log, &paths.work, &cfg.l4t.bsp_url)?; - fetch_one(log, &paths.work, &cfg.l4t.rootfs_url)?; + fetch_one(log, &paths.downloads, cfg.l4t.bsp_url())?; + fetch_one(log, &paths.downloads, cfg.l4t.rootfs_url())?; - log.ok(&format!("Tarballs in {}.", paths.work.display())); + log.ok(&format!("Tarballs in {}.", paths.downloads.display())); Ok(()) } diff --git a/src/stages/stage.rs b/src/stages/stage.rs index ee17693..b462eac 100644 --- a/src/stages/stage.rs +++ b/src/stages/stage.rs @@ -7,19 +7,21 @@ use std::fs; use std::path::{Path, PathBuf}; pub fn run(_cfg: &Config, paths: &Paths, log: &Logger) -> Result<()> { - let bsp = find_tarball(&paths.work, "Jetson_Linux_R", "_aarch64.tbz2").ok_or_else(|| { - Error::Missing( - "BSP tarball (Jetson_Linux_R*_aarch64.tbz2) not found in work/; run `fetch`".into(), - ) - })?; + let bsp = + find_tarball(&paths.downloads, "Jetson_Linux_R", "_aarch64.tbz2").ok_or_else(|| { + Error::Missing( + "BSP tarball (Jetson_Linux_R*_aarch64.tbz2) not found; run `fetch`".into(), + ) + })?; let rootfs = find_tarball( - &paths.work, + &paths.downloads, "Tegra_Linux_Sample-Root-Filesystem_R", "_aarch64.tbz2", ) - .ok_or_else(|| Error::Missing("rootfs tarball not found in work/; run `fetch`".into()))?; + .ok_or_else(|| Error::Missing("rootfs tarball not found; run `fetch`".into()))?; log.sudo_validate()?; + fs::create_dir_all(&paths.work).ok(); if paths.l4t_dir.exists() { log.info("reusing existing Linux_for_Tegra/ (BSP already extracted)");