From bc451349a0326f582199154a51acebfea3744a8f Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Thu, 10 Sep 2026 01:41:23 +0000 Subject: [PATCH 1/4] feat(orion): catalog VM images in RustFS/mono and pick by image_id Upload built qcow2 blobs to object storage, register metadata for list/delete, resolve image_id to a signed URL on start-runner, and stop needrestart from SIGTERM-killing overnight VMs. --- Cargo.lock | 209 +++++++++------- Cargo.toml | 6 +- ceres/src/model/mod.rs | 1 + ceres/src/model/orion_image.rs | 60 +++++ ceres/src/model/orion_runner.rs | 4 + io-orbit/src/object_storage.rs | 20 ++ .../m20260909_120000_create_orion_vm_image.rs | 75 ++++++ jupiter-migrate/src/migration/mod.rs | 2 + jupiter/callisto/src/mod.rs | 1 + jupiter/callisto/src/orion_vm_image.rs | 29 +++ jupiter/callisto/src/prelude.rs | 6 +- jupiter/src/service/mod.rs | 1 + jupiter/src/service/orion_vm_image_service.rs | 117 +++++++++ jupiter/src/storage/mod.rs | 6 +- jupiter/src/storage/mono_storage.rs | 16 +- jupiter/src/storage/orion_vm_image_storage.rs | 113 +++++++++ jupiter/src/tests.rs | 6 +- mono/src/api/api_router.rs | 7 +- mono/src/api/router/mod.rs | 1 + mono/src/api/router/orion_image_router.rs | 225 ++++++++++++++++++ mono/src/api/router/orion_runner_router.rs | 48 +++- .../OrionClient/OrionImagesTable.tsx | 104 ++++++++ .../hooks/OrionClient/useDeleteOrionImage.ts | 40 ++++ .../hooks/OrionClient/useGetOrionImages.ts | 48 ++++ moon/apps/web/pages/[org]/oc/index.tsx | 67 +++++- moon/packages/types/generated.ts | 2 + orion-scheduler/README.md | 20 +- orion-scheduler/TESTING.md | 29 ++- .../etc/needrestart-orion-scheduler.conf | 3 + .../etc/target_config.json.template | 15 ++ orion-scheduler/install.sh | 14 ++ orion-scheduler/scripts/build-custom-image.sh | 105 +++++++- .../systemd/orion-scheduler.service | 4 + orion/Cargo.toml | 2 +- 34 files changed, 1280 insertions(+), 126 deletions(-) create mode 100644 ceres/src/model/orion_image.rs create mode 100644 jupiter-migrate/src/migration/m20260909_120000_create_orion_vm_image.rs create mode 100644 jupiter/callisto/src/orion_vm_image.rs create mode 100644 jupiter/src/service/orion_vm_image_service.rs create mode 100644 jupiter/src/storage/orion_vm_image_storage.rs create mode 100644 mono/src/api/router/orion_image_router.rs create mode 100644 moon/apps/web/components/OrionClient/OrionImagesTable.tsx create mode 100644 moon/apps/web/hooks/OrionClient/useDeleteOrionImage.ts create mode 100644 moon/apps/web/hooks/OrionClient/useGetOrionImages.ts create mode 100644 orion-scheduler/etc/needrestart-orion-scheduler.conf create mode 100644 orion-scheduler/etc/target_config.json.template diff --git a/Cargo.lock b/Cargo.lock index 3628c22e8..2324987e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,9 +121,9 @@ checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" [[package]] name = "aligned_box" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ebdec4a15fc241b75eb9a55d320cef51549f95f739f8ce3dce24b19b9e5f24a" +checksum = "3a67318d4d368c1c40975ac23a92dd95bbbbeaab1d1c8597000035bb50455189" [[package]] name = "alloc-no-stdlib" @@ -524,9 +524,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.44" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" dependencies = [ "compression-codecs", "compression-core", @@ -973,9 +973,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -1392,7 +1392,7 @@ dependencies = [ "pgp 0.20.0", "rand 0.10.2", "regex", - "reqwest 0.13.4", + "reqwest 0.13.5", "rkyv", "saturn", "sea-orm", @@ -1610,16 +1610,16 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" dependencies = [ "brotli", "compression-core", "flate2", "memchr", - "zstd 0.13.3", - "zstd-safe 7.3.0", + "zstd 0.14.0", + "zstd-safe 8.0.0", ] [[package]] @@ -1759,6 +1759,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -2067,12 +2073,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -2091,15 +2097,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -2115,13 +2121,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -2440,7 +2446,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -2694,11 +2700,17 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -3588,9 +3600,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "ctutils", "subtle", @@ -3949,7 +3961,7 @@ dependencies = [ "common", "futures", "object_store", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "tempfile", @@ -4181,7 +4193,7 @@ dependencies = [ "rand 0.10.2", "redis", "redis-test", - "reqwest 0.13.4", + "reqwest 0.13.5", "ring", "rsa 0.9.10", "rustls", @@ -4281,7 +4293,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ac372c120eb893b086d1a12027669cf2b478d1f71204021ffa7adf57948d63" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "kvm-bindings", "libc", "vmm-sys-util 0.15.0", @@ -4432,7 +4444,7 @@ checksum = "cf5f1ba3c5498a7b893d4398dfd6de0f21d87d16f1385258950d62d5a7806af8" dependencies = [ "async-trait", "asyncfuse", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "clap", "futures", @@ -4476,7 +4488,7 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "plain", "redox_syscall 0.9.4", @@ -4906,8 +4918,8 @@ dependencies = [ "percent-encoding", "rand 0.10.2", "regex", - "reqwest 0.13.4", - "russh 0.63.2", + "reqwest 0.13.5", + "russh 0.63.3", "saturn", "sea-orm", "serde", @@ -4933,6 +4945,33 @@ dependencies = [ "vault", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "munge" version = "0.4.7" @@ -5000,7 +5039,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -5013,7 +5052,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -5209,7 +5248,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -5226,7 +5265,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", ] @@ -5287,7 +5326,7 @@ dependencies = [ "percent-encoding", "quick-xml", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest 0.13.5", "rustls-pki-types", "serde", "serde_json", @@ -5347,7 +5386,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -5411,7 +5450,7 @@ dependencies = [ [[package]] name = "orion" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "api-model", @@ -5422,7 +5461,7 @@ dependencies = [ "itertools 0.15.0", "once_cell", "parse-display 0.11.0", - "reqwest 0.13.4", + "reqwest 0.13.5", "scorpiofs", "serde", "serde_json", @@ -5447,7 +5486,7 @@ dependencies = [ "anyhow", "api-model", "common", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "tracing", ] @@ -5479,7 +5518,7 @@ dependencies = [ "common", "futures-util", "http", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "tokio", @@ -6458,7 +6497,7 @@ dependencies = [ "nanoid", "qapi", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest 0.13.5", "russh 0.62.7", "russh-sftp", "serde", @@ -6767,7 +6806,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -6776,7 +6815,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -6908,11 +6947,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "encoding_rs", "futures-channel", @@ -7029,7 +7068,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "once_cell", "serde", "serde_derive", @@ -7085,7 +7124,7 @@ checksum = "9decb68e4e44e1079700e54f17c8f23806ec53d7e0db73ab1c71d9dabc666812" dependencies = [ "aes 0.9.3", "aws-lc-rs", - "bitflags 2.13.1", + "bitflags 2.13.2", "block-padding 0.4.2", "byteorder", "bytes", @@ -7151,13 +7190,13 @@ dependencies = [ [[package]] name = "russh" -version = "0.63.2" +version = "0.63.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e134e2480f4e86f83e4aa45b4c0a9723f84beaffa694c54bdf056e74efdd7dd" +checksum = "036204edbd199552a5b3832f63c60dcdf395dc44c7f06b4af1c0e8139cc11bce" dependencies = [ "aes 0.9.3", "aws-lc-rs", - "bitflags 2.13.1", + "bitflags 2.13.2", "block-padding 0.4.2", "byteorder", "bytes", @@ -7238,7 +7277,7 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "chrono", "dashmap 6.2.1", @@ -7362,7 +7401,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7375,7 +7414,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7384,9 +7423,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "aws-lc-rs", "log", @@ -7577,7 +7616,7 @@ dependencies = [ "log", "once_cell", "radix_trie 0.3.0", - "reqwest 0.13.4", + "reqwest 0.13.5", "ring", "serde", "serde_json", @@ -7839,7 +7878,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7971,11 +8010,11 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", @@ -7992,14 +8031,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -8491,7 +8530,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "bytes", "chrono", @@ -8523,7 +8562,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "chrono", "crc", @@ -8929,7 +8968,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -9447,7 +9486,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -9466,7 +9505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "async-compression", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-core", "http", @@ -9966,9 +10005,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -10751,18 +10790,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -10892,11 +10931,11 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" dependencies = [ - "zstd-safe 7.3.0", + "zstd-safe 8.0.0", ] [[package]] @@ -10911,9 +10950,9 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 58e42ae0f..085827441 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ orion = { path = "orion" } orion-client = { path = "clients/orion-client" } orion-scheduler-client = { path = "clients/orion-scheduler-client" } -git-internal = "0.8.7" +git-internal = "0.9.0" libvault = "0.3.0" #==== @@ -63,7 +63,7 @@ futures = "0.3.34" futures-util = "0.3.34" axum = { version = "0.8.9", features = ["macros", "json"] } axum-extra = "0.12.6" -russh = "0.63.2" +russh = "0.63.3" tower-http = "0.7.1" tower = "0.5.3" tower-sessions = { version = "0.15", features = ["memory-store"] } @@ -93,7 +93,7 @@ hmac = "0.13" idgenerator = "2.0.0" config = "0.15.25" -reqwest = "0.13.4" +reqwest = "0.13.5" uuid = "1.24.1" regex = "1.13.1" ctrlc = "3.5.2" diff --git a/ceres/src/model/mod.rs b/ceres/src/model/mod.rs index 1b067fea6..4ff4d9a8f 100644 --- a/ceres/src/model/mod.rs +++ b/ceres/src/model/mod.rs @@ -15,6 +15,7 @@ pub mod label; pub mod merge_queue; pub mod note; pub mod notification; +pub mod orion_image; pub mod orion_runner; pub mod serde_snowflake; pub mod tag; diff --git a/ceres/src/model/orion_image.rs b/ceres/src/model/orion_image.rs new file mode 100644 index 000000000..345f7f934 --- /dev/null +++ b/ceres/src/model/orion_image.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct OrionVmImageResponse { + pub id: String, + pub digest: String, + pub object_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub info_object_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub built_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rust: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub buck2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub python: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kernel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub created_at: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct OrionVmImageListResponse { + pub count: usize, + pub images: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct RegisterOrionVmImageRequest { + /// Content digest, e.g. `sha256:`. + pub digest: String, + /// Key under the `orion-images/` namespace, e.g. `{hex}/debian-13-buck2.qcow2`. + pub object_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub info_object_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub built_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rust: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub buck2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub python: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kernel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} diff --git a/ceres/src/model/orion_runner.rs b/ceres/src/model/orion_runner.rs index 140fbeb90..1925da6bb 100644 --- a/ceres/src/model/orion_runner.rs +++ b/ceres/src/model/orion_runner.rs @@ -14,6 +14,10 @@ pub struct StartRunnerRequest { pub image_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub image_digest: Option, + /// Catalog image id from `GET /api/v1/orion/images`. Mutually exclusive with + /// `image_path` / `image_url`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub image_disk_gb: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/io-orbit/src/object_storage.rs b/io-orbit/src/object_storage.rs index e3e5a619b..45a23b2e8 100644 --- a/io-orbit/src/object_storage.rs +++ b/io-orbit/src/object_storage.rs @@ -19,6 +19,11 @@ pub struct ObjectKey { impl ObjectKey { pub fn default_sharding(&self) -> String { + // Orion VM images keep an explicit catalog layout: + // `orion-images/{sha256_hex}/debian-13-buck2.qcow2` (and sidecar). + if self.namespace == ObjectNamespace::OrionImage { + return format!("{}/{}", self.namespace, self.key.trim_start_matches('/')); + } let id = &self.key; if id.len() < 6 { // For short keys, don't shard or use a different strategy @@ -48,6 +53,8 @@ pub enum ObjectNamespace { Log, /// Artifact protocol objects (`docs/artifacts-protocol.md`), keyed by UUID string. Artifact, + /// Orion scheduler VM images: flat `orion-images/{digest_hex}/…` (no hash sharding). + OrionImage, } impl ObjectNamespace { @@ -58,6 +65,7 @@ impl ObjectNamespace { ObjectNamespace::Media => "media", ObjectNamespace::Log => "log", ObjectNamespace::Artifact => "artifact", + ObjectNamespace::OrionImage => "orion-images", } } } @@ -375,4 +383,16 @@ mod tests { assert_eq!(path.as_ref(), "git/ab/cd/ef/1234567890"); } + + #[test] + fn test_orion_image_namespace_is_unsharded() { + let key = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: "abc123/debian-13-buck2.qcow2".to_string(), + }; + assert_eq!( + key.default_sharding(), + "orion-images/abc123/debian-13-buck2.qcow2" + ); + } } diff --git a/jupiter-migrate/src/migration/m20260909_120000_create_orion_vm_image.rs b/jupiter-migrate/src/migration/m20260909_120000_create_orion_vm_image.rs new file mode 100644 index 000000000..a69957a8c --- /dev/null +++ b/jupiter-migrate/src/migration/m20260909_120000_create_orion_vm_image.rs @@ -0,0 +1,75 @@ +//! Catalog of Orion scheduler VM images stored in object storage (RustFS). + +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(OrionVmImage::Table) + .if_not_exists() + .col(string(OrionVmImage::Id).primary_key()) + .col( + ColumnDef::new(OrionVmImage::Digest) + .string() + .not_null() + .unique_key(), + ) + .col(text(OrionVmImage::ObjectKey)) + .col(text_null(OrionVmImage::InfoObjectKey)) + .col(string_null(OrionVmImage::ImageName)) + .col(string_null(OrionVmImage::BuiltAt)) + .col(string_null(OrionVmImage::Rust)) + .col(string_null(OrionVmImage::Buck2)) + .col(string_null(OrionVmImage::Python)) + .col(string_null(OrionVmImage::Kernel)) + .col(big_integer_null(OrionVmImage::SizeBytes)) + .col(string_null(OrionVmImage::Label)) + .col(date_time(OrionVmImage::CreatedAt)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_orion_vm_image_created_at") + .table(OrionVmImage::Table) + .col(OrionVmImage::CreatedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(OrionVmImage::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum OrionVmImage { + Table, + Id, + Digest, + ObjectKey, + InfoObjectKey, + ImageName, + BuiltAt, + Rust, + Buck2, + Python, + Kernel, + SizeBytes, + Label, + CreatedAt, +} diff --git a/jupiter-migrate/src/migration/mod.rs b/jupiter-migrate/src/migration/mod.rs index b6f052c50..ca90e14cb 100644 --- a/jupiter-migrate/src/migration/mod.rs +++ b/jupiter-migrate/src/migration/mod.rs @@ -104,6 +104,7 @@ mod m20260723_080000_cla_sign_check_not_required; mod m20260804_120000_actor_to_campsite_user_id; mod m20260804_130000_data_backfill_ledger; mod m20260811_100000_create_campsite_member_identity; +mod m20260909_120000_create_orion_vm_image; mod runner; pub use runner::apply_migrations; @@ -195,6 +196,7 @@ impl MigratorTrait for Migrator { Box::new(m20260804_120000_actor_to_campsite_user_id::Migration), Box::new(m20260804_130000_data_backfill_ledger::Migration), Box::new(m20260811_100000_create_campsite_member_identity::Migration), + Box::new(m20260909_120000_create_orion_vm_image::Migration), ] } } diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index 17aa1536d..4fe0db83d 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -65,6 +65,7 @@ pub mod note_views; pub mod notes; pub mod notification_event_types; pub mod orion_tasks; +pub mod orion_vm_image; pub mod path_check_configs; pub mod reactions; pub mod sea_orm_active_enums; diff --git a/jupiter/callisto/src/orion_vm_image.rs b/jupiter/callisto/src/orion_vm_image.rs new file mode 100644 index 000000000..8804190fe --- /dev/null +++ b/jupiter/callisto/src/orion_vm_image.rs @@ -0,0 +1,29 @@ +//! `SeaORM` Entity for Orion scheduler VM image catalog. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "orion_vm_image")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(unique)] + pub digest: String, + #[sea_orm(column_type = "Text")] + pub object_key: String, + #[sea_orm(column_type = "Text")] + pub info_object_key: Option, + pub image_name: Option, + pub built_at: Option, + pub rust: Option, + pub buck2: Option, + pub python: Option, + pub kernel: Option, + pub size_bytes: Option, + pub label: Option, + pub created_at: DateTime, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index 6c6317aef..9af22f9ec 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -31,9 +31,9 @@ pub use super::{ mega_webhook_event_type::Entity as MegaWebhookEventType, merge_queue::Entity as MergeQueue, non_member_note_views::Entity as NonMemberNoteViews, note_views::Entity as NoteViews, notes::Entity as Notes, notification_event_types::Entity as NotificationEventTypes, - orion_tasks::Entity as OrionTasks, path_check_configs::Entity as PathCheckConfigs, - reactions::Entity as Reactions, ssh_keys::Entity as SshKeys, - target_build_status::Entity as TargetBuildStatus, + orion_tasks::Entity as OrionTasks, orion_vm_image::Entity as OrionVmImage, + path_check_configs::Entity as PathCheckConfigs, reactions::Entity as Reactions, + ssh_keys::Entity as SshKeys, target_build_status::Entity as TargetBuildStatus, target_state_histories::Entity as TargetStateHistories, user_approval_status::Entity as UserApprovalStatus, user_notification_preferences::Entity as UserNotificationPreferences, diff --git a/jupiter/src/service/mod.rs b/jupiter/src/service/mod.rs index 3221ba777..6d28444ab 100644 --- a/jupiter/src/service/mod.rs +++ b/jupiter/src/service/mod.rs @@ -9,5 +9,6 @@ pub mod issue_service; pub mod lfs_service; pub mod merge_queue_service; pub mod mono_service; +pub mod orion_vm_image_service; pub mod reviewer_service; pub mod webhook_service; diff --git a/jupiter/src/service/orion_vm_image_service.rs b/jupiter/src/service/orion_vm_image_service.rs new file mode 100644 index 000000000..9a91514e3 --- /dev/null +++ b/jupiter/src/service/orion_vm_image_service.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use callisto::orion_vm_image; +use common::errors::MegaError; +use io_orbit::{ + factory::MegaObjectStorageWrapper, + object_storage::{ObjectKey, ObjectNamespace}, +}; +use reqwest::Method; + +use crate::storage::{ + base_storage::{BaseStorage, StorageConnector}, + orion_vm_image_storage::{OrionVmImageStorage, UpsertOrionVmImage}, +}; + +/// Presigned GET TTL for scheduler first-pull of multi-GB qcow2 images. +pub const ORION_IMAGE_PRESIGN_TTL_SECS: u64 = 6 * 60 * 60; + +#[derive(Clone)] +pub struct OrionVmImageService { + st: OrionVmImageStorage, + obj_storage: MegaObjectStorageWrapper, +} + +impl OrionVmImageService { + pub fn new(base: BaseStorage, obj_storage: MegaObjectStorageWrapper) -> Self { + Self { + st: OrionVmImageStorage { base }, + obj_storage, + } + } + + pub fn mock() -> Self { + Self::new(BaseStorage::mock(), MegaObjectStorageWrapper::mock()) + } + + pub fn supports_presigned_urls(&self) -> bool { + self.obj_storage.supports_presigned_urls() + } + + pub async fn list(&self) -> Result, MegaError> { + self.st.list_all().await + } + + pub async fn get(&self, id: &str) -> Result, MegaError> { + self.st.find_by_id(id).await + } + + pub async fn upsert( + &self, + input: UpsertOrionVmImage, + ) -> Result { + self.st.upsert(input).await + } + + /// Delete catalog row and best-effort remove qcow2 + sidecar objects. + pub async fn delete(&self, id: &str) -> Result, MegaError> { + let Some(model) = self.st.delete_by_id(id).await? else { + return Ok(None); + }; + let qcow2 = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: model.object_key.clone(), + }; + if let Err(e) = self.obj_storage.inner.delete(&qcow2).await { + tracing::warn!( + "failed to delete orion image object {}: {}", + qcow2.default_sharding(), + e + ); + } + if let Some(info_key) = &model.info_object_key { + let info = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: info_key.clone(), + }; + if let Err(e) = self.obj_storage.inner.delete(&info).await { + tracing::warn!( + "failed to delete orion image info object {}: {}", + info.default_sharding(), + e + ); + } + } + Ok(Some(model)) + } + + pub async fn signed_get_url(&self, model: &orion_vm_image::Model) -> Result { + let key = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: model.object_key.clone(), + }; + let url = self + .obj_storage + .inner + .signed_url( + &key, + Method::GET, + Duration::from_secs(ORION_IMAGE_PRESIGN_TTL_SECS), + ) + .await?; + url.ok_or_else(|| { + MegaError::ObjStorage( + "object storage does not support presigned URLs; configure S3-compatible RustFS" + .into(), + ) + }) + } +} + +/// Strip optional `sha256:` / `sha512:` prefix for object-key layout. +pub fn digest_hex(digest: &str) -> &str { + digest + .strip_prefix("sha256:") + .or_else(|| digest.strip_prefix("sha512:")) + .unwrap_or(digest) +} diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index 03d583d6d..21d81b068 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -26,6 +26,7 @@ pub mod notification_storage; pub mod user_approval_storage; pub use notification_storage::NotificationStorage; pub mod note_storage; +pub mod orion_vm_image_storage; pub mod stg_common; pub mod user_storage; pub mod vault_storage; @@ -43,7 +44,7 @@ use crate::{ cla_service::ClaService, code_review_service::CodeReviewService, git_service::GitService, import_service::ImportService, issue_service::IssueService, lfs_service::LfsService, merge_queue_service::MergeQueueService, mono_service::MonoService, - webhook_service::WebhookService, + orion_vm_image_service::OrionVmImageService, webhook_service::WebhookService, }, storage::{ audit_storage::AuditStorage, @@ -155,6 +156,7 @@ pub struct Storage { pub cl_service: CLService, pub merge_queue_service: MergeQueueService, pub artifact_service: ArtifactService, + pub orion_vm_image_service: OrionVmImageService, pub buck_service: BuckService, pub mono_service: MonoService, pub import_service: ImportService, @@ -292,6 +294,7 @@ impl Storage { cl_service: CLService::new(base.clone()), merge_queue_service, artifact_service, + orion_vm_image_service: OrionVmImageService::new(base.clone(), object_store.clone()), buck_service, git_service, mono_service, @@ -509,6 +512,7 @@ impl Storage { cl_service: CLService::mock(), merge_queue_service: MergeQueueService::mock(), artifact_service: ArtifactService::mock(), + orion_vm_image_service: OrionVmImageService::mock(), buck_service: BuckService::mock(), config: Arc::downgrade(&*CONFIG), git_service: GitService::mock(), diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index 687849210..32baf1316 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -906,6 +906,14 @@ impl MonoStorage { } } +fn last_wins_mega_filepaths(pairs: Vec<(String, String)>) -> Vec<(String, String)> { + let mut map = HashMap::with_capacity(pairs.len()); + for (blob_id, file_path) in pairs { + map.insert(blob_id, file_path); + } + map.into_iter().collect() +} + #[cfg(test)] mod tests { use callisto::mega_blob; @@ -1117,11 +1125,3 @@ mod tests { .expect("missing blob is not an error"); } } - -fn last_wins_mega_filepaths(pairs: Vec<(String, String)>) -> Vec<(String, String)> { - let mut map = HashMap::with_capacity(pairs.len()); - for (blob_id, file_path) in pairs { - map.insert(blob_id, file_path); - } - map.into_iter().collect() -} diff --git a/jupiter/src/storage/orion_vm_image_storage.rs b/jupiter/src/storage/orion_vm_image_storage.rs new file mode 100644 index 000000000..1fa8f64b2 --- /dev/null +++ b/jupiter/src/storage/orion_vm_image_storage.rs @@ -0,0 +1,113 @@ +use std::ops::Deref; + +use callisto::orion_vm_image; +use chrono::Utc; +use common::errors::MegaError; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::storage::base_storage::{BaseStorage, StorageConnector}; + +#[derive(Clone)] +pub struct OrionVmImageStorage { + pub base: BaseStorage, +} + +impl Deref for OrionVmImageStorage { + type Target = BaseStorage; + fn deref(&self) -> &Self::Target { + &self.base + } +} + +#[derive(Debug, Clone, Default)] +pub struct UpsertOrionVmImage { + pub digest: String, + pub object_key: String, + pub info_object_key: Option, + pub image_name: Option, + pub built_at: Option, + pub rust: Option, + pub buck2: Option, + pub python: Option, + pub kernel: Option, + pub size_bytes: Option, + pub label: Option, +} + +impl OrionVmImageStorage { + pub async fn list_all(&self) -> Result, MegaError> { + Ok(orion_vm_image::Entity::find() + .order_by_desc(orion_vm_image::Column::CreatedAt) + .all(self.get_connection()) + .await?) + } + + pub async fn find_by_id(&self, id: &str) -> Result, MegaError> { + Ok(orion_vm_image::Entity::find_by_id(id.to_string()) + .one(self.get_connection()) + .await?) + } + + pub async fn find_by_digest( + &self, + digest: &str, + ) -> Result, MegaError> { + Ok(orion_vm_image::Entity::find() + .filter(orion_vm_image::Column::Digest.eq(digest)) + .one(self.get_connection()) + .await?) + } + + pub async fn upsert( + &self, + input: UpsertOrionVmImage, + ) -> Result { + if let Some(existing) = self.find_by_digest(&input.digest).await? { + let mut am = existing.into_active_model(); + am.object_key = Set(input.object_key); + am.info_object_key = Set(input.info_object_key); + am.image_name = Set(input.image_name); + am.built_at = Set(input.built_at); + am.rust = Set(input.rust); + am.buck2 = Set(input.buck2); + am.python = Set(input.python); + am.kernel = Set(input.kernel); + am.size_bytes = Set(input.size_bytes); + am.label = Set(input.label); + Ok(am.update(self.get_connection()).await?) + } else { + let model = orion_vm_image::Model { + id: Uuid::new_v4().to_string(), + digest: input.digest, + object_key: input.object_key, + info_object_key: input.info_object_key, + image_name: input.image_name, + built_at: input.built_at, + rust: input.rust, + buck2: input.buck2, + python: input.python, + kernel: input.kernel, + size_bytes: input.size_bytes, + label: input.label, + created_at: Utc::now().naive_utc(), + }; + Ok(model + .into_active_model() + .insert(self.get_connection()) + .await?) + } + } + + pub async fn delete_by_id(&self, id: &str) -> Result, MegaError> { + let Some(model) = self.find_by_id(id).await? else { + return Ok(None); + }; + orion_vm_image::Entity::delete_by_id(id.to_string()) + .exec(self.get_connection()) + .await?; + Ok(Some(model)) + } +} diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index be3ec7582..289cab3ac 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -16,7 +16,7 @@ use crate::{ cla_service::ClaService, code_review_service::CodeReviewService, git_service::GitService, import_service::ImportService, issue_service::IssueService, lfs_service::LfsService, merge_queue_service::MergeQueueService, mono_service::MonoService, - webhook_service::WebhookService, + orion_vm_image_service::OrionVmImageService, webhook_service::WebhookService, }, storage::{ AppService, Storage, @@ -117,6 +117,10 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { cl_service: CLService::mock(), merge_queue_service: MergeQueueService::mock(), artifact_service: ArtifactService::new(base.clone(), MegaObjectStorageWrapper::mock()), + orion_vm_image_service: OrionVmImageService::new( + base.clone(), + MegaObjectStorageWrapper::mock(), + ), buck_service: BuckService::mock(), config: Arc::downgrade(&config), git_service: GitService::mock(), diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index e0ba4c076..0511bba28 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -17,9 +17,9 @@ use crate::api::{ router::{ admin_router, artifacts_router, bot_router, buck_router, build_trigger_router, cl_router, code_review_router, commit_router, conv_router, dynamic_sidebar_router, gpg_router, - group_router, issue_router, label_router, merge_queue_router, orion_runner_router, - permission_router, preview_router, repo_router, reviewer_router, tag_router, user_router, - webhook_router, + group_router, issue_router, label_router, merge_queue_router, orion_image_router, + orion_runner_router, permission_router, preview_router, repo_router, reviewer_router, + tag_router, user_router, webhook_router, }, }; @@ -52,6 +52,7 @@ pub fn routers() -> OpenApiRouter { .merge(webhook_router::routers()) .merge(bot_router::routers()) .merge(orion_runner_router::routers()) + .merge(orion_image_router::routers()) } /// Health Check diff --git a/mono/src/api/router/mod.rs b/mono/src/api/router/mod.rs index b24ea18f3..3b3ab0b04 100644 --- a/mono/src/api/router/mod.rs +++ b/mono/src/api/router/mod.rs @@ -14,6 +14,7 @@ pub mod issue_router; pub mod label_router; pub mod lfs_router; pub mod merge_queue_router; +pub mod orion_image_router; pub mod orion_runner_router; pub mod permission_router; pub mod preview_router; diff --git a/mono/src/api/router/orion_image_router.rs b/mono/src/api/router/orion_image_router.rs new file mode 100644 index 000000000..62dea625a --- /dev/null +++ b/mono/src/api/router/orion_image_router.rs @@ -0,0 +1,225 @@ +use anyhow::anyhow; +use api_model::common::CommonResult; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use ceres::model::orion_image::{ + OrionVmImageListResponse, OrionVmImageResponse, RegisterOrionVmImageRequest, +}; +use jupiter::storage::orion_vm_image_storage::UpsertOrionVmImage; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::{ + MonoApiServiceState, api_common::group_permission::ensure_admin, api_doc::ORION_RUNNER_TAG, + error::ApiError, oauth::model::LoginUser, +}; + +pub fn routers() -> OpenApiRouter { + OpenApiRouter::new().nest( + "/orion/images", + OpenApiRouter::new() + .routes(routes!(list_orion_images)) + .routes(routes!(register_orion_image)) + .routes(routes!(delete_orion_image)), + ) +} + +fn to_response( + id: String, + digest: String, + object_key: String, + info_object_key: Option, + image_name: Option, + built_at: Option, + rust: Option, + buck2: Option, + python: Option, + kernel: Option, + size_bytes: Option, + label: Option, + created_at: chrono::NaiveDateTime, +) -> OrionVmImageResponse { + OrionVmImageResponse { + id, + digest, + object_key, + info_object_key, + image_name, + built_at, + rust, + buck2, + python, + kernel, + size_bytes, + label, + created_at: created_at.and_utc().to_rfc3339(), + } +} + +macro_rules! model_to_response { + ($m:expr) => { + to_response( + $m.id, + $m.digest, + $m.object_key, + $m.info_object_key, + $m.image_name, + $m.built_at, + $m.rust, + $m.buck2, + $m.python, + $m.kernel, + $m.size_bytes, + $m.label, + $m.created_at, + ) + }; +} + +/// List registered Orion VM images (toolchain metadata for the UI catalog). +#[utoipa::path( + get, + path = "/", + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + tag = ORION_RUNNER_TAG +)] +async fn list_orion_images( + user: LoginUser, + State(state): State, +) -> Result>, ApiError> { + ensure_admin(&state, &user).await?; + let images = state + .services() + .storage() + .orion_vm_image_service + .list() + .await + .map_err(ApiError::from)?; + let images: Vec<_> = images.into_iter().map(|m| model_to_response!(m)).collect(); + let count = images.len(); + Ok(Json(CommonResult::success(Some( + OrionVmImageListResponse { count, images }, + )))) +} + +/// Register (upsert) an image after build-script upload to RustFS. +#[utoipa::path( + post, + path = "/", + request_body = RegisterOrionVmImageRequest, + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + tag = ORION_RUNNER_TAG +)] +async fn register_orion_image( + user: LoginUser, + State(state): State, + Json(req): Json, +) -> Result>, ApiError> { + ensure_admin(&state, &user).await?; + let digest = req.digest.trim().to_string(); + if digest.is_empty() || req.object_key.trim().is_empty() { + return Err(ApiError::bad_request(anyhow!( + "digest and object_key are required" + ))); + } + if !(digest.starts_with("sha256:") || digest.starts_with("sha512:")) { + return Err(ApiError::bad_request(anyhow!( + "digest must start with sha256: or sha512:" + ))); + } + + let model = state + .services() + .storage() + .orion_vm_image_service + .upsert(UpsertOrionVmImage { + digest, + object_key: req.object_key.trim().trim_start_matches('/').to_string(), + info_object_key: req + .info_object_key + .map(|k| k.trim().trim_start_matches('/').to_string()) + .filter(|k| !k.is_empty()), + image_name: req.image_name, + built_at: req.built_at, + rust: req.rust, + buck2: req.buck2, + python: req.python, + kernel: req.kernel, + size_bytes: req.size_bytes, + label: req.label, + }) + .await + .map_err(ApiError::from)?; + + Ok(Json(CommonResult::success(Some(model_to_response!(model))))) +} + +/// Delete a catalog entry and its RustFS objects. +#[utoipa::path( + delete, + path = "/{id}", + params( + ("id" = String, Path, description = "Catalog image id") + ), + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + (status = 404, description = "Not found"), + (status = 409, description = "Image still in use by a runner"), + ), + tag = ORION_RUNNER_TAG +)] +async fn delete_orion_image( + user: LoginUser, + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + ensure_admin(&state, &user).await?; + + let svc = &state.services().storage().orion_vm_image_service; + let existing = + svc.get(&id).await.map_err(ApiError::from)?.ok_or_else(|| { + ApiError::with_status(StatusCode::NOT_FOUND, anyhow!("image not found")) + })?; + + if let Some(client) = state.orion_scheduler_client() { + if let Ok(list) = client.list_vms().await { + let in_use = list.vms.iter().any(|vm| { + vm.image_digest + .as_deref() + .is_some_and(|d| d == existing.digest) + }); + if in_use { + return Err(ApiError::with_status( + StatusCode::CONFLICT, + anyhow!( + "image {} is still referenced by a tracked runner", + existing.digest + ), + )); + } + } + } + + let deleted = svc + .delete(&id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::with_status(StatusCode::NOT_FOUND, anyhow!("image not found")))?; + + Ok(Json(CommonResult::success(Some(model_to_response!( + deleted + ))))) +} diff --git a/mono/src/api/router/orion_runner_router.rs b/mono/src/api/router/orion_runner_router.rs index 40fa7a9b6..3c0be2424 100644 --- a/mono/src/api/router/orion_runner_router.rs +++ b/mono/src/api/router/orion_runner_router.rs @@ -245,15 +245,57 @@ async fn start_runner( let build = &state.services().storage().config().build; let env = derive_runner_env(build)?; + let mut image_path = req.image_path; + let mut image_url = req.image_url; + let mut image_digest = req.image_digest; + + if let Some(image_id) = req + .image_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + if image_path.is_some() || image_url.is_some() { + return Err(ApiError::bad_request(anyhow!( + "image_id cannot be combined with image_path or image_url" + ))); + } + let model = state + .services() + .storage() + .orion_vm_image_service + .get(image_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| { + ApiError::with_status(StatusCode::NOT_FOUND, anyhow!("image_id not found")) + })?; + let url = state + .services() + .storage() + .orion_vm_image_service + .signed_get_url(&model) + .await + .map_err(|e| { + ApiError::with_status( + StatusCode::SERVICE_UNAVAILABLE, + anyhow!("failed to sign image URL: {e}"), + ) + })?; + image_url = Some(url); + image_digest = Some(model.digest); + image_path = None; + } + let payload = StartRunnerPayload { target: req.target, replace: req.replace, server_ws: env.server_ws, scorpio_base_url: env.scorpio_base_url, scorpio_lfs_url: env.scorpio_lfs_url, - image_path: req.image_path, - image_url: req.image_url, - image_digest: req.image_digest, + image_path, + image_url, + image_digest, image_disk_gb: req.image_disk_gb, image_cpus: req.image_cpus, image_memory_mb: req.image_memory_mb, diff --git a/moon/apps/web/components/OrionClient/OrionImagesTable.tsx b/moon/apps/web/components/OrionClient/OrionImagesTable.tsx new file mode 100644 index 000000000..b9a10977b --- /dev/null +++ b/moon/apps/web/components/OrionClient/OrionImagesTable.tsx @@ -0,0 +1,104 @@ +'use client' + +import { Button, UIText } from '@gitmono/ui' + +import { useDeleteOrionImage } from '@/hooks/OrionClient/useDeleteOrionImage' +import type { OrionVmImage } from '@/hooks/OrionClient/useGetOrionImages' + +function shortDigest(digest?: string | null) { + if (!digest) return '—' + const hex = digest.replace(/^sha256:/, '').replace(/^sha512:/, '') + return hex.length > 12 ? `${hex.slice(0, 12)}…` : hex +} + +function formatBytes(n?: number | null) { + if (n == null || n <= 0) return '—' + const gb = n / (1024 * 1024 * 1024) + if (gb >= 1) return `${gb.toFixed(1)} GiB` + const mb = n / (1024 * 1024) + return `${mb.toFixed(0)} MiB` +} + +type Props = { + images: OrionVmImage[] + isLoading: boolean + error: Error | null +} + +export function OrionImagesTable({ images, isLoading, error }: Props) { + const { mutate: deleteImage, isPending: isDeleting, variables: deletingId } = useDeleteOrionImage() + + if (error) { + return ( + + Failed to load images: {error.message} + + ) + } + + if (isLoading) { + return ( + + Loading images… + + ) + } + + if (!images.length) { + return ( + + No catalog images yet. Build with RustFS upload + register to populate this list. + + ) + } + + return ( +
+ + + + + + + + + + + + + + {images.map((img) => ( + + + + + + + + + + + ))} + +
BuiltRustPythonBuck2KernelDigestSize +
{img.built_at || '—'}{img.rust || '—'}{img.python || '—'}{img.buck2 || '—'} + {img.kernel || '—'} + + {shortDigest(img.digest)} + {formatBytes(img.size_bytes)} + +
+
+ ) +} diff --git a/moon/apps/web/hooks/OrionClient/useDeleteOrionImage.ts b/moon/apps/web/hooks/OrionClient/useDeleteOrionImage.ts new file mode 100644 index 000000000..ce66e4852 --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/useDeleteOrionImage.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { MONO_API_URL } from '@gitmono/config' + +import type { OrionVmImage } from './useGetOrionImages' + +type DeleteEnvelope = { + req_result: boolean + err_message?: string + data?: OrionVmImage | null +} + +export function useDeleteOrionImage() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + const base = MONO_API_URL.replace(/\/$/, '') + const res = await fetch(`${base}/api/v1/orion/images/${encodeURIComponent(id)}`, { + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }) + const body = (await res.json()) as DeleteEnvelope + + if (!res.ok || !body?.req_result) { + throw new Error(body?.err_message || `Failed to delete image (${res.status})`) + } + return body.data + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['GET:/api/v1/orion/images'] }) + toast.success('Image deleted') + }, + onError: (error: Error) => { + toast.error(error?.message || 'Failed to delete image') + } + }) +} diff --git a/moon/apps/web/hooks/OrionClient/useGetOrionImages.ts b/moon/apps/web/hooks/OrionClient/useGetOrionImages.ts new file mode 100644 index 000000000..ba14e99c3 --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/useGetOrionImages.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' + +import { MONO_API_URL } from '@gitmono/config' + +export type OrionVmImage = { + id: string + digest: string + object_key: string + info_object_key?: string | null + image_name?: string | null + built_at?: string | null + rust?: string | null + buck2?: string | null + python?: string | null + kernel?: string | null + size_bytes?: number | null + label?: string | null + created_at: string +} + +type ListEnvelope = { + req_result: boolean + err_message?: string + data?: { count: number; images: OrionVmImage[] } | null +} + +async function fetchOrionImages(): Promise { + const base = MONO_API_URL.replace(/\/$/, '') + const res = await fetch(`${base}/api/v1/orion/images`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }) + const body = (await res.json()) as ListEnvelope + + if (!res.ok || !body?.req_result || !body.data) { + throw new Error(body?.err_message || `Failed to list Orion images (${res.status})`) + } + return body.data.images ?? [] +} + +export function useGetOrionImages(enabled: boolean) { + return useQuery({ + queryKey: ['GET:/api/v1/orion/images'], + enabled, + queryFn: fetchOrionImages, + staleTime: 30_000 + }) +} diff --git a/moon/apps/web/pages/[org]/oc/index.tsx b/moon/apps/web/pages/[org]/oc/index.tsx index 1227a6cd9..963412af1 100644 --- a/moon/apps/web/pages/[org]/oc/index.tsx +++ b/moon/apps/web/pages/[org]/oc/index.tsx @@ -15,6 +15,7 @@ import { Button, UIText } from '@gitmono/ui' import { RefreshIcon } from '@gitmono/ui/Icons' import { AppLayout } from '@/components/Layout/AppLayout' +import { OrionImagesTable } from '@/components/OrionClient/OrionImagesTable' import { domainFromClientHostname, OrionClient, @@ -25,6 +26,7 @@ import { import AuthAppProviders from '@/components/Providers/AuthAppProviders' import { useAdminCheck } from '@/hooks/admin/useAdminCheck' import { usePostOrionClientsInfo } from '@/hooks/OrionClient/OrionClientsInfo' +import { useGetOrionImages } from '@/hooks/OrionClient/useGetOrionImages' import { useGetRunnerList } from '@/hooks/OrionClient/useGetRunnerList' import { useGetRunnerStatus } from '@/hooks/OrionClient/useGetRunnerStatus' import { usePostStartRunner } from '@/hooks/OrionClient/usePostStartRunner' @@ -83,6 +85,7 @@ const OrionClientPage: PageWithLayout = () => { const [terminalClientId, setTerminalClientId] = React.useState(null) const [terminalDomain, setTerminalDomain] = React.useState(null) const [copyFeedback, setCopyFeedback] = React.useState(false) + const [selectedImageId, setSelectedImageId] = React.useState('') const logPanelRef = React.useRef(null) const terminalPanelRef = React.useRef(null) const logsScrollRef = React.useRef(null) @@ -105,6 +108,11 @@ const OrionClientPage: PageWithLayout = () => { error: runnerListError, refetch: refetchRunners } = useGetRunnerList(isAdmin) + const { + data: orionImages = [], + isLoading: isLoadingImages, + error: orionImagesError + } = useGetOrionImages(isAdmin) const runnerStatusVmId = logSource === 'runner' ? activeLogKey : null const { data: runnerStatus } = useGetRunnerStatus(runnerStatusVmId, activePhase) const { logs: runnerLogs, status: runnerLogsStatus, error: runnerLogsError } = useRunnerLogsSSE(activeLogKey) @@ -259,7 +267,10 @@ const OrionClientPage: PageWithLayout = () => { const handleStartRunner = React.useCallback( (replace = false) => { startRunner( - { replace }, + { + replace, + ...(selectedImageId ? { image_id: selectedImageId } : {}) + }, { onSuccess: (data) => { openLogPanel(data.vm_id, 'runner', { @@ -270,7 +281,7 @@ const OrionClientPage: PageWithLayout = () => { } ) }, - [openLogPanel, startRunner] + [openLogPanel, selectedImageId, startRunner] ) const handleViewClientLogs = React.useCallback( @@ -414,13 +425,39 @@ const OrionClientPage: PageWithLayout = () => {
{isAdmin ? ( - + <> + + + ) : null} {!showingOverlay ? ( + + + + + ) +} diff --git a/moon/apps/web/components/OrionClient/OrionImagesTable.tsx b/moon/apps/web/components/OrionClient/OrionImagesTable.tsx index b9a10977b..fe471ce48 100644 --- a/moon/apps/web/components/OrionClient/OrionImagesTable.tsx +++ b/moon/apps/web/components/OrionClient/OrionImagesTable.tsx @@ -1,8 +1,10 @@ 'use client' +import { useState } from 'react' + import { Button, UIText } from '@gitmono/ui' -import { useDeleteOrionImage } from '@/hooks/OrionClient/useDeleteOrionImage' +import { DeleteOrionImageDialog } from '@/components/OrionClient/DeleteOrionImageDialog' import type { OrionVmImage } from '@/hooks/OrionClient/useGetOrionImages' function shortDigest(digest?: string | null) { @@ -26,7 +28,7 @@ type Props = { } export function OrionImagesTable({ images, isLoading, error }: Props) { - const { mutate: deleteImage, isPending: isDeleting, variables: deletingId } = useDeleteOrionImage() + const [pendingImage, setPendingImage] = useState(null) if (error) { return ( @@ -47,58 +49,59 @@ export function OrionImagesTable({ images, isLoading, error }: Props) { if (!images.length) { return ( - No catalog images yet. Build with RustFS upload + register to populate this list. + No catalog images yet. Use Upload image to add a qcow2, or register via the build script. ) } return ( -
- - - - - - - - - - - - - - {images.map((img) => ( - - - - - - - - - + <> +
+
BuiltRustPythonBuck2KernelDigestSize -
{img.built_at || '—'}{img.rust || '—'}{img.python || '—'}{img.buck2 || '—'} - {img.kernel || '—'} - - {shortDigest(img.digest)} - {formatBytes(img.size_bytes)} - -
+ + + + + + + + + + - ))} - -
BuiltRustPythonBuck2KernelDigestSize
-
+ + + {images.map((img) => ( + + {img.built_at || '—'} + {img.rust || '—'} + {img.python || '—'} + {img.buck2 || '—'} + + {img.kernel || '—'} + + + {shortDigest(img.digest)} + + {formatBytes(img.size_bytes)} + + + + + ))} + + +
+ + { + if (!open) setPendingImage(null) + }} + /> + ) } diff --git a/moon/apps/web/components/OrionClient/PageWrapper.tsx b/moon/apps/web/components/OrionClient/PageWrapper.tsx new file mode 100644 index 000000000..22230ca7f --- /dev/null +++ b/moon/apps/web/components/OrionClient/PageWrapper.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useRouter } from 'next/router' + +import { SubnavigationTab } from '@/components/Titlebar/Subnavigation' +import { useScope } from '@/contexts/scope' +import { useAdminCheck } from '@/hooks/admin/useAdminCheck' + +interface Props { + children: React.ReactNode +} + +export function OrionClientPageWrapper({ children }: Props) { + const router = useRouter() + const { scope } = useScope() + const { data: adminCheck } = useAdminCheck() + const isAdmin = adminCheck?.data?.is_admin || false + + return ( +
+
+
+ + Runners + + {isAdmin ? ( + + Image Management + + ) : null} +
+
+ {children} +
+ ) +} diff --git a/moon/apps/web/components/OrionClient/UploadOrionImageDialog.tsx b/moon/apps/web/components/OrionClient/UploadOrionImageDialog.tsx new file mode 100644 index 000000000..48afa4639 --- /dev/null +++ b/moon/apps/web/components/OrionClient/UploadOrionImageDialog.tsx @@ -0,0 +1,257 @@ +'use client' + +import { useMemo, useState } from 'react' +import { toast } from 'react-hot-toast' + +import { MONO_API_URL } from '@gitmono/config' +import { UIText } from '@gitmono/ui' +import { Button } from '@gitmono/ui/Button' +import { Dialog } from '@gitmono/ui/Dialog' +import { TextField } from '@gitmono/ui/TextField' + +import { putWithProgress, resolveMonoUploadUrl, sha256HexOfFile } from '@/hooks/OrionClient/orionImageUpload' +import { usePresignOrionImage } from '@/hooks/OrionClient/usePresignOrionImage' +import { useRegisterOrionImage } from '@/hooks/OrionClient/useRegisterOrionImage' + +type ImageInfoSidecar = { + built_at?: string + rust?: string + buck2?: string + python?: string + kernel?: string +} + +type UploadPhase = + | 'idle' + | 'hashing' + | 'presigning' + | 'uploading_image' + | 'uploading_info' + | 'registering' + +interface UploadOrionImageDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +function defaultImageName(fileName: string) { + const base = fileName.replace(/\.qcow2$/i, '').trim() + return base || 'debian-13-buck2' +} + +function phaseLabel(phase: UploadPhase, progress: number): string { + const pct = Math.round(progress * 100) + switch (phase) { + case 'hashing': + return `Hashing image… ${pct}%` + case 'presigning': + return 'Requesting upload URL…' + case 'uploading_image': + return `Uploading image… ${pct}%` + case 'uploading_info': + return `Uploading image-info.json… ${pct}%` + case 'registering': + return 'Registering catalog entry…' + default: + return '' + } +} + +export function UploadOrionImageDialog({ open, onOpenChange }: UploadOrionImageDialogProps) { + const [qcow2File, setQcow2File] = useState(null) + const [infoFile, setInfoFile] = useState(null) + const [imageName, setImageName] = useState('') + const [label, setLabel] = useState('') + const [phase, setPhase] = useState('idle') + const [progress, setProgress] = useState(0) + + const { mutateAsync: presign } = usePresignOrionImage() + const { mutateAsync: registerImage } = useRegisterOrionImage() + + const busy = phase !== 'idle' + const statusText = useMemo(() => phaseLabel(phase, progress), [phase, progress]) + + const reset = () => { + setQcow2File(null) + setInfoFile(null) + setImageName('') + setLabel('') + setPhase('idle') + setProgress(0) + } + + const onUpload = async () => { + if (!qcow2File || busy) return + + try { + setPhase('hashing') + setProgress(0) + const hex = await sha256HexOfFile(qcow2File, setProgress) + const digest = `sha256:${hex}` + const resolvedName = imageName.trim() || defaultImageName(qcow2File.name) + + let infoMeta: ImageInfoSidecar = {} + if (infoFile) { + try { + infoMeta = JSON.parse(await infoFile.text()) as ImageInfoSidecar + } catch { + throw new Error('image-info.json is not valid JSON') + } + } + + setPhase('presigning') + setProgress(0) + const urls = await presign({ + digest, + image_name: resolvedName, + with_info: Boolean(infoFile) + }) + + setPhase('uploading_image') + setProgress(0) + await putWithProgress( + resolveMonoUploadUrl(urls.qcow2_put_url, MONO_API_URL), + qcow2File, + setProgress, + { withCredentials: true } + ) + + if (infoFile && urls.info_put_url) { + setPhase('uploading_info') + setProgress(0) + await putWithProgress( + resolveMonoUploadUrl(urls.info_put_url, MONO_API_URL), + infoFile, + setProgress, + { withCredentials: true } + ) + } + + setPhase('registering') + setProgress(0) + await registerImage({ + digest, + object_key: urls.object_key, + info_object_key: urls.info_object_key ?? null, + image_name: resolvedName, + built_at: infoMeta.built_at ?? null, + rust: infoMeta.rust ?? null, + buck2: infoMeta.buck2 ?? null, + python: infoMeta.python ?? null, + kernel: infoMeta.kernel ?? null, + size_bytes: qcow2File.size, + label: label.trim() || null + }) + + reset() + onOpenChange(false) + } catch (error) { + const message = error instanceof Error ? error.message : 'Upload failed' + toast.error(message) + setPhase('idle') + setProgress(0) + } + } + + return ( + { + if (busy) return + if (!next) reset() + onOpenChange(next) + }} + size='lg' + > + + Upload image + + Upload a qcow2 to object storage and register it in the Orion catalog. Optionally include + image-info.json for toolchain metadata. + + + + + + + + + + + + + {statusText ? ( + + {statusText} + + ) : null} + + + + + + + + + + ) +} diff --git a/moon/apps/web/components/OrionClient/index.tsx b/moon/apps/web/components/OrionClient/index.tsx index 9d2ebd9d7..0a2ccd41e 100644 --- a/moon/apps/web/components/OrionClient/index.tsx +++ b/moon/apps/web/components/OrionClient/index.tsx @@ -5,3 +5,4 @@ export * from './ClientsTable' export * from './RunnersTable' export * from './domainFromHostname' export * from './VmTerminal' +export * from './PageWrapper' diff --git a/moon/apps/web/components/Sidebar/SidebarMenu/DynamicSidebarItem.tsx b/moon/apps/web/components/Sidebar/SidebarMenu/DynamicSidebarItem.tsx index 83c2bf6fc..c4beb5e3d 100644 --- a/moon/apps/web/components/Sidebar/SidebarMenu/DynamicSidebarItem.tsx +++ b/moon/apps/web/components/Sidebar/SidebarMenu/DynamicSidebarItem.tsx @@ -42,7 +42,8 @@ export function DynamicSidebarItem({ config }: DynamicSidebarItemProps) { } const href = `/${scope}${config.href}` - const isActive = router.pathname === `/[org]${config.href}` + const routePath = `/[org]${config.href}` + const isActive = router.pathname === routePath || router.pathname.startsWith(`${routePath}/`) const Component = componentMap[config.public_id as keyof typeof componentMap] diff --git a/moon/apps/web/hooks/OrionClient/orionImageUpload.ts b/moon/apps/web/hooks/OrionClient/orionImageUpload.ts new file mode 100644 index 000000000..c84d3ed8f --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/orionImageUpload.ts @@ -0,0 +1,91 @@ +import { createSHA256 } from 'hash-wasm' + +/** Stream SHA-256 of a File/Blob without loading it entirely into memory. */ +export async function sha256HexOfFile( + file: Blob, + onProgress?: (ratio: number) => void +): Promise { + const hasher = await createSHA256() + hasher.init() + const total = file.size || 1 + let done = 0 + + const stream = file.stream() + const reader = stream.getReader() + + for (;;) { + const { done: eof, value } = await reader.read() + if (eof) break + if (value) { + hasher.update(value) + done += value.byteLength + onProgress?.(Math.min(1, done / total)) + } + } + + onProgress?.(1) + return hasher.digest() +} + +/** PUT a blob to an upload URL with progress (0–1). + * Prefer mono proxy URLs (`/api/v1/orion/images/objects/...`) with credentials. + */ +export function putWithProgress( + url: string, + blob: Blob, + onProgress?: (ratio: number) => void, + options?: { contentType?: string; withCredentials?: boolean } +): Promise { + const contentType = options?.contentType + const withCredentials = options?.withCredentials ?? false + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('PUT', url) + xhr.withCredentials = withCredentials + if (contentType) { + xhr.setRequestHeader('Content-Type', contentType) + } + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable || !onProgress) return + onProgress(event.total > 0 ? event.loaded / event.total : 0) + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress?.(1) + resolve() + return + } + const detail = (xhr.responseText || '').trim().slice(0, 240) + reject( + new Error( + `Upload failed (HTTP ${xhr.status})${detail ? `: ${detail}` : ''}` + ) + ) + } + + xhr.onerror = () => { + reject(new Error('Upload network error')) + } + + // Avoid browser auto Content-Type from Blob.type when none was requested. + const body = + contentType || !blob.type + ? blob + : blob.slice(0, blob.size, '') + + xhr.send(body) + }) +} + +/** Resolve a mono-relative or absolute put URL against MONO_API_URL. */ +export function resolveMonoUploadUrl(putUrlOrPath: string, monoApiBase: string): string { + if (/^https?:\/\//i.test(putUrlOrPath)) { + return putUrlOrPath + } + const base = monoApiBase.replace(/\/$/, '') + const path = putUrlOrPath.startsWith('/') ? putUrlOrPath : `/${putUrlOrPath}` + return `${base}${path}` +} diff --git a/moon/apps/web/hooks/OrionClient/usePresignOrionImage.ts b/moon/apps/web/hooks/OrionClient/usePresignOrionImage.ts new file mode 100644 index 000000000..a4b4ba733 --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/usePresignOrionImage.ts @@ -0,0 +1,49 @@ +import { useMutation } from '@tanstack/react-query' + +import { MONO_API_URL } from '@gitmono/config' + +export type PresignOrionImageRequest = { + digest: string + image_name?: string + with_info?: boolean +} + +export type PresignOrionImageResponse = { + object_key: string + info_object_key?: string | null + qcow2_put_url: string + info_put_url?: string | null + expires_in_secs: number +} + +type Envelope = { + req_result: boolean + err_message?: string + data?: PresignOrionImageResponse | null +} + +export function usePresignOrionImage() { + return useMutation({ + mutationFn: async (body: PresignOrionImageRequest) => { + const base = MONO_API_URL.replace(/\/$/, '') + const res = await fetch(`${base}/api/v1/orion/images/presign`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + const text = await res.text() + let json: Envelope | null = null + try { + json = text ? (JSON.parse(text) as Envelope) : null + } catch { + throw new Error(text || `Failed to prepare upload (${res.status})`) + } + + if (!res.ok || !json?.req_result || !json.data) { + throw new Error(json?.err_message || text || `Failed to prepare upload (${res.status})`) + } + return json.data + } + }) +} diff --git a/moon/apps/web/hooks/OrionClient/useRegisterOrionImage.ts b/moon/apps/web/hooks/OrionClient/useRegisterOrionImage.ts new file mode 100644 index 000000000..86c70c5bb --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/useRegisterOrionImage.ts @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { MONO_API_URL } from '@gitmono/config' + +import type { OrionVmImage } from './useGetOrionImages' + +export type RegisterOrionImageRequest = { + digest: string + object_key: string + info_object_key?: string | null + image_name?: string | null + built_at?: string | null + rust?: string | null + buck2?: string | null + python?: string | null + kernel?: string | null + size_bytes?: number | null + label?: string | null +} + +type Envelope = { + req_result: boolean + err_message?: string + data?: OrionVmImage | null +} + +export function useRegisterOrionImage() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (body: RegisterOrionImageRequest) => { + const base = MONO_API_URL.replace(/\/$/, '') + const res = await fetch(`${base}/api/v1/orion/images`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + const json = (await res.json()) as Envelope + + if (!res.ok || !json?.req_result) { + throw new Error(json?.err_message || `Failed to register image (${res.status})`) + } + return json.data + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['GET:/api/v1/orion/images'] }) + toast.success('Image uploaded') + }, + onError: (error: Error) => { + toast.error(error?.message || 'Failed to register image') + } + }) +} diff --git a/moon/apps/web/package.json b/moon/apps/web/package.json index f0c3a1777..148b9932a 100644 --- a/moon/apps/web/package.json +++ b/moon/apps/web/package.json @@ -70,6 +70,7 @@ "framer-motion": "catalog:", "github-markdown-css": "catalog:", "gray-matter": "catalog:", + "hash-wasm": "^4.12.0", "jotai": "catalog:", "jotai-scope": "catalog:", "js-base64": "catalog:", diff --git a/moon/apps/web/pages/[org]/oc/images.tsx b/moon/apps/web/pages/[org]/oc/images.tsx new file mode 100644 index 000000000..cc161825d --- /dev/null +++ b/moon/apps/web/pages/[org]/oc/images.tsx @@ -0,0 +1,75 @@ +'use client' + +import { useState } from 'react' +import Head from 'next/head' + +import { Button, UIText } from '@gitmono/ui' + +import { AppLayout } from '@/components/Layout/AppLayout' +import { OrionClientPageWrapper } from '@/components/OrionClient/PageWrapper' +import { OrionImagesTable } from '@/components/OrionClient/OrionImagesTable' +import { UploadOrionImageDialog } from '@/components/OrionClient/UploadOrionImageDialog' +import AuthAppProviders from '@/components/Providers/AuthAppProviders' +import { useAdminCheck } from '@/hooks/admin/useAdminCheck' +import { useGetOrionImages } from '@/hooks/OrionClient/useGetOrionImages' +import { PageWithLayout } from '@/utils/types' + +const OrionImagesPage: PageWithLayout = () => { + const { data: adminCheck } = useAdminCheck() + const isAdmin = adminCheck?.data?.is_admin || false + const { + data: orionImages = [], + isLoading: isLoadingImages, + error: orionImagesError + } = useGetOrionImages(isAdmin) + const [uploadOpen, setUploadOpen] = useState(false) + + return ( + <> + + Image Management + + +
+
+
+

Image Management

+ + Browse, upload, and delete Orion VM catalog images. + +
+ {isAdmin ? ( + + ) : null} +
+ {isAdmin ? ( + <> + + + + ) : ( + + Admin access is required to manage VM images. + + )} +
+
+ + ) +} + +OrionImagesPage.getProviders = (page: React.ReactElement, pageProps: any) => { + return ( + + {page} + + ) +} + +export default OrionImagesPage diff --git a/moon/apps/web/pages/[org]/oc/index.tsx b/moon/apps/web/pages/[org]/oc/index.tsx index 963412af1..342f5e052 100644 --- a/moon/apps/web/pages/[org]/oc/index.tsx +++ b/moon/apps/web/pages/[org]/oc/index.tsx @@ -15,10 +15,10 @@ import { Button, UIText } from '@gitmono/ui' import { RefreshIcon } from '@gitmono/ui/Icons' import { AppLayout } from '@/components/Layout/AppLayout' -import { OrionImagesTable } from '@/components/OrionClient/OrionImagesTable' import { domainFromClientHostname, OrionClient, + OrionClientPageWrapper, OrionClientStatus, RunnersTable, VmTerminal @@ -108,11 +108,7 @@ const OrionClientPage: PageWithLayout = () => { error: runnerListError, refetch: refetchRunners } = useGetRunnerList(isAdmin) - const { - data: orionImages = [], - isLoading: isLoadingImages, - error: orionImagesError - } = useGetOrionImages(isAdmin) + const { data: orionImages = [], isLoading: isLoadingImages } = useGetOrionImages(isAdmin) const runnerStatusVmId = logSource === 'runner' ? activeLogKey : null const { data: runnerStatus } = useGetRunnerStatus(runnerStatusVmId, activePhase) const { logs: runnerLogs, status: runnerLogsStatus, error: runnerLogsError } = useRunnerLogsSSE(activeLogKey) @@ -414,6 +410,7 @@ const OrionClientPage: PageWithLayout = () => { Orion Client + {/* AppLayout main is overflow-hidden; this page must own scrolling when the list is visible. */}
= () => { {!showingOverlay ? ( <> - {isAdmin ? ( -
- - VM images - - -
- ) : null} = () => { ) : null}
+
) } diff --git a/moon/packages/types/generated.ts b/moon/packages/types/generated.ts index 84e6908fb..86145793b 100644 --- a/moon/packages/types/generated.ts +++ b/moon/packages/types/generated.ts @@ -4314,6 +4314,53 @@ export type CommonResultMergeBoxRes = { req_result: boolean } +export type CommonResultOrionVmImageListResponse = { + data?: { + /** @min 0 */ + count: number + images: OrionVmImageResponse[] + } + err_message: string + req_result: boolean +} + +export type CommonResultOrionVmImageResponse = { + data?: { + buck2?: string | null + built_at?: string | null + created_at: string + digest: string + id: string + image_name?: string | null + info_object_key?: string | null + kernel?: string | null + label?: string | null + object_key: string + python?: string | null + rust?: string | null + /** @format int64 */ + size_bytes?: number | null + } + err_message: string + req_result: boolean +} + +export type CommonResultPresignOrionVmImageResponse = { + data?: { + /** + * @format int64 + * @min 0 + */ + expires_in_secs: number + info_object_key?: string | null + info_put_url?: string | null + object_key: string + qcow2_put_url: string + } + err_message: string + req_result: boolean +} + export type CommonResultQueueListResponse = { /** Queue list response */ data?: { @@ -5516,6 +5563,29 @@ export type ObjectError = { message: string } +export type OrionVmImageListResponse = { + /** @min 0 */ + count: number + images: OrionVmImageResponse[] +} + +export type OrionVmImageResponse = { + buck2?: string | null + built_at?: string | null + created_at: string + digest: string + id: string + image_name?: string | null + info_object_key?: string | null + kernel?: string | null + label?: string | null + object_key: string + python?: string | null + rust?: string | null + /** @format int64 */ + size_bytes?: number | null +} + export type PageParamsCommitHistoryParams = { additional: { /** author: author name filter */ @@ -5604,6 +5674,27 @@ export type PositionResponse = { position_status: PositionStatus } +export type PresignOrionVmImageRequest = { + /** Content digest, e.g. `sha256:`. */ + digest: string + /** Base name used in the object key (default `debian-13-buck2`). */ + image_name?: string | null + /** When true, also return a PUT URL for `{hex}/image-info.json`. */ + with_info?: boolean +} + +export type PresignOrionVmImageResponse = { + /** + * @format int64 + * @min 0 + */ + expires_in_secs: number + info_object_key?: string | null + info_put_url?: string | null + object_key: string + qcow2_put_url: string +} + /** Error details for API */ export type QueueError = { /** Failure type for API */ @@ -5682,6 +5773,23 @@ export type Ref = { name: string } +export type RegisterOrionVmImageRequest = { + buck2?: string | null + built_at?: string | null + /** Content digest, e.g. `sha256:`. */ + digest: string + image_name?: string | null + info_object_key?: string | null + kernel?: string | null + label?: string | null + /** Key under the `orion-images/` namespace, e.g. `{hex}/debian-13-buck2.qcow2`. */ + object_key: string + python?: string | null + rust?: string | null + /** @format int64 */ + size_bytes?: number | null +} + export type RemoveGpgRequest = { key_id: string } @@ -5851,6 +5959,11 @@ export type StartRunnerRequest = { * @min 0 */ image_disk_gb?: number | null + /** + * Catalog image id from `GET /api/v1/orion/images`. Mutually exclusive with + * `image_path` / `image_url`. + */ + image_id?: string | null /** * @format int32 * @min 0 @@ -5858,8 +5971,6 @@ export type StartRunnerRequest = { image_memory_mb?: number | null image_path?: string | null image_url?: string | null - /** Catalog image id from GET /api/v1/orion/images. */ - image_id?: string | null /** Force recreate when a Running VM already exists for this mono's domain. */ replace?: boolean /** @@ -9256,6 +9367,19 @@ export type PatchApiOrganizationsNotesSyncStateParams = { export type PatchApiOrganizationsNotesSyncStateData = any +export type GetApiOrionImagesData = CommonResultOrionVmImageListResponse + +export type PostApiOrionImagesData = CommonResultOrionVmImageResponse + +export type PostApiOrionImagesPresignData = CommonResultPresignOrionVmImageResponse + +export type DeleteApiOrionImagesByIdParams = { + /** Catalog image id */ + id: string +} + +export type DeleteApiOrionImagesByIdData = CommonResultOrionVmImageResponse + export type GetApiOrionRunnersData = CommonResultRunnerListResponse export type PostApiOrionRunnersData = CommonResultStartRunnerResponse @@ -21074,6 +21198,106 @@ export class Api extends HttpClient { + const base = 'GET:/api/v1/orion/images' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: () => dataTaggedQueryKey([base]), + request: (params: RequestParams = {}) => + this.request({ + path: `/api/v1/orion/images`, + method: 'GET', + format: 'json', + ...params + }) + } + }, + + /** + * No description + * + * @tags Automation & Integrations + * @name PostApiOrionImages + * @summary Register (upsert) an image after build-script upload to RustFS. + * @request POST:/api/v1/orion/images + */ + postApiOrionImages: () => { + const base = 'POST:/api/v1/orion/images' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: () => dataTaggedQueryKey([base]), + request: (data: RegisterOrionVmImageRequest, params: RequestParams = {}) => + this.request({ + path: `/api/v1/orion/images`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params + }) + } + }, + + /** + * No description + * + * @tags Automation & Integrations + * @name PostApiOrionImagesPresign + * @summary Issue presigned PUT URLs for browser upload of a qcow2 (+ optional image-info.json). + * @request POST:/api/v1/orion/images/presign + */ + postApiOrionImagesPresign: () => { + const base = 'POST:/api/v1/orion/images/presign' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: () => dataTaggedQueryKey([base]), + request: (data: PresignOrionVmImageRequest, params: RequestParams = {}) => + this.request({ + path: `/api/v1/orion/images/presign`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params + }) + } + }, + + /** + * No description + * + * @tags Automation & Integrations + * @name DeleteApiOrionImagesById + * @summary Delete a catalog entry and its RustFS objects. + * @request DELETE:/api/v1/orion/images/{id} + */ + deleteApiOrionImagesById: () => { + const base = 'DELETE:/api/v1/orion/images/{id}' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: (id: string) => dataTaggedQueryKey([base, id]), + request: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/orion/images/${id}`, + method: 'DELETE', + format: 'json', + ...params + }) + } + }, + /** * No description * diff --git a/moon/pnpm-lock.yaml b/moon/pnpm-lock.yaml index 790755afa..940b9be75 100644 --- a/moon/pnpm-lock.yaml +++ b/moon/pnpm-lock.yaml @@ -711,7 +711,7 @@ importers: version: 4.4.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@hookform/resolvers': specifier: 'catalog:' - version: 5.4.2(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@6.15.0))(ajv@6.15.0)(react-hook-form@7.83.0(react@19.2.8))(zod@4.4.3) + version: 5.4.2(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.83.0(react@19.2.8))(zod@4.4.3) '@mui/icons-material': specifier: 'catalog:' version: 9.2.0(@mui/material@9.2.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.8)(supports-color@8.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.8)(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.8)(supports-color@8.1.1))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react@19.2.17)(react@19.2.8) @@ -847,6 +847,9 @@ importers: gray-matter: specifier: 'catalog:' version: 4.0.3 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 jotai: specifier: 'catalog:' version: 2.20.2(@babel/core@7.29.7(supports-color@8.1.1))(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.8) @@ -7364,6 +7367,9 @@ packages: resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} engines: {node: '>= 0.8'} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -11999,14 +12005,14 @@ snapshots: y-prosemirror: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) yjs: 13.6.31 - '@hookform/resolvers@5.4.2(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@6.15.0))(ajv@6.15.0)(react-hook-form@7.83.0(react@19.2.8))(zod@4.4.3)': + '@hookform/resolvers@5.4.2(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.83.0(react@19.2.8))(zod@4.4.3)': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.83.0(react@19.2.8) optionalDependencies: '@standard-schema/spec': 1.1.0 - ajv: 6.15.0 - ajv-formats: 2.1.1(ajv@6.15.0) + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) zod: 4.4.3 '@humanfs/core@0.19.2': @@ -14977,11 +14983,6 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv-formats@2.1.1(ajv@6.15.0): - optionalDependencies: - ajv: 6.15.0 - optional: true - ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -16781,6 +16782,8 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + hash-wasm@4.12.0: {} + hash.js@1.1.7: dependencies: inherits: 2.0.4 diff --git a/orion-scheduler/README.md b/orion-scheduler/README.md index cbdad8348..f64c85448 100644 --- a/orion-scheduler/README.md +++ b/orion-scheduler/README.md @@ -151,13 +151,44 @@ curl -X POST http://localhost:8080/webhook \ ### 镜像 catalog(RustFS + mono) -构建脚本可将 qcow2 上传到 RustFS,并注册到 mono: +构建脚本可将 qcow2 上传到各环境 RustFS,并注册到对应 mono catalog。 + +**鉴权(推荐)**:脚本在 register 前调用 `POST /api/v1/bots/bootstrap-orion-image`(header `X-Mega-Init-Secret` = 与 mono 相同的 `MEGA_INIT_BOOTSTRAP_SECRET`),自动创建 bot `orion-image-publisher` 并签发短命 `bot_` token;每次 bootstrap 会吊销旧的 `orion-image-register` token。 + +**多环境 fan-out(推荐)**:设置 `ORION_IMAGE_FANOUT` 为 JSON 数组(内联)或文件路径(以 `/`、`./` 开头或以 `.json` 结尾): + +```json +[ + { + "name": "mega-dev", + "register_url": "https://git.example-dev/api/v1/orion/images", + "bootstrap_secret": "...", + "rustfs_endpoint": "https://rustfs.example-dev", + "rustfs_access_key": "...", + "rustfs_secret_key": "...", + "rustfs_bucket": "...", + "rustfs_region": "us-east-1" + } +] +``` + +| 字段 | 说明 | +|------|------| +| `bootstrap_secret` | 与该环境 mono 的 `MEGA_INIT_BOOTSTRAP_SECRET` 一致;缺省时用环境变量 `MEGA_INIT_BOOTSTRAP_SECRET` | +| `bootstrap_url` | 可选;默认由 `register_url` 推导为 `…/api/v1/bots/bootstrap-orion-image` | +| `token` | 可选;若设置则跳过 bootstrap,直接用该 Bearer | +| 其余 | 该目标的 RustFS 与 `POST /api/v1/orion/images` | + +每个目标:上传 qcow2 + sidecar →(bootstrap 换票)→ POST register;单目标失败打 WARNING 并继续。 + +**单环境兼容**:未设 `ORION_IMAGE_FANOUT` 时仍可用: | Env | 说明 | |-----|------| | `RUSTFS_ENDPOINT` / `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` / `RUSTFS_BUCKET` | S3 兼容上传(path-style) | | `ORION_IMAGE_REGISTER_URL` | 通常为 `https:///api/v1/orion/images` | -| `ORION_IMAGE_REGISTER_TOKEN` | 管理员 Bearer token | +| `MEGA_INIT_BOOTSTRAP_SECRET` | 推荐:自动 bootstrap publisher bot | +| `ORION_IMAGE_REGISTER_TOKEN` | 可选:静态 `bot_` token(有则不再 bootstrap) | 对象键:`orion-images/{sha256_hex}/debian-13-buck2.qcow2` + `image-info.json`。 diff --git a/orion-scheduler/TESTING.md b/orion-scheduler/TESTING.md index 138342716..9d1beb8ce 100644 --- a/orion-scheduler/TESTING.md +++ b/orion-scheduler/TESTING.md @@ -242,15 +242,41 @@ fuser 8080/tcp 2>/dev/null || echo "Port 8080 is free" ## 4. 构建镜像并上传到 RustFS +多环境 fan-out(推荐;用 bootstrap secret 自动建 bot + 换票): + ```bash sudo modprobe nbd max_part=8 +export ORION_IMAGE_FANOUT='[ + { + "name": "mega-dev", + "register_url": "https://git.example-dev/api/v1/orion/images", + "bootstrap_secret": "'"$MEGA_INIT_BOOTSTRAP_SECRET"'", + "rustfs_endpoint": "https://rustfs.example-dev", + "rustfs_access_key": "...", + "rustfs_secret_key": "...", + "rustfs_bucket": "...", + "rustfs_region": "us-east-1" + } +]' +# 或: export ORION_IMAGE_FANOUT=./orion-image-fanout.json +# 亦可省略各目标 bootstrap_secret,统一 export MEGA_INIT_BOOTSTRAP_SECRET=... + +sudo -E bash ~/mega/orion-scheduler/scripts/build-custom-image.sh +``` + +脚本对每个目标:`POST …/bots/bootstrap-orion-image`(`X-Mega-Init-Secret`)→ 用返回的 `bot_` token 调 `POST …/orion/images`。也可在目标里设静态 `token` 跳过 bootstrap。 + +单环境兼容: + +```bash export RUSTFS_ENDPOINT=https://rustfs.example.com export RUSTFS_ACCESS_KEY=... export RUSTFS_SECRET_KEY=... export RUSTFS_BUCKET=mega export ORION_IMAGE_REGISTER_URL=https://git.example.com/api/v1/orion/images -export ORION_IMAGE_REGISTER_TOKEN= +export MEGA_INIT_BOOTSTRAP_SECRET=... # 推荐:自动 bootstrap +# 或: export ORION_IMAGE_REGISTER_TOKEN=bot_... sudo -E bash ~/mega/orion-scheduler/scripts/build-custom-image.sh # 本地仍发布到 ~/.local/share/qlean/images/ @@ -266,7 +292,28 @@ orion-images/{sha256_hex}/image-info.json UI(Campsite POC)通过 `GET /api/v1/orion/images` 列出工具链版本;Start Runner 传 `image_id`,mono 签发预签名 URL 给 scheduler。 -未设 RustFS / register env 时脚本只做本地发布(与以前相同)。 +未设 `ORION_IMAGE_FANOUT` / RustFS / register env 时脚本只做本地发布(与以前相同)。 + +### 本地无法构建时的 mock 上传 + +跳过 qemu/chroot,写 1MiB 假文件后直接走 Stage 8(无需 root): + +```bash +export MOCK_UPLOAD=1 +export MOCK_IMAGE_BYTES=1048576 # 可选,默认 1MiB +export OUTPUT_DIR=/tmp/orion-mock-images +export RUSTFS_ENDPOINT=http://127.0.0.1:19000 +export RUSTFS_ACCESS_KEY=rustfsadmin +export RUSTFS_SECRET_KEY=rustfsadmin +export RUSTFS_BUCKET=mega +export ORION_IMAGE_REGISTER_URL=http://127.0.0.1:8000/api/v1/orion/images +export MEGA_INIT_BOOTSTRAP_SECRET='...' # 须与 mono 进程环境变量一致,且 ≥32 字符 +# mono 还需要 MEGA_BOT_TOKEN_HMAC_SECRET(≥32)才能签发 bot_ token + +bash orion-scheduler/scripts/build-custom-image.sh +``` + +`SKIP_BUILD=1` 与 `MOCK_UPLOAD=1` 等价。 --- @@ -285,6 +332,6 @@ UI(Campsite POC)通过 `GET /api/v1/orion/images` 列出工具链版本;St | Scorpio 挂载问题 | `curl '.../scorpio/status?domain=...'`(看 `disk.df_root` / `disk.du`) | | Guest 磁盘打满 / worker Lost | VM 内 `df -h /`;清 `/data/scorpio/antares/{upper,cl}` 或 `systemctl restart orion-runner`;新盘建议 `image_disk_gb: 50` | | 重启后状态丢了 | 内存 map;磁盘 qemu 靠启动 reap;重新 POST webhook | -| 镜像 catalog 为空 | 构建时设 RustFS + `ORION_IMAGE_REGISTER_*`;查 mono `GET /api/v1/orion/images` | +| 镜像 catalog 为空 | 构建时设 `ORION_IMAGE_FANOUT` 或 RustFS + `ORION_IMAGE_REGISTER_URL` + `MEGA_INIT_BOOTSTRAP_SECRET`(或静态 token);查 mono `GET /api/v1/orion/images`;bootstrap 失败查 secret 是否与 mono 一致 | | Start Runner 选镜像失败 | mono 对象存储需支持预签名(RustFS/S3);本地 backend 无 signed URL | | 进 VM 调试 | [SSH 进入 VM](#ssh-进入-vm) | diff --git a/orion-scheduler/scripts/build-custom-image.sh b/orion-scheduler/scripts/build-custom-image.sh index 653a4a053..58bb4a798 100755 --- a/orion-scheduler/scripts/build-custom-image.sh +++ b/orion-scheduler/scripts/build-custom-image.sh @@ -8,7 +8,16 @@ # # Usage: sudo ./build-custom-image.sh # -# Note: Must run as root because qemu-nbd / mount / chroot need it. +# Mock upload (no qemu/chroot; no root required): +# MOCK_UPLOAD=1 OUTPUT_DIR=/tmp/orion-mock-images \ +# RUSTFS_ENDPOINT=http://127.0.0.1:19000 \ +# RUSTFS_ACCESS_KEY=... RUSTFS_SECRET_KEY=... RUSTFS_BUCKET=mega \ +# ORION_IMAGE_REGISTER_URL=http://127.0.0.1:8000/api/v1/orion/images \ +# MEGA_INIT_BOOTSTRAP_SECRET=... \ +# bash scripts/build-custom-image.sh +# Optional: MOCK_IMAGE_BYTES=1048576 (default 1MiB), SKIP_BUILD=1 (alias of MOCK_UPLOAD). +# +# Note: Real builds must run as root because qemu-nbd / mount / chroot need it. # Images are published to the invoking user's ~/.local/share/qlean/images # (e.g. /home/orion/... when run as `sudo -u` or `sudo` from user orion), # not /root/. Override with OUTPUT_DIR=... if needed. @@ -149,6 +158,15 @@ log_cmd() { echo "[build-custom-image] \$ $*" } +# Portable file size (GNU stat -c on Linux, BSD -f on macOS). +file_size_bytes() { + if stat -c%s "$1" >/dev/null 2>&1; then + stat -c%s "$1" + else + stat -f%z "$1" + fi +} + # Unmount image tree safely. Never `rm -rf` the mount dir while /proc|/sys|/dev # are still bind-mounted — that prints endless "Operation not permitted" under # proc and can wedge the host. Prefer lazy umount if busy after Ctrl-C. @@ -280,6 +298,54 @@ download_base_image() { echo "[build-custom-image] Base image downloaded and verified ($(du -sh "$BASE_IMAGE" | cut -f1))" } +# ============================================================================ +# Mock path: skip qemu/chroot build; write a tiny fake qcow2 and jump to Stage 8. +# ============================================================================ +MOCK_UPLOAD="${MOCK_UPLOAD:-${SKIP_BUILD:-0}}" +PUBLISHED_IMAGE="$OUTPUT_DIR/$IMAGE_NAME.qcow2" +PUBLISHED_JSON="$OUTPUT_DIR/$IMAGE_NAME.json" + +if [ "$MOCK_UPLOAD" = "1" ] || [ "$MOCK_UPLOAD" = "true" ]; then + log_stage "mock-publish" + MOCK_IMAGE_BYTES="${MOCK_IMAGE_BYTES:-1048576}" + echo "[build-custom-image] MOCK_UPLOAD=1: skipping real build" + echo "[build-custom-image] OUTPUT_DIR=$OUTPUT_DIR" + echo "[build-custom-image] MOCK_IMAGE_BYTES=$MOCK_IMAGE_BYTES" + mkdir -p "$IMAGE_DIR" "$OUTPUT_DIR" + + # Pseudo-qcow2 payload (not a real qcow2; enough to exercise upload/register). + head -c "$MOCK_IMAGE_BYTES" /dev/urandom > "$CUSTOM_IMAGE" + cp "$CUSTOM_IMAGE" "$PUBLISHED_IMAGE" + NEW_DIGEST=$(shasum -a 256 "$PUBLISHED_IMAGE" 2>/dev/null | awk '{print $1}') + if [ -z "$NEW_DIGEST" ]; then + NEW_DIGEST=$(sha256sum "$PUBLISHED_IMAGE" | awk '{print $1}') + fi + cat > "$IMAGE_DIR/image-info.json" < "$IMAGE_DIR/checksums" + cat > "$PUBLISHED_JSON" <&2 + echo "" + return 0 + fi + cat "$raw" + ;; + *) + printf '%s\n' "$raw" + ;; + esac +} + +legacy_orion_image_fanout_json() { if [ -z "${RUSTFS_ENDPOINT:-}" ] || [ -z "${RUSTFS_ACCESS_KEY:-}" ] \ || [ -z "${RUSTFS_SECRET_KEY:-}" ] || [ -z "${RUSTFS_BUCKET:-}" ]; then - echo "[build-custom-image] RustFS env incomplete; skipping upload/register" - echo "[build-custom-image] set RUSTFS_ENDPOINT RUSTFS_ACCESS_KEY RUSTFS_SECRET_KEY RUSTFS_BUCKET" + echo "" return 0 fi - if ! command -v aws >/dev/null 2>&1; then - echo "[build-custom-image] WARNING: aws CLI not found; skipping RustFS upload" >&2 + jq -n \ + --arg name "legacy" \ + --arg register_url "${ORION_IMAGE_REGISTER_URL:-}" \ + --arg token "${ORION_IMAGE_REGISTER_TOKEN:-}" \ + --arg bootstrap_secret "${MEGA_INIT_BOOTSTRAP_SECRET:-}" \ + --arg rustfs_endpoint "$RUSTFS_ENDPOINT" \ + --arg rustfs_access_key "$RUSTFS_ACCESS_KEY" \ + --arg rustfs_secret_key "$RUSTFS_SECRET_KEY" \ + --arg rustfs_bucket "$RUSTFS_BUCKET" \ + --arg rustfs_region "${RUSTFS_REGION:-us-east-1}" \ + '[{ + name: $name, + register_url: $register_url, + token: $token, + bootstrap_secret: $bootstrap_secret, + rustfs_endpoint: $rustfs_endpoint, + rustfs_access_key: $rustfs_access_key, + rustfs_secret_key: $rustfs_secret_key, + rustfs_bucket: $rustfs_bucket, + rustfs_region: $rustfs_region + }]' +} + +# Derive POST /api/v1/bots/bootstrap-orion-image from register_url when needed. +orion_image_bootstrap_url_from_register() { + local register_url="$1" + local explicit="$2" + if [ -n "$explicit" ]; then + printf '%s\n' "$explicit" + return 0 + fi + if [[ "$register_url" == *"/api/v1/orion/images" ]]; then + printf '%s\n' "${register_url%/api/v1/orion/images}/api/v1/bots/bootstrap-orion-image" + return 0 + fi + if [[ "$register_url" == *"/api/v1/orion/images/" ]]; then + printf '%s\n' "${register_url%/api/v1/orion/images/}/api/v1/bots/bootstrap-orion-image" + return 0 + fi + echo "" +} + +# Resolve register Bearer token: static token, else mint via bootstrap-orion-image. +resolve_orion_image_register_token() { + local name="$1" + local target_json="$2" + local register_url="$3" + + local token bootstrap_secret bootstrap_url resp + token=$(jq -r '.token // empty' <<<"$target_json") + if [ -n "$token" ]; then + printf '%s\n' "$token" + return 0 + fi + + bootstrap_secret=$(jq -r '.bootstrap_secret // empty' <<<"$target_json") + if [ -z "$bootstrap_secret" ]; then + bootstrap_secret="${MEGA_INIT_BOOTSTRAP_SECRET:-}" + fi + bootstrap_url=$(orion_image_bootstrap_url_from_register \ + "$register_url" \ + "$(jq -r '.bootstrap_url // empty' <<<"$target_json")") + + if [ -z "$bootstrap_secret" ] || [ -z "$bootstrap_url" ]; then + echo "" return 0 fi + echo "[build-custom-image] [$name] Bootstrapping orion-image-publisher via $bootstrap_url ..." >&2 + resp=$(curl -fsS -X POST "$bootstrap_url" \ + -H "X-Mega-Init-Secret: ${bootstrap_secret}" \ + -H "Content-Type: application/json" \ + -d '{}' 2>/dev/null) || { + echo "[build-custom-image] WARNING: [$name] bootstrap-orion-image failed" >&2 + echo "" + return 0 + } + + token=$(jq -r '.data.token // empty' <<<"$resp") + if [ -z "$token" ]; then + echo "[build-custom-image] WARNING: [$name] bootstrap returned no token: $resp" >&2 + echo "" + return 0 + fi + echo "[build-custom-image] [$name] Got bot token for bot_name=$(jq -r '.data.bot_name // empty' <<<"$resp")" >&2 + printf '%s\n' "$token" +} + +upload_orion_image_to_target() { + local image_file="$1" + local digest_hex="$2" + local info_file="$3" + local target_json="$4" + + local name endpoint access_key secret_key bucket region + name=$(jq -r '.name // "unnamed"' <<<"$target_json") + endpoint=$(jq -r '.rustfs_endpoint // empty' <<<"$target_json") + access_key=$(jq -r '.rustfs_access_key // empty' <<<"$target_json") + secret_key=$(jq -r '.rustfs_secret_key // empty' <<<"$target_json") + bucket=$(jq -r '.rustfs_bucket // empty' <<<"$target_json") + region=$(jq -r '.rustfs_region // "us-east-1"' <<<"$target_json") + + if [ -z "$endpoint" ] || [ -z "$access_key" ] || [ -z "$secret_key" ] || [ -z "$bucket" ]; then + echo "[build-custom-image] WARNING: target '$name' missing RustFS fields; skipping" >&2 + return 1 + fi + local object_key="${digest_hex}/${IMAGE_NAME}.qcow2" local info_key="${digest_hex}/image-info.json" - local s3_qcow2="s3://${RUSTFS_BUCKET}/orion-images/${object_key}" - local s3_info="s3://${RUSTFS_BUCKET}/orion-images/${info_key}" - - echo "[build-custom-image] Uploading qcow2 to ${s3_qcow2} ..." - AWS_ACCESS_KEY_ID="$RUSTFS_ACCESS_KEY" \ - AWS_SECRET_ACCESS_KEY="$RUSTFS_SECRET_KEY" \ - AWS_DEFAULT_REGION="${RUSTFS_REGION:-us-east-1}" \ - aws --endpoint-url "$RUSTFS_ENDPOINT" s3 cp \ - --only-show-errors \ - "$image_file" "$s3_qcow2" + local s3_qcow2="s3://${bucket}/orion-images/${object_key}" + local s3_info="s3://${bucket}/orion-images/${info_key}" + + echo "[build-custom-image] [$name] Uploading qcow2 to ${s3_qcow2} ..." + if ! AWS_ACCESS_KEY_ID="$access_key" \ + AWS_SECRET_ACCESS_KEY="$secret_key" \ + AWS_DEFAULT_REGION="$region" \ + aws --endpoint-url "$endpoint" s3 cp \ + --only-show-errors \ + "$image_file" "$s3_qcow2"; then + echo "[build-custom-image] WARNING: [$name] qcow2 upload failed" >&2 + return 1 + fi if [ -f "$info_file" ]; then - echo "[build-custom-image] Uploading sidecar to ${s3_info} ..." - AWS_ACCESS_KEY_ID="$RUSTFS_ACCESS_KEY" \ - AWS_SECRET_ACCESS_KEY="$RUSTFS_SECRET_KEY" \ - AWS_DEFAULT_REGION="${RUSTFS_REGION:-us-east-1}" \ - aws --endpoint-url "$RUSTFS_ENDPOINT" s3 cp \ - --only-show-errors \ - "$info_file" "$s3_info" + echo "[build-custom-image] [$name] Uploading sidecar to ${s3_info} ..." + if ! AWS_ACCESS_KEY_ID="$access_key" \ + AWS_SECRET_ACCESS_KEY="$secret_key" \ + AWS_DEFAULT_REGION="$region" \ + aws --endpoint-url "$endpoint" s3 cp \ + --only-show-errors \ + "$info_file" "$s3_info"; then + echo "[build-custom-image] WARNING: [$name] sidecar upload failed" >&2 + return 1 + fi fi - if [ -z "${ORION_IMAGE_REGISTER_URL:-}" ] || [ -z "${ORION_IMAGE_REGISTER_TOKEN:-}" ]; then - echo "[build-custom-image] ORION_IMAGE_REGISTER_URL/TOKEN unset; upload done, catalog not registered" + local register_url token + register_url=$(jq -r '.register_url // empty' <<<"$target_json") + if [ -z "$register_url" ]; then + echo "[build-custom-image] [$name] register_url unset; upload done, catalog not registered" + return 0 + fi + + token=$(resolve_orion_image_register_token "$name" "$target_json" "$register_url") + if [ -z "$token" ]; then + echo "[build-custom-image] [$name] no token (set token, or bootstrap_secret / MEGA_INIT_BOOTSTRAP_SECRET); upload done, catalog not registered" return 0 fi local size_bytes built_at rust_ver buck2_ver python_ver kernel_ver - size_bytes=$(stat -c%s "$image_file") + size_bytes=$(file_size_bytes "$image_file") built_at=$(jq -r '.built_at // empty' "$info_file" 2>/dev/null || true) rust_ver=$(jq -r '.rust // empty' "$info_file" 2>/dev/null || true) buck2_ver=$(jq -r '.buck2 // empty' "$info_file" 2>/dev/null || true) @@ -1114,16 +1320,71 @@ upload_and_register_orion_image() { size_bytes: $size_bytes }') - echo "[build-custom-image] Registering catalog at $ORION_IMAGE_REGISTER_URL ..." - if ! curl -fsS -X POST "$ORION_IMAGE_REGISTER_URL" \ - -H "Authorization: Bearer ${ORION_IMAGE_REGISTER_TOKEN}" \ + echo "[build-custom-image] [$name] Registering catalog at $register_url ..." + if ! curl -fsS -X POST "$register_url" \ + -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$body"; then - echo "[build-custom-image] WARNING: catalog register failed (objects may still be in RustFS)" >&2 - return 0 + echo "[build-custom-image] WARNING: [$name] catalog register failed (objects may still be in RustFS)" >&2 + return 1 fi echo "" - echo "[build-custom-image] Catalog register OK" + echo "[build-custom-image] [$name] Catalog register OK" + return 0 +} + +upload_and_register_orion_image() { + local image_file="$1" + local digest_hex="$2" + local info_file="$3" + + if [ -z "${ORION_IMAGE_FANOUT:-}" ] \ + && { [ -z "${RUSTFS_ENDPOINT:-}" ] || [ -z "${RUSTFS_ACCESS_KEY:-}" ] \ + || [ -z "${RUSTFS_SECRET_KEY:-}" ] || [ -z "${RUSTFS_BUCKET:-}" ]; }; then + echo "[build-custom-image] No ORION_IMAGE_FANOUT or RUSTFS_* targets; skipping upload/register" + echo "[build-custom-image] set ORION_IMAGE_FANOUT=...json or RUSTFS_ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET" + return 0 + fi + + if ! command -v jq >/dev/null 2>&1; then + echo "[build-custom-image] WARNING: jq not found; skipping RustFS upload/register" >&2 + return 0 + fi + if ! command -v aws >/dev/null 2>&1; then + echo "[build-custom-image] WARNING: aws CLI not found; skipping RustFS upload" >&2 + return 0 + fi + + local fanout_json + fanout_json=$(resolve_orion_image_fanout_json) + if [ -z "$fanout_json" ]; then + fanout_json=$(legacy_orion_image_fanout_json) + fi + if [ -z "$fanout_json" ]; then + echo "[build-custom-image] No ORION_IMAGE_FANOUT or RUSTFS_* targets; skipping upload/register" + return 0 + fi + + if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"$fanout_json"; then + echo "[build-custom-image] WARNING: ORION_IMAGE_FANOUT must be a JSON array; skipping" >&2 + return 0 + fi + + local count i target_json + count=$(jq 'length' <<<"$fanout_json") + if [ "$count" -eq 0 ]; then + echo "[build-custom-image] ORION_IMAGE_FANOUT is empty; skipping upload/register" + return 0 + fi + + echo "[build-custom-image] Fan-out to $count target(s) ..." + i=0 + while [ "$i" -lt "$count" ]; do + target_json=$(jq -c --argjson i "$i" '.[$i]' <<<"$fanout_json") + upload_orion_image_to_target "$image_file" "$digest_hex" "$info_file" "$target_json" \ + || true + i=$((i + 1)) + done } PUBLISH_SOURCE="$PUBLISHED_IMAGE" From 0cd71821838dfd92299057961a19e84ac22ff4a8 Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Thu, 10 Sep 2026 10:29:48 +0000 Subject: [PATCH 3/4] feat(orion): fan-out image publish and public presign endpoint Allow build-custom-image to upload/register across envs, avoid Start Runner hangs behind the update lock, and sign Orion image URLs with an optional public RustFS host so out-of-cluster schedulers can download. --- .../orion-scheduler-client/src/http_client.rs | 14 +- clients/orion-scheduler-client/src/lib.rs | 13 + common/src/config/mod.rs | 7 + config/config.toml | 9 + io-orbit/src/adapter.rs | 14 + io-orbit/src/factory.rs | 164 +++++++++- jupiter/src/service/orion_vm_image_service.rs | 5 + mono/src/api/router/orion_runner_router.rs | 120 ++++++-- .../web/hooks/OrionClient/useRunnerLogsSSE.ts | 67 +++- moon/apps/web/pages/[org]/oc/index.tsx | 32 +- orion-scheduler/DESIGN.md | 25 +- orion-scheduler/README.md | 29 +- orion-scheduler/TESTING.md | 29 +- .../etc/target_config.json.template | 2 - orion-scheduler/scripts/build-custom-image.sh | 283 ++++++++++++++--- .../scripts/register-orion-image.sh | 100 ++++++ orion-scheduler/src/config.rs | 23 +- orion-scheduler/src/handlers.rs | 289 +++++++++++++++--- orion-scheduler/src/main.rs | 6 +- orion-scheduler/src/orion_deployer.rs | 20 +- orion-scheduler/target_config.json.template | 2 - 21 files changed, 1050 insertions(+), 203 deletions(-) create mode 100755 orion-scheduler/scripts/register-orion-image.sh diff --git a/clients/orion-scheduler-client/src/http_client.rs b/clients/orion-scheduler-client/src/http_client.rs index 8ee043d36..58098be0b 100644 --- a/clients/orion-scheduler-client/src/http_client.rs +++ b/clients/orion-scheduler-client/src/http_client.rs @@ -59,16 +59,24 @@ impl OrionSchedulerHttpClient { "Starting runner via scheduler: server_ws={}", payload.server_ws ); + // Conflict checks must not block behind a multi-minute image download; + // scheduler returns 503 quickly when the update lock is busy. Keep a + // modest client budget for network + signing + lock try. let req = self .client .post(&url) - .timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) .json(&payload); let res = self.auth_headers(req).send().await?; let status = res.status(); let body: StartRunnerSchedulerResponse = res.json().await?; - // 200 OK (idempotent), 202 Accepted (provisioning), 409 Conflict - if status.is_success() || status.as_u16() == 202 || status.as_u16() == 409 { + // 200 OK (idempotent), 202 Accepted (provisioning), 409 Conflict, + // 503 Busy (another provision holds the update lock). + if status.is_success() + || status.as_u16() == 202 + || status.as_u16() == 409 + || status.as_u16() == 503 + { Ok(body) } else { Err(anyhow::anyhow!( diff --git a/clients/orion-scheduler-client/src/lib.rs b/clients/orion-scheduler-client/src/lib.rs index b3d6458c5..403372925 100644 --- a/clients/orion-scheduler-client/src/lib.rs +++ b/clients/orion-scheduler-client/src/lib.rs @@ -28,6 +28,19 @@ pub struct StartRunnerPayload { pub image_cpus: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image_memory_mb: Option, + /// Catalog metadata (from mono when starting via `image_id`). + #[serde(skip_serializing_if = "Option::is_none")] + pub image_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_built_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toolchain_rust: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toolchain_buck2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toolchain_python: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kernel: Option, /// When set, write `ORION_RETAIN_ANTARES_MOUNTS` into the guest `.env`. #[serde(skip_serializing_if = "Option::is_none")] pub retain_antares_mounts: Option, diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index 13e114906..192d28fac 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -614,6 +614,13 @@ pub struct S3Config { pub access_key_id: String, pub secret_access_key: String, pub endpoint_url: String, + /// Optional endpoint used only when generating presigned URLs. + /// + /// Keep `endpoint_url` as the in-cluster address for mono PUT/GET, and set + /// this to a host that out-of-cluster clients (e.g. orion-scheduler) can + /// reach. Empty = sign with `endpoint_url` (unchanged behavior). + #[serde(default)] + pub presign_endpoint_url: String, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] diff --git a/config/config.toml b/config/config.toml index 3808f8b86..17d4dbf16 100644 --- a/config/config.toml +++ b/config/config.toml @@ -152,6 +152,15 @@ secret_access_key = "" # set this to the service endpoint. endpoint_url = "http://localhost:9000" +# Optional: endpoint used only for presigned GET/PUT URLs. +# Use when mono talks to RustFS over an in-cluster URL but external clients +# (orion-scheduler on the host) need a public hostname. Signature includes +# Host, so this must match the URL clients will open. Empty = use endpoint_url. +# Example: +# endpoint_url = "http://rustfs.mega-dev.svc.cluster.local:9000" +# presign_endpoint_url = "https://rustfs.xuanwu.openatom.cn" +presign_endpoint_url = "" + [object_storage.gcs] # Name of the GCS bucket diff --git a/io-orbit/src/adapter.rs b/io-orbit/src/adapter.rs index 0a9e8f9be..9daf29c96 100644 --- a/io-orbit/src/adapter.rs +++ b/io-orbit/src/adapter.rs @@ -41,6 +41,9 @@ pub enum UploadStrategy { pub struct ObjectStoreAdapter { /// The concrete backend store used for all object operations. pub store: BackendStore, + /// Optional S3 client used only for `signed_url` (public/presign endpoint). + /// When `None`, signing uses [`Self::store`]. + pub presign_store: Option>, /// The upload strategy used when writing new objects. pub upload_strategy: UploadStrategy, } @@ -165,6 +168,16 @@ impl MegaObjectStorage for ObjectStoreAdapter { ) -> Result, MegaError> { let path = key.to_object_store_path(); + if let Some(presign) = &self.presign_store { + return Ok(Some( + presign + .signed_url(method, &path, expires_in) + .await + .map_err(IoOrbitError::from)? + .to_string(), + )); + } + let url = match &self.store { BackendStore::S3(s3) => Some( s3.signed_url(method, &path, expires_in) @@ -1171,6 +1184,7 @@ mod tests { let local = Arc::new(LocalFileSystem::new_with_prefix(dir.path()).unwrap()); let adapter = ObjectStoreAdapter { store: BackendStore::Local(Arc::clone(&local)), + presign_store: None, upload_strategy: UploadStrategy::SinglePut, }; let key = ObjectKey { diff --git a/io-orbit/src/factory.rs b/io-orbit/src/factory.rs index b82159d95..d7ac4fcb5 100644 --- a/io-orbit/src/factory.rs +++ b/io-orbit/src/factory.rs @@ -4,10 +4,14 @@ use std::{ }; use common::{ - config::{ObjectStorageBackend, ObjectStorageConfig}, + config::{ObjectStorageBackend, ObjectStorageConfig, S3Config}, errors::MegaError, }; -use object_store::{aws::AmazonS3Builder, gcp::GoogleCloudStorageBuilder, local::LocalFileSystem}; +use object_store::{ + aws::{AmazonS3, AmazonS3Builder}, + gcp::GoogleCloudStorageBuilder, + local::LocalFileSystem, +}; use crate::{ adapter::{BackendStore, ObjectStoreAdapter, UploadStrategy}, @@ -42,6 +46,7 @@ impl MegaObjectStorageWrapper { let store = BackendStore::Local(Arc::new(fs)); let adapter = Arc::new(ObjectStoreAdapter { store, + presign_store: None, upload_strategy: UploadStrategy::SinglePut, }); MegaObjectStorageWrapper::new(adapter) @@ -62,26 +67,58 @@ impl ObjectStorageFactory { } } -/// Shared S3 / S3-compatible construction (differs only by endpoint and upload strategy). -async fn build_s3_like( - cfg: &ObjectStorageConfig, +fn build_amazon_s3( + s3_cfg: &S3Config, + endpoint: &str, compatible: bool, -) -> Result { - let s3_cfg = cfg.s3.clone(); +) -> Result { let mut builder = AmazonS3Builder::new() .with_region(&s3_cfg.region) .with_bucket_name(&s3_cfg.bucket) .with_access_key_id(&s3_cfg.access_key_id) .with_secret_access_key(&s3_cfg.secret_access_key); - if compatible { - builder = builder - .with_endpoint(&s3_cfg.endpoint_url) - .with_allow_http(true) - .with_virtual_hosted_style_request(false); + + if compatible || !endpoint.is_empty() { + if !endpoint.is_empty() { + builder = builder.with_endpoint(endpoint); + } + if compatible { + // S3-compatible (RustFS/MinIO): path-style; allow plain HTTP for + // in-cluster endpoints. HTTPS public endpoints still work with + // allow_http(true). + builder = builder + .with_allow_http(true) + .with_virtual_hosted_style_request(false); + } else if endpoint.starts_with("http://") { + builder = builder.with_allow_http(true); + } } - let s3 = builder - .build() - .map_err(|e| MegaError::Other(e.to_string()))?; + + builder.build().map_err(|e| MegaError::Other(e.to_string())) +} + +/// Shared S3 / S3-compatible construction (differs only by endpoint and upload strategy). +async fn build_s3_like( + cfg: &ObjectStorageConfig, + compatible: bool, +) -> Result { + let s3_cfg = &cfg.s3; + let endpoint = if compatible { + s3_cfg.endpoint_url.as_str() + } else { + // Real AWS: empty endpoint uses the default regional endpoint. + "" + }; + let s3 = build_amazon_s3(s3_cfg, endpoint, compatible)?; + + let presign_store = { + let presign_ep = s3_cfg.presign_endpoint_url.trim(); + if presign_ep.is_empty() { + None + } else { + Some(Arc::new(build_amazon_s3(s3_cfg, presign_ep, compatible)?)) + } + }; let store = BackendStore::S3(Arc::new(s3)); let upload_strategy = if compatible { @@ -91,6 +128,7 @@ async fn build_s3_like( }; let adapter = Arc::new(ObjectStoreAdapter { store, + presign_store, upload_strategy, }); @@ -106,6 +144,7 @@ async fn build_gcs(cfg: &ObjectStorageConfig) -> Result Result S3Config { + S3Config { + region: "us-east-1".into(), + bucket: "buck2hub-assets".into(), + access_key_id: "testkey".into(), + secret_access_key: "testsecret".into(), + endpoint_url: endpoint.into(), + presign_endpoint_url: presign.into(), + } + } + + #[tokio::test] + async fn signed_url_uses_endpoint_url_when_presign_unset() { + let s3_cfg = sample_s3_config("http://rustfs.internal:9000", ""); + let s3 = build_amazon_s3(&s3_cfg, &s3_cfg.endpoint_url, true).unwrap(); + let path = Path::from("orion-images/abc/debian-13-buck2.qcow2"); + let url = s3 + .signed_url(Method::GET, &path, Duration::from_secs(60)) + .await + .unwrap() + .to_string(); + assert!( + url.starts_with("http://rustfs.internal:9000/"), + "expected internal host, got {url}" + ); + } + + #[tokio::test] + async fn signed_url_uses_presign_endpoint_when_set() { + let s3_cfg = sample_s3_config( + "http://rustfs.mega-dev.svc.cluster.local:9000", + "https://rustfs.xuanwu.openatom.cn", + ); + let presign = build_amazon_s3(&s3_cfg, &s3_cfg.presign_endpoint_url, true).unwrap(); + let path = Path::from("orion-images/abc/debian-13-buck2.qcow2"); + let url = presign + .signed_url(Method::GET, &path, Duration::from_secs(60)) + .await + .unwrap() + .to_string(); + assert!( + url.starts_with("https://rustfs.xuanwu.openatom.cn/"), + "expected public host, got {url}" + ); + assert!( + !url.contains("svc.cluster.local"), + "presign URL must not use in-cluster host: {url}" + ); + } + + #[tokio::test] + async fn factory_wires_presign_store_for_compatible() { + use crate::factory::ObjectStorageFactory; + + let cfg = ObjectStorageConfig { + storage_type: ObjectStorageBackend::S3Compatible, + s3: sample_s3_config( + "http://rustfs.internal:9000", + "https://rustfs.example.public", + ), + gcs: GcsConfig::default(), + local: LocalConfig::default(), + }; + let wrapper = ObjectStorageFactory::build(&cfg).await.unwrap(); + let key = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: "deadbeef/debian-13-buck2.qcow2".into(), + }; + let url = wrapper + .inner + .signed_url(&key, Method::GET, Duration::from_secs(120)) + .await + .unwrap() + .expect("s3compatible must support presign"); + assert!( + url.starts_with("https://rustfs.example.public/"), + "factory should sign with presign_endpoint_url, got {url}" + ); + } +} diff --git a/jupiter/src/service/orion_vm_image_service.rs b/jupiter/src/service/orion_vm_image_service.rs index fb2462fd4..258c1c6d6 100644 --- a/jupiter/src/service/orion_vm_image_service.rs +++ b/jupiter/src/service/orion_vm_image_service.rs @@ -42,6 +42,11 @@ impl OrionVmImageService { self.st.list_all().await } + /// Newest catalog row by `created_at` (same order as [`Self::list`]). + pub async fn latest(&self) -> Result, MegaError> { + Ok(self.list().await?.into_iter().next()) + } + pub async fn get(&self, id: &str) -> Result, MegaError> { self.st.find_by_id(id).await } diff --git a/mono/src/api/router/orion_runner_router.rs b/mono/src/api/router/orion_runner_router.rs index 3c0be2424..aa058944d 100644 --- a/mono/src/api/router/orion_runner_router.rs +++ b/mono/src/api/router/orion_runner_router.rs @@ -245,46 +245,92 @@ async fn start_runner( let build = &state.services().storage().config().build; let env = derive_runner_env(build)?; - let mut image_path = req.image_path; + // Host-local paths are not accepted on this API; use catalog `image_id` + // (or omit for latest) / explicit `image_url`+digest. + if req + .image_path + .as_deref() + .map(str::trim) + .is_some_and(|s| !s.is_empty()) + { + return Err(ApiError::bad_request(anyhow!( + "image_path is not supported; use image_id or omit for latest catalog image" + ))); + } + let mut image_url = req.image_url; let mut image_digest = req.image_digest; + let mut image_name: Option = None; + let mut image_built_at: Option = None; + let mut toolchain_rust: Option = None; + let mut toolchain_buck2: Option = None; + let mut toolchain_python: Option = None; + let mut kernel: Option = None; + + let image_svc = &state.services().storage().orion_vm_image_service; - if let Some(image_id) = req + let catalog_model = if let Some(image_id) = req .image_id .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { - if image_path.is_some() || image_url.is_some() { + if image_url.is_some() { return Err(ApiError::bad_request(anyhow!( - "image_id cannot be combined with image_path or image_url" + "image_id cannot be combined with image_url" ))); } - let model = state - .services() - .storage() - .orion_vm_image_service - .get(image_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| { - ApiError::with_status(StatusCode::NOT_FOUND, anyhow!("image_id not found")) - })?; - let url = state - .services() - .storage() - .orion_vm_image_service - .signed_get_url(&model) - .await - .map_err(|e| { - ApiError::with_status( - StatusCode::SERVICE_UNAVAILABLE, - anyhow!("failed to sign image URL: {e}"), - ) - })?; + Some( + image_svc + .get(image_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| { + ApiError::with_status(StatusCode::NOT_FOUND, anyhow!("image_id not found")) + })?, + ) + } else if image_url.is_none() { + // No explicit URL → newest catalog image (by created_at). + Some( + image_svc + .latest() + .await + .map_err(ApiError::from)? + .ok_or_else(|| { + ApiError::bad_request(anyhow!("no catalog images; register an image first")) + })?, + ) + } else { + None + }; + + if let Some(model) = catalog_model { + let url = image_svc.signed_get_url(&model).await.map_err(|e| { + ApiError::with_status( + StatusCode::SERVICE_UNAVAILABLE, + anyhow!("failed to sign image URL: {e}"), + ) + })?; image_url = Some(url); image_digest = Some(model.digest); - image_path = None; + image_name = model.image_name; + image_built_at = model.built_at; + toolchain_rust = model.rust; + toolchain_buck2 = model.buck2; + toolchain_python = model.python; + kernel = model.kernel; + } + + if image_url.is_some() + && image_digest + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + return Err(ApiError::bad_request(anyhow!( + "image_digest is required when image_url is provided" + ))); } let payload = StartRunnerPayload { @@ -293,12 +339,18 @@ async fn start_runner( server_ws: env.server_ws, scorpio_base_url: env.scorpio_base_url, scorpio_lfs_url: env.scorpio_lfs_url, - image_path, + image_path: None, image_url, image_digest, image_disk_gb: req.image_disk_gb, image_cpus: req.image_cpus, image_memory_mb: req.image_memory_mb, + image_name, + image_built_at, + toolchain_rust, + toolchain_buck2, + toolchain_python, + kernel, retain_antares_mounts: req.retain_antares_mounts, }; @@ -320,6 +372,18 @@ async fn start_runner( )); } + if sched_resp.status == "busy" { + return Err(ApiError::with_status( + StatusCode::SERVICE_UNAVAILABLE, + anyhow!( + "{}", + sched_resp + .error + .unwrap_or_else(|| "scheduler is busy; retry shortly".to_string()) + ), + )); + } + let vm_id = sched_resp.vm_id.ok_or_else(|| { ApiError::with_status( StatusCode::BAD_GATEWAY, diff --git a/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts b/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts index 05d8d30fd..e0fd1a2b5 100644 --- a/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts +++ b/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts @@ -9,6 +9,13 @@ const MAX_LOG_CHARS = 400_000 /** Older schedulers spam this every second while the VM is still provisioning. */ const TRANSIENT_NO_VM_RE = /^Error:\s*No running VM for key\b/i +/** + * Provisioning / image-download status lines from orion-scheduler SSE. + * These update in place so the log panel does not fill with progress spam. + */ +const PROVISION_PROGRESS_RE = + /^(Downloading image for |Waiting for VM .+ to finish provisioning|Waiting for VM to (finish provisioning|become available))/ + /** Strip CSI / OSC ANSI sequences so terminal-colored scheduler logs render cleanly in HTML. */ function stripAnsi(text: string): string { return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') @@ -43,6 +50,45 @@ function filterTransientVmErrors(chunk: string, alreadyWaiting: boolean): { text return { text: kept.join('\n'), waiting } } +function isProvisionProgressLine(line: string): boolean { + return PROVISION_PROGRESS_RE.test(line.trim()) +} + +/** Append chunk lines; replace the last in-place progress line when status updates. */ +function mergeLogChunk(prev: string, chunk: string): string { + const incoming = chunk.split('\n') + let lines = prev ? prev.split('\n') : [] + + // Drop a trailing empty entry from the trailing newline of `prev`. + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines = lines.slice(0, -1) + } + + for (const raw of incoming) { + const line = raw + if (line === '' && incoming.length === 1) { + continue + } + if (isProvisionProgressLine(line)) { + const lastIdx = lines.length - 1 + if (lastIdx >= 0 && isProvisionProgressLine(lines[lastIdx])) { + lines[lastIdx] = line + } else { + lines.push(line) + } + continue + } + lines.push(line) + } + + let next = lines.join('\n') + if (chunk.endsWith('\n') && !next.endsWith('\n')) { + next += '\n' + } + if (next.length <= MAX_LOG_CHARS) return next + return next.slice(next.length - MAX_LOG_CHARS) +} + /** * Subscribe to mono-proxied Orion runner startup logs (SSE). * `streamKey` is a scheduler VM id or domain host (client hostname is the WS URL). @@ -90,21 +136,18 @@ export function useRunnerLogsSSE(streamKey: string | null) { if (!chunk.trim()) return - // Real log content arrived — clear the transient-wait gate so a later - // reprovision can announce waiting again if needed. - if (!TRANSIENT_NO_VM_RE.test(chunk.trim()) && !chunk.includes('Waiting for VM')) { + // Real guest/system logs arrived — clear the transient-wait gate so a later + // reprovision can announce waiting again if needed. Progress lines keep the gate. + const trimmed = chunk.trim() + if ( + !TRANSIENT_NO_VM_RE.test(trimmed) && + !isProvisionProgressLine(trimmed) && + !trimmed.includes('Waiting for VM') + ) { waitingForVmRef.current = false } - setLogs((prev) => { - // EventSource joins multi-line SSE `data:` fields with `\n` but does not - // guarantee a trailing newline between successive events. - const sep = prev && !prev.endsWith('\n') && !chunk.startsWith('\n') ? '\n' : '' - const next = prev ? `${prev}${sep}${chunk}` : chunk - - if (next.length <= MAX_LOG_CHARS) return next - return next.slice(next.length - MAX_LOG_CHARS) - }) + setLogs((prev) => mergeLogChunk(prev, chunk)) } es.onerror = () => { diff --git a/moon/apps/web/pages/[org]/oc/index.tsx b/moon/apps/web/pages/[org]/oc/index.tsx index 342f5e052..915269635 100644 --- a/moon/apps/web/pages/[org]/oc/index.tsx +++ b/moon/apps/web/pages/[org]/oc/index.tsx @@ -86,6 +86,7 @@ const OrionClientPage: PageWithLayout = () => { const [terminalDomain, setTerminalDomain] = React.useState(null) const [copyFeedback, setCopyFeedback] = React.useState(false) const [selectedImageId, setSelectedImageId] = React.useState('') + const didAutoSelectImageRef = React.useRef(false) const logPanelRef = React.useRef(null) const terminalPanelRef = React.useRef(null) const logsScrollRef = React.useRef(null) @@ -115,6 +116,18 @@ const OrionClientPage: PageWithLayout = () => { runnerLogsRef.current = runnerLogs + // On first catalog load, pin the select to the newest image so the UI matches + // what will start. Operators can still choose "Latest (catalog)" (empty) so + // mono re-resolves newest on each Start. + React.useEffect(() => { + if (didAutoSelectImageRef.current) return + if (isLoadingImages || orionImages.length === 0) return + didAutoSelectImageRef.current = true + if (!selectedImageId) { + setSelectedImageId(orionImages[0].id) + } + }, [isLoadingImages, orionImages, selectedImageId]) + const { mutate, isPending, error } = usePostOrionClientsInfo() const [clientsPage, setClientsPage] = React.useState(null) @@ -429,9 +442,9 @@ const OrionClientPage: PageWithLayout = () => { className='max-w-xs rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900' value={selectedImageId} onChange={(e) => setSelectedImageId(e.target.value)} - disabled={isStartingRunner || isLoadingImages} + disabled={isStartingRunner || isLoadingImages || orionImages.length === 0} > - + {orionImages.map((img) => (