diff --git a/Cargo.lock b/Cargo.lock index 3628c22e8..e1a4e771a 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", ] @@ -1383,7 +1383,7 @@ dependencies = [ "chrono", "common", "futures", - "git-internal", + "git-internal 0.9.0", "hex", "io-orbit", "jupiter", @@ -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", @@ -1595,7 +1595,7 @@ dependencies = [ "config", "directories", "envsubst", - "git-internal", + "git-internal 0.9.0", "idgenerator", "pgp 0.20.0", "redis", @@ -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]] @@ -3236,6 +3248,52 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "git-internal" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d265f273ae8635bbcfd3634d0a8705a1970cd734d9eb5b9ff0eaaac14f4a067" +dependencies = [ + "ahash", + "async-trait", + "axum", + "blake3", + "bstr", + "byteorder", + "bytes", + "chrono", + "colored", + "crc32fast", + "dashmap 6.2.1", + "diffs", + "encoding_rs", + "flate2", + "futures", + "futures-util", + "hex", + "libc", + "lru-mem", + "memchr", + "num_cpus", + "path-absolutize", + "rayon", + "rkyv", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "similar", + "tempfile", + "thiserror 2.0.20", + "threadpool", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "uuid", + "zstd-sys", +] + [[package]] name = "glob" version = "0.3.4" @@ -3588,9 +3646,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 +4007,7 @@ dependencies = [ "common", "futures", "object_store", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "tempfile", @@ -4170,7 +4228,7 @@ dependencies = [ "chrono", "common", "futures", - "git-internal", + "git-internal 0.9.0", "hex", "hmac 0.13.0", "idgenerator", @@ -4181,7 +4239,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 +4339,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 +4490,7 @@ checksum = "cf5f1ba3c5498a7b893d4398dfd6de0f21d87d16f1385258950d62d5a7806af8" dependencies = [ "async-trait", "asyncfuse", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "clap", "futures", @@ -4476,7 +4534,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", @@ -4892,7 +4950,7 @@ dependencies = [ "common", "ctrlc", "futures", - "git-internal", + "git-internal 0.9.0", "http", "io-orbit", "jemallocator", @@ -4906,8 +4964,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 +4991,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 +5085,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 +5098,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 +5294,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 +5311,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 +5372,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 +5432,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 +5496,7 @@ dependencies = [ [[package]] name = "orion" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "api-model", @@ -5422,7 +5507,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 +5532,7 @@ dependencies = [ "anyhow", "api-model", "common", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "tracing", ] @@ -5479,7 +5564,7 @@ dependencies = [ "common", "futures-util", "http", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "tokio", @@ -6458,7 +6543,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 +6852,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 +6861,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 +6993,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 +7114,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 +7170,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 +7236,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 +7323,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 +7447,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 +7460,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 +7469,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", @@ -7570,14 +7655,14 @@ dependencies = [ "dashmap 6.2.1", "env_logger", "futures", - "git-internal", + "git-internal 0.8.7", "hex", "libc", "libfuse-fs", "log", "once_cell", "radix_trie 0.3.0", - "reqwest 0.13.4", + "reqwest 0.13.5", "ring", "serde", "serde_json", @@ -7839,7 +7924,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 +8056,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 +8077,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 +8576,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 +8608,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "chrono", "crc", @@ -8929,7 +9014,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 +9532,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 +9551,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 +10051,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 +10836,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 +10977,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 +10996,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/application/api_service/mono/admin/bot.rs b/ceres/src/application/api_service/mono/admin/bot.rs index 2a0c71b36..04ad289b5 100644 --- a/ceres/src/application/api_service/mono/admin/bot.rs +++ b/ceres/src/application/api_service/mono/admin/bot.rs @@ -122,6 +122,24 @@ impl AdminApplicationService { }) } + /// Ensure orion-image-publisher bot + fresh catalog-register token + /// (for secret-gated bootstrap-orion-image). + pub async fn ensure_orion_image_publisher_bot_token( + &self, + ) -> Result { + let (bot, token_plain) = self + .ctx + .storage() + .bots_storage() + .ensure_orion_image_publisher_bot_token() + .await?; + Ok(crate::model::bots::BootstrapInitBotResponse { + bot_id: bot.id, + bot_name: bot.name, + token: token_plain, + }) + } + pub async fn list_bot_tokens(&self, bot_id: i64) -> Result, MegaError> { Ok(self .ctx diff --git a/ceres/src/application/api_service/mono/admin/permissions.rs b/ceres/src/application/api_service/mono/admin/permissions.rs index 5b2ea3b14..cb43fb248 100644 --- a/ceres/src/application/api_service/mono/admin/permissions.rs +++ b/ceres/src/application/api_service/mono/admin/permissions.rs @@ -29,11 +29,17 @@ const ADMIN_CACHE_KEY_SUFFIX: &str = "admin:list"; impl AdminApplicationService { /// Check if a user is an admin (config `monorepo.admin` or Cedar). + /// + /// Config admins are resolved without loading Cedar, so they remain valid + /// when `.mega_cedar.json` is missing or its blob cannot be fetched. pub async fn check_is_admin(&self, username: &str) -> Result { let username = username.trim(); if username.is_empty() { return Ok(false); } + if self.config_admins().iter().any(|a| a == username) { + return Ok(true); + } let admins = self.get_effective_admins().await?; Ok(admins.iter().any(|a| a == username)) } @@ -69,7 +75,7 @@ impl AdminApplicationService { /// /// Config admins are always merged after cache/file load so a stale Redis /// Cedar list cannot drop configured admins. If `.mega_cedar.json` is - /// missing, config admins alone still apply. + /// missing or cannot be fetched/parsed, config admins alone still apply. async fn get_effective_admins(&self) -> Result, MegaError> { let cedar_admins = self.get_cedar_admins().await?; Ok(self.merge_with_config_admins(cedar_admins)) @@ -199,8 +205,57 @@ impl AdminApplicationService { } fn is_admin_config_unavailable(err: &MegaError) -> bool { - let msg = err.to_string(); - msg.contains(".mega_cedar.json not found") - || msg.contains("Root ref not found") - || msg.contains("Root tree not found") + match err { + MegaError::ObjStorageNotFound(_) + | MegaError::ObjStorageInconsistent(_) + | MegaError::ObjStorage(_) + | MegaError::SerdeJson(_) => true, + MegaError::Other(msg) => { + msg.contains(".mega_cedar.json not found") + || msg.contains("Root ref not found") + || msg.contains("Root tree not found") + || msg.contains("UTF-8 decode failed") + || msg.contains("JSON parse failed") + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn obj_storage_inconsistent_is_unavailable() { + let err = MegaError::ObjStorageInconsistent( + "[obj_missing_in_s3_but_has_meta] Object missing".into(), + ); + assert!(is_admin_config_unavailable(&err)); + } + + #[test] + fn obj_storage_not_found_is_unavailable() { + let err = MegaError::ObjStorageNotFound("blob missing".into()); + assert!(is_admin_config_unavailable(&err)); + } + + #[test] + fn missing_cedar_file_is_unavailable() { + let err = MegaError::Other(".mega_cedar.json not found in root directory".into()); + assert!(is_admin_config_unavailable(&err)); + } + + #[test] + fn utf8_and_json_parse_failures_are_unavailable() { + let utf8 = MegaError::Other("UTF-8 decode failed: invalid utf-8".into()); + let json = MegaError::Other("JSON parse failed: expected value".into()); + assert!(is_admin_config_unavailable(&utf8)); + assert!(is_admin_config_unavailable(&json)); + } + + #[test] + fn unrelated_bad_request_is_not_unavailable() { + let err = MegaError::BadRequest("admins must not be empty".into()); + assert!(!is_admin_config_unavailable(&err)); + } } diff --git a/ceres/src/model/bots.rs b/ceres/src/model/bots.rs index cda7908a9..eb3a10c65 100644 --- a/ceres/src/model/bots.rs +++ b/ceres/src/model/bots.rs @@ -126,6 +126,7 @@ impl From for InstallationTargetTypeEnum { #[derive(Debug, Clone)] pub struct BotIdentity { pub bot_id: i64, + pub bot_name: String, pub token_id: i64, } @@ -133,6 +134,7 @@ impl BotIdentity { pub fn from_models(bot: callisto::bots::Model, token: callisto::bot_tokens::Model) -> Self { Self { bot_id: bot.id, + bot_name: bot.name, token_id: token.id, } } @@ -160,10 +162,11 @@ pub struct CreateBotTokenResponse { pub token_plain: String, } -/// Response for mega-init bot bootstrap (`POST /bots/bootstrap-init`). +/// Response for secret-gated bot bootstrap (`POST /bots/bootstrap-init` or +/// `POST /bots/bootstrap-orion-image`). /// /// Requires header `X-Mega-Init-Secret` matching `MEGA_INIT_BOOTSTRAP_SECRET`. -/// `token` is a `bot_` push token returned once; use as Bearer (or Basic password). +/// `token` is a `bot_` token returned once; use as Bearer (or Basic password). #[derive(Serialize, ToSchema)] pub struct BootstrapInitBotResponse { #[serde(serialize_with = "serialize_i64_as_string")] 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..2e440d97d --- /dev/null +++ b/ceres/src/model/orion_image.rs @@ -0,0 +1,83 @@ +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, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct PresignOrionVmImageRequest { + /// Content digest, e.g. `sha256:`. + pub digest: String, + /// Base name used in the object key (default `debian-13-buck2`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_name: Option, + /// When true, also return a PUT URL for `{hex}/image-info.json`. + #[serde(default)] + pub with_info: bool, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)] +pub struct PresignOrionVmImageResponse { + pub object_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub info_object_key: Option, + pub qcow2_put_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub info_put_url: Option, + pub expires_in_secs: u64, +} 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/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/docker/demo/init-rustfs-bucket.sh b/docker/demo/init-rustfs-bucket.sh index 289fee594..02ebc735d 100644 --- a/docker/demo/init-rustfs-bucket.sh +++ b/docker/demo/init-rustfs-bucket.sh @@ -50,6 +50,7 @@ STRING_TO_SIGN="PUT\n\n\n${DATE_HEADER}\n${BUCKET_PATH}" SIGNATURE="$(printf "%b" "${STRING_TO_SIGN}" | openssl sha1 -hmac "${SECRET_KEY}" -binary | base64)" echo "Creating bucket '${BUCKET}' at ${ENDPOINT}..." +HTTP_CODE="000" for i in $(seq 1 10); do HTTP_CODE="$( curl -s -o /dev/null -w "%{http_code}" \ @@ -62,14 +63,64 @@ for i in $(seq 1 10); do # 200/201: created; 409: already exists / conflict if [ "${HTTP_CODE}" = "200" ] || [ "${HTTP_CODE}" = "201" ] || [ "${HTTP_CODE}" = "409" ]; then echo "Bucket init finished (HTTP ${HTTP_CODE})." - exit 0 + break fi echo "Bucket init attempt ${i}/10 returned HTTP ${HTTP_CODE}, retrying..." >&2 sleep 1 done -echo "Bucket init failed after retries; last HTTP ${HTTP_CODE}." >&2 -exit 1 +if [ "${HTTP_CODE}" != "200" ] && [ "${HTTP_CODE}" != "201" ] && [ "${HTTP_CODE}" != "409" ]; then + echo "Bucket init failed after retries; last HTTP ${HTTP_CODE}." >&2 + exit 1 +fi + +# Browser direct uploads (Orion qcow2) need CORS on the bucket. +# Origins match config.local.toml oauth.allowed_cors_origins / local moon hosts. +CORS_XML=' + + + http://app.gitmono.local + http://local.gitmega.com + http://app.gitmono.test + https://app.gitmega.com + http://localhost + http://localhost:80 + http://127.0.0.1 + http://127.0.0.1:80 + GET + PUT + HEAD + POST + * + ETag + x-amz-request-id + 3600 + +' +DATE_HEADER="$(date -u +'%a, %d %b %Y %H:%M:%S GMT')" +CONTENT_TYPE="application/xml" +# SigV2 for PutBucketCors: PUT + content-type + date + /bucket?cors +STRING_TO_SIGN="PUT\n\n${CONTENT_TYPE}\n${DATE_HEADER}\n${BUCKET_PATH}?cors" +SIGNATURE="$(printf "%b" "${STRING_TO_SIGN}" | openssl sha1 -hmac "${SECRET_KEY}" -binary | base64)" + +echo "Applying CORS for browser uploads on bucket '${BUCKET}'..." +CORS_CODE="$( + curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${ENDPOINT}${BUCKET_PATH}?cors" \ + -H "Date: ${DATE_HEADER}" \ + -H "Content-Type: ${CONTENT_TYPE}" \ + -H "Authorization: AWS ${ACCESS_KEY}:${SIGNATURE}" \ + --data-binary "${CORS_XML}" \ + || echo "000" +)" + +if [ "${CORS_CODE}" = "200" ] || [ "${CORS_CODE}" = "204" ]; then + echo "CORS applied (HTTP ${CORS_CODE})." +else + # Non-fatal: bucket may already work; warn so local upload debugging is obvious. + echo "Warning: failed to apply CORS (HTTP ${CORS_CODE}). Browser PUT uploads may fail." >&2 +fi +exit 0 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/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..258c1c6d6 --- /dev/null +++ b/jupiter/src/service/orion_vm_image_service.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use callisto::orion_vm_image; +use common::errors::MegaError; +use io_orbit::{ + factory::MegaObjectStorageWrapper, + object_storage::{ObjectByteStream, ObjectKey, ObjectMeta, 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 + } + + /// 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 + } + + pub async fn upsert( + &self, + input: UpsertOrionVmImage, + ) -> Result { + self.st.upsert(input).await + } + + /// Delete RustFS/local objects first, then the catalog row. + /// Object-not-found is treated as success so a prior partial delete can finish. + pub async fn delete(&self, id: &str) -> Result, MegaError> { + let Some(model) = self.st.find_by_id(id).await? else { + return Ok(None); + }; + + let qcow2 = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: model.object_key.clone(), + }; + match self.obj_storage.inner.delete(&qcow2).await { + Ok(()) => {} + Err(MegaError::ObjStorageNotFound(_)) => { + tracing::info!( + "orion image object already absent: {}", + qcow2.default_sharding() + ); + } + Err(e) => { + return Err(MegaError::ObjStorage(format!( + "failed to delete orion image object {}: {e}", + qcow2.default_sharding() + ))); + } + } + + if let Some(info_key) = &model.info_object_key { + let info = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: info_key.clone(), + }; + match self.obj_storage.inner.delete(&info).await { + Ok(()) => {} + Err(MegaError::ObjStorageNotFound(_)) => { + tracing::info!( + "orion image info object already absent: {}", + info.default_sharding() + ); + } + Err(e) => { + return Err(MegaError::ObjStorage(format!( + "failed to delete orion image info object {}: {e}", + info.default_sharding() + ))); + } + } + } + + self.st.delete_by_id(id).await + } + + pub async fn signed_get_url(&self, model: &orion_vm_image::Model) -> Result { + self.signed_url_for_key(&model.object_key, Method::GET) + .await + } + + pub async fn signed_put_url(&self, object_key: &str) -> Result { + self.signed_url_for_key(object_key, Method::PUT).await + } + + async fn signed_url_for_key( + &self, + object_key: &str, + method: Method, + ) -> Result { + let key = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: object_key.to_string(), + }; + let url = self + .obj_storage + .inner + .signed_url( + &key, + method, + 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(), + ) + }) + } + + /// Stream an object into the Orion image namespace (bounded buffering / multipart). + pub async fn put_object_stream( + &self, + object_key: &str, + data: ObjectByteStream, + ) -> Result<(), MegaError> { + let key = ObjectKey { + namespace: ObjectNamespace::OrionImage, + key: object_key.trim().trim_start_matches('/').to_string(), + }; + if key.key.is_empty() || key.key.contains("..") { + return Err(MegaError::Other("invalid object_key".into())); + } + self.obj_storage + .inner + .put_stream_bounded(&key, data, ObjectMeta::default()) + .await + } + + /// Build catalog object keys: `{digest_hex}/{image_name}.qcow2` (+ optional info sidecar). + pub fn object_keys(digest: &str, image_name: &str) -> (String, String) { + let hex = digest_hex(digest); + let name = image_name.trim().trim_end_matches(".qcow2"); + let name = if name.is_empty() { + "debian-13-buck2" + } else { + name + }; + ( + format!("{hex}/{name}.qcow2"), + format!("{hex}/image-info.json"), + ) + } +} + +/// 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/bots_storage.rs b/jupiter/src/storage/bots_storage.rs index 6f7d0ff70..828e8ac1d 100644 --- a/jupiter/src/storage/bots_storage.rs +++ b/jupiter/src/storage/bots_storage.rs @@ -317,6 +317,41 @@ impl BotsStorage { Ok((bot, token_plain)) } + /// Ensure the fixed orion-image-publisher bot exists and return a fresh register token. + /// + /// Creates bot `orion-image-publisher` if missing, revokes any existing + /// `orion-image-register` tokens, then issues a new token (plaintext returned once). + /// No org installation — catalog register only checks bot name via BotAuth. + pub async fn ensure_orion_image_publisher_bot_token( + &self, + ) -> Result<(bots::Model, String), MegaError> { + const PUBLISHER_BOT_NAME: &str = "orion-image-publisher"; + const PUBLISHER_TOKEN_NAME: &str = "orion-image-register"; + + let bot = match self.find_bot_by_name(PUBLISHER_BOT_NAME).await? { + Some(existing) => existing, + None => { + let (bot, _private_pem) = self + .register_bot(PUBLISHER_BOT_NAME, None, 0, PermissionScopeEnum::Write) + .await?; + bot + } + }; + + if bot.status != BotStatusEnum::Enabled { + return Err(MegaError::Other(format!( + "publisher bot '{PUBLISHER_BOT_NAME}' exists but is disabled" + ))); + } + + self.revoke_bot_tokens_by_name(bot.id, PUBLISHER_TOKEN_NAME) + .await?; + let (_model, token_plain) = self + .generate_bot_token(bot.id, PUBLISHER_TOKEN_NAME, None) + .await?; + Ok((bot, token_plain)) + } + /// Ensure an Enabled Organization installation for a system bot (target_id 0). pub async fn ensure_system_org_installation(&self, bot_id: i64) -> Result<(), MegaError> { let existing = bot_installations::Entity::find() 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/Cargo.toml b/mono/Cargo.toml index bbb451a0a..352ba23c7 100644 --- a/mono/Cargo.toml +++ b/mono/Cargo.toml @@ -73,6 +73,7 @@ utoipa-axum = { workspace = true } utoipa-swagger-ui = { workspace = true, features = ["axum"] } once_cell = { workspace = true } base64 = { workspace = true } +io-orbit = { workspace = true } [features] fastcdc = ["ceres/fastcdc"] @@ -85,7 +86,6 @@ jemallocator = { workspace = true } mimalloc = { workspace = true } [dev-dependencies] -io-orbit = { workspace = true } sea-orm = { workspace = true } tempfile = { workspace = true } jupiter-migrate = { workspace = true } 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/admin_router.rs b/mono/src/api/router/admin_router.rs index e715843f4..f4e3c2ef0 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -82,8 +82,8 @@ async fn is_admin_me( ) -> Result>, ApiError> { let admin = state.services().admin(); let cedar_id = user.cedar_user_id(); - // On config/load failure (e.g. missing `.mega_cedar.json`), treat as non-admin - // so AccountApprovalGuard can still honor an approved user_approval_status. + // Cedar load failure is handled inside check_is_admin (config admins still apply). + // Remaining unexpected errors still treat as non-admin so AccountApprovalGuard can proceed. let is_admin = match admin.check_is_admin(cedar_id).await { Ok(v) => v, Err(e) => { diff --git a/mono/src/api/router/bot_router.rs b/mono/src/api/router/bot_router.rs index 73b0cbb91..5b48e5434 100644 --- a/mono/src/api/router/bot_router.rs +++ b/mono/src/api/router/bot_router.rs @@ -101,6 +101,7 @@ pub fn routers() -> OpenApiRouter { "/bots", OpenApiRouter::new() .routes(routes!(bootstrap_init_bot)) + .routes(routes!(bootstrap_orion_image_bot)) .routes(routes!(install_bot)) .routes(routes!(list_installed_bot)) .routes(routes!(change_installation_status)) @@ -139,6 +140,36 @@ async fn bootstrap_init_bot( Ok(Json(CommonResult::success(Some(resp)))) } +/// Bootstrap orion-image-publisher bot + catalog-register token. +/// +/// Gated by the same shared secret `MEGA_INIT_BOOTSTRAP_SECRET` via header +/// `X-Mega-Init-Secret`. Creates the `orion-image-publisher` bot if needed and +/// returns a fresh `bot_` token for `POST /api/v1/orion/images`. +#[utoipa::path( + post, + path = "/bootstrap-orion-image", + params( + ("X-Mega-Init-Secret" = String, Header, description = "Must match MEGA_INIT_BOOTSTRAP_SECRET on mono-engine") + ), + responses( + (status = 200, body = CommonResult, content_type = "application/json"), + (status = 401, description = "Missing/invalid X-Mega-Init-Secret or secret not configured"), + ), + tag = BOT_TAG +)] +async fn bootstrap_orion_image_bot( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + ensure_init_bootstrap_secret(&headers)?; + let resp = state + .services() + .admin() + .ensure_orion_image_publisher_bot_token() + .await?; + Ok(Json(CommonResult::success(Some(resp)))) +} + /// Install bot #[utoipa::path( post, 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..ec65b9975 --- /dev/null +++ b/mono/src/api/router/orion_image_router.rs @@ -0,0 +1,383 @@ +use anyhow::anyhow; +use api_model::common::CommonResult; +use axum::{ + Json, + body::Body, + extract::{DefaultBodyLimit, FromRef, FromRequestParts, Path, Request, State}, + http::{StatusCode, request::Parts}, + routing::put, +}; +use ceres::model::orion_image::{ + OrionVmImageListResponse, OrionVmImageResponse, PresignOrionVmImageRequest, + PresignOrionVmImageResponse, RegisterOrionVmImageRequest, +}; +use futures::TryStreamExt; +use io_orbit::object_storage::ObjectByteStream; +use jupiter::{ + service::orion_vm_image_service::OrionVmImageService, + 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::{BotAuth, api_store::OAuthApiStore, model::LoginUser}, +}; + +/// Max browser-proxied Orion image upload (qcow2). +const ORION_IMAGE_UPLOAD_MAX_BYTES: usize = 32 * 1024 * 1024 * 1024; + +/// Bot allowed to register catalog entries via `Authorization: Bearer bot_…`. +const ORION_IMAGE_PUBLISHER_BOT: &str = "orion-image-publisher"; + +/// Auth for catalog register: admin session cookie, or publisher bot bearer. +struct OrionImageRegisterAuth; + +impl FromRequestParts for OrionImageRegisterAuth +where + MonoApiServiceState: FromRef, + OAuthApiStore: FromRef, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let api_state = MonoApiServiceState::from_ref(state); + + if let Ok(bot) = BotAuth::from_request_parts(parts, state).await { + if bot.bot_name == ORION_IMAGE_PUBLISHER_BOT { + return Ok(Self); + } + tracing::warn!( + bot_id = bot.bot_id, + bot_name = %bot.bot_name, + "orion image register rejected: bot is not {ORION_IMAGE_PUBLISHER_BOT}" + ); + return Err(ApiError::with_status( + StatusCode::FORBIDDEN, + anyhow!("bot is not authorized to register Orion images"), + )); + } + + let user = LoginUser::from_request_parts(parts, state) + .await + .map_err(|_| { + ApiError::with_status(StatusCode::UNAUTHORIZED, anyhow!("Unauthorized")) + })?; + ensure_admin(&api_state, &user).await?; + Ok(Self) + } +} + +pub fn routers() -> OpenApiRouter { + OpenApiRouter::new().nest( + "/orion/images", + OpenApiRouter::new() + .routes(routes!(list_orion_images)) + .routes(routes!(presign_orion_image)) + .routes(routes!(register_orion_image)) + .routes(routes!(delete_orion_image)) + .route( + "/objects/{*object_key}", + put(upload_orion_image_object) + .layer(DefaultBodyLimit::max(ORION_IMAGE_UPLOAD_MAX_BYTES)), + ), + ) +} + +#[allow(clippy::too_many_arguments)] +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 }, + )))) +} + +/// Prepare catalog object keys for a browser upload (via mono object proxy). +#[utoipa::path( + post, + path = "/presign", + request_body = PresignOrionVmImageRequest, + 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 presign_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() { + return Err(ApiError::bad_request(anyhow!("digest is required"))); + } + if !(digest.starts_with("sha256:") || digest.starts_with("sha512:")) { + return Err(ApiError::bad_request(anyhow!( + "digest must start with sha256: or sha512:" + ))); + } + + let image_name = req + .image_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("debian-13-buck2"); + let (object_key, info_object_key) = OrionVmImageService::object_keys(&digest, image_name); + + // Browser uploads go through mono (`PUT /objects/{key}`) so clients do not need + // a browser-reachable RustFS endpoint / CORS. Paths are relative to the mono API root. + let qcow2_put_url = format!("/api/v1/orion/images/objects/{object_key}"); + let (info_object_key, info_put_url) = if req.with_info { + ( + Some(info_object_key.clone()), + Some(format!("/api/v1/orion/images/objects/{info_object_key}")), + ) + } else { + (None, None) + }; + + Ok(Json(CommonResult::success(Some( + PresignOrionVmImageResponse { + object_key, + info_object_key, + qcow2_put_url, + info_put_url, + expires_in_secs: 0, + }, + )))) +} + +/// Stream object bytes into the Orion image namespace (admin browser upload proxy). +async fn upload_orion_image_object( + user: LoginUser, + State(state): State, + Path(object_key): Path, + req: Request, +) -> Result { + ensure_admin(&state, &user).await?; + let object_key = object_key.trim().trim_start_matches('/').to_string(); + if object_key.is_empty() + || object_key.contains("..") + || object_key.starts_with('/') + || !object_key.contains('/') + { + return Err(ApiError::bad_request(anyhow!( + "object_key must look like '{{digest_hex}}/{{filename}}'" + ))); + } + + let data: ObjectByteStream = Box::pin( + req.into_body() + .into_data_stream() + .map_err(std::io::Error::other), + ); + + state + .services() + .storage() + .orion_vm_image_service + .put_object_stream(&object_key, data) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// 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 session or orion-image-publisher bot"), + ), + tag = ORION_RUNNER_TAG +)] +async fn register_orion_image( + _auth: OrionImageRegisterAuth, + State(state): State, + Json(req): Json, +) -> Result>, ApiError> { + 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() + && 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..aa058944d 100644 --- a/mono/src/api/router/orion_runner_router.rs +++ b/mono/src/api/router/orion_runner_router.rs @@ -245,18 +245,112 @@ async fn start_runner( let build = &state.services().storage().config().build; let env = derive_runner_env(build)?; + // 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; + + let catalog_model = if let Some(image_id) = req + .image_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + if image_url.is_some() { + return Err(ApiError::bad_request(anyhow!( + "image_id cannot be combined with image_url" + ))); + } + 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_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 { 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: 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, }; @@ -278,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/components/OrionClient/DeleteOrionImageDialog.tsx b/moon/apps/web/components/OrionClient/DeleteOrionImageDialog.tsx new file mode 100644 index 000000000..2d18fb93b --- /dev/null +++ b/moon/apps/web/components/OrionClient/DeleteOrionImageDialog.tsx @@ -0,0 +1,66 @@ +'use client' + +import { Button } from '@gitmono/ui/Button' +import { Dialog } from '@gitmono/ui/Dialog' + +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 +} + +interface DeleteOrionImageDialogProps { + image: OrionVmImage | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function DeleteOrionImageDialog({ image, open, onOpenChange }: DeleteOrionImageDialogProps) { + const { mutate: deleteImage, isPending } = useDeleteOrionImage() + + const label = image?.image_name?.trim() || shortDigest(image?.digest) + const digestHint = image?.image_name ? ` (${shortDigest(image.digest)})` : '' + + const onDelete = () => { + if (!image) return + + deleteImage(image.id, { + onSuccess: () => { + onOpenChange(false) + } + }) + } + + return ( + { + if (isPending) return + onOpenChange(next) + }} + > + + Delete image + + Are you sure you want to delete {label} + {digestHint}? This removes the catalog entry and its objects from storage. + + + + + + + + + + + ) +} diff --git a/moon/apps/web/components/OrionClient/OrionImagesTable.tsx b/moon/apps/web/components/OrionClient/OrionImagesTable.tsx new file mode 100644 index 000000000..c30d4ec81 --- /dev/null +++ b/moon/apps/web/components/OrionClient/OrionImagesTable.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useState } from 'react' + +import { Button, UIText } from '@gitmono/ui' + +import { DeleteOrionImageDialog } from '@/components/OrionClient/DeleteOrionImageDialog' +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 [pendingImage, setPendingImage] = useState(null) + + if (error) { + return ( + + Failed to load images: {error.message} + + ) + } + + if (isLoading) { + return ( + + Loading images… + + ) + } + + if (!images.length) { + return ( + + 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)} + +
+
+ + { + 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..e58327e69 --- /dev/null +++ b/moon/apps/web/components/OrionClient/UploadOrionImageDialog.tsx @@ -0,0 +1,250 @@ +'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..67bcc37c3 --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/orionImageUpload.ts @@ -0,0 +1,86 @@ +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/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/hooks/OrionClient/usePresignOrionImage.ts b/moon/apps/web/hooks/OrionClient/usePresignOrionImage.ts new file mode 100644 index 000000000..99a565aaa --- /dev/null +++ b/moon/apps/web/hooks/OrionClient/usePresignOrionImage.ts @@ -0,0 +1,50 @@ +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/hooks/OrionClient/useRunnerLogsSSE.ts b/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts index 05d8d30fd..9450d3def 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,48 @@ 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 +139,19 @@ 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/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..a706334f3 --- /dev/null +++ b/moon/apps/web/pages/[org]/oc/images.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useState } from 'react' +import Head from 'next/head' + +import { Button, UIText } from '@gitmono/ui' + +import { AppLayout } from '@/components/Layout/AppLayout' +import { OrionImagesTable } from '@/components/OrionClient/OrionImagesTable' +import { OrionClientPageWrapper } from '@/components/OrionClient/PageWrapper' +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 1227a6cd9..4bf9a5a89 100644 --- a/moon/apps/web/pages/[org]/oc/index.tsx +++ b/moon/apps/web/pages/[org]/oc/index.tsx @@ -18,6 +18,7 @@ import { AppLayout } from '@/components/Layout/AppLayout' import { domainFromClientHostname, OrionClient, + OrionClientPageWrapper, OrionClientStatus, RunnersTable, VmTerminal @@ -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,8 @@ 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 didAutoSelectImageRef = React.useRef(false) const logPanelRef = React.useRef(null) const terminalPanelRef = React.useRef(null) const logsScrollRef = React.useRef(null) @@ -105,12 +109,25 @@ const OrionClientPage: PageWithLayout = () => { error: runnerListError, refetch: refetchRunners } = useGetRunnerList(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) 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) @@ -259,7 +276,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 +290,7 @@ const OrionClientPage: PageWithLayout = () => { } ) }, - [openLogPanel, startRunner] + [openLogPanel, selectedImageId, startRunner] ) const handleViewClientLogs = React.useCallback( @@ -403,315 +423,352 @@ const OrionClientPage: PageWithLayout = () => { Orion Client - {/* AppLayout main is overflow-hidden; this page must own scrolling when the list is visible. */} -
-
-
-
-

Orion

-
-
- {isAdmin ? ( - - ) : null} - {!showingOverlay ? ( -
-
- - {showingTerminal && activeTerminalKey ? ( -
-
-
- - {terminalSource === 'client' && terminalClientId - ? `Client ${terminalClientId}` - : `Runner ${activeTerminalKey}`} - - - Terminal for {terminalDomain ?? activeTerminalKey} - -
- + + {/* AppLayout main is overflow-hidden; this page must own scrolling when the list is visible. */} +
+
+
+
+

Orion

-
- +
+ {isAdmin ? ( + <> + + + + ) : null} + {!showingOverlay ? ( +
- ) : null} - {showingLogs ? ( -
-
-
- - {logSource === 'client' && logClientId ? `Client ${logClientId}` : `Runner ${activeLogKey}`} - - {logSource === 'client' ? ( + {showingTerminal && activeTerminalKey ? ( +
+
+
+ + {terminalSource === 'client' && terminalClientId + ? `Client ${terminalClientId}` + : `Runner ${activeTerminalKey}`} + - Streaming scheduler logs for domain {activeDomain ?? activeLogKey} + Terminal for {terminalDomain ?? activeTerminalKey} - ) : null} -
-
- {isAdmin && activeLogKey ? ( - - ) : null} -
+
+
+ +
-
- {(runnerStatus?.domain ?? activeDomain) ? ( - - Domain: {runnerStatus?.domain ?? activeDomain} - - ) : null} - {logSource === 'runner' ? ( - - Phase:{' '} - {runnerStatus?.phase ?? activePhase ?? 'unknown'} - - ) : null} - {runnerStatus?.vm_ip ? ( - - VM IP: {runnerStatus.vm_ip} - - ) : null} - {runnerStatus?.image_name || runnerStatus?.image_digest ? ( - - Image: {runnerStatus.image_name ?? 'unknown'} - {runnerStatus.image_digest - ? ` (${runnerStatus.image_digest.replace(/^sha256:/, '').slice(0, 12)})` - : ''} - - ) : null} - {runnerStatus?.image_built_at ? ( - - Built: {runnerStatus.image_built_at} - - ) : null} - {runnerStatus?.image_cpus != null || - runnerStatus?.image_memory_mb != null || - runnerStatus?.image_disk_gb != null ? ( - - Resources:{' '} - {[ - runnerStatus.image_cpus != null ? `${runnerStatus.image_cpus} vCPU` : null, - runnerStatus.image_memory_mb != null - ? `${Math.round(runnerStatus.image_memory_mb / 1024)} GiB RAM` - : null, - runnerStatus.image_disk_gb != null ? `${runnerStatus.image_disk_gb} GiB disk` : null - ] - .filter(Boolean) - .join(' / ')} - - ) : null} - {runnerStatus?.toolchain_rust || runnerStatus?.toolchain_buck2 || runnerStatus?.toolchain_python ? ( - - Toolchains:{' '} - {[ - runnerStatus.toolchain_rust ? `rust ${runnerStatus.toolchain_rust}` : null, - runnerStatus.toolchain_buck2 ? `buck2 ${runnerStatus.toolchain_buck2}` : null, - runnerStatus.toolchain_python ? `python ${runnerStatus.toolchain_python}` : null - ] - .filter(Boolean) - .join(' · ')} - - ) : null} - {runnerStatus?.kernel ? ( - - Kernel: {runnerStatus.kernel} - - ) : null} - {runnerStatus?.uptime_secs != null ? ( - - Uptime: {formatUptime(runnerStatus.uptime_secs)} - - ) : null} - {runnerStatus?.log_file ? ( - - Log file: {runnerStatus.log_file} - - ) : null} - {runnerStatus?.error ? ( - - {runnerStatus.error} - - ) : null} - {logSource === 'runner' && runnerStatus?.phase === 'failed' ? ( - - ) : null} -
+ ) : null} -
-
- - {logSource === 'client' ? 'Runner logs' : 'Startup logs'} - -
- {runnerLogsStatus === 'connecting' || /Waiting for VM\b/i.test(runnerLogs) ? ( - - - Waiting… - - ) : ( - - {runnerLogsStatus === 'streaming' - ? 'Live' - : runnerLogsStatus === 'error' - ? 'Disconnected' - : 'Idle'} + {showingLogs ? ( +
+
+
+ + {logSource === 'client' && logClientId ? `Client ${logClientId}` : `Runner ${activeLogKey}`} + + {logSource === 'client' ? ( + + Streaming scheduler logs for domain {activeDomain ?? activeLogKey} - )} - {runnerLogs ? ( + ) : null} +
+
+ {isAdmin && activeLogKey ? ( ) : null} +
- {runnerLogsError ? ( - - {runnerLogsError} - - ) : null} -
{ - // Stop auto-follow as soon as the user scrolls up. - if (e.deltaY < 0) { - logsFollowRef.current = false - } - }} - onScroll={(e) => { - const el = e.currentTarget - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight - - logsFollowRef.current = distanceFromBottom < 40 - }} - style={{ height: 320, maxHeight: 320, overflowY: 'auto', overflowX: 'auto' }} - className='w-full cursor-text rounded border border-gray-200 bg-black/90 outline-hidden select-text focus:ring-2 focus:ring-blue-500/40 dark:border-gray-700' - > - {isVmWaitingLog(runnerLogs) ? ( -
- -

- {vmWaitingLabel(runnerLogs)} - - +

+ {(runnerStatus?.domain ?? activeDomain) ? ( + + Domain: {runnerStatus?.domain ?? activeDomain} + + ) : null} + {logSource === 'runner' ? ( + + Phase:{' '} + {runnerStatus?.phase ?? activePhase ?? 'unknown'} + + ) : null} + {runnerStatus?.vm_ip ? ( + + VM IP: {runnerStatus.vm_ip} + + ) : null} + {runnerStatus?.image_name || runnerStatus?.image_digest ? ( + + Image:{' '} + {runnerStatus.image_name || + (runnerStatus.image_digest + ? `sha256:${runnerStatus.image_digest.replace(/^sha256:/, '').slice(0, 12)}…` + : 'unknown')} + {runnerStatus.image_name && runnerStatus.image_digest + ? ` (${runnerStatus.image_digest.replace(/^sha256:/, '').slice(0, 12)})` + : ''} + + ) : null} + {runnerStatus?.image_built_at ? ( + + Built: {runnerStatus.image_built_at} + + ) : null} + {runnerStatus?.image_cpus != null || + runnerStatus?.image_memory_mb != null || + runnerStatus?.image_disk_gb != null ? ( + + Resources:{' '} + {[ + runnerStatus.image_cpus != null ? `${runnerStatus.image_cpus} vCPU` : null, + runnerStatus.image_memory_mb != null + ? `${Math.round(runnerStatus.image_memory_mb / 1024)} GiB RAM` + : null, + runnerStatus.image_disk_gb != null ? `${runnerStatus.image_disk_gb} GiB disk` : null + ] + .filter(Boolean) + .join(' / ')} + + ) : null} + {runnerStatus?.toolchain_rust || runnerStatus?.toolchain_buck2 || runnerStatus?.toolchain_python ? ( + + Toolchains:{' '} + {[ + runnerStatus.toolchain_rust ? `rust ${runnerStatus.toolchain_rust}` : null, + runnerStatus.toolchain_buck2 ? `buck2 ${runnerStatus.toolchain_buck2}` : null, + runnerStatus.toolchain_python ? `python ${runnerStatus.toolchain_python}` : null + ] + .filter(Boolean) + .join(' · ')} + + ) : null} + {runnerStatus?.kernel ? ( + + Kernel: {runnerStatus.kernel} + + ) : null} + {runnerStatus?.uptime_secs != null ? ( + + Uptime: {formatUptime(runnerStatus.uptime_secs)} + + ) : null} + {runnerStatus?.log_file ? ( + + Log file: {runnerStatus.log_file} + + ) : null} + {runnerStatus?.error ? ( + + {runnerStatus.error} + + ) : null} + {logSource === 'runner' && runnerStatus?.phase === 'failed' ? ( + + ) : null} +
+ +
+
+ + {logSource === 'client' ? 'Runner logs' : 'Startup logs'} + +
+ {runnerLogsStatus === 'connecting' || /Waiting for VM\b/i.test(runnerLogs) ? ( + + + Waiting… -

+ ) : ( + + {runnerLogsStatus === 'streaming' + ? 'Live' + : runnerLogsStatus === 'error' + ? 'Disconnected' + : 'Idle'} + + )} + {runnerLogs ? ( + + ) : null}
- ) : ( -
-                      {runnerLogs ||
-                        (runnerLogsStatus === 'connecting'
-                          ? 'Waiting for log stream…'
-                          : 'No log lines yet. Logs appear while the runner is running.')}
-                    
- )} +
+ {runnerLogsError ? ( + + {runnerLogsError} + + ) : null} +
{ + // Stop auto-follow as soon as the user scrolls up. + if (e.deltaY < 0) { + logsFollowRef.current = false + } + }} + onScroll={(e) => { + const el = e.currentTarget + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + + logsFollowRef.current = distanceFromBottom < 40 + }} + style={{ height: 320, maxHeight: 320, overflowY: 'auto', overflowX: 'auto' }} + className='w-full cursor-text rounded border border-gray-200 bg-black/90 outline-hidden select-text focus:ring-2 focus:ring-blue-500/40 dark:border-gray-700' + > + {isVmWaitingLog(runnerLogs) ? ( +
+ +

+ {vmWaitingLabel(runnerLogs)} + + + +

+
+ ) : ( +
+                        {runnerLogs ||
+                          (runnerLogsStatus === 'connecting'
+                            ? 'Waiting for log stream…'
+                            : 'No log lines yet. Logs appear while the runner is running.')}
+                      
+ )} +
+ + Scroll inside the box to browse. ⌘/Ctrl+A select all, ⌘/Ctrl+C copy. Scroll to bottom to resume live + follow. +
- - Scroll inside the box to browse. ⌘/Ctrl+A select all, ⌘/Ctrl+C copy. Scroll to bottom to resume live - follow. -
-
- ) : null} + ) : null} - {!showingOverlay ?
: null} -
+ {!showingOverlay ?
: null} +
- {!showingOverlay ? ( - <> - setStatusFilter(value)} - statusOptions={statusOptions} - canManage={isAdmin} - onViewRunnerLogs={handleViewRunnerLogs} - onConnectRunnerTerminal={handleConnectRunnerTerminal} - onViewClientLogs={handleViewClientLogs} - onConnectClientTerminal={handleConnectTerminal} - /> - - {pageCount > 1 ? ( -
- - setCurrentPage(page)} - /> - -
- ) : null} - - ) : null} -
+ {!showingOverlay ? ( + <> + setStatusFilter(value)} + statusOptions={statusOptions} + canManage={isAdmin} + onViewRunnerLogs={handleViewRunnerLogs} + onConnectRunnerTerminal={handleConnectRunnerTerminal} + onViewClientLogs={handleViewClientLogs} + onConnectClientTerminal={handleConnectTerminal} + /> + + {pageCount > 1 ? ( +
+ + setCurrentPage(page)} + /> + +
+ ) : null} + + ) : null} +
+ ) } diff --git a/moon/packages/types/generated.ts b/moon/packages/types/generated.ts index c707e0559..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 @@ -9254,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 @@ -21072,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/DESIGN.md b/orion-scheduler/DESIGN.md index e52ba9280..f81c759f6 100644 --- a/orion-scheduler/DESIGN.md +++ b/orion-scheduler/DESIGN.md @@ -183,14 +183,14 @@ Webhook 端点健康检查。 | `target` | string | 否 | 仅作日志 / 展示标签(已废弃查表) | | `sync` | bool | 否 | `true` 同步阻塞至部署完成(默认 `false` → 202) | | `replace` | bool | 否 | 同 domain 已 Running 时强制重建(默认幂等 200) | -| `image_path` | string | 否 | 本地 qcow2 镜像路径,与 `image_url` 互斥;未指定时使用 `default_image` | -| `image_url` | string | 否 | 远程 HTTPS 镜像 URL,与 `image_path` 互斥 | +| `image_path` | string | 条件* | 本地 qcow2 镜像路径,与 `image_url` 互斥(ops only;无静默 default) | +| `image_url` | string | 条件* | 远程 HTTPS 镜像 URL,与 `image_path` 互斥 | | `image_digest` | string | 否* | SHA256/SHA512 hash。`image_path` 或 `image_url` 存在时必填 | | `image_disk_gb` | u32 | 否 | VM 磁盘大小(GB),未指定时使用 `default_image` | | `image_cpus` | u32 | 否 | vCPU 数,未指定时使用 `default_image` | | `image_memory_mb` | u32 | 否 | 内存 MB,未指定时使用 `default_image` | -> **约束**:`image_path` 和 `image_url` 互斥。提供了两者之一时 `image_digest` 必须提供。未传任何 `image_*` 时用 `default_image`(字段级 merge)。达 `max_vms` 且新 domain 时 **503**。 +> **约束**:必须提供 `image_url` 或 `image_path` 之一(否则 400);二者互斥。提供了两者之一时 `image_digest` 必须提供。`default_image` 仅 merge disk/cpu/memory。达 `max_vms` 且新 domain 时 **503**。 **同 domain 响应**: @@ -305,7 +305,7 @@ flowchart TD REG --> OLD{"同 domain 有旧 machine?"} OLD -->|是| SD["shutdown 旧实例
(Failed / replace)"] OLD -->|否| IMG - SD --> IMG["merge image_* + default_image
KeepAliveMachine::new"] + SD --> IMG["require image_url or image_path; merge sizing defaults
KeepAliveMachine::new"] IMG --> DEP["SFTP 部署 orion + runner-config"] DEP --> ENV["sed 写入 SERVER_WS / scorpio URLs"] ENV --> START["systemctl start orion-runner"] @@ -363,8 +363,6 @@ flowchart TD "ssh_public_key_path": "~/.ssh/orion_vm_access.pub", "max_vms": 8, "default_image": { - "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", - "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 @@ -381,14 +379,13 @@ flowchart TD | `orion_binary_path` | string | 无默认值(必填) | Orion 二进制文件路径 | | `ssh_public_key_path` | string | 无默认值(必填) | SSH 公钥路径 | | `max_vms` | u32 | 无(不限制) | 同时跟踪的 VM 上限(按 domain);新域超出 → 503 | -| `default_image` | object | 见模板 | 默认 VM 镜像五参数;webhook 未传 `image_*` 时使用 | -| `default_image.image_path` | string | — | 本地 qcow2 路径 | -| `default_image.image_digest` | string | — | SHA256 校验和 | -| `default_image.image_disk_gb` | u32 | 50 | 磁盘 GB(长生命周期 VM 建议 ≥50;已部署的 `/etc/.../target_config.json` 需人工改) | - +| `default_image` | object | 见模板 | 默认 VM **规格**(disk/cpu/memory);无静默本地镜像路径 | +| `default_image.image_disk_gb` | u32 | 50 | 磁盘 GB | | `default_image.image_cpus` | u32 | 8 | vCPU 数 | | `default_image.image_memory_mb` | u32 | 16000 | 内存 MB | +> 旧配置中的 `image_path` / `image_digest` 会被忽略。 + #### Mega UI URL 推导 Mega UI 调用 `POST /api/v1/orion/runners` 时不传 env URL;mono 从 `build.runner_connect_domain` 推导: @@ -456,10 +453,10 @@ sudo ./orion-scheduler/scripts/build-custom-image.sh ``` **约束**: -- `image_path` 和 `image_url` 互斥,不能同时设置 +- 必须提供 `image_url` 或 `image_path` 之一(否则 400);二者互斥 - 提供了 `image_path` 或 `image_url` 时必须同时提供 `image_digest`(格式 `sha256:...` 或 `sha512:...`) -- 资源参数(`image_disk_gb`、`image_cpus`、`image_memory_mb`)可选,不提供时使用 `default_image` 默认值 -- 不提供任何镜像参数时,使用 `default_image` 配置块 +- 资源参数(`image_disk_gb`、`image_cpus`、`image_memory_mb`)可选,不提供时使用 `default_image` 规格默认值 +- 无静默本地 default 镜像;product 路径由 mono catalog(最新或 `image_id`)签发 RustFS URL #### 实现方式 diff --git a/orion-scheduler/README.md b/orion-scheduler/README.md index 53f4a84cb..2fea389bc 100644 --- a/orion-scheduler/README.md +++ b/orion-scheduler/README.md @@ -39,8 +39,6 @@ flowchart LR "max_vms": 8, "retain_antares_mounts": false, "default_image": { - "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", - "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 @@ -99,7 +97,7 @@ GHA 等需同步等待的调用方可传 `"sync": true` 保留旧行为(阻塞 ### Mega UI / mono 代理 -Admin 用户通过 Mega UI Orion Client 页调用 mono `POST /api/v1/orion/runners`(空 body);mono 从 `build.runner_connect_domain` 拼接 `git.` / `orion.` 子域名推导 env URL 后转发至 scheduler。scheduler URL 仅配置在 mono 服务端(`build.orion_scheduler_url`),浏览器不直连 scheduler。 +Admin 用户通过 Mega UI Orion Client 页调用 mono `POST /api/v1/orion/runners`(可选 `image_id`;省略则最新 catalog);mono 从 `build.runner_connect_domain` 拼接 `git.` / `orion.` 子域名推导 env URL,并对 catalog 镜像签发 RustFS URL 后转发至 scheduler。scheduler URL 仅配置在 mono 服务端(`build.orion_scheduler_url`),浏览器不直连 scheduler。 --- @@ -126,10 +124,11 @@ curl -X POST http://localhost:8080/webhook \ -d '{ "server_ws": "wss://orion.gitmega.com/ws", "scorpio_base_url": "https://git.gitmega.com", - "scorpio_lfs_url": "https://git.gitmega.com" + "scorpio_lfs_url": "https://git.gitmega.com", + "image_url": "https://rustfs.example/orion-images/.../debian-13-buck2.qcow2", + "image_digest": "sha256:..." }' ``` - | 字段 | 类型 | 必填 | 说明 | | ----------------- | ------ | ---- | -------------------------------------------------------- | | `server_ws` | string | 是 | Orion WebSocket URL,写入 VM 内 `.env` 的 `SERVER_WS` | @@ -139,15 +138,60 @@ curl -X POST http://localhost:8080/webhook \ | `action` | string | 否 | GitHub Actions 事件类型,仅作日志记录 | | `sync` | bool | 否 | 为 `true` 时同步阻塞至部署完成(默认 `false`,立即 202 返回) | | `replace` | bool | 否 | Running 状态下为 `true` 时强制重建(默认幂等返回已有实例) | -| `image_path` | string | 否 | 本地 qcow2 镜像路径;未指定时使用 `default_image.image_path` | -| `image_url` | string | 否 | 远程 HTTPS URL | +| `image_path` | string | 条件必填 | 本地 qcow2 镜像路径(ops curl);与 `image_url` 互斥;**不再有静默本地 default** | +| `image_url` | string | 条件必填 | 远程 HTTPS URL(catalog / RustFS 签名 URL);与 `image_path` 二选一必填 | | `image_digest` | string | 条件必填 | 镜像 SHA256/SHA512 校验和,提供 `image_path` 或 `image_url` 时必须指定 | | `image_disk_gb` | u32 | 否 | 虚拟机磁盘大小(GB);未指定时使用 `default_image.image_disk_gb` | | `image_cpus` | u32 | 否 | 虚拟 CPU 数量;未指定时使用 `default_image.image_cpus` | | `image_memory_mb` | u32 | 否 | 内存大小(MB);未指定时使用 `default_image.image_memory_mb` | | `retain_antares_mounts` | bool | 否 | 写入 guest `.env` 的 `ORION_RETAIN_ANTARES_MOUNTS`(`true`→`1`,`false`→`0`);省略则保留 `.env.prod` 原值 | -> `image_path` 与 `image_url` 互斥,不可同时指定。提供镜像参数时 `image_digest` 必须提供(格式:`sha256:...` 或 `sha512:...`)。未传任何 `image_*` 字段时,scheduler 使用 `target_config.json` 中的 `default_image` 块。 +> `image_path` 与 `image_url` 互斥,必须提供其一(否则 400)。提供镜像参数时 `image_digest` 必须提供(格式:`sha256:...` 或 `sha512:...`)。`default_image` 仅提供 disk/cpu/memory 默认值,不再填充本地 qcow2 路径。 + +### 镜像 catalog(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` | +| `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`。 + +Campsite POC:镜像列表展示 rust / python / buck2 / kernel;Start Runner 可选 catalog 项(`image_id`)或 **Latest(省略则取 catalog 最新)**。mono 解析为预签名 `image_url` + `image_digest` 再调 scheduler `/webhook`。 ### GHA / 外部 webhook 迁移 @@ -163,11 +207,13 @@ curl -X POST http://localhost:8080/webhook \ -d '{ "server_ws": "wss://orion.example.com/ws", "scorpio_base_url": "https://git.example.com", - "scorpio_lfs_url": "https://git.example.com" + "scorpio_lfs_url": "https://git.example.com", + "image_url": "https://rustfs.example/orion-images/.../debian-13-buck2.qcow2", + "image_digest": "sha256:..." }' ``` -Mega UI 通过 mono 代理启动 runner 时不传 env URL;mono 从基础域名 `runner_connect_domain` 拼接 `git.` / `orion.` 子域名,无需传 `image_*`(由 scheduler `default_image` 填充)。 +Mega UI 通过 mono 代理启动 runner 时不传 env URL;mono 从基础域名 `runner_connect_domain` 拼接 `git.` / `orion.` 子域名。镜像:传 `image_id` 用指定 catalog 项,省略则取 catalog 最新一条(按 `created_at`),经 RustFS 签名 URL 转发给 scheduler。 --- @@ -198,7 +244,7 @@ sudo bash scripts/build-custom-image.sh 3. 通过 `qemu-nbd` 挂载、`growpart` + `resize2fs` 扩展分区;若基础镜像目录缺少 `vmlinuz-*` / `initrd.img-*`,自动从镜像内 `/boot` 提取 4. chroot 进入镜像并安装: - Rust 1.95.0 toolchain(在 host 上预下载 tarball,避免 chroot 内 DNS 问题) - - apt 包:`clang lld pkg-config protobuf-compiler zstd fuse curl git seccomp libseccomp-dev libpython3-dev openssl libssl-dev build-essential` + - apt 包:`clang lld pkg-config libelf-dev protobuf-compiler zstd fuse curl git seccomp libseccomp-dev libpython3-dev openssl libssl-dev build-essential` - buck2(`2026-04-15` 版本) - SSH 公钥写入 `/root/.ssh/authorized_keys` - 软链 `rustc` / `cargo` → `/usr/local/bin/`,确保默认 PATH 可找到 @@ -241,18 +287,18 @@ sudo bash scripts/build-custom-image.sh | `ssh_public_key_path` | string | 无默认值(必填) | SSH 公钥路径 | | `max_vms` | u32 | 无(不限制) | 同时跟踪的 domain/VM 上限;超出新 domain 返回 503 | | `retain_antares_mounts` | bool | 无(不写入) | webhook 省略时的默认;写入 guest `ORION_RETAIN_ANTARES_MOUNTS` | -| `default_image` | object | 见模板 | 默认 VM 镜像参数;webhook 未传 `image_*` 时使用 | +| `default_image` | object | 见模板 | 默认 VM **规格**(disk/cpu/memory);不再提供默认本地镜像路径 | ### `default_image` | 字段 | 类型 | 说明 | | ------------------ | ------ | ------------------------------------------------ | -| `image_path` | string | 本地 qcow2 镜像路径 | -| `image_digest` | string | 镜像 SHA256 校验和 | | `image_disk_gb` | u32 | 磁盘大小(GB) | | `image_cpus` | u32 | vCPU 数量 | | `image_memory_mb` | u32 | 内存(MB) | +> 旧配置中的 `image_path` / `image_digest` 会被忽略。Start Runner 镜像一律由 mono catalog(RustFS 签名 URL)或 webhook 显式 `image_url`/`image_path` 提供。 + 环境 URL(`server_ws`、`scorpio_base_url`、`scorpio_lfs_url`)由 webhook 请求体传入,不再通过 `targets` 查表。 --- @@ -344,6 +390,7 @@ Release bundle 内部结构: orion-scheduler-vX.Y.Z-linux-amd64/ ├── bin/orion-scheduler ├── etc/target_config.json.template +├── etc/needrestart-orion-scheduler.conf ├── systemd/orion-scheduler.service ├── install.sh └── VERSION @@ -379,11 +426,14 @@ sudo journalctl -u orion-scheduler -f | qlean 状态目录 | `/var/lib/orion-scheduler/qlean/{images,runs}`,软链到 `~orion/.local/share/qlean` | | 日志 & 缓存 | `/var/log/orion-scheduler`、`/var/cache/orion-scheduler` | | systemd | `/etc/systemd/system/orion-scheduler.service`,`daemon-reload` + `enable` | +| needrestart | `/etc/needrestart/conf.d/orion-scheduler.conf`(存在 `conf.d` 时安装;禁止 apt 钩子自动重启本服务) | 可用环境变量覆盖默认值:`PREFIX` / `ETC_DIR` / `STATE_DIR` / `LOG_DIR` / `CACHE_DIR` / `SERVICE_USER` / `SERVICE_GROUP` / `SKIP_ENABLE=1`。 升级时,下载新 tarball 再跑一次 `install.sh` 即可——配置不会被覆盖,systemd unit 会重启。 +**VM 一夜消失**:`unattended-upgrade` + needrestart 曾会对本服务发 SIGTERM,从而关掉全部跟踪中的 QEMU。装好上述 drop-in 后应不再发生;若仍出现,查 `journalctl -u orion-scheduler` 是否有 `Received SIGTERM`,并确认 `/etc/needrestart/conf.d/orion-scheduler.conf` 在位。 + ### 升级 / 回滚 | 操作 | 命令 | diff --git a/orion-scheduler/TESTING.md b/orion-scheduler/TESTING.md index 3e45aef8e..15dfc2f1f 100644 --- a/orion-scheduler/TESTING.md +++ b/orion-scheduler/TESTING.md @@ -240,20 +240,101 @@ fuser 8080/tcp 2>/dev/null || echo "Port 8080 is free" --- -## 4. 构建镜像并上传到 S3 +## 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。 + +单环境兼容(优先 `scripts/.env`,已 export 的变量优先;`sudo -E` 可覆盖): + +```bash +cp ~/mega/orion-scheduler/scripts/.env.example ~/mega/orion-scheduler/scripts/.env +# 填入 RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY,以及 MEGA_INIT_BOOTSTRAP_SECRET +# 或 ORION_IMAGE_REGISTER_TOKEN + sudo bash ~/mega/orion-scheduler/scripts/build-custom-image.sh -# 输出 sha256:,用作 webhook 的 image_digest +# 本地仍发布到 ~/.local/share/qlean/images/ +# 若 .env 密钥齐全:上传 orion-images/{sha256}/… 并 POST 注册 catalog +``` + +也可继续用环境变量(会写回 `scripts/.env`): + +```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 MEGA_INIT_BOOTSTRAP_SECRET=... # 推荐:自动 bootstrap +# 或: export ORION_IMAGE_REGISTER_TOKEN=bot_... + +sudo -E bash ~/mega/orion-scheduler/scripts/build-custom-image.sh ``` +构建缓存:Rust tarball 在 `/var/cache/orion-image/rust/`(按版本分文件);compact 后的 qcow2 在 `/var/cache/orion-image/built//`。切回以前的 `RUST_VERSION` 会复用对应镜像,不必重新 chroot。强制重建:`FORCE_REBUILD=1 sudo bash scripts/build-custom-image.sh`。 + +对象布局: + +```text +orion-images/{sha256_hex}/debian-13-buck2.qcow2 +orion-images/{sha256_hex}/image-info.json +``` + +UI(Campsite POC)通过 `GET /api/v1/orion/images` 列出工具链版本;Start Runner 传 `image_id`,mono 签发预签名 URL 给 scheduler。 + +若 mono 对内用集群内 RustFS(`*.svc.cluster.local`),而 orion-scheduler 跑在集群外,须在 mono 的 `[object_storage.s3]` 配置公网签发地址,例如: + +```toml +endpoint_url = "http://rustfs.mega-dev.svc.cluster.local:9000" +presign_endpoint_url = "https://rustfs.xuanwu.openatom.cn" +``` + +`presign_endpoint_url` 只影响预签名 URL 的 Host(签名包含 Host,不能事后改写);对内 PUT/GET 仍走 `endpoint_url`。留空则签发仍用 `endpoint_url`。 + +未设 `ORION_IMAGE_FANOUT` / RustFS / register env 时脚本只做本地发布(与以前相同)。 + +### 本地无法构建时的 mock 上传 + +跳过 qemu/chroot,写 1MiB 假文件后直接走 Stage 8(无需 root): + ```bash -aws s3 cp ~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2 \ - s3://gitmega/images/debian-13-buck2.qcow2 --progress +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 ``` -`image_digest` 使用构建脚本输出的本地文件 hash;上传前后内容不变则 hash 一致。 +`SKIP_BUILD=1` 与 `MOCK_UPLOAD=1` 等价。 --- @@ -272,4 +353,6 @@ aws s3 cp ~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2 \ | 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 为空 | 构建时设 `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;集群外 scheduler 若拉不动 `*.svc.cluster.local`,给 mono 配 `presign_endpoint_url` 公网 RustFS | | 进 VM 调试 | [SSH 进入 VM](#ssh-进入-vm) | diff --git a/orion-scheduler/etc/needrestart-orion-scheduler.conf b/orion-scheduler/etc/needrestart-orion-scheduler.conf new file mode 100644 index 000000000..edf12e365 --- /dev/null +++ b/orion-scheduler/etc/needrestart-orion-scheduler.conf @@ -0,0 +1,3 @@ +# Keep orion-scheduler out of needrestart automatic restarts. +# Restarting this unit SIGTERMs it and shuts down every tracked QEMU VM. +$nrconf{override_rc}->{qr(^orion-scheduler\.service$)} = 0; diff --git a/orion-scheduler/etc/target_config.json.template b/orion-scheduler/etc/target_config.json.template new file mode 100644 index 000000000..96b22e51d --- /dev/null +++ b/orion-scheduler/etc/target_config.json.template @@ -0,0 +1,13 @@ +{ + "log_dir": "/var/log/orion-scheduler", + "orion_source_dir": "/path/to/mega/orion", + "orion_binary_path": "/path/to/mega/target/debug/orion", + "ssh_public_key_path": "~/.ssh/orion_vm_access.pub", + "max_vms": 8, + "retain_antares_mounts": false, + "default_image": { + "image_disk_gb": 50, + "image_cpus": 8, + "image_memory_mb": 16000 + } +} diff --git a/orion-scheduler/install.sh b/orion-scheduler/install.sh index 58aff2c29..dd24e8b42 100755 --- a/orion-scheduler/install.sh +++ b/orion-scheduler/install.sh @@ -4,6 +4,7 @@ # Layout expected in the same directory as this script: # bin/orion-scheduler # etc/target_config.json.template +# etc/needrestart-orion-scheduler.conf # systemd/orion-scheduler.service # # Env vars (all optional): @@ -38,6 +39,7 @@ fi for f in "$SCRIPT_DIR/bin/orion-scheduler" \ "$SCRIPT_DIR/etc/target_config.json.template" \ + "$SCRIPT_DIR/etc/needrestart-orion-scheduler.conf" \ "$SCRIPT_DIR/systemd/orion-scheduler.service"; do if [[ ! -f "$f" ]]; then echo "[install] missing bundle file: $f" >&2 @@ -98,6 +100,18 @@ install -o root -g root -m 0644 \ /etc/systemd/system/orion-scheduler.service systemctl daemon-reload +# Prevent needrestart (unattended-upgrade) from restarting this unit and +# thereby SIGTERM-killing every tracked QEMU VM. Non-fatal if needrestart +# is not installed on the host. +if [[ -d /etc/needrestart/conf.d ]]; then + echo "[install] installing needrestart override" + install -o root -g root -m 0644 \ + "$SCRIPT_DIR/etc/needrestart-orion-scheduler.conf" \ + /etc/needrestart/conf.d/orion-scheduler.conf +else + echo "[install] needrestart conf.d absent; skipping override (install needrestart to protect VMs from apt upgrades)" +fi + if [[ "$SKIP_ENABLE" == "1" ]]; then echo "[install] SKIP_ENABLE=1, leaving unit disabled" echo "[install] Done." diff --git a/orion-scheduler/scripts/build-custom-image.sh b/orion-scheduler/scripts/build-custom-image.sh index b2e40c3eb..7b946f4f6 100755 --- a/orion-scheduler/scripts/build-custom-image.sh +++ b/orion-scheduler/scripts/build-custom-image.sh @@ -8,16 +8,105 @@ # # 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. +# +# RustFS / catalog keys live in scripts/.env (see .env.example). The script +# sources that file then rewrites it when keys are present. Exported env vars +# win (sudo -E). Switching RUST_VERSION reuses /var/cache/orion-image/{rust,built}/ +# instead of rebuilding from scratch. FORCE_REBUILD=1 skips the built-image cache. set -eo pipefail # ============================================================================ # Configuration # ============================================================================ +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ORION_IMAGE_ENV_FILE:-$SCRIPT_DIR/.env}" + +# Source .env without clobbering variables already set in the process environment. +load_orion_image_env() { + local f="$1" + local had_endpoint=0 had_access=0 had_secret=0 had_bucket=0 had_region=0 + local had_reg_url=0 had_reg_token=0 had_bootstrap=0 had_fanout=0 + local s_endpoint="" s_access="" s_secret="" s_bucket="" s_region="" + local s_reg_url="" s_reg_token="" s_bootstrap="" s_fanout="" + [ -f "$f" ] || return 0 + [ -n "${RUSTFS_ENDPOINT+x}" ] && { had_endpoint=1; s_endpoint="$RUSTFS_ENDPOINT"; } + [ -n "${RUSTFS_ACCESS_KEY+x}" ] && { had_access=1; s_access="$RUSTFS_ACCESS_KEY"; } + [ -n "${RUSTFS_SECRET_KEY+x}" ] && { had_secret=1; s_secret="$RUSTFS_SECRET_KEY"; } + [ -n "${RUSTFS_BUCKET+x}" ] && { had_bucket=1; s_bucket="$RUSTFS_BUCKET"; } + [ -n "${RUSTFS_REGION+x}" ] && { had_region=1; s_region="$RUSTFS_REGION"; } + [ -n "${ORION_IMAGE_REGISTER_URL+x}" ] && { had_reg_url=1; s_reg_url="$ORION_IMAGE_REGISTER_URL"; } + [ -n "${ORION_IMAGE_REGISTER_TOKEN+x}" ] && { had_reg_token=1; s_reg_token="$ORION_IMAGE_REGISTER_TOKEN"; } + [ -n "${MEGA_INIT_BOOTSTRAP_SECRET+x}" ] && { had_bootstrap=1; s_bootstrap="$MEGA_INIT_BOOTSTRAP_SECRET"; } + [ -n "${ORION_IMAGE_FANOUT+x}" ] && { had_fanout=1; s_fanout="$ORION_IMAGE_FANOUT"; } + set -a + # shellcheck disable=SC1090 + . "$f" + set +a + [ "$had_endpoint" = 1 ] && RUSTFS_ENDPOINT="$s_endpoint" + [ "$had_access" = 1 ] && RUSTFS_ACCESS_KEY="$s_access" + [ "$had_secret" = 1 ] && RUSTFS_SECRET_KEY="$s_secret" + [ "$had_bucket" = 1 ] && RUSTFS_BUCKET="$s_bucket" + [ "$had_region" = 1 ] && RUSTFS_REGION="$s_region" + [ "$had_reg_url" = 1 ] && ORION_IMAGE_REGISTER_URL="$s_reg_url" + [ "$had_reg_token" = 1 ] && ORION_IMAGE_REGISTER_TOKEN="$s_reg_token" + [ "$had_bootstrap" = 1 ] && MEGA_INIT_BOOTSTRAP_SECRET="$s_bootstrap" + [ "$had_fanout" = 1 ] && ORION_IMAGE_FANOUT="$s_fanout" + echo "[build-custom-image] Loaded image env from $f" +} + +shell_quote_env() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +# Persist current RustFS + catalog settings so the next run does not need sudo -E. +write_orion_image_env() { + local f="$1" + local tmp owner old_umask + if [ -z "${RUSTFS_ACCESS_KEY:-}" ] || [ -z "${RUSTFS_SECRET_KEY:-}" ]; then + echo "[build-custom-image] RUSTFS_ACCESS_KEY/SECRET_KEY unset; not writing $f" + return 0 + fi + tmp=$(mktemp) + old_umask=$(umask) + umask 077 + cat > "$tmp" < "$dest" </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 +429,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" </dev/null 2>&1; then fi release_nbd "$CUSTOM_IMAGE" +# ============================================================================ +# Recipe cache: reuse a previously compacted qcow2 for this toolchain pin. +# Switching RUST_VERSION back to a version we already built skips chroot. +# REUSE_BUILT_IMAGE is independent of SKIP_BUILD (MOCK_UPLOAD alias). +# ============================================================================ +REUSE_BUILT_IMAGE=0 +CACHED_QCOW2="${IMAGE_RECIPE_CACHE}/${IMAGE_NAME}.qcow2" +CACHED_INFO="${IMAGE_RECIPE_CACHE}/image-info.json" +echo "[build-custom-image] Image recipe: $IMAGE_RECIPE_ID" +echo "[build-custom-image] Image cache: $IMAGE_RECIPE_CACHE" +if [ "${FORCE_REBUILD:-0}" = "1" ]; then + echo "[build-custom-image] FORCE_REBUILD=1; ignoring built-image cache" +elif [ -s "$CACHED_QCOW2" ]; then + if [ -n "$KERNEL" ] && [ -f "$KERNEL" ] && [ -n "$INITRD" ] && [ -f "$INITRD" ]; then + REUSE_BUILT_IMAGE=1 + echo "[build-custom-image] Reusing cached image ($(du -sh "$CACHED_QCOW2" | cut -f1))" + cp "$CACHED_QCOW2" "$CUSTOM_IMAGE" + else + echo "[build-custom-image] Cached image exists but kernel/initrd missing under $BASE_DIR; rebuilding" + fi +else + echo "[build-custom-image] No built-image cache for this recipe" +fi + +if [ "$REUSE_BUILT_IMAGE" -eq 0 ]; then # ============================================================================ # Stage 1: Copy + resize qcow2 # ============================================================================ @@ -409,6 +633,14 @@ download_rust_tarball() { echo "[build-custom-image] Rust tarball downloaded: $(du -sh "$RUST_TARBALL" | cut -f1)" } +mkdir -p "$RUST_CACHE_DIR" +_old_rust="/tmp/rust-${RUST_VERSION}-${RUST_ARCH}.tar.xz" +if [ ! -f "$RUST_TARBALL" ] && [ -s "$_old_rust" ]; then + echo "[build-custom-image] Migrating Rust tarball $_old_rust -> $RUST_TARBALL" + cp -a "$_old_rust" "$RUST_TARBALL" +fi +unset _old_rust + resolve_rust_channel if [ -f "$RUST_TARBALL" ] && verify_rust_tarball; then @@ -673,7 +905,7 @@ cat /etc/apt/mirrors/debian.list 2>/dev/null || true cat /etc/apt/mirrors/debian-security.list 2>/dev/null || true apt-get update apt-get install -y \ - clang lld pkg-config protobuf-compiler zstd fuse curl git nginx \ + clang lld pkg-config libelf-dev protobuf-compiler zstd fuse curl git nginx \ seccomp libseccomp-dev libpython3-dev openssl libssl-dev build-essential ca-certificates echo "=== [chroot] Verifying installed tools ===" @@ -882,30 +1114,6 @@ if [ "$TEE_RC" -ne 0 ]; then fi echo "[build-custom-image] chroot install OK" -# Write image metadata into the still-mounted guest (and later onto the host -# beside the published qcow2). Scheduler reads the host sidecar at VM create. -write_orion_image_info() { - local dest="$1" - local built_at kernel_ver - built_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - kernel_ver="" - if [ -n "${KERNEL:-}" ]; then - kernel_ver=$(basename "$KERNEL" | sed 's/^vmlinuz-//') - fi - cat > "$dest" <&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 "" + return 0 + fi + 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" +} + +find_mc_bin() { + local cand + if command -v mc >/dev/null 2>&1; then + command -v mc + return 0 + fi + for cand in \ + "${HOME}/.local/bin/mc" \ + "/home/${SUDO_USER:-}/.local/bin/mc" \ + /home/orion/.local/bin/mc; do + if [ -n "$cand" ] && [ -x "$cand" ]; then + printf '%s\n' "$cand" + return 0 + fi + done + echo "" +} + +s3_cp_file() { + local src="$1" s3_uri="$2" endpoint="$3" access_key="$4" secret_key="$5" region="$6" + if command -v aws >/dev/null 2>&1; then + 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 "$src" "$s3_uri" + return $? + fi + local mc_bin + mc_bin=$(find_mc_bin) + if [ -z "$mc_bin" ]; then + echo "[build-custom-image] WARNING: neither aws nor mc found" >&2 + return 1 + fi + local alias="orion-rustfs-$$" + "$mc_bin" alias set "$alias" "$endpoint" "$access_key" "$secret_key" \ + --api S3v4 --path on >/dev/null + local dest="${s3_uri#s3://}" + local rc=0 + "$mc_bin" cp "$src" "${alias}/${dest}" || rc=$? + "$mc_bin" alias remove "$alias" >/dev/null 2>&1 || true + return $rc +} + +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://${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 ! s3_cp_file "$image_file" "$s3_qcow2" "$endpoint" "$access_key" "$secret_key" "$region"; then + echo "[build-custom-image] WARNING: [$name] qcow2 upload failed" >&2 + return 1 + fi + + if [ -f "$info_file" ]; then + echo "[build-custom-image] [$name] Uploading sidecar to ${s3_info} ..." + if ! s3_cp_file "$info_file" "$s3_info" "$endpoint" "$access_key" "$secret_key" "$region"; then + echo "[build-custom-image] WARNING: [$name] sidecar upload failed" >&2 + return 1 + fi + fi + + 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=$(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) + python_ver=$(jq -r '.python // empty' "$info_file" 2>/dev/null || true) + kernel_ver=$(jq -r '.kernel // empty' "$info_file" 2>/dev/null || true) + + local body + body=$(jq -n \ + --arg digest "sha256:${digest_hex}" \ + --arg object_key "$object_key" \ + --arg info_object_key "$info_key" \ + --arg image_name "$IMAGE_NAME" \ + --arg built_at "$built_at" \ + --arg rust "$rust_ver" \ + --arg buck2 "$buck2_ver" \ + --arg python "$python_ver" \ + --arg kernel "$kernel_ver" \ + --argjson size_bytes "$size_bytes" \ + '{ + digest: $digest, + object_key: $object_key, + info_object_key: $info_object_key, + image_name: $image_name, + built_at: (if $built_at == "" then null else $built_at end), + rust: (if $rust == "" then null else $rust end), + buck2: (if $buck2 == "" then null else $buck2 end), + python: (if $python == "" then null else $python end), + kernel: (if $kernel == "" then null else $kernel end), + size_bytes: $size_bytes + }') + + 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: [$name] catalog register failed (objects may still be in RustFS)" >&2 + return 1 + fi + echo "" + 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 && [ -z "$(find_mc_bin)" ]; then + echo "[build-custom-image] WARNING: neither aws nor mc 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" +if [ ! -f "$PUBLISH_SOURCE" ]; then + PUBLISH_SOURCE="$CUSTOM_IMAGE" +fi +INFO_SIDECAR="$OUTPUT_DIR/${IMAGE_NAME}.image-info.json" +if [ ! -f "$INFO_SIDECAR" ]; then + INFO_SIDECAR="$IMAGE_DIR/image-info.json" +fi +upload_and_register_orion_image "$PUBLISH_SOURCE" "$NEW_DIGEST" "$INFO_SIDECAR" + log_stage "done" echo "" echo "[build-custom-image] ===============================================" diff --git a/orion-scheduler/scripts/register-orion-image.sh b/orion-scheduler/scripts/register-orion-image.sh new file mode 100755 index 000000000..02e54132d --- /dev/null +++ b/orion-scheduler/scripts/register-orion-image.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Register an already-uploaded Orion qcow2 in the mono image catalog. +# +# Usage (from repo root or this directory): +# ORION_IMAGE_REGISTER_TOKEN= \ +# bash scripts/register-orion-image.sh [digest_hex] +# +# Defaults: latest published flat image under ~/.local/share/qlean/images/ +# Sources scripts/.env the same way as build-custom-image.sh. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ORION_IMAGE_ENV_FILE:-$SCRIPT_DIR/.env}" + +# Source .env without clobbering variables already set in the process environment. +if [ -f "$ENV_FILE" ]; then + _had_url=0 _had_token=0 + [ -n "${ORION_IMAGE_REGISTER_URL+x}" ] && { _had_url=1; _s_url="$ORION_IMAGE_REGISTER_URL"; } + [ -n "${ORION_IMAGE_REGISTER_TOKEN+x}" ] && { _had_token=1; _s_token="$ORION_IMAGE_REGISTER_TOKEN"; } + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a + [ "$_had_url" = 1 ] && ORION_IMAGE_REGISTER_URL="$_s_url" + [ "$_had_token" = 1 ] && ORION_IMAGE_REGISTER_TOKEN="$_s_token" + unset _had_url _had_token _s_url _s_token +fi + +ORION_IMAGE_REGISTER_URL="${ORION_IMAGE_REGISTER_URL:-https://git.rk8s.xuanwu.openatom.cn/api/v1/orion/images}" +IMAGE_NAME="${IMAGE_NAME:-debian-13-buck2}" + +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + QLEAN_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)" +else + QLEAN_HOME="${HOME}" +fi +OUTPUT_DIR="${OUTPUT_DIR:-$QLEAN_HOME/.local/share/qlean/images}" +IMAGE_FILE="${IMAGE_FILE:-$OUTPUT_DIR/${IMAGE_NAME}.qcow2}" +INFO_FILE="${INFO_FILE:-$OUTPUT_DIR/${IMAGE_NAME}.image-info.json}" + +if [ -z "${ORION_IMAGE_REGISTER_TOKEN:-}" ]; then + echo "ORION_IMAGE_REGISTER_TOKEN is required (admin Bearer)" >&2 + exit 1 +fi +if [ ! -f "$IMAGE_FILE" ]; then + echo "image not found: $IMAGE_FILE" >&2 + exit 1 +fi + +DIGEST_HEX="${1:-}" +if [ -z "$DIGEST_HEX" ]; then + DIGEST_HEX=$(sha256sum "$IMAGE_FILE" | awk '{print $1}') +fi +OBJECT_KEY="${DIGEST_HEX}/${IMAGE_NAME}.qcow2" +INFO_KEY="${DIGEST_HEX}/image-info.json" +SIZE_BYTES=$(stat -c%s "$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) +python_ver=$(jq -r '.python // empty' "$INFO_FILE" 2>/dev/null || true) +kernel_ver=$(jq -r '.kernel // empty' "$INFO_FILE" 2>/dev/null || true) + +body=$(jq -n \ + --arg digest "sha256:${DIGEST_HEX}" \ + --arg object_key "$OBJECT_KEY" \ + --arg info_object_key "$INFO_KEY" \ + --arg image_name "$IMAGE_NAME" \ + --arg built_at "$built_at" \ + --arg rust "$rust_ver" \ + --arg buck2 "$buck2_ver" \ + --arg python "$python_ver" \ + --arg kernel "$kernel_ver" \ + --argjson size_bytes "$SIZE_BYTES" \ + '{ + digest: $digest, + object_key: $object_key, + info_object_key: $info_object_key, + image_name: $image_name, + built_at: (if $built_at == "" then null else $built_at end), + rust: (if $rust == "" then null else $rust end), + buck2: (if $buck2 == "" then null else $buck2 end), + python: (if $python == "" then null else $python end), + kernel: (if $kernel == "" then null else $kernel end), + size_bytes: $size_bytes + }') + +echo "POST $ORION_IMAGE_REGISTER_URL" +echo "digest=sha256:$DIGEST_HEX object_key=$OBJECT_KEY" +tmp=$(mktemp) +code=$(curl -sS -o "$tmp" -w '%{http_code}' -X POST "$ORION_IMAGE_REGISTER_URL" \ + -H "Authorization: Bearer ${ORION_IMAGE_REGISTER_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$body" || true) +echo "HTTP $code" +cat "$tmp" +echo +rm -f "$tmp" +[ "$code" = "200" ] diff --git a/orion-scheduler/src/config.rs b/orion-scheduler/src/config.rs index eeed7cfb9..13b59cc42 100644 --- a/orion-scheduler/src/config.rs +++ b/orion-scheduler/src/config.rs @@ -19,11 +19,19 @@ pub struct TargetConfig { pub retain_antares_mounts: Option, } -/// Default VM image parameters used when webhook omits image_* fields. +/// Default VM sizing used when webhook omits disk/cpu/memory. +/// Image source must be supplied explicitly (`image_url` or `image_path`); +/// there is no silent host-local default qcow2. #[derive(Debug, Clone, Deserialize)] pub struct DefaultImageConfig { - pub image_path: String, - pub image_digest: String, + /// Ignored if present in older configs (local default path removed). + #[serde(default)] + #[allow(dead_code)] + pub image_path: Option, + /// Ignored if present in older configs. + #[serde(default)] + #[allow(dead_code)] + pub image_digest: Option, pub image_disk_gb: u32, pub image_cpus: u32, pub image_memory_mb: u32, @@ -32,10 +40,8 @@ pub struct DefaultImageConfig { impl Default for DefaultImageConfig { fn default() -> Self { Self { - image_path: "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2" - .to_string(), - image_digest: "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5" - .to_string(), + image_path: None, + image_digest: None, image_disk_gb: 50, image_cpus: 8, image_memory_mb: 16000, @@ -251,6 +257,7 @@ mod tests { assert_eq!(d.image_disk_gb, 50); assert_eq!(d.image_cpus, 8); assert_eq!(d.image_memory_mb, 16000); - assert!(d.image_digest.starts_with("sha256:")); + assert!(d.image_path.is_none()); + assert!(d.image_digest.is_none()); } } diff --git a/orion-scheduler/src/handlers.rs b/orion-scheduler/src/handlers.rs index aeeb04de6..814327ae7 100644 --- a/orion-scheduler/src/handlers.rs +++ b/orion-scheduler/src/handlers.rs @@ -34,6 +34,13 @@ pub struct ImageParams { pub disk_gb: Option, pub cpus: Option, pub memory_mb: Option, + /// Catalog / caller-provided metadata (preferred over local sidecar). + pub image_name: Option, + pub image_built_at: Option, + pub toolchain_rust: Option, + pub toolchain_buck2: Option, + pub toolchain_python: Option, + pub kernel: Option, } #[derive(Debug, Serialize)] @@ -83,33 +90,35 @@ pub struct GithubWebhookPayload { /// VM memory in MB. #[serde(default, skip_serializing_if = "Option::is_none")] pub image_memory_mb: Option, + /// Optional catalog metadata (filled by mono when starting via `image_id`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_built_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolchain_rust: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolchain_buck2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolchain_python: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kernel: Option, /// When set, write `ORION_RETAIN_ANTARES_MOUNTS` into the guest `.env` /// (`true`→`1`, `false`→`0`). Omitted → leave `.env.prod` value unchanged. #[serde(default, skip_serializing_if = "Option::is_none")] pub retain_antares_mounts: Option, } -/// Merge webhook image overrides with scheduler `default_image` config. +/// Merge webhook image overrides with scheduler sizing defaults. +/// Does not invent a host-local image path/digest; caller must supply +/// `image_url` or `image_path` (+ digest). pub fn merge_image_params( payload: &GithubWebhookPayload, default: &DefaultImageConfig, ) -> ImageParams { let url = payload.image_url.clone(); - let path = if url.is_some() { - payload.image_path.clone() - } else { - payload - .image_path - .clone() - .or_else(|| Some(default.image_path.clone())) - }; - let digest = payload.image_digest.clone().or_else(|| { - if path.is_some() || url.is_some() { - Some(default.image_digest.clone()) - } else { - None - } - }); + let path = payload.image_path.clone(); + let digest = payload.image_digest.clone(); ImageParams { path, @@ -118,6 +127,12 @@ pub fn merge_image_params( disk_gb: payload.image_disk_gb.or(Some(default.image_disk_gb)), cpus: payload.image_cpus.or(Some(default.image_cpus)), memory_mb: payload.image_memory_mb.or(Some(default.image_memory_mb)), + image_name: payload.image_name.clone(), + image_built_at: payload.image_built_at.clone(), + toolchain_rust: payload.toolchain_rust.clone(), + toolchain_buck2: payload.toolchain_buck2.clone(), + toolchain_python: payload.toolchain_python.clone(), + kernel: payload.kernel.clone(), } } @@ -127,7 +142,7 @@ mod merge_tests { use crate::config::DefaultImageConfig; #[test] - fn merge_uses_defaults_when_payload_omits_image_fields() { + fn merge_uses_sizing_defaults_when_payload_omits_image_fields() { let default = DefaultImageConfig::default(); let payload = GithubWebhookPayload { action: None, @@ -143,15 +158,21 @@ mod merge_tests { image_disk_gb: None, image_cpus: None, image_memory_mb: None, + image_name: None, + image_built_at: None, + toolchain_rust: None, + toolchain_buck2: None, + toolchain_python: None, + kernel: None, retain_antares_mounts: None, }; let merged = merge_image_params(&payload, &default); - assert_eq!(merged.path.as_deref(), Some(default.image_path.as_str())); - assert_eq!( - merged.digest.as_deref(), - Some(default.image_digest.as_str()) - ); + assert!(merged.path.is_none()); + assert!(merged.url.is_none()); + assert!(merged.digest.is_none()); assert_eq!(merged.disk_gb, Some(default.image_disk_gb)); + assert_eq!(merged.cpus, Some(default.image_cpus)); + assert_eq!(merged.memory_mb, Some(default.image_memory_mb)); } #[test] @@ -171,12 +192,53 @@ mod merge_tests { image_disk_gb: Some(64), image_cpus: None, image_memory_mb: None, + image_name: None, + image_built_at: None, + toolchain_rust: None, + toolchain_buck2: None, + toolchain_python: None, + kernel: None, retain_antares_mounts: None, }; let merged = merge_image_params(&payload, &default); assert_eq!(merged.disk_gb, Some(64)); assert_eq!(merged.cpus, Some(default.image_cpus)); } + + #[test] + fn merge_keeps_explicit_url_without_config_digest() { + let default = DefaultImageConfig::default(); + let payload = GithubWebhookPayload { + action: None, + target: None, + sync: false, + replace: false, + server_ws: "ws://orion.test/ws".into(), + scorpio_base_url: "http://git.test".into(), + scorpio_lfs_url: "http://git.test".into(), + image_path: None, + image_url: Some("https://rustfs.example/img.qcow2".into()), + image_digest: Some("sha256:abc".into()), + image_disk_gb: None, + image_cpus: None, + image_memory_mb: None, + image_name: Some("debian-13-buck2".into()), + image_built_at: None, + toolchain_rust: None, + toolchain_buck2: None, + toolchain_python: None, + kernel: None, + retain_antares_mounts: None, + }; + let merged = merge_image_params(&payload, &default); + assert!(merged.path.is_none()); + assert_eq!( + merged.url.as_deref(), + Some("https://rustfs.example/img.qcow2") + ); + assert_eq!(merged.digest.as_deref(), Some("sha256:abc")); + assert_eq!(merged.image_name.as_deref(), Some("debian-13-buck2")); + } } /// GET /webhook @@ -274,9 +336,69 @@ pub async fn webhook_post_handler( } }; - // Conflict / idempotency checks (hold update lock briefly). + // Fast conflict / idempotency checks without waiting on the update lock. + // `handle_update` holds that lock for the whole create (including multi-GB + // image downloads); blocking here made mono's start_runner client time out. + if let Some(existing) = state.get_vm_by_domain(&domain).await { + match existing.phase { + VmPhase::Provisioning => { + return ( + StatusCode::CONFLICT, + Json(WebhookResponse { + status: "conflict".to_string(), + vm_id: Some(existing.id.clone()), + domain: Some(domain), + phase: Some(existing.phase.as_str().to_string()), + error: Some("VM already provisioning for this domain".to_string()), + orion_log_file: existing.log_file.clone(), + }), + ) + .into_response(); + } + VmPhase::Running if !payload.replace => { + return ( + StatusCode::OK, + Json(WebhookResponse { + status: "ok".to_string(), + vm_id: Some(existing.id.clone()), + domain: Some(domain), + phase: Some(existing.phase.as_str().to_string()), + error: None, + orion_log_file: existing.log_file.clone(), + }), + ) + .into_response(); + } + VmPhase::Running | VmPhase::Failed => { + // replace=true or Failed: allow recreate (handle_update will shut down). + } + } + } + + // Brief try-lock so a concurrent download/provision returns quickly instead + // of holding the HTTP request open until mono's client timeout. { - let _guard = state.lock_update().await; + let Some(_guard) = state + .try_lock_update(std::time::Duration::from_secs(2)) + .await + else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(WebhookResponse { + status: "busy".to_string(), + vm_id: None, + domain: Some(domain), + phase: None, + error: Some( + "scheduler is busy provisioning another VM; retry shortly".to_string(), + ), + orion_log_file: None, + }), + ) + .into_response(); + }; + + // Re-check under the lock for races with a just-started provision. if let Some(existing) = state.get_vm_by_domain(&domain).await { match existing.phase { VmPhase::Provisioning => { @@ -307,9 +429,7 @@ pub async fn webhook_post_handler( ) .into_response(); } - VmPhase::Running | VmPhase::Failed => { - // replace=true or Failed: allow recreate (handle_update will shut down). - } + VmPhase::Running | VmPhase::Failed => {} } } else if let Some(max) = state.config.read().await.max_vms() { let count = state.vm_count().await; @@ -335,6 +455,22 @@ pub async fn webhook_post_handler( let config_retain = cfg.retain_antares_mounts(); drop(cfg); let image_params = merge_image_params(&payload, &default_image); + if image_params.url.is_none() && image_params.path.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json(WebhookResponse { + status: "error".to_string(), + vm_id: None, + domain: Some(domain), + phase: None, + error: Some( + "image_url or image_path is required (no local default image)".to_string(), + ), + orion_log_file: None, + }), + ) + .into_response(); + } let target_config = TargetConfig { server_ws: payload.server_ws.clone(), @@ -856,8 +992,8 @@ fn hash_line(line: &str) -> u64 { /// Multi-VM: pass `?domain=` or `?vm_id=` to select which runner's logs to stream. /// /// While the selected VM is still provisioning (no machine handle yet), the -/// stream emits a single waiting line instead of repeating "No running VM" -/// errors every tick. +/// stream emits provisioning / image-download progress instead of repeating +/// "No running VM" errors every tick. pub async fn logs_stream_handler( State(state): State>, Query(q): Query, @@ -867,7 +1003,7 @@ pub async fn logs_stream_handler( let mut ticker = interval(std::time::Duration::from_secs(1)); let mut journal_cursor = LogCursor::default(); let mut orion_log_offset: u64 = 0; - let mut waiting_announced = false; + let mut last_provision_line: Option = None; let mut failure_announced = false; loop { @@ -881,7 +1017,7 @@ pub async fn logs_stream_handler( .await { Ok(snapshot) => { - waiting_announced = false; + last_provision_line = None; failure_announced = false; snapshot } @@ -890,12 +1026,10 @@ pub async fn logs_stream_handler( if is_vm_not_ready_error(&msg) { match orion_deployer::get_status_by_key(&state, key.as_deref()).await { Some(vm) if vm.phase == VmPhase::Provisioning => { - if !waiting_announced { - waiting_announced = true; - yield Ok(Event::default().data(format!( - "Waiting for VM {} to finish provisioning…", - vm.id - ))); + let line = provisioning_status_line(&vm); + if last_provision_line.as_deref() != Some(line.as_str()) { + last_provision_line = Some(line.clone()); + yield Ok(Event::default().data(line)); } } Some(vm) if vm.phase == VmPhase::Failed => { @@ -911,11 +1045,10 @@ pub async fn logs_stream_handler( } } Some(_) | None => { - if !waiting_announced { - waiting_announced = true; - yield Ok(Event::default().data( - "Waiting for VM to become available…", - )); + let line = "Waiting for VM to become available…".to_string(); + if last_provision_line.as_deref() != Some(line.as_str()) { + last_provision_line = Some(line.clone()); + yield Ok(Event::default().data(line)); } } } @@ -957,6 +1090,78 @@ fn is_vm_not_ready_error(msg: &str) -> bool { || msg.contains("No VM machine handle available") } +/// Human-readable provisioning status, including qcow2 download progress when +/// qlean is writing `~/.local/share/qlean/images/sha256-.part`. +fn provisioning_status_line(vm: &crate::state::VmInfo) -> String { + match image_download_progress(vm.image_digest.as_deref()) { + Some((done, Some(total))) if total > 0 => { + let pct = (100.0 * done as f64 / total as f64).clamp(0.0, 100.0); + format!( + "Downloading image for {}: {} / {} ({:.0}%)", + vm.id, + format_bytes(done), + format_bytes(total), + pct + ) + } + Some((done, _)) if done > 0 => { + format!("Downloading image for {}: {} …", vm.id, format_bytes(done)) + } + _ => format!("Waiting for VM {} to finish provisioning…", vm.id), + } +} + +fn format_bytes(n: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + let n = n as f64; + if n >= GIB { + format!("{:.2} GiB", n / GIB) + } else if n >= MIB { + format!("{:.1} MiB", n / MIB) + } else if n >= KIB { + format!("{:.0} KiB", n / KIB) + } else { + format!("{n:.0} B") + } +} + +/// Read in-progress qlean image download from the digest-keyed `.part` file +/// (and optional `.part.len` sidecar written by qlean when Content-Length is known). +fn image_download_progress(digest: Option<&str>) -> Option<(u64, Option)> { + let digest = digest?; + let hex = digest + .strip_prefix("sha256:") + .or_else(|| digest.strip_prefix("sha512:")) + .unwrap_or(digest); + if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let algo = if digest.starts_with("sha512:") { + "sha512" + } else { + "sha256" + }; + let images_dir = qlean_images_dir()?; + let part = images_dir.join(format!("{algo}-{hex}.part")); + let meta = std::fs::metadata(&part).ok()?; + if !meta.is_file() { + return None; + } + let done = meta.len(); + let total = std::fs::read_to_string(format!("{}.len", part.display())) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .filter(|&t| t > 0); + Some((done, total)) +} + +fn qlean_images_dir() -> Option { + let home = std::env::var_os("HOME")?; + Some(std::path::PathBuf::from(home).join(".local/share/qlean/images")) +} + /// Append a log section with a title header and colored log lines to `output`. fn append_logs_section(output: &mut String, title: &str, lines: &[&str]) { use std::fmt::Write; diff --git a/orion-scheduler/src/main.rs b/orion-scheduler/src/main.rs index 15cbf2ba2..31abba5f2 100644 --- a/orion-scheduler/src/main.rs +++ b/orion-scheduler/src/main.rs @@ -113,8 +113,10 @@ async fn main() -> anyhow::Result<()> { let config = config::Config::load(&config_path).await?; let config = Arc::new(tokio::sync::RwLock::new(config)); tracing::info!( - "[startup] Config loaded, default_image path: {}, max_vms: {:?}", - config.read().await.default_image().image_path, + "[startup] Config loaded, default VM sizing: {}GB disk / {} cpus / {}MB mem, max_vms: {:?}", + config.read().await.default_image().image_disk_gb, + config.read().await.default_image().image_cpus, + config.read().await.default_image().image_memory_mb, config.read().await.max_vms() ); diff --git a/orion-scheduler/src/orion_deployer.rs b/orion-scheduler/src/orion_deployer.rs index cad3ecee0..2199c2bc5 100644 --- a/orion-scheduler/src/orion_deployer.rs +++ b/orion-scheduler/src/orion_deployer.rs @@ -86,6 +86,8 @@ fn vm_info_from_params( .as_deref() .map(load_image_info_sidecar) .unwrap_or_default(); + // Prefer catalog/webhook metadata (URL starts have no local sidecar), then + // fall back to `*.image-info.json` beside a local qcow2 path. VmInfo { id: id.to_string(), domain: domain.to_string(), @@ -100,12 +102,12 @@ fn vm_info_from_params( image_cpus: image_params.cpus, image_memory_mb: image_params.memory_mb, image_disk_gb: image_params.disk_gb, - image_name: sidecar.image_name, - image_built_at: sidecar.built_at, - toolchain_rust: sidecar.rust, - toolchain_buck2: sidecar.buck2, - toolchain_python: sidecar.python, - kernel: sidecar.kernel, + image_name: image_params.image_name.clone().or(sidecar.image_name), + image_built_at: image_params.image_built_at.clone().or(sidecar.built_at), + toolchain_rust: image_params.toolchain_rust.clone().or(sidecar.rust), + toolchain_buck2: image_params.toolchain_buck2.clone().or(sidecar.buck2), + toolchain_python: image_params.toolchain_python.clone().or(sidecar.python), + kernel: image_params.kernel.clone().or(sidecar.kernel), } } @@ -620,6 +622,12 @@ mod tests { disk_gb: Some(30), cpus: Some(8), memory_mb: Some(16000), + image_name: None, + image_built_at: None, + toolchain_rust: None, + toolchain_buck2: None, + toolchain_python: None, + kernel: None, }; let (spec, disk, cpus, mem) = build_image_spec(¶ms).unwrap(); assert!(spec.is_some()); diff --git a/orion-scheduler/systemd/orion-scheduler.service b/orion-scheduler/systemd/orion-scheduler.service index ff4bb7368..6ae964cac 100644 --- a/orion-scheduler/systemd/orion-scheduler.service +++ b/orion-scheduler/systemd/orion-scheduler.service @@ -35,6 +35,10 @@ AmbientCapabilities=CAP_NET_ADMIN Restart=on-failure RestartSec=5s +# stop/restart tears down ALL keep-alive VMs (SIGTERM → shutdown_all_vms). +# Do not casually restart this unit; use webhook replace / POST /shutdown for +# VM lifecycle. Package hooks must not auto-restart it — install.sh drops +# /etc/needrestart/conf.d/orion-scheduler.conf (override_rc => 0). # Give qlean a chance to power off running VMs gracefully before SIGKILL. TimeoutStopSec=60s KillMode=mixed diff --git a/orion-scheduler/target_config.json.template b/orion-scheduler/target_config.json.template index f35896598..96b22e51d 100644 --- a/orion-scheduler/target_config.json.template +++ b/orion-scheduler/target_config.json.template @@ -6,8 +6,6 @@ "max_vms": 8, "retain_antares_mounts": false, "default_image": { - "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", - "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 diff --git a/orion/Cargo.toml b/orion/Cargo.toml index f1f6ecfce..9009eef41 100644 --- a/orion/Cargo.toml +++ b/orion/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orion" -version = "0.1.4" +version = "0.1.5" edition = "2024" [[bin]]